diff --git a/CHANGELOG.md b/CHANGELOG.md index baa7f95..175a6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 未发布(issue #5 + #6) + +推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。 + +### 行为变更(**请先读这一条**) + +- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none`、`True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。 +- **`MiniMax-M2.7` / `MiniMax-M2.5` 配 `ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。 +- **`provider=openai` 的源配任何非 `None` 的 `ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。 +- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。 + +### 新增 + +- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。 +- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。 +- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。 +- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。 + +### 下游请读 + +- **`reasoning_tokens` 的 `None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。 +- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`。 +- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。 +- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。 +- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。 + ## 1.0.5(2026-07-31) 采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。 diff --git a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md index 594350d..b281a81 100644 --- a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md +++ b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md @@ -18,7 +18,7 @@ date: 2026-08-02 | 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) | | 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 | | 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" | -| 判据 | 首选 `usage.completion_tokens_details.reasoning_tokens`;该字段缺失时以 `completion_tokens` 兜底(关闭推理应 <30,推理中 >150) | +| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) | | 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 | **方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。 @@ -71,6 +71,21 @@ date: 2026-08-02 **结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。 +### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮) + +初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪: + +| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` | +|---|---|---| +| 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` | +| 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 | + +**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。 + +而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。 + +另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。 + ### 2.4 M3 的稳定性 同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。 @@ -177,3 +192,4 @@ LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temp 4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。 5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。 6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。 +7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。 diff --git a/research-wiki/index.md b/research-wiki/index.md index ca16d39..3bbb1e8 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-02 09:49 UTC +> 自动生成,更新时间:2026-08-02 10:55 UTC ## design (21) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -59,7 +59,7 @@ - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## schema (1) -- [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls` +- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` diff --git a/research-wiki/log.md b/research-wiki/log.md index 3d481c2..0618779 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -82,3 +82,5 @@ - [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design - [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability) - [2026-08-02 09:49 UTC] 重建索引: 53 篇页面 +- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面 +- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测) diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index 236478e..9c3ee5d 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -1,11 +1,11 @@ --- type: schema node_id: schema:llm-calls -title: "表结构: llm_calls(遥测 21 字段)" +title: "表结构: llm_calls(遥测 22 字段)" date: 2026-07-20 --- -# 表结构: llm_calls(遥测 21 字段) +# 表结构: llm_calls(遥测 22 字段) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) @@ -27,6 +27,7 @@ date: 2026-07-20 | cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | | sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 | +| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 | ## usage/成本口径(2026-07-30,est_tokens 解耦) @@ -53,6 +54,8 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; ## 采样参数口径(2026-07-31,issue #4) +`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。 + `sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。 三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同: diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py new file mode 100644 index 0000000..9e2eb33 --- /dev/null +++ b/tests/e2e/test_thinking_live.py @@ -0,0 +1,385 @@ +"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。 + +本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径, +验证不了"这个参数在这个模型上到底关没关掉推理"。 + +两条判据纪律(来自 findings §4c 的实测教训): + +1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出 + 长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文), + 开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。 + 而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None, + 开启 15/15 大于 0。 +2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的 + `prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧 + token 数随之变大。这是相对比较,不硬编码任何具体数值。 +3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回 + usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c), + 开启方向因此可能偶尔观测不到;关闭方向不受影响。 + +源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 +""" + +import dataclasses +import json +import os +from collections import Counter +from datetime import datetime +from pathlib import Path + +import pytest +from dotenv import dotenv_values + +from polygateway import GatewayClient, GatewaySettings +from polygateway.errors import RequestRejectedError +from polygateway.providers import DEFAULT_CAPABILITIES, get_capability + +_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) + +pytestmark = pytest.mark.skipif( + not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)" +) + +_OUT_DIR = Path("tests/outputs/e2e") +_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10")) + +# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数, +# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里 +_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。" + +_ON_MIN_COMPLETION = 100 +"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。""" + +_ROWS: list[dict] = [] + +# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。 +# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是 +# 在 L8 里被"源不可用"这个假理由吞掉 +_MODEL_PROVIDER = { + "MiniMax-M3": "minimax", + "MiniMax-M2.7": "minimax", + "MiniMax-M2.5": "minimax", + "qwen3.7-plus": "qwen", + "deepseek-v4-pro": "deepseek", +} + + +def _base_settings() -> GatewaySettings: + # 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次 + # 变成对第一轮的回放,整组判据随之失效 + return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"}) + + +def _settings(**source_overrides) -> GatewaySettings: + base = _base_settings() + source = dataclasses.replace(base.sources[0], **source_overrides) + return dataclasses.replace(base, sources=(source,)) + + +async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]: + """跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。""" + client = GatewayClient.from_settings(_settings(**source_overrides)) + observations = [] + try: + for i in range(rounds): + resp = await client.chat( + [{"role": "user", "content": _PROMPT}], + stream=stream, + # 每轮独立 salt: 即便某层缓存意外开着也不会回放 + cache_salt=f"thinking-live-{i}", + ) + observations.append( + { + "round": i + 1, + "prompt_tokens": resp.prompt_tokens, + "completion_tokens": resp.completion_tokens, + "reasoning_tokens": resp.reasoning_tokens, + "content": resp.content[:60], + } + ) + finally: + await client.aclose() + return observations + + +def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None: + _ROWS.append( + { + "matrix": matrix_id, + "desc": desc, + "status": status, + "detail": detail, + "observations": observations or [], + } + ) + + +def _reasoning_off(obs: dict) -> bool: + """关闭方向: 只看 reasoning_tokens。 + + **刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做 + "只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会 + 把这种正常波动误判成"没关掉"。 + """ + return obs["reasoning_tokens"] in (None, 0) + + +def _reasoning_on(obs: dict) -> bool: + """开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。 + + 不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大 + (实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。 + 仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。 + """ + reasoning = obs["reasoning_tokens"] + if reasoning is not None: + return reasoning > 0 + return obs["completion_tokens"] > _ON_MIN_COMPLETION + + +def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str): + """源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。""" + _record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200]) + pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}") + + +@pytest.fixture(scope="module", autouse=True) +def _write_report(): + yield + _OUT_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = _OUT_DIR / f"test_thinking_live_{ts}.md" + lines = [ + "# 推理开关与 reasoning_tokens 真实 API 验证", + "", + f"- 时间: {ts}", + f"- 每档轮数: {_ROUNDS}", + "- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限" + "(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)", + f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})", + "- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数", + "", + "## 矩阵结论", + "", + "| 矩阵 | 场景 | 结论 | 说明 |", + "|---|---|---|---|", + ] + total_calls = 0 + for row in _ROWS: + detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160] + lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |") + total_calls += len(row["observations"]) + lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""] + for row in _ROWS: + if not row["observations"]: + continue + lines += [f"### {row['matrix']} — {row['desc']}", "", "```json"] + lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2)) + lines += ["```", ""] + uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")] + if uncovered: + lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""] + path.write_text("\n".join(lines), encoding="utf-8") + print(f"\n[e2e 报告] {path}") + + +class TestMiniMaxM3: + """M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。""" + + async def test_l1_disable_actually_disables(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False) + offs = [o for o in obs if _reasoning_off(o)] + _record( + "L1", + "enable_thinking=False(流式)", + "PASS" if len(offs) == len(obs) else "FAIL", + f"{len(offs)}/{len(obs)} 轮确认未推理", + obs, + ) + assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}" + + async def test_l2_enable_actually_enables(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True) + ons = [o for o in obs if _reasoning_on(o)] + _record( + "L2", + "enable_thinking=True(流式,注入 medium)", + "PASS" if len(ons) * 2 > len(obs) else "FAIL", + f"{len(ons)}/{len(obs)} 轮观察到推理", + obs, + ) + assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}" + + async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self): + """确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。 + + 供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是 + 本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值, + 供应商改模板也不会让它假红。 + """ + rounds = max(3, _ROUNDS // 3) + off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False) + on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True) + off_max = max(o["prompt_tokens"] for o in off) + on_min = min(o["prompt_tokens"] for o in on) + _record( + "L2b", + "关闭/开启的 prompt_tokens 可分", + "PASS" if off_max < on_min else "FAIL", + f"关闭档最大 {off_max} < 开启档最小 {on_min}", + off + on, + ) + assert off_max < on_min, ( + f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型" + ) + + async def test_l3_no_opinion_is_the_model_default(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None) + _record("L3", "enable_thinking=None(不干预,基线)", "PASS", "仅记录基线,不断言方向", obs) + assert len(obs) == _ROUNDS + + async def test_l4_extra_body_overrides_the_profile(self): + """profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。 + + 判据是行为而非报文: 若 extra_body 没赢,拿到的就是 none 的结果(不推理)。 + """ + rounds = max(3, _ROUNDS // 2) + obs = await _run_rounds( + rounds, + model="MiniMax-M3", + enable_thinking=False, + extra_body={"reasoning_effort": "high"}, + ) + ons = [o for o in obs if _reasoning_on(o)] + _record( + "L4", + "extra_body 覆盖 profile 注入", + "PASS" if len(ons) * 2 > len(obs) else "FAIL", + f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)", + obs, + ) + assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}" + + async def test_l5_non_stream_path_matches_stream(self): + """非流式快路径独立于流式实现,采集与注入都要各自验一遍。""" + rounds = max(3, _ROUNDS // 2) + off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False) + on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True) + offs = [o for o in off if _reasoning_off(o)] + ons = [o for o in on if _reasoning_on(o)] + ok = len(offs) == len(off) and len(ons) * 2 > len(on) + _record( + "L5", + "非流式路径重跑 L1/L2", + "PASS" if ok else "FAIL", + f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮", + off + on, + ) + assert len(offs) == len(off), f"非流式关闭方向未满足: {off}" + assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}" + + +class TestOtherProviders: + """qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。""" + + @pytest.mark.parametrize( + ("matrix", "provider", "model"), + [("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")], + ) + async def test_existing_profiles_still_disable(self, matrix, provider, model): + desc = f"{provider} enable_thinking=False" + try: + obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False) + except Exception as exc: # 渠道未开通/下线: 记为未覆盖 + _skip_if_unreachable(exc, matrix, desc) + offs = [o for o in obs if _reasoning_off(o)] + _record( + matrix, + desc, + "PASS" if len(offs) == len(obs) else "FAIL", + f"{len(offs)}/{len(obs)} 轮确认未推理", + obs, + ) + assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}" + + +class TestCapabilityDrift: + """L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。""" + + def test_every_capability_has_a_provider_mapping(self): + """能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。""" + missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER)) + assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}" + + @pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES)) + async def test_declared_capability_matches_reality(self, model): + cap = get_capability(model) + provider = _MODEL_PROVIDER[model] + rounds = max(3, _ROUNDS // 2) + desc = f"{model} 声明 can_disable={cap.can_disable}" + if not cap.can_disable: + # 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用) + with pytest.raises(ValueError, match=model): + GatewayClient.from_settings( + _settings(provider=provider, model=model, enable_thinking=False) + ) + _record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致") + return + try: + obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False) + except Exception as exc: + _skip_if_unreachable(exc, "L8", desc) + offs = [o for o in obs if _reasoning_off(o)] + verdict = Counter(_reasoning_off(o) for o in obs) + _record( + "L8", + desc, + "PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)", + f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭", + obs, + ) + assert len(offs) == len(obs), ( + f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES" + ) + + +class TestAssemblyGuardAgainstRealConfig: + """L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。""" + + def test_l9_m27_rejected_at_assembly(self): + with pytest.raises(ValueError, match="MiniMax-M2.7"): + GatewayClient.from_settings( + _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) + ) + _record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求") + + def test_l9_unknown_shape_rejected_at_assembly(self): + with pytest.raises(ValueError, match="register_provider"): + GatewayClient.from_settings( + _settings(provider="openai", model="kimi-k3", enable_thinking=False) + ) + _record("L9", "provider=openai 形态未知", "PASS", "装配期报错并指路") + + async def test_transport_layer_rejects_when_guard_is_bypassed(self): + """构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。""" + settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) + client = GatewayClient.from_settings( + dataclasses.replace( + settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),) + ) + ) + try: + # 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport + bad = dataclasses.replace(settings.sources[0], enable_thinking=False) + with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"): + await client._terminal._transport.complete( + messages=[{"role": "user", "content": _PROMPT}], + source=bad, + stream=True, + overlay={}, + call_id="e2e-guard", + ) + finally: + await client.aclose() + _record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")