refactor: give reasoning decisions their own module
providers.py had been holding two jobs: the registry of what each provider looks like, and the decisions made from those declarations. Adding response-side judgement would have made it the module for everything about reasoning, so the decisions move to thinking.py and the registry keeps only profiles and their lookup. Moving a module breaks any deep-path import of what moved, so the six public symbols are promoted to the package root at the same time. The top level is this library's stated API surface; giving downstream a stable name to import is what makes the next reorganisation harmless. observe_thinking stays unexported — downstream reads the verdict off LLMResponse, and exporting it would be a permanent promise for nothing.
This commit is contained in:
@@ -24,6 +24,13 @@ from polygateway.ocr import OcrClient
|
|||||||
from polygateway.pricing import ModelPrice, PricingTable
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
||||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||||
|
from polygateway.thinking import (
|
||||||
|
ThinkingCapability,
|
||||||
|
ThinkingUnsupportedError,
|
||||||
|
get_capability,
|
||||||
|
register_capability,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
EmbeddingResponse,
|
EmbeddingResponse,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
@@ -32,6 +39,7 @@ from polygateway.types import (
|
|||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.3.0"
|
__version__ = "1.3.0"
|
||||||
@@ -63,9 +71,15 @@ __all__ = [
|
|||||||
"SourceDeadError",
|
"SourceDeadError",
|
||||||
"SourceNotConfiguredError",
|
"SourceNotConfiguredError",
|
||||||
"TelemetryStatus",
|
"TelemetryStatus",
|
||||||
|
"ThinkingCapability",
|
||||||
|
"ThinkingObservation",
|
||||||
|
"ThinkingUnsupportedError",
|
||||||
"TransientError",
|
"TransientError",
|
||||||
"__version__",
|
"__version__",
|
||||||
"gather_bounded",
|
"gather_bounded",
|
||||||
|
"get_capability",
|
||||||
|
"register_capability",
|
||||||
"register_provider",
|
"register_provider",
|
||||||
|
"resolve_thinking",
|
||||||
"telemetry_schema_sql",
|
"telemetry_schema_sql",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from polygateway.middleware.structured import StructuredMW
|
|||||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||||
from polygateway.ports import TelemetryStatusProvider
|
from polygateway.ports import TelemetryStatusProvider
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.providers import get_capability, get_provider, resolve_thinking
|
from polygateway.providers import get_provider
|
||||||
from polygateway.sources import (
|
from polygateway.sources import (
|
||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
HealthAwareSelector,
|
HealthAwareSelector,
|
||||||
@@ -34,6 +34,7 @@ from polygateway.sources import (
|
|||||||
RoundRobinSelector,
|
RoundRobinSelector,
|
||||||
SourceCooldownMemo,
|
SourceCooldownMemo,
|
||||||
)
|
)
|
||||||
|
from polygateway.thinking import get_capability, resolve_thinking
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -58,7 +59,8 @@ if TYPE_CHECKING:
|
|||||||
TelemetryRecorder,
|
TelemetryRecorder,
|
||||||
Transport,
|
Transport,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile, ThinkingCapability
|
from polygateway.providers import ProviderProfile
|
||||||
|
from polygateway.thinking import ThinkingCapability
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
||||||
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
||||||
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
||||||
|
|
||||||
|
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理,
|
||||||
|
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -10,8 +13,6 @@ from dataclasses import dataclass
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderProfile:
|
class ProviderProfile:
|
||||||
@@ -87,144 +88,6 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ThinkingUnsupportedError(ValueError):
|
|
||||||
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
|
||||||
|
|
||||||
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
|
||||||
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
|
||||||
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
|
||||||
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
|
||||||
错误误贴成"推理开关无法满足")。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ThinkingCapability:
|
|
||||||
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
|
||||||
|
|
||||||
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
|
||||||
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
|
||||||
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
|
||||||
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
|
||||||
|
|
||||||
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
|
||||||
"""
|
|
||||||
|
|
||||||
can_disable: bool
|
|
||||||
evidence: str
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
|
||||||
{
|
|
||||||
"MiniMax-M3": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
|
|
||||||
),
|
|
||||||
"MiniMax-M2.7": ThinkingCapability(
|
|
||||||
can_disable=False,
|
|
||||||
evidence=(
|
|
||||||
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
|
||||||
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"MiniMax-M2.5": ThinkingCapability(
|
|
||||||
can_disable=False,
|
|
||||||
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
|
||||||
),
|
|
||||||
"qwen3.7-plus": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
|
||||||
),
|
|
||||||
"deepseek-v4-pro": ThinkingCapability(
|
|
||||||
can_disable=True,
|
|
||||||
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_capability(
|
|
||||||
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
|
||||||
) -> ThinkingCapability | None:
|
|
||||||
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
|
||||||
|
|
||||||
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
|
||||||
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
|
||||||
"""
|
|
||||||
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
|
||||||
|
|
||||||
|
|
||||||
def register_capability(
|
|
||||||
model: str,
|
|
||||||
capability: ThinkingCapability,
|
|
||||||
*,
|
|
||||||
base: Mapping[str, ThinkingCapability] | None = None,
|
|
||||||
) -> dict[str, ThinkingCapability]:
|
|
||||||
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
|
||||||
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
|
||||||
table[model] = capability
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_thinking(
|
|
||||||
profile: ProviderProfile,
|
|
||||||
capability: ThinkingCapability | None,
|
|
||||||
enable_thinking: bool | None,
|
|
||||||
*,
|
|
||||||
model: str,
|
|
||||||
warn_unregistered: bool = True,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
|
||||||
|
|
||||||
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
|
||||||
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
|
||||||
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
|
||||||
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
|
||||||
|
|
||||||
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
|
||||||
`capability` 为 None(未登记)时无从从别处取得模型名。
|
|
||||||
|
|
||||||
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
|
||||||
调用再喊只会刷屏。判定结果不受此参数影响。
|
|
||||||
"""
|
|
||||||
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
|
||||||
if enable_thinking is None:
|
|
||||||
return {}
|
|
||||||
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
|
||||||
direction = "thinking_on" if enable_thinking else "thinking_off"
|
|
||||||
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
|
||||||
if slot is None:
|
|
||||||
raise ThinkingUnsupportedError(
|
|
||||||
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
|
||||||
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
|
||||||
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
|
||||||
)
|
|
||||||
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
|
||||||
if capability is None:
|
|
||||||
if warn_unregistered:
|
|
||||||
_warn_unregistered(model, profile, slot)
|
|
||||||
return slot
|
|
||||||
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
|
||||||
if enable_thinking is False and not capability.can_disable:
|
|
||||||
raise ThinkingUnsupportedError(
|
|
||||||
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
|
||||||
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
|
||||||
f"需要关闭思维链请换用支持关闭的模型"
|
|
||||||
)
|
|
||||||
return slot
|
|
||||||
|
|
||||||
|
|
||||||
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
|
||||||
logger.warning(
|
|
||||||
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
|
||||||
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
|
||||||
model,
|
|
||||||
profile.name,
|
|
||||||
dict(slot),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_provider(
|
def get_provider(
|
||||||
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
||||||
) -> ProviderProfile:
|
) -> ProviderProfile:
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。
|
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.providers import ProviderProfile
|
||||||
from polygateway.types import ThinkingObservation
|
from polygateway.types import ThinkingObservation
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +35,141 @@ def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> Thinking
|
|||||||
if reasoning_tokens is None:
|
if reasoning_tokens is None:
|
||||||
return ThinkingObservation.UNKNOWN
|
return ThinkingObservation.UNKNOWN
|
||||||
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingUnsupportedError(ValueError):
|
||||||
|
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
||||||
|
|
||||||
|
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
||||||
|
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
||||||
|
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
||||||
|
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
||||||
|
错误误贴成"推理开关无法满足")。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
||||||
|
|
||||||
|
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
||||||
|
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
||||||
|
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
||||||
|
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
||||||
|
|
||||||
|
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
||||||
|
"""
|
||||||
|
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"MiniMax-M3": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
|
||||||
|
),
|
||||||
|
"MiniMax-M2.7": ThinkingCapability(
|
||||||
|
can_disable=False,
|
||||||
|
evidence=(
|
||||||
|
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
||||||
|
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"MiniMax-M2.5": ThinkingCapability(
|
||||||
|
can_disable=False,
|
||||||
|
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
||||||
|
),
|
||||||
|
"qwen3.7-plus": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
||||||
|
),
|
||||||
|
"deepseek-v4-pro": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability(
|
||||||
|
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||||
|
) -> ThinkingCapability | None:
|
||||||
|
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
||||||
|
|
||||||
|
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
||||||
|
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
||||||
|
"""
|
||||||
|
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
||||||
|
|
||||||
|
|
||||||
|
def register_capability(
|
||||||
|
model: str,
|
||||||
|
capability: ThinkingCapability,
|
||||||
|
*,
|
||||||
|
base: Mapping[str, ThinkingCapability] | None = None,
|
||||||
|
) -> dict[str, ThinkingCapability]:
|
||||||
|
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||||
|
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
||||||
|
table[model] = capability
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
warn_unregistered: bool = True,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
||||||
|
|
||||||
|
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
||||||
|
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
||||||
|
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
||||||
|
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
||||||
|
|
||||||
|
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||||
|
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||||
|
|
||||||
|
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
||||||
|
调用再喊只会刷屏。判定结果不受此参数影响。
|
||||||
|
"""
|
||||||
|
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
||||||
|
if enable_thinking is None:
|
||||||
|
return {}
|
||||||
|
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
||||||
|
direction = "thinking_on" if enable_thinking else "thinking_off"
|
||||||
|
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
||||||
|
if slot is None:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
||||||
|
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
||||||
|
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
||||||
|
)
|
||||||
|
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
||||||
|
if capability is None:
|
||||||
|
if warn_unregistered:
|
||||||
|
_warn_unregistered(model, profile, slot)
|
||||||
|
return slot
|
||||||
|
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
||||||
|
if enable_thinking is False and not capability.can_disable:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
||||||
|
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
||||||
|
f"需要关闭思维链请换用支持关闭的模型"
|
||||||
|
)
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
||||||
|
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
||||||
|
model,
|
||||||
|
profile.name,
|
||||||
|
dict(slot),
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,15 +22,14 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.providers import (
|
from polygateway.providers import ProviderProfile, get_provider
|
||||||
ProviderProfile,
|
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||||
|
from polygateway.thinking import (
|
||||||
ThinkingCapability,
|
ThinkingCapability,
|
||||||
ThinkingUnsupportedError,
|
ThinkingUnsupportedError,
|
||||||
get_capability,
|
get_capability,
|
||||||
get_provider,
|
|
||||||
resolve_thinking,
|
resolve_thinking,
|
||||||
)
|
)
|
||||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
|
||||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
|
from polygateway.thinking import DEFAULT_CAPABILITIES, get_capability
|
||||||
|
|
||||||
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
|||||||
@@ -49,3 +49,28 @@ def test_telemetry_status_exported():
|
|||||||
assert "TelemetryStatus" in polygateway.__all__
|
assert "TelemetryStatus" in polygateway.__all__
|
||||||
assert polygateway.TelemetryStatus is not None
|
assert polygateway.TelemetryStatus is not None
|
||||||
assert "TelemetryStatusProvider" not in polygateway.__all__
|
assert "TelemetryStatusProvider" not in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_public_surface_exported():
|
||||||
|
"""issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。
|
||||||
|
|
||||||
|
搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类
|
||||||
|
深路径 import。给下游一个稳定引用点,是以后再重组不再破坏下游的前提——本库
|
||||||
|
的约定是「顶层导出即公共 API 面」。
|
||||||
|
|
||||||
|
`observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部
|
||||||
|
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
|
||||||
|
永久承诺。
|
||||||
|
"""
|
||||||
|
for name in (
|
||||||
|
"ThinkingCapability",
|
||||||
|
"ThinkingObservation",
|
||||||
|
"ThinkingUnsupportedError",
|
||||||
|
"get_capability",
|
||||||
|
"register_capability",
|
||||||
|
"resolve_thinking",
|
||||||
|
):
|
||||||
|
assert hasattr(polygateway, name), name
|
||||||
|
assert name in polygateway.__all__, name
|
||||||
|
assert "observe_thinking" not in polygateway.__all__
|
||||||
|
assert "reconcile_thinking" not in polygateway.__all__
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from polygateway.providers import (
|
from polygateway.providers import (
|
||||||
DEFAULT_CAPABILITIES,
|
|
||||||
DEFAULT_PROFILES,
|
DEFAULT_PROFILES,
|
||||||
ProviderProfile,
|
ProviderProfile,
|
||||||
ThinkingCapability,
|
|
||||||
get_capability,
|
|
||||||
get_provider,
|
get_provider,
|
||||||
register_capability,
|
|
||||||
register_provider,
|
register_provider,
|
||||||
resolve_thinking,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,83 +69,3 @@ class TestPureFunctionRegistration:
|
|||||||
def test_default_profiles_mapping_is_read_only(self):
|
def test_default_profiles_mapping_is_read_only(self):
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
def _warnings():
|
|
||||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
|
||||||
messages: list[str] = []
|
|
||||||
sink_id = logger.add(messages.append, level="WARNING")
|
|
||||||
return messages, sink_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestThinkingCapability:
|
|
||||||
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
|
||||||
|
|
||||||
def test_registered_models_carry_evidence(self):
|
|
||||||
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
|
||||||
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
|
||||||
cap = get_capability(model)
|
|
||||||
assert cap is not None and cap.evidence.strip()
|
|
||||||
|
|
||||||
def test_m3_can_disable_but_m2x_cannot(self):
|
|
||||||
assert get_capability("MiniMax-M3").can_disable is True
|
|
||||||
assert get_capability("MiniMax-M2.7").can_disable is False
|
|
||||||
assert get_capability("MiniMax-M2.5").can_disable is False
|
|
||||||
|
|
||||||
def test_unregistered_model_is_unknown(self):
|
|
||||||
assert get_capability("some-brand-new-model") is None
|
|
||||||
|
|
||||||
def test_register_capability_is_pure(self):
|
|
||||||
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
|
||||||
assert get_capability("x-1", table=table) is not None
|
|
||||||
assert get_capability("x-1") is None # 默认表未被污染
|
|
||||||
|
|
||||||
def test_default_capabilities_mapping_is_read_only(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveThinking:
|
|
||||||
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
|
||||||
|
|
||||||
def test_rule1_none_injects_nothing(self):
|
|
||||||
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
|
||||||
assert got == {}
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enable", [True, False])
|
|
||||||
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
|
||||||
with pytest.raises(ValueError, match="register_provider") as exc:
|
|
||||||
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
|
||||||
assert "extra_body" in str(exc.value)
|
|
||||||
|
|
||||||
def test_rule3_unregistered_model_warns_but_passes(self):
|
|
||||||
messages, sink_id = _warnings()
|
|
||||||
try:
|
|
||||||
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
|
||||||
finally:
|
|
||||||
logger.remove(sink_id)
|
|
||||||
assert got == {"reasoning_effort": "none"}
|
|
||||||
assert any("MiniMax-M9" in m for m in messages)
|
|
||||||
|
|
||||||
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
|
||||||
cap = get_capability("MiniMax-M2.7")
|
|
||||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
|
||||||
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
|
||||||
|
|
||||||
def test_rule4_only_blocks_the_off_direction(self):
|
|
||||||
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
|
||||||
cap = get_capability("MiniMax-M2.7")
|
|
||||||
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
|
||||||
assert got == {"reasoning_effort": "medium"}
|
|
||||||
|
|
||||||
def test_rule5_normal_path(self):
|
|
||||||
cap = get_capability("MiniMax-M3")
|
|
||||||
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
|
||||||
"reasoning_effort": "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_unknown_shape_beats_capability_check(self):
|
|
||||||
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
|
||||||
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
|
||||||
with pytest.raises(ValueError, match="register_provider"):
|
|
||||||
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
|
||||||
|
|||||||
@@ -6,11 +6,27 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.thinking import observe_thinking
|
from polygateway.providers import get_provider
|
||||||
|
from polygateway.thinking import (
|
||||||
|
DEFAULT_CAPABILITIES,
|
||||||
|
ThinkingCapability,
|
||||||
|
get_capability,
|
||||||
|
observe_thinking,
|
||||||
|
register_capability,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
from polygateway.types import ThinkingObservation
|
from polygateway.types import ThinkingObservation
|
||||||
|
|
||||||
|
|
||||||
|
def _warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
return messages, sink_id
|
||||||
|
|
||||||
|
|
||||||
class TestObserveThinking:
|
class TestObserveThinking:
|
||||||
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
|
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
|
||||||
|
|
||||||
@@ -74,3 +90,76 @@ def test_unknown_strings_are_rejected(bogus):
|
|||||||
"""非法值必须抛 ValueError: 缓存回放靠它把污染数据挡成"未命中"(设计 §6)。"""
|
"""非法值必须抛 ValueError: 缓存回放靠它把污染数据挡成"未命中"(设计 §6)。"""
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
ThinkingObservation(bogus)
|
ThinkingObservation(bogus)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingCapability:
|
||||||
|
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
||||||
|
|
||||||
|
def test_registered_models_carry_evidence(self):
|
||||||
|
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
||||||
|
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
||||||
|
cap = get_capability(model)
|
||||||
|
assert cap is not None and cap.evidence.strip()
|
||||||
|
|
||||||
|
def test_m3_can_disable_but_m2x_cannot(self):
|
||||||
|
assert get_capability("MiniMax-M3").can_disable is True
|
||||||
|
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||||
|
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||||
|
|
||||||
|
def test_unregistered_model_is_unknown(self):
|
||||||
|
assert get_capability("some-brand-new-model") is None
|
||||||
|
|
||||||
|
def test_register_capability_is_pure(self):
|
||||||
|
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
||||||
|
assert get_capability("x-1", table=table) is not None
|
||||||
|
assert get_capability("x-1") is None # 默认表未被污染
|
||||||
|
|
||||||
|
def test_default_capabilities_mapping_is_read_only(self):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveThinking:
|
||||||
|
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
||||||
|
|
||||||
|
def test_rule1_none_injects_nothing(self):
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
||||||
|
assert got == {}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("enable", [True, False])
|
||||||
|
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
||||||
|
with pytest.raises(ValueError, match="register_provider") as exc:
|
||||||
|
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
||||||
|
assert "extra_body" in str(exc.value)
|
||||||
|
|
||||||
|
def test_rule3_unregistered_model_warns_but_passes(self):
|
||||||
|
messages, sink_id = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert got == {"reasoning_effort": "none"}
|
||||||
|
assert any("MiniMax-M9" in m for m in messages)
|
||||||
|
|
||||||
|
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
||||||
|
cap = get_capability("MiniMax-M2.7")
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
||||||
|
|
||||||
|
def test_rule4_only_blocks_the_off_direction(self):
|
||||||
|
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
||||||
|
cap = get_capability("MiniMax-M2.7")
|
||||||
|
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
||||||
|
assert got == {"reasoning_effort": "medium"}
|
||||||
|
|
||||||
|
def test_rule5_normal_path(self):
|
||||||
|
cap = get_capability("MiniMax-M3")
|
||||||
|
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
||||||
|
"reasoning_effort": "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_unknown_shape_beats_capability_check(self):
|
||||||
|
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
||||||
|
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
||||||
|
|||||||
Reference in New Issue
Block a user