419 lines
31 KiB
Markdown
419 lines
31 KiB
Markdown
# 实现计划: 推理档位一等化
|
|
|
|
- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准)
|
|
- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。
|
|
- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。
|
|
- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。
|
|
- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。
|
|
|
|
---
|
|
|
|
## 文件结构
|
|
|
|
| 文件 | 动作 | 职责 |
|
|
|---|---|---|
|
|
| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 |
|
|
| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 |
|
|
| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 |
|
|
| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 |
|
|
| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 |
|
|
| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 |
|
|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 |
|
|
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) |
|
|
| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 |
|
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 |
|
|
| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 |
|
|
| `src/polygateway/__init__.py` | 修改 | 导出 `Effort`、`ThinkingWire` |
|
|
| `.env.example` | 修改 | 两个新键的模板与注释 |
|
|
| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 |
|
|
| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 |
|
|
| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 |
|
|
| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 |
|
|
| `tests/unit/test_telemetry.py`、`tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 |
|
|
| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) |
|
|
|
|
---
|
|
|
|
## 关键接口(跨任务消费,此处定稿)
|
|
|
|
```python
|
|
# types.py
|
|
class Effort(StrEnum):
|
|
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
|
|
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
|
|
|
|
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
|
|
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
|
|
```
|
|
|
|
```python
|
|
# providers.py
|
|
@dataclass(frozen=True)
|
|
class ThinkingWire:
|
|
off: Mapping[str, Any] | None
|
|
on_base: Mapping[str, Any] | None
|
|
effort_key: str | None
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderProfile:
|
|
name: str
|
|
thinking: ThinkingWire
|
|
strip_think_tags: bool
|
|
supports_native_schema: bool = False
|
|
```
|
|
|
|
```python
|
|
# thinking.py
|
|
@dataclass(frozen=True)
|
|
class ThinkingCapability:
|
|
supported_efforts: tuple[Effort, ...]
|
|
evidence: str
|
|
|
|
@property
|
|
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
|
|
@property
|
|
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
|
|
@property
|
|
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
|
|
|
|
@dataclass(frozen=True)
|
|
class ThinkingResolution:
|
|
"""注入片段 + **实际**生效档。
|
|
|
|
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
|
|
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
|
|
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
|
|
"""
|
|
payload: Mapping[str, Any]
|
|
applied_effort: Effort | None # Phase 1(不表态)为 None
|
|
|
|
def resolve_thinking(
|
|
profile: ProviderProfile,
|
|
capability: ThinkingCapability | None,
|
|
effort: Effort | None,
|
|
*,
|
|
model: str,
|
|
fallback: str = "error", # "error" | "nearest"
|
|
warn_unregistered: bool = True,
|
|
) -> ThinkingResolution: ...
|
|
|
|
def reconcile_thinking(
|
|
*,
|
|
effort: Effort | None,
|
|
observation: ThinkingObservation,
|
|
capability: ThinkingCapability | None,
|
|
model: str,
|
|
) -> str | None: ...
|
|
```
|
|
|
|
```python
|
|
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
|
|
# SourceConfig: reasoning_effort: Effort | None = None
|
|
# effort_fallback: str = "error"
|
|
# ChatRequest: reasoning_effort: Effort | None = None
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1 — `Effort` 词汇与能力表重构
|
|
|
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改)
|
|
|
|
**行为**:
|
|
1. `types.py` 新增 `Effort` 与 `_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。
|
|
2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`。
|
|
3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。
|
|
4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py` 的 `ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py` 读 `can_disable` 处确认派生属性可用。
|
|
5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
|
|
|
|
**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头):
|
|
|
|
| model | supported_efforts |
|
|
|---|---|
|
|
| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` |
|
|
| `glm-5.2` | `(NONE, HIGH, MAX)` |
|
|
| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` |
|
|
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` |
|
|
| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` |
|
|
| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` |
|
|
| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` |
|
|
| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` |
|
|
| `MiniMax-M3` | `(NONE, AUTO)` |
|
|
| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` |
|
|
| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` |
|
|
|
|
`claude-haiku-5`、`gemini-3-flash`、`kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
|
|
|
|
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort` 对 `(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered` 对 `(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)` 与 `(AUTO,)` 为假;空元组构造报 `ValueError`。
|
|
|
|
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable`、`::test_cheapest_effort_skips_none`、`::test_is_tiered_excludes_none_and_auto`、`::test_empty_efforts_rejected`。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort` 落 `types.py` 不得产生反向依赖,设计 §13 第 6 条)
|
|
|
|
- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it`
|
|
|
|
---
|
|
|
|
## Task 2 — `ThinkingWire` 与 8 段 provider 表
|
|
|
|
**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改)
|
|
|
|
**行为**:
|
|
1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
|
|
2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`。
|
|
3. `DEFAULT_PROFILES` 由 4 段扩到 8 段:
|
|
|
|
| provider | off | on_base | effort_key |
|
|
|---|---|---|---|
|
|
| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` |
|
|
| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
|
| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
|
| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
|
|
| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
|
| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
|
| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
|
| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
|
|
|
|
`__init__.py` 同步导出 `ThinkingWire`。`openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`。
|
|
|
|
**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。
|
|
|
|
**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered`、`::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS
|
|
|
|
- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own`
|
|
|
|
---
|
|
|
|
## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射
|
|
|
|
**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改)
|
|
|
|
**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。
|
|
|
|
| Phase | 条件 | 结果 |
|
|
|---|---|---|
|
|
| 1 | `effort is None` | 返回 `{}` |
|
|
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
|
|
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
|
|
| 4 | `effort is NONE` 且 `not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
|
|
| 5 | `effort not in supported_efforts` 且 `fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
|
|
|
|
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
|
|
|
|
**注入形态**:
|
|
- `effort is NONE` → `wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。
|
|
- `effort is AUTO` → `wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。
|
|
- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。
|
|
|
|
**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER` 在 `supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
|
|
|
|
**验收**: 五关各自触发与不触发;`medium` 在 `(LOW, HIGH, MAX)` 上 `nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。
|
|
|
|
**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing`、`::test_phase2_unknown_wire_points_to_register`、`::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model`、`::test_phase5_says_toggle_only_for_switch_model`。
|
|
另: `::test_nearest_ties_go_cheaper`、`::test_none_never_maps`、`::test_auto_injects_on_base_only`、`::test_effort_key_none_rejects_tier`、`::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
|
|
|
|
- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have`
|
|
|
|
---
|
|
|
|
## Task 4 — 源级配置入口
|
|
|
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改)
|
|
|
|
**行为**:
|
|
1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None` 与 `effort_fallback: str = "error"`。
|
|
2. `config.py` 的 `_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")`、`"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。
|
|
3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。
|
|
4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking` 与 `reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True` 且 `reasoning_effort is NONE`;或 `enable_thinking is False` 且 `reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。
|
|
5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。
|
|
|
|
**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。
|
|
|
|
**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed`、`::test_invalid_effort_lists_vocabulary`、`::test_contradictory_thinking_flags_rejected`、`::test_consistent_flags_allowed`。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS
|
|
|
|
- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself`
|
|
|
|
---
|
|
|
|
## Task 5 — 请求级入口与优先级
|
|
|
|
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
|
|
|
|
**行为**:
|
|
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`。
|
|
2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`。
|
|
3. 新增纯函数(放 `thinking.py`,与其余推理决策同处):
|
|
|
|
```python
|
|
def effective_effort(
|
|
*, request_effort: Effort | None, source_effort: Effort | None,
|
|
enable_thinking: bool | None,
|
|
) -> Effort | None:
|
|
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
|
|
```
|
|
|
|
语法糖映射: `True` → `Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False` → `Effort.NONE`;`None` → 不表态。
|
|
|
|
**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。
|
|
|
|
**测试**(先失败后通过): `::test_request_effort_wins_over_source`、`::test_none_request_does_not_clear_source`、`::test_enable_thinking_true_is_auto`、`::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
|
|
|
- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to`
|
|
|
|
---
|
|
|
|
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
|
|
|
|
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
|
|
|
|
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
|
|
|
|
**行为**:
|
|
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
|
|
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
|
|
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`。
|
|
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72`、`tests/unit/test_backpressure.py:213`、`tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
|
|
|
|
**不动**: `EmbeddingTransport`、`OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
|
|
|
|
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
|
|
|
|
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort` 与 `ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
|
|
|
|
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
|
|
|
|
---
|
|
|
|
## Task 6 — 缓存 key
|
|
|
|
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
|
|
|
|
**行为**:
|
|
1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
|
|
2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`。
|
|
3. `CacheMW.__call__` 传 `request.reasoning_effort`。
|
|
|
|
**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
|
|
|
|
**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
|
|
|
|
**测试**(先失败后通过): `::test_request_tier_changes_key`、`::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS
|
|
|
|
- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled`
|
|
|
|
---
|
|
|
|
## Task 7 — 遥测新增 `reasoning_effort` 列
|
|
|
|
**文件**: `src/polygateway/telemetry/schema.py`、`src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
|
|
|
|
**行为**:
|
|
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
|
|
2. `ports.py`: `record_llm_call` 加 `reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
|
|
3. 两个 recorder 落库新列。
|
|
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt` 增 `applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407` 与 `ocr.py:451` 传 `None`(无推理语义)。三个 emit 入口取值口径分列:
|
|
|
|
| 入口 | 取值 | 理由 |
|
|
|---|---|---|
|
|
| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
|
|
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
|
|
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
|
|
|
|
与 `sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。
|
|
5. 值为 `Effort` 时取 `.value` 落库,`None` 落 `NULL`——与 `thinking_observation` 同一先例(`StrEnum` 是 `str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
|
|
|
|
**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。
|
|
|
|
**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier`、`::test_cache_hit_records_request_tier_only`、`::test_absent_tier_is_null`。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS
|
|
|
|
- [ ] 提交: `feat: record which tier a call actually ran at`
|
|
|
|
---
|
|
|
|
## Task 8 — transport 接线与对账
|
|
|
|
**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改)
|
|
|
|
**行为**:
|
|
1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body` 与 `overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。
|
|
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
|
|
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
|
|
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
|
|
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
|
|
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
|
|
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
|
|
- `RetryMW` 在 `retry.py:375` 的 `TransportResult → LLMResponse` 转换处带上该字段。
|
|
|
|
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。
|
|
|
|
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected`、`::test_no_retry_on_tier_error`、`::test_throttle_key_separates_tiers`、`::test_reconcile_none_vs_observed`、`::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
|
|
|
|
- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct`
|
|
|
|
---
|
|
|
|
## Task 9 — 全套件、文档与 wiki
|
|
|
|
**文件**: `CHANGELOG.md`、`.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号)
|
|
|
|
**行为**:
|
|
1. `make lint` + `make test` 全绿;`make format`。
|
|
2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。
|
|
3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
|
|
4. 版本号 `1.4.0`(minor: 公共 API 破坏性变更),两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。
|
|
|
|
**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
|
|
|
|
**验证**: `conda run -n PolyGateway make ci` → PASS
|
|
|
|
- [ ] 提交: `docs: cut 1.4.0 notes for the tier work`
|
|
|
|
---
|
|
|
|
## Task 10 — e2e 实测校正初始能力表(标 `slow`)
|
|
|
|
**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
|
|
|
|
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
|
|
|
|
**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
|
|
|
|
**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。
|
|
|
|
**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关)
|
|
|
|
- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does`
|
|
|
|
---
|
|
|
|
## 执行顺序与依赖
|
|
|
|
```
|
|
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
|
|
T2(wire) ─────────┘ ↑
|
|
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
|
|
│ │
|
|
└─→ T6(缓存 key) ↓
|
|
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
|
|
```
|
|
|
|
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。
|
|
|
|
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。
|