Skip to content

fix(core): strip reasoning_content from Cerebras requests - #11049

Merged
wenshao merged 2 commits into
mainfrom
fix/issue-11045-cerebras-reasoning-content
Sep 5, 2026
Merged

fix(core): strip reasoning_content from Cerebras requests#11049
wenshao merged 2 commits into
mainfrom
fix/issue-11045-cerebras-reasoning-content

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a Cerebras provider that removes the non-standard messages[].reasoning_content field at the outbound request boundary — the same treatment Mistral already receives — and detects it by hostname (api.cerebras.ai and subdomains) in determineProvider(). The existing stripReasoningContent helper moves from mistral.ts to the shared provider/utils.ts unchanged, so both providers strip through one implementation. Session history itself is never mutated.

Why it's needed

Cerebras' OpenAI-compatible endpoint rejects reasoning_content on input with HTTP 400 (wrong_api_format: messages.N.assistant.reasoning_content ... is unsupported). The history converter writes that field on every assistant turn that carried reasoning parts, and determineProvider() has no Cerebras branch, so api.cerebras.ai falls through to DefaultOpenAICompatibleProvider, which ships the field verbatim. Result: the first turn succeeds, then every multi-turn request — including background subagents replaying session history — fails with 400 status code (no body), exactly as reported in #11045. The strip stays at the outbound request boundary, so DeepSeek/Qwen thinking-mode endpoints that require reasoning_content replayed are untouched.

Reviewer Test Plan

How to verify

packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts is red on main and green with this change. The multi-turn case starts a local OpenAI-compatible endpoint that 400s on any request body containing reasoning_content (returning the same validation payload as api.cerebras.ai): before the fix the follow-up turn fails with exactly 400 status code (no body); after the fix both turns succeed and the assistant message on the wire carries no reasoning_content. Unit cases additionally cover subdomain hosts, hostile hostnames (api.cerebras.ai.evil.example stays on the default provider), non-mutation of source history, and unchanged pass-through for non-Cerebras endpoints.

Commands: cd packages/core && npx vitest run src/core/openaiContentGenerator/ (22 files, 901 tests, all pass), npm run typecheck in packages/core (clean), npx eslint on the changed files (clean).

Evidence (Before & After)

Before (main @ 74fe3a65):

× multi-turn against a Cerebras-like strict endpoint (issue #11045) > replays reasoning history on follow-up turns without shipping reasoning_content
  → 400 status code (no body)
Test Files  1 failed (1)
     Tests  3 failed | 2 passed (5)

After (this branch):

✓ src/core/openaiContentGenerator/provider/cerebras.test.ts (5 tests)
Test Files  1 passed (1)
     Tests  5 passed (5)

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

vitest unit tests plus a local HTTP stand-in endpoint; npm workspaces install on Linux x64, Node v24.

Risk & Scope

  • Main risk or tradeoff: Cerebras sessions lose cross-turn thinking replay — the field is deleted rather than renamed to Cerebras' accepted reasoning input field. This matches the proven Mistral pattern and is strictly better than every multi-turn request failing; renaming can be a follow-up if maintainers prefer it. Detection is hostname-only because Cerebras serves third-party model names (qwen-3.8-27b, gpt-oss-120b, llama-*), so a model-name fallback would misroute other providers' models.
  • Not validated / out of scope: no real Cerebras key in the dev environment, so endpoint behavior is verified against a mock replicating the issue's validation payload; the secondary 400 status code (no body) error-message surfacing belongs in its own ticket per the triage thread.
  • Breaking changes / migration notes: none; the Mistral strip helper moved to provider/utils.ts unchanged.

Linked Issues

Fixes #11045

中文说明

这个 PR 做了什么

新增一个 Cerebras provider,在出站请求边界移除非标准的 messages[].reasoning_content 字段——与 Mistral 已有的处理方式一致——并在 determineProvider() 中按 hostname(api.cerebras.ai 及其子域名)识别。原有的 stripReasoningContent 辅助函数从 mistral.ts 原样移到共享的 provider/utils.ts,两个 provider 共用同一份实现。会话历史本身不会被改动。

为什么需要

Cerebras 的 OpenAI 兼容端点对输入中的 reasoning_content 一律返回 HTTP 400(wrong_api_formatmessages.N.assistant.reasoning_content ... is unsupported)。历史转换器会在每个带 reasoning part 的 assistant 轮次上写入该字段,而 determineProvider() 没有 Cerebras 分支,所以 api.cerebras.ai 落到 DefaultOpenAICompatibleProvider,原样把字段发出去。结果就是:第一轮成功,之后每一轮多轮请求(包括重放会话历史的后台 subagent)都报 400 status code (no body),与 #11045 描述完全一致。strip 只发生在出站请求边界,因此需要回传 reasoning_content 的 DeepSeek/Qwen 思考模式端点不受影响。

评审验证计划

如何验证

packages/core/src/core/openaiContentGenerator/provider/cerebras.test.tsmain 上是红的,打上本补丁后变绿。多轮用例会启动一个本地 OpenAI 兼容端点,凡是请求体含 reasoning_content 就返回 400(返回与 api.cerebras.ai 相同的校验报错):修复前第二轮恰好报 400 status code (no body);修复后两轮都成功,且发到线上的 assistant 消息不带 reasoning_content。单元用例还覆盖了子域名、恶意域名(api.cerebras.ai.evil.example 仍走默认 provider)、不改写源历史、以及非 Cerebras 端点行为不变。

命令:cd packages/core && npx vitest run src/core/openaiContentGenerator/(22 个文件、901 个测试全部通过),在 packages/core 下 npm run typecheck(干净),对改动文件跑 npx eslint(干净)。

前后证据

修复前(main @ 74fe3a65):

× multi-turn against a Cerebras-like strict endpoint (issue #11045) > replays reasoning history on follow-up turns without shipping reasoning_content
  → 400 status code (no body)
Test Files  1 failed (1)
     Tests  3 failed | 2 passed (5)

修复后(本分支):

✓ src/core/openaiContentGenerator/provider/cerebras.test.ts (5 tests)
Test Files  1 passed (1)
     Tests  5 passed (5)

测试环境

操作系统 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

vitest 单元测试 + 本地 HTTP 模拟端点;Linux x64 上 npm workspaces 安装,Node v24。

风险与范围

  • 主要风险或取舍:Cerebras 会话会丢失跨轮的 thinking 回传——字段被直接删除,而不是改名为 Cerebras 接受的 reasoning 输入字段。这与 Mistral 已验证的模式一致,且严格好于每一轮多轮请求都失败;如果 maintainer 倾向于改名,可以作为后续改进。识别只按 hostname,因为 Cerebras 托管的是第三方模型名(qwen-3.8-27bgpt-oss-120bllama-*),按模型名兜底会误路由其他 provider 的模型。
  • 未验证 / 超出范围:开发环境没有真实 Cerebras key,端点行为是用复刻 issue 校验报错的 mock 验证的;次要问题(400 status code (no body) 错误信息呈现)按分诊线程的结论另开 issue。
  • 破坏性变更 / 迁移说明:无;Mistral 的 strip 辅助函数只是原样移到 provider/utils.ts

关联 Issue

Fixes #11045

Cerebras' OpenAI-compatible endpoint rejects the non-standard
`messages[].reasoning_content` field on input with HTTP 400
(`wrong_api_format`). `determineProvider()` has no Cerebras branch, so
`api.cerebras.ai` falls through to the default provider, which ships
the field verbatim — every multi-turn request that replays a thinking
turn fails with `400 status code (no body)`.

Add a hostname-detected Cerebras provider subclass that removes
`reasoning_content` at the outbound request boundary only, matching
the existing Mistral handling. Session history stays intact, so
DeepSeek/Qwen thinking-mode replay (which requires the field) is
unaffected. The strip helper moves from `mistral.ts` to the shared
`provider/utils.ts` unchanged.

Detection is hostname-only: Cerebras serves third-party model names
(qwen-3.8-27b, gpt-oss-120b, llama-*), so a model-name fallback would
misroute other providers' models. Deleting the field rather than
renaming it to Cerebras' accepted `reasoning` input field matches the
proven Mistral pattern; the trade-off (no cross-turn thinking replay)
is the same already-accepted behavior Mistral has.

Fixes #11045

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtnduz2xjf
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on 6b0c8a3 did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— 6b0c8a3 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this one is easy to say yes to at the gate level.

Template looks good ✓ — every required heading is filled in, including the Risk & Scope tradeoff and the Chinese translation, and the prose is unwrapped the way the template asks.

Problem: observed, not theoretical. #11045 is a P1 report with a two-curl reproduction, the exact wrong_api_format payload Cerebras returns, and a verified workaround from patching the installed chunk. I confirmed the root cause independently in the tree rather than taking the issue's word for it: the history converter writes reasoning_content on every assistant turn that carried reasoning parts (converter.ts:473, and again at converter.ts:760), unconditionally, and determineProvider() has no Cerebras branch — so api.cerebras.ai falls through to DefaultOpenAICompatibleProvider, which ships the field verbatim. First turn succeeds, every replay turn 400s. That is exactly the reported shape.

Direction: aligned, and the CHANGELOG backs it. core: filter Mistral reasoning content at request boundary (#3882) is the precedent being extended here. Around it sits a run of entries in the other direction — preserving reasoning_content across merges, rewind, compression, session resume and DeepSeek tool-call replays (#5815, #3747, #3737, #3729, #3590). Read together, the project has repeatedly decided that this field is load-bearing in shared history and that a provider who rejects it gets reshaped at the outbound boundary, never in the history. This PR is that same decision applied to a new host, which is why deleting rather than renaming, and stripping rather than fixing the converter, is the right instinct. provider/README.md also says to create a provider class exactly when there is a request-level behavioral difference, and this is one.

Size: core paths, so counting per the gate rules — 96 production lines (cerebras.ts 56, utils.ts 16, openaiContentGenerator/index.ts 10, mistral.ts 1+12, provider/index.ts 1) and 269 test lines (cerebras.test.ts), 0 generated/schema. Well under 500, and fix type, so no Tier 1 concern and no size escalation. You hold admin on this repo, so it is maintainer-authored and the two-tier gate is exempt regardless — I ran it anyway, and I could name every downstream consumer, so nothing to escalate.

Approach: scope feels right and I could not find a smaller version of it. The one edit that looks like drive-by refactoring — lifting stripReasoningContent out of mistral.ts — is not: sharing requires it, the moved body is byte-identical to the original, it was module-private so nothing outside mistral.ts could have imported it, and provider/utils.ts is already the established home for this kind of shared wire-shape helper (ensureReasoningContentOnAssistantMessage, imported by both mimo.ts and deepseek.ts). Reusing one implementation instead of growing a second copy is the right call.

I also checked the thing that would actually break this rather than the thing that looks risky: whether an earlier determineProvider() branch claims api.cerebras.ai before the new one runs. It does not. DashScope, ModelScope and MiniMax are hostname-only; DeepSeek keys on a deepseek substring; Z.ai on a glm- prefix; MiMo on mimo-; Mistral on its own host plus mistral-family markers. None of those match Cerebras' host or its third-party model names (qwen-3.8-27b, gpt-oss-120b, llama-*). Hostname-only detection is correct here, for the reason you give — a model-name fallback would misroute other providers' models.

Risk: openaiContentGenerator is on the revert-correlated high-risk path list, so I am not treating a green suite as sufficient on its own. CI evidence is quoted by real check name in the review comment, and I named the sandboxed lane there too. Review depth: full, no stage skipped.

Moving on to code review. 🔍

中文说明

感谢贡献!这个 PR 在准入层面很容易通过。

模板完整 ✓ —— 所有必需小节都填写了,包括 Risk & Scope 的权衡说明和中文翻译,正文也没有硬折行。

问题:是已观测到的 bug,不是理论性加固。#11045 是一个 P1 报告,带两条 curl 复现、Cerebras 返回的原始 wrong_api_format 报文,以及打补丁到已安装 chunk 后验证过的临时绕过方案。我没有只信 issue 的描述,而是在代码里独立确认了根因:历史转换器会在每个带 reasoning part 的 assistant 轮次上无条件写入 reasoning_contentconverter.ts:473,以及 converter.ts:760),而 determineProvider() 没有 Cerebras 分支 —— 所以 api.cerebras.ai 落到 DefaultOpenAICompatibleProvider,把该字段原样发出去。第一轮成功、之后每轮回放都 400,与报告的现象完全一致。

方向:对齐,而且 CHANGELOG 支持这个判断。core: filter Mistral reasoning content at request boundary#3882)就是本 PR 延续的先例。它周围还有一批方向相反的条目 —— 在 merge、rewind、压缩、会话恢复以及 DeepSeek tool-call 回放中保留 reasoning_content#5815#3747#3737#3729#3590)。合起来看,本项目反复做出的决定是:这个字段在共享历史里是有承载作用的,拒绝它的 provider 应当在出站请求边界做重塑,而不是去改历史。本 PR 就是把同一个决定应用到新的 host 上 —— 这也是为什么「删除而非改名」「strip 而非改转换器」是正确的思路。provider/README.md 同样写明:只有在存在请求级行为差异时才新建 provider 类,本 PR 正属于这种情况。

规模:触及核心路径,按门禁规则统计 —— 生产代码 96 行cerebras.ts 56、utils.ts 16、openaiContentGenerator/index.ts 10、mistral.ts 1+12、provider/index.ts 1),测试 269 行cerebras.test.ts),生成/schema 0 行。远低于 500 行阈值,且类型为 fix,因此不涉及 Tier 1,也无需按规模升级。你在本仓库持有 admin 权限,属于 maintainer 本人提交的 PR,两级门禁本就豁免 —— 我仍然完整跑了一遍,并且能点名所有下游消费者,所以没有需要上报的内容。

方案:范围合理,我找不到更小的实现版本。唯一看起来像顺手重构的改动 —— 把 stripReasoningContentmistral.ts 提出来 —— 并不是:共用就需要它,移动后的函数体与原实现逐字节一致,它原本是模块私有的(mistral.ts 之外不可能有人 import),而 provider/utils.ts 本来就是这类共享 wire-shape helper 的既定归属(ensureReasoningContentOnAssistantMessage 就被 mimo.tsdeepseek.ts 引用)。复用同一份实现而不是再长出一份副本,是正确选择。

我还检查了真正会让这个改动失效的地方,而不是看起来危险的地方:determineProvider() 中更早的分支会不会先把 api.cerebras.ai 认领走。答案是不会。DashScope、ModelScope、MiniMax 只按 hostname 判断;DeepSeek 依据 deepseek 子串;Z.ai 依据 glm- 前缀;MiMo 依据 mimo-;Mistral 依据自身 host 加 mistral 系列标记。这些都匹配不到 Cerebras 的 host,也匹配不到它托管的第三方模型名(qwen-3.8-27bgpt-oss-120bllama-*)。这里只按 hostname 识别是正确的,理由正如你所说 —— 加模型名兜底会误路由其他 provider 的模型。

风险openaiContentGenerator 位于与 revert 相关联的高风险路径清单上,所以我不会仅凭测试全绿就认为足够。审查评论里会按真实 check 名称引用 CI 证据,同时点名沙箱验证通道。审查深度:完整,未跳过任何阶段。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 6b0c8a30658d5ebfd52b481af04c13813966c4d4 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Code review

What I would have done from the title and the "Why it's needed" alone, before opening the diff: the converter writes a non-standard field into shared history, one endpoint rejects it, and the codebase already solved exactly this for Mistral. So — add a CerebrasOpenAICompatibleProvider extending DefaultOpenAICompatibleProvider that overrides buildRequest to strip the field after super, gate it on hostname in determineProvider(), and lift the strip helper somewhere shared rather than duplicating it. The only judgement calls are hostname-only versus a model-name fallback, and delete versus rename.

The diff is that, and both judgement calls are right. Hostname-only, because Cerebras serves other vendors' model names, so a model fallback would misroute real DeepSeek/GLM/MiMo configs. Delete rather than rename, because that is the proven Mistral shape and #11045 itself recommends it as provider-safe.

No blocking findings. Three things I verified independently, because they are the ways a boundary fix like this silently half-works:

The strip covers both wire paths. provider.buildRequest has exactly one call site — pipeline.ts:880, inside executeWithErrorHandling, which serves streaming and non-streaming through the same isStreaming flag. There is no second request path that bypasses the override.

History really is untouched. stripReasoningContent returns the original object when the field is absent and copies before deleting when it is present; the test asserts the source request still carries reasoning_content after buildRequest returns. Same guarantee Mistral already relies on, and the moved body is byte-identical to the one that left mistral.ts.

Nothing upstream can steal the route. I walked every earlier branch in determineProvider(): DashScope, ModelScope and MiniMax are hostname-only; DeepSeek keys on a deepseek substring; Z.ai on a glm- prefix; MiMo on mimo-; Mistral on its own host plus mistral-family markers. None match api.cerebras.ai, and none match Cerebras' third-party model names.

Two non-blocking observations:

1. For qwen3-named models this renames rather than deletes, and the description does not say so. DefaultOpenAICompatibleProvider.buildRequest maps messages through mirrorReasoningContentToReasoning when isQwen3Model(request.model) — the name contains qwen3 — copying reasoning_content into reasoning. Your override calls super.buildRequest first and strips after, so for those models the wire keeps reasoning, which #11045 says Cerebras accepts on input. Net effect: cross-turn thinking replay survives for qwen3-named models and is dropped for everything else. The reported model does not take that path (qwen-3.8-27b contains qwen-3, not qwen3), so #11045's case deletes as described. Benign interaction, arguably a free win — but "Cerebras sessions lose cross-turn thinking replay" in Risk & Scope only holds for non-qwen3 names. Worth a clause, and if you want it pinned rather than incidental, one case with a qwen3-prefixed model name would do it.

2. The "Before" output cannot be a plain main run. A description-accuracy point, not a code problem. As printed — 3 failed | 2 passed (5), multi-turn failing on 400 status code (no body) — all five tests executed, so ./cerebras.js resolved. On plain main that file does not exist and the top-level import { CerebrasOpenAICompatibleProvider } from './cerebras.js' fails at module load, which vitest reports as a failed file with zero tests collected, not 5. The 3/2 split is exactly what a tree with detection present but the buildRequest override absent produces: cases 1 and 2 fail (nothing strips), the hostile-hostname and api.openai.com cases pass (nothing should strip), and the multi-turn case 400s. So the red/green pair does demonstrate the strip is load-bearing, which is the claim that matters — it just was not captured against 74fe3a65. To be explicit: that is my reading of your pasted output against the test source. I did not re-run it and I did not execute any PR code.

For completeness rather than as a request: the CerebrasOpenAICompatibleProvider re-export added to openaiContentGenerator/index.ts has no consumer today, exactly like the MistralOpenAICompatibleProvider and MiniMaxOpenAICompatibleProvider lines above it, and core/index.ts does not re-export this module, so it is not package public API. Consistent with precedent; fine to keep.

The test is stronger than it needed to be, which is the main reason I am comfortable here. The multi-turn case stands up a real HTTP endpoint that 400s on any body containing reasoning_content and drives the actual generateContent path — session history → converter → provider boundary → wire — then asserts on the bytes that reached the server. A wire oracle, not a mock asserting on a mock. It follows mistral.test.ts closely enough (same describe shape, same non-mutation and hostile-hostname cases) that the suite reads as one family.

sequenceDiagram
    participant P1 as Session history
    participant P2 as OpenAIContentConverter
    participant P3 as ContentGenerationPipeline
    participant P4 as Cerebras provider buildRequest
    participant P5 as api.cerebras.ai endpoint
    P1->>P2: contents with thought parts
    P2->>P3: assistant message carrying reasoning_content
    P3->>P4: buildRequest(baseRequest, userPromptId)
    Note over P4: super mirrors to reasoning for qwen3 names, then strip runs
    P4->>P4: messages.map(stripReasoningContent)
    P4->>P5: wire body without reasoning_content
    P5-->>P3: 200 instead of 400 wrong_api_format
Loading
Files changed (6)
File What changed
packages/core/src/core/openaiContentGenerator/provider/cerebras.ts New provider: hostname-only detection plus a buildRequest override that strips the field after the base implementation runs
packages/core/src/core/openaiContentGenerator/provider/utils.ts Receives the shared strip helper, body unchanged, alongside the existing ensure-reasoning helper
packages/core/src/core/openaiContentGenerator/provider/mistral.ts Loses its module-private copy of the helper and imports the shared one; behavior identical
packages/core/src/core/openaiContentGenerator/index.ts New dispatch branch placed last before the default provider, plus import and re-export
packages/core/src/core/openaiContentGenerator/provider/index.ts One export line
packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts Four routing cases (host, subdomain, hostile hostname, non-Cerebras host) plus the multi-turn wire oracle against a local strict endpoint

Testing

Which evidence this carries: the PR's own CI, read through the API. This is an unattended CI run, so I did not build, run, or execute anything from this branch — no test output below is mine. Real check names and conclusions for 6b0c8a30, fetched once, no polling:

Final CI results for 6b0c8a3 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Secret scan (TruffleHog) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Nothing is red. The three checks that would actually exercise this change — the ubuntu unit suite, lint/static, and the no-AK integration run — were all still in flight at the fetch, so there is no test conclusion to report yet and I am not guessing one. Qwen Code CI is the only pull_request-event workflow still running. verify and tmux-testing are skipped because neither lane has been triggered.

Pre-existing base failure, so a red head check is not automatically this PR's. Base main at 74fe3a65 is itself red on Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x), and the failing job's log names exactly one test out of 28,589 — nothing to do with content generation:

FAIL  src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke point > routes every per-request runtime-root pin through runWithPinnedRuntimeBaseDir
AssertionError: acpAgent.ts must not name runWithAcpRuntimeOutputDir directly. ... (see #10095)
 Test Files  1 failed | 1010 passed (1011)
      Tests  1 failed | 28488 passed | 100 skipped (28589)

Those two legs are skipped on PR runs in this repo and only execute post-merge, which is why they show skipped here rather than red — that is repo policy, not a gap this PR introduced. If the ubuntu leg lands red, that ACP guard test is the first thing to check before blaming this diff.

Not verified, with reasons:

  • not verified: that the new tests fail without the diff. The suite is still running, and a green suite proves tests pass, not that they pin the change. My reading of the pasted before-output above is that the strip is load-bearing, but that is inference from your text, not an observed run.
  • not verified: that a real api.cerebras.ai accepts the stripped body. No lane here holds a Cerebras key. What stands behind that claim is the reporter's two-curl reproduction in Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045 and their verified workaround patching the installed chunk — good evidence, but it is theirs, not this PR's CI.
  • not verified: the author-reported 901 tests across 22 files, clean typecheck, clean eslint. Those are the author's claims from a local Linux run; I did not re-run them and am not presenting them as evidence.

Sandboxed verification would settle the first gap: @qwen-code /verify — an A/B against base 74fe3a65 would prove the determineProvider() routing branch and the buildRequest strip are load-bearing rather than merely passing, which is the one claim the pending suite cannot make on its own. You have write access, so this is a direct trigger, not a sponsored run. The second gap needs a real Cerebras key and no lane can close it; @qwen-code /tmux would show the user-visible turn-2 recovery if someone holding a key wants to drive it, but there is no TUI surface change here, so /verify is the useful one.

中文说明

代码审查

只看标题和「为什么需要」时我会怎么做(在看 diff 之前): 转换器把一个非标准字段写进了共享历史,某个端点拒绝它,而代码库里已经为 Mistral 解决过完全相同的问题。所以——新增一个继承 DefaultOpenAICompatibleProviderCerebrasOpenAICompatibleProvider,override buildRequest,在 super 之后 strip 掉该字段;在 determineProvider() 里按 hostname 分流;把 strip helper 提到共享位置而不是复制一份。真正需要判断的只有两点:只按 hostname 还是加模型名兜底,以及删除还是改名。

diff 正是如此,且两个判断都对。只按 hostname,是因为 Cerebras 托管其他厂商的模型名,加模型名兜底会误路由真正的 DeepSeek/GLM/MiMo 配置。删除而非改名,因为这是 Mistral 已验证过的形态,#11045 本身也推荐这种做法作为 provider-safe 方案。

没有阻塞性问题。有三点我独立验证过,因为这类边界修复正是容易在这几处悄悄失效:

strip 覆盖了全部出站路径。 provider.buildRequest 只有一个调用点——pipeline.ts:880,位于 executeWithErrorHandling 内部,而流式与非流式共用同一个 isStreaming 分支。不存在绕过该 override 的第二条请求路径。

会话历史确实没有被改写。 stripReasoningContent 在字段不存在时直接返回原对象,存在时先拷贝再删除;测试断言了 buildRequest 返回后源请求仍带着 reasoning_content。与 Mistral 依赖的保证一致,且移动后的函数体与离开 mistral.ts 的那份逐字节相同。

上游分支不会抢走路由。 我逐个看了 determineProvider() 中更早的分支:DashScope、ModelScope、MiniMax 只按 hostname;DeepSeek 依据 deepseek 子串;Z.ai 依据 glm- 前缀;MiMo 依据 mimo-;Mistral 依据自身 host 加 mistral 系列标记。都匹配不到 api.cerebras.ai,也匹配不到 Cerebras 托管的第三方模型名。

两条非阻塞的观察:

1. 对 qwen3 命名的模型,这个改动实际是「改名」而不是「删除」,而描述里没有提到。 DefaultOpenAICompatibleProvider.buildRequestisQwen3Model(request.model)(模型名包含 qwen3)时会把消息过一遍 mirrorReasoningContentToReasoning,将 reasoning_content 复制到 reasoning。你的 override 先调 super.buildRequest、后做 strip,所以这类模型发到线上仍保留 reasoning——而 #11045 说明 Cerebras 接受 reasoning 作为输入。最终效果是:qwen3 命名的模型保留跨轮思考回放,其余模型才丢弃。issue 报告的模型不走这条路径(qwen-3.8-27bqwen-3,不含 qwen3),所以报告场景确实如描述那样删除。这是良性交互,甚至算白赚的好处——但 Risk & Scope 里那句「Cerebras 会话会失去跨轮思考回放」只对非 qwen3 命名成立。建议补一句;如果希望这个行为被固定而非偶然产物,加一个 qwen3 前缀模型名的用例即可。

2. "Before" 的输出不可能来自纯 main 这是描述准确性问题,不是代码问题。按贴出的内容——3 failed | 2 passed (5),多轮用例报 400 status code (no body)——五个测试都执行了,说明 ./cerebras.js 解析成功。而在纯 main 上该文件不存在,顶层的 import { CerebrasOpenAICompatibleProvider } from './cerebras.js' 会在模块加载阶段失败,vitest 会报为「文件失败、收集到 0 个测试」,而不是 5 个。这个 3/2 分布恰好对应「检测逻辑已就位、但 buildRequest override 还不存在」的树:用例 1、2 失败(无人 strip),恶意域名与 api.openai.com 两个用例通过(本就不该 strip),多轮用例 400。所以这组红/绿证明的是 strip 本身承重,这正是关键结论——只是它不是在 74fe3a65 上采集的。需要说明:以上是我对照测试源码读你贴出的输出得出的判断,我没有重跑,也没有执行任何 PR 代码。

另外仅作说明、并非要求:加到 openaiContentGenerator/index.tsCerebrasOpenAICompatibleProvider 再导出目前没有消费者,与紧邻其上的 MistralOpenAICompatibleProviderMiniMaxOpenAICompatibleProvider 两行情况相同,且 core/index.ts 并未再导出本模块——因此它不属于包的公共 API。与既有先例一致,保留没问题。

测试本身写得比需要的更扎实,这也是我在这里放心的主要原因:多轮用例启动了一个真实 HTTP 端点,凡请求体含 reasoning_content 就返回 400,并驱动真实的 generateContent 路径——会话历史 → 转换器 → provider 边界 → 线上报文——然后对实际到达服务端的字节做断言。这是一个 wire oracle,而不是「用 mock 断言 mock」。它与 mistral.test.ts 贴合得足够紧(相同的 describe 结构、相同的非改写与恶意域名用例),整套测试读起来像同一个家族。

测试证据

本评论携带的证据类型:PR 自身的 CI,通过 API 读取。 这是无人值守的 CI 运行,我没有构建、运行或执行本分支的任何代码——下面没有任何测试出自我手。以上是 6b0c8a30 上真实的 check 名称与结论,只抓取一次,未轮询(表格由 finalize 任务在 CI 结束后原地更新)。

没有任何 check 是红的。但真正能检验这个改动的三项——ubuntu 单元测试、lint/static、no-AK 集成测试——在抓取时都还在运行,所以目前没有测试结论可报,我也不会去猜。Qwen Code CI 是唯一仍在跑的 pull_request 事件 workflow。verifytmux-testing 被跳过,因为两条通道都未触发。

base 上已有既存失败,所以 head 变红不必然归因于本 PR。 base main74fe3a65)自身的 Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x) 就是红的,失败 job 日志显示 28,589 个测试中只有 1 个失败,且与内容生成毫无关系(原文见上方英文代码块:acpAgent.test.ts 的 runtime-root pinning 守卫测试)。这两个 leg 在本仓库的 PR 运行中是跳过的、只在合并后执行,所以这里显示 skipped 而非红色——这是仓库策略,不是本 PR 造成的缺口。如果 ubuntu leg 最终变红,请先核对是不是这个 ACP 守卫测试,再归因于本 diff。

未验证项及原因:

  • 未验证:新测试在去掉 diff 后是否会失败。套件仍在运行,而全绿只能证明测试通过,不能证明测试固定住了改动。我对上面 before 输出的解读是 strip 承重,但那是从你的文字推断,不是观测到的运行结果。
  • 未验证:真实的 api.cerebras.ai 是否接受 strip 之后的请求体。这里没有任何通道持有 Cerebras key。支撑该说法的是 Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045 报告者的两条 curl 复现,以及他打补丁到已安装 chunk 后验证过的绕过方案——证据不错,但那是他的,不是本 PR CI 的。
  • 未验证:作者自述的「22 个文件 901 个测试」、typecheck 干净、eslint 干净。这些是作者在本地 Linux 上的声明,我没有重跑,也不作为证据呈现。

沙箱验证可以补齐第一个缺口:@qwen-code /verify——对 base 74fe3a65 做 A/B,可以证明 determineProvider() 的路由分支和 buildRequest 的 strip 是承重的,而不只是「通过了」,这正是仍在运行的套件自己无法给出的结论。你有写权限,所以这是直接触发,不是 sponsored run。第二个缺口需要真实 Cerebras key,任何通道都补不上;如果有人持有 key,@qwen-code /tmux 可以展示第二轮恢复的实际用户可见效果,但这里没有 TUI 界面改动,所以有用的是 /verify

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 6b0c8a30658d5ebfd52b481af04c13813966c4d4 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid, minimal, and it fixes a P1 that makes an entire provider unusable past turn one; the two nits are about the description's precision, not the code.

My independent proposal, written from the title and the motivation before I opened the diff, was the same shape the PR took: a provider subclass that strips after super, hostname-gated dispatch, and the helper lifted somewhere shared. I did not find a simpler path it missed. That is not automatically a point in its favour — it can also mean I anchored on the same precedent the author did — but here the precedent is in the tree, documented in provider/README.md, and endorsed by the issue reporter, so converging on it is the right outcome rather than a lack of imagination.

The thing I would want a maintainer to know is that I did not take the framing on trust. I confirmed the converter writes reasoning_content unconditionally on reasoning turns (converter.ts:473, converter.ts:760) and that determineProvider() had no Cerebras branch, so the fall-through to DefaultOpenAICompatibleProvider is real. I walked all seven earlier dispatch branches to be sure nothing claims api.cerebras.ai first. I found the single provider.buildRequest call site to confirm streaming is not a bypass. And I pulled base CI to classify what is already red on main so a failure on this PR cannot be misattributed. The problem exists, the fix is at the right boundary, and nothing else moves.

Two reservations, both non-blocking, both in the description rather than the diff:

The qwen3 interaction is the one I would actually like addressed. Because super.buildRequest mirrors reasoning_content into reasoning for qwen3-named models before your strip runs, those models keep thinking replay and everything else loses it — so the Risk & Scope line reads as more pessimistic than the code is. That is a good problem to have, but it is undocumented, and undocumented behaviour that depends on a substring match in someone else's function is the kind of thing that gets "fixed" later by a well-meaning reorder. One clause in the description, or one test with a qwen3-prefixed model name, closes it.

The before/after evidence is the load-bearing part of the description and, as printed, was not captured against main — a missing ./cerebras.js fails the file at import, so 3 failed | 2 passed (5) cannot be a plain base run. The red/green still proves the strip matters, which is the claim that counts, so I am not asking for a re-run; I am noting it because "red on main" is exactly the kind of statement a reviewer skips verifying, and I would rather it be accurate.

On volume: you have 41 open PRs right now, all from the last two days. I am not holding that against this one — it is judged on its own diff, and it is one of the cleaner things I reviewed today. But it is why I checked the unskimmable parts instead of pattern-matching the shape. Related and useful: base main's red macOS/Windows legs are the acpAgent.test.ts runtime-root pinning guard, which your own #11036 is open to fix — so that noise is already owned and is not a reason to hold this PR.

If I had to maintain this in six months I would thank the author: one more provider in a documented pattern, a shared helper instead of a second copy, and a test that asserts on real bytes rather than on a mock. The only thing I did not get to see is CI land, and I am not going to approve against a result that does not exist yet — the ubuntu unit suite, lint, and the integration run were all still in flight at my single fetch. So approval is deferred until CI lands green on 6b0c8a30658d5ebfd52b481af04c13813966c4d4; if anything lands red, or that ACP guard test turns out to be the cause, the deferral is withheld rather than honoured.

中文说明

Confidence: 4/5 —— 扎实、改动最小,修掉的是一个让整个 provider 在第二轮之后完全不可用的 P1;两条保留意见都在描述的准确性上,不在代码上。

在看 diff 之前,我只根据标题和动机写下的独立方案,与这个 PR 采取的形态相同:一个在 super 之后做 strip 的 provider 子类、按 hostname 分流、把 helper 提到共享位置。我没有找到它漏掉的更简路径。这不必然算优点——也可能只是我和作者锚定了同一个先例——但这里的先例本来就在代码库里、写在 provider/README.md 里、并且被 issue 报告者认可,所以收敛到它是正确结果,而不是缺乏想象力。

我最希望 maintainer 知道的是:我没有采信这套叙述。我确认了转换器在 reasoning 轮次上无条件写入 reasoning_contentconverter.ts:473converter.ts:760),也确认了 determineProvider() 原本没有 Cerebras 分支,所以落到 DefaultOpenAICompatibleProvider 是真实发生的。我逐个走了前面七个分流分支,确认没有谁会先认领 api.cerebras.ai。我找到了 provider.buildRequest 唯一的调用点,确认流式不是绕过路径。我还拉取了 base 的 CI,把 main 上本来就红的部分归类清楚,这样本 PR 上出现的任何失败都不会被错误归因。问题确实存在,修复确实在正确的边界上,且没有牵动别的东西。

两点保留意见,都不阻塞,都在描述而非 diff 里:

qwen3 那个交互是我真正希望处理的。由于 super.buildRequest 会在你的 strip 之前,为 qwen3 命名的模型把 reasoning_content 镜像成 reasoning,这类模型保留了思考回放,其余模型才丢弃——所以 Risk & Scope 那句话写得比代码实际行为更悲观。这是个好问题,但它没有被记录,而这种依赖别人函数里某个子串匹配的未记录行为,正是日后容易被善意的重排序「修坏」的那类东西。描述里补一句,或者加一个 qwen3 前缀模型名的测试,就能闭合。

before/after 证据是描述里承重的部分,而按贴出的内容,它不是在 main 上采集的——./cerebras.js 缺失会在 import 阶段让整个文件失败,所以 3 failed | 2 passed (5) 不可能是纯 base 运行的结果。这组红/绿依然证明了 strip 是关键,而这正是要紧的结论,所以我不要求重跑;我指出来是因为「在 main 上是红的」恰好是 reviewer 最容易跳过不验证的那类陈述,我更希望它是准确的。

关于数量:你现在有 41 个 open PR,全部来自最近两天。我没有因此对这个 PR 有偏见——它是按自己的 diff 评判的,而且是我今天看过的比较干净的改动之一。但这正是我去核查那些无法略读的部分、而不是按形态做模式匹配的原因。相关且有用的一点:base main 上红着的 macOS/Windows leg 是 acpAgent.test.ts 的 runtime-root pinning 守卫测试,而你自己的 #11036 已经开着要修它——所以那个噪音已经有人负责,不构成本 PR 被搁置的理由。

如果六个月后由我来维护这份代码,我会感谢作者:多了一个遵循既定模式的 provider、一份共享 helper 而不是第二份副本、以及一个对真实字节而非 mock 做断言的测试。我唯一没能看到的是 CI 落地,而我不会对着一个尚不存在的结果去 approve——在我那次单次抓取时,ubuntu 单元测试、lint 和集成测试都还在运行。因此批准推迟到 CI 在 6b0c8a30658d5ebfd52b481af04c13813966c4d4 上全绿之后;如果有任何一项变红,或者查明原因是那个 ACP 守卫测试,这个推迟会被撤回而不是执行。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 6b0c8a30658d5ebfd52b481af04c13813966c4d4 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Test Plan (not a blocker): src/core/openaiContentGenerator/provider/cerebras.test.tsno such file or directory; Tests 5 passed — this review observed 1945, 298 passed; 2 passed — this review observed 1945, 298 passed.

中文说明

已审查。 建议见行内评论。

Test Plan(非阻断):src/core/openaiContentGenerator/provider/cerebras.test.tsno such file or directory; Tests 5 passed — this review observed 1945, 298 passed; 2 passed — this review observed 1945, 298 passed

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +24 to +28
const hostname = new URL(baseUrl).hostname.toLowerCase();
return (
hostname === CEREBRAS_API_HOST ||
hostname.endsWith(`.${CEREBRAS_API_HOST}`)
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: isCerebrasProvider is a near-verbatim copy of isMistralHostname — same URL parse, lowercasing, exact-or-dot-suffix match, same try/catch — differing only in the host constant, and the buildRequest override this PR adds is byte-for-byte identical to Mistral's. The same matcher shape is hand-copied across roughly eight provider files. This PR already consolidated stripReasoningContent into provider/utils.ts; the hostname matcher belongs in that same shared home. The matcher encodes security-relevant semantics — exact host or dot-prefixed subdomain, rejecting lookalikes like api.cerebras.ai.evil.example, which this PR's own hostile-hostname test pins — so with ~8 hand-copies, any hardening of host matching (trailing-dot hostnames, IPv6 literals, IDN) must be applied to every copy in lockstep, and missing one silently misroutes or over-strips for that provider.

Witness:

cerebras buildRequest == mistral buildRequest: True
matcher sweep — new URL(...).hostname.toLowerCase() + exact-or-dot-suffix + try/catch:
  8 hand-copies total: mistral.ts, cerebras.ts, deepseek.ts, zai.ts, mimo.ts,
  openrouter.ts (regex sweep) + modelscope.ts, minimax.ts (direct read)

Extract a shared helper next to stripReasoningContent in provider/utils.ts and use it from cerebras.ts and mistral.ts — the two call sites this diff touches:

export function isProviderApiHost(
  config: ContentGeneratorConfig,
  host: string,
): boolean {
  const baseUrl = config.baseUrl ?? '';
  if (!baseUrl) return false;
  try {
    const hostname = new URL(baseUrl).hostname.toLowerCase();
    return hostname === host || hostname.endsWith(`.${host}`);
  } catch {
    return false;
  }
}

The rewiring must preserve Mistral's hostname-OR-model-marker routing — if (isMistralHostname(config)) return true; followed by the MISTRAL_MODEL_MARKERS check (mistral.ts:37-41). If applied, please confirm the extraction keeps the matching semantics: cerebras.test.ts's "does not treat hostile hostnames containing api.cerebras.ai as Cerebras" and "strips reasoning_content for Cerebras subdomains" pin the exact-or-dot-suffix semantics, and either goes red if the shared helper regresses to hostname.includes(host).

中文说明

R1-3:isCerebrasProviderisMistralHostname 几乎逐字相同——同样的 URL 解析、小写化、精确或点前缀子域名匹配、同样的 try/catch——只有 host 常量不同;本 PR 新增的 buildRequest 重写也与 Mistral 的逐字节一致。同一匹配逻辑在约 8 个 provider 文件中被手工复制。本 PR 已经把 stripReasoningContent 收敛到 provider/utils.ts;hostname 匹配器也应该放到同一个共享位置。该匹配器承载安全相关语义——精确匹配或点前缀子域名、拒绝形似域名(如 api.cerebras.ai.evil.example,本 PR 自己的恶意域名测试固定了这一点)——因此在约 8 份手工副本的情况下,任何对主机匹配的加固(尾点域名、IPv6 字面量、IDN)都必须同步应用到每一份,漏掉任何一份都会让对应 provider 悄悄误路由或过度 strip。

建议:在 provider/utils.ts 中、stripReasoningContent 旁边抽取共享辅助函数,并在 cerebras.tsmistral.ts——即本 diff 触及的两个调用点——中使用(代码见英文部分)。

修复约束:重接线必须保留 Mistral 的「hostname 或模型标记」路由——if (isMistralHostname(config)) return true; 之后是 MISTRAL_MODEL_MARKERS 检查(mistral.ts:37-41)。修复见证:如果应用此改动,请确认抽取没有改变匹配语义——cerebras.test.ts 的「does not treat hostile hostnames containing api.cerebras.ai as Cerebras」与「strips reasoning_content for Cerebras subdomains」固定了精确或点前缀后缀语义;若共享 helper 退化为 hostname.includes(host),这两个用例会变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real bundled CLI against a live api.cerebras.ai stand-in

I built both arms from source and drove the bundled CLI end to end, rather than re-running the unit tests. The defect reproduces exactly as reported, the fix closes it, and I found no regression on the other providers. From my side this is merge-ready; the two notes below are about the test plan's wording and one uncovered detection edge, not the code.

Rig

  • Same tree, two bundles: npm ci && npm run build && npm run bundle at 6b0c8a30 (head) and at 74fe3a65 (base) → dist/cli.js e357562886… vs bbaf538a1e….
  • Runs happen in node:24-bookworm-slim with --add-host api.cerebras.ai:127.0.0.1 (plus the subdomain / look-alike / DeepSeek hosts) and a private CA via NODE_EXTRA_CA_CERTS, so the configured base URL is literally https://api.cerebras.ai/v1 and determineProvider() runs on a real, resolved hostname — not on a synthetic config object.
  • The stand-in behaves like Cerebras in both directions: it streams thinking back in Cerebras' own response field reasoning (not reasoning_content), and it rejects any request body containing reasoning_content with the exact wrong_api_format payload from Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045. Every request body is logged verbatim.
  • One headless session per cell: qwen -p "List the files in this directory." --approval-mode yolo --model qwen-3-32b. Turn 1 answers with a tool call, so the CLI replays the thinking turn on turn 2 — this drives the streaming path (the PR's own test covers the non-streaming generateContent path, so the two are complementary).

Result

end-to-end A/B

  • base (main @ 74fe3a65) — turn 1 succeeds, turn 2 ships "reasoning_content" on the assistant message and the run dies with exactly [API Error: 400 status code (no body)], the symptom string from the issue.
  • head (this PR) — the same session completes (exit 0), and the assistant message on the wire is {"role":"assistant","content":"","tool_calls":[…]} with the field gone.
  • History is genuinely untouched, not just the in-memory object the unit test asserts on: the persisted session file after the head run still contains {"text":"The user asked for the workspace listing. I will call list_directory.","thought":true}.

Scope and non-regression (12 live runs) + mutation matrix

matrix

  • Subdomain (proxy.api.cerebras.ai) is fixed too; the hostile look-alike (api.cerebras.ai.evil.example), DeepSeek and a self-hosted localhost endpoint are byte-identical between the two arms — the strip does not leak to providers that need the field replayed.
  • The qwen3 reasoning mirror interacts correctly: with a model named qwen3-32b, base ships reasoning_content and the mirrored reasoning (still 400), while head ships only reasoning — the field Cerebras accepts — so thinking replay actually survives in that configuration. Ordering (super.buildRequest first, strip second) is what makes that work.
  • 9 mutants against the PR's tests: 7 killed, 2 survivedM7 (strip assistant messages only) is an equivalent mutant; M9 is a real coverage gap, see note 3.
  • On macOS (the PR's table marks it ⚠️): packages/core src/core/openaiContentGenerator/22 files, 901 tests, all pass; npm run typecheck clean; eslint on the six changed files clean.

Notes (none blocking)

  1. The "Before" evidence is not reproducible against literal main. On 74fe3a65 the new test file cannot even load — Cannot find module './cerebras.js'Test Files 1 failed (1), Tests no tests. The quoted Tests 3 failed | 2 passed (5) corresponds to an ablated tree (provider + dispatch present, strip removed), which my M1 mutant reproduces exactly, same three test names. Worth rewording the test plan to say so; the underlying claim holds, and the A/B above is the stronger form of it.
  2. Known residual: Cerebras behind a relay. Because detection is hostname-only (rightly so — the model-name fallback Mistral uses would misroute here), a user who reaches Cerebras through LiteLLM / a corporate gateway / any non-cerebras.ai host keeps 400ing; the localhost row is that case. Fine for this PR's scope, but worth one line in the issue as the known residual — a follow-up could react to the wrong_api_format 400 itself and retry once without the field, which would cover every relay.
  3. M9 survived: the dot separator is only half-pinned. Rewriting the match as hostname.endsWith(CEREBRAS_API_HOST) (dropping the .) passes all 901 tests, yet it over-matches xapi.cerebras.ai. The hostile-hostname test pins the suffix side only. One extra case would close it — and it is the concrete evidence for the bot's R1-3 suggestion about lifting this matcher into provider/utils.ts: the semantics it calls security-relevant are currently guarded by a test in only one direction, in ~8 hand-copies.
  4. The 400 body really is discarded. My stand-in returns the full JSON validation message and the CLI still prints 400 status code (no body). That is the deferred half of Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045: after this PR Cerebras users stop hitting it, but any other 400 stays undiagnosable. Confirms the triage decision to split it out.
  5. Not verified here: real api.cerebras.ai behaviour (no key). One shape worth a thought if anyone has one — a thinking-only tool-call turn now ships content: "" with no reasoning field at all (the "" exists precisely because reasoning was present, converter.ts:748); my stand-in accepts it, real Cerebras is unverified. main sends the same content: "" today, so this is not a regression introduced here.

CI: everything green except Test (ubuntu-latest, Node 22.x), still running as I write this.

中文说明

维护者验证 —— 用打包 CLI 打真实 api.cerebras.ai 替身

我从源码构建了两条臂,并且驱动打包后的 CLI 跑端到端,而不是复跑单测。缺陷完全按报告复现,本 PR 修好了它,其它 provider 无回归。 从我这边看可以合入;下面两条只针对测试计划的措辞和一处未覆盖的识别边界,不涉及代码本身。

验证台

  • 同一棵树、两个 bundle:分别在 6b0c8a30(head)与 74fe3a65(base)跑 npm ci && npm run build && npm run bundledist/cli.jse357562886…bbaf538a1e…
  • 运行在 node:24-bookworm-slim 容器里,--add-host api.cerebras.ai:127.0.0.1(外加子域名 / 仿冒域名 / DeepSeek 域名),并用私有 CA 配 NODE_EXTRA_CA_CERTS,所以配置里的 base URL 就是字面上的 https://api.cerebras.ai/v1determineProvider() 是在真实解析出来的主机名上做判断,而不是在人造 config 对象上。
  • 替身端点两个方向都忠实于 Cerebras:回包用 Cerebras 自己的 reasoning 字段(不是 reasoning_content)流式返回思考;请求体只要含 reasoning_content 就按 Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045 的原样 wrong_api_format 报文返回 400。每个请求体都原样落盘。
  • 每格一次 headless 会话:qwen -p "List the files in this directory." --approval-mode yolo --model qwen-3-32b。第一轮回一个工具调用,于是第二轮 CLI 会重放这次带思考的轮次——走的是流式链路(PR 自带用例覆盖的是非流式 generateContent,两者互补)。

结果

  • base(main @ 74fe3a65:第一轮成功,第二轮把 "reasoning_content" 发上线,运行以 [API Error: 400 status code (no body)] 结束,与 issue 里的症状字符串一模一样。
  • head(本 PR):同一次会话跑完(exit 0),线上的 assistant 消息是 {"role":"assistant","content":"","tool_calls":[…]},字段已消失。
  • 历史确实没被改写,而且不只是单测断言的内存对象:head 跑完后落盘的会话文件里仍然有 {"text":"The user asked for the workspace listing. I will call list_directory.","thought":true}

作用域与非回归(12 次真机运行)+ 变异矩阵

  • 子域名(proxy.api.cerebras.ai)同样被修好;仿冒域名(api.cerebras.ai.evil.example)、DeepSeek、以及自建 localhost 端点在两条臂上逐字节一致——strip 没有外溢到需要回传该字段的 provider。
  • 与 qwen3 的 reasoning 镜像逻辑配合正确:模型名为 qwen3-32b 时,base 会同时发 reasoning_content 和镜像出来的 reasoning(依然 400),head 只发 reasoning——正是 Cerebras 接受的字段——所以这种配置下跨轮思考反而保住了。顺序(先 super.buildRequest 再 strip)是这一点成立的原因。
  • 针对 PR 自带用例做了 9 个变异体:杀死 7 个、存活 2 个——M7(只 strip assistant 消息)是等价变异体;M9 是真实覆盖缺口,见第 3 条。
  • macOS 上(PR 表格标的是 ⚠️):packages/coresrc/core/openaiContentGenerator/22 个文件、901 个测试全过npm run typecheck 干净;对 6 个改动文件跑 eslint 干净。

备注(均不阻断)

  1. 「修复前」证据在字面 main 上复现不出来。74fe3a65 上新测试文件根本加载不了——Cannot find module './cerebras.js'Test Files 1 failed (1)Tests no tests。PR 引用的 Tests 3 failed | 2 passed (5) 对应的是一棵被消融过的树(provider 与 dispatch 都在、只去掉 strip),我的 M1 变异体正好复现了它,失败的三个用例名完全一致。建议在测试计划里改写这一段;结论本身没问题,上面的 A/B 是它更强的形式。
  2. 已知残留:Cerebras 走中转时仍会失败。 识别只按 hostname(这是对的——Mistral 那种按模型名兜底在这里会误路由),所以通过 LiteLLM / 公司网关 / 任何非 cerebras.ai 主机访问 Cerebras 的用户仍会 400;矩阵里的 localhost 行就是这个场景。作为本 PR 的范围没问题,但值得在 issue 里写一行作为已知残留——后续可以直接对 wrong_api_format 这个 400 做一次去字段重试,那样能覆盖所有中转形态。
  3. M9 存活:点号分隔符只被单向钉住。 把匹配改成 hostname.endsWith(CEREBRAS_API_HOST)(去掉那个 .)能通过全部 901 个测试,但它会误匹配 xapi.cerebras.ai。仿冒域名用例只钉了后缀那一侧。补一个用例即可闭合——这也正是 bot 那条 R1-3(把匹配器提到 provider/utils.ts)的实证依据:它称之为安全相关的语义,目前在约 8 份手抄副本里只有单向测试保护。
  4. 400 的响应体确实被丢弃了。 我的替身端点返回了完整的 JSON 校验报错,CLI 依然打印 400 status code (no body)。这就是 Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input #11045 被拆出去的另一半:本 PR 之后 Cerebras 用户不会再撞上它,但其它 400 依然无法自诊断。印证了分诊时拆单的判断。
  5. 这里没验到的:真实 api.cerebras.ai 的行为(没有 key)。有 key 的话有个形态值得看一眼——只有思考 + 工具调用的轮次,现在会发 content: "" 且完全不带 reasoning 字段(这个 "" 恰恰是因为当时存在 reasoning 才产生的,converter.ts:748);我的替身接受它,真实 Cerebras 未验证。main 今天发的也是同样的 content: "",所以这不是本 PR 引入的回归。

CI:除 Test (ubuntu-latest, Node 22.x) 在我写这条评论时仍在跑,其余全绿。

@wenshao
wenshao enabled auto-merge September 5, 2026 00:56
Comment thread packages/core/src/core/openaiContentGenerator/index.ts

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review pass on head 368194b — no blockers found. (Attempted to approve, but GitHub does not allow approving your own PR, so this stands as the review record.)

Verified locally at head 368194b (worktree at that exact SHA): npm run typecheck in packages/core clean; vitest run src/core/openaiContentGenerator/ 22 files / 901 tests all pass, including the 5 new tests in cerebras.test.ts; eslint clean on all six changed files.

What I checked in the code:

  • Hostname detection is exact-or-dot-suffix on api.cerebras.ai; api.cerebras.ai.evil.example falls through to the default provider (pinned by test), and the strip never mutates session history — only the outbound request.
  • The Cerebras branch sits after the model-name-based detections (DeepSeek/Zai/MiMo/Mistral markers) in determineProvider(); a collision would need a Cerebras-hosted model carrying another provider's marker, which the current Cerebras catalog does not have. That edge class is already tracked in the existing non-blocking inline note.
  • stripReasoningContent moved from mistral.ts to provider/utils.ts byte-for-byte; Mistral now shares it, and DeepSeek/Qwen endpoints that require reasoning_content replay are untouched.

Non-blocking follow-ups already open inline: shared host-matcher extraction (bot R1-3) and the model-name routing edge. CI note: ubuntu Test and Lint & Static were still in flight on the slow runner pool at review time; integration and desktop-shell checks are green.

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Review result: no blockers. Verified at head 368194be in an isolated worktree — typecheck clean, src/core/openaiContentGenerator suite 22 files / 901 tests all pass (including the 5 new Cerebras tests), eslint clean on all six changed files. Hostname detection is exact-or-dot-suffix with hostile-lookalike rejection pinned by test, the strip stays at the outbound boundary without mutating session history, and the stripReasoningContent move to provider/utils.ts is byte-for-byte. The two open inline threads (shared host-matcher extraction, model-name routing edge) are non-blocking follow-ups.

One platform boundary: gh pr review --approve was rejected with "Review Can not approve your own pull request" — this PR is authored by the same account (yiliang114) running this session, so the approval must come from another maintainer (wenshao has already approved; reviewDecision still shows REVIEW_REQUIRED). The full verification record was posted as a comment review instead. At review time the ubuntu Test and Lint & Static jobs were still running on the slow runner pool; integration and desktop-shell checks were green.

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

That's just the late completion notice for the npm ci I already polled and consumed — the verification it enabled is done (typecheck, 901/901 tests, eslint all clean), the review is posted, and the worktree is removed. Nothing further to act on.

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Permission required to run a tool

Tool: ask_user_question
Action: Ask user 1 question
Parameters: questions (1 item)

Reply with:
/approve Submit
/deny Cancel

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Test Plan (not a blocker): src/core/openaiContentGenerator/provider/cerebras.test.tsno such file or directory; Tests 5 passed — this review observed 23394, 1945, 28579, 298, 1815, 504, 5995, 94 passed; 2 passed — this review observed 23394, 1945, 28579, 298, 1815, 504, 5995, 94 passed.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/openaiContentGenerator/provider/cerebras.ts:21 — [probe] Both negative branches of isCerebrasProvider (empty baseUrl, unparseable baseUrl) have no test; a catch->true mutant survives the full 904-test suite, which wou…
  • packages/core/src/telemetry/gen-ai-provider.ts:66 — [probe] This PR makes Cerebras a named provider, but the gen_ai telemetry registry has no cerebras hostname entry, so Cerebras traffic is attributed to 'openai'; every other recognized ven…
中文说明

已审查。 建议见行内评论。

Test Plan(非阻断):src/core/openaiContentGenerator/provider/cerebras.test.tsno such file or directory; Tests 5 passed — this review observed 23394, 1945, 28579, 298, 1815, 504, 5995, 94 passed; 2 passed — this review observed 23394, 1945, 28579, 298, 1815, 504, 5995, 94 passed

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

return (
hostname === CEREBRAS_API_HOST ||
hostname.endsWith(`.${CEREBRAS_API_HOST}`)
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: isCerebrasProvider is a near-verbatim copy of isMistralHostname — same URL parse, lowercasing, exact-or-dot-suffix match, same try/catch — differing only in the host constant, and the buildRequest override this PR adds is byte-for-byte identical to Mistral's. The same matcher shape is hand-copied across roughly eight provider files. This PR already consolidated stripReasoningContent into provider/utils.ts; the hostname matcher belongs in that same shared home. The matcher encodes security-relevant semantics — exact host or dot-prefixed subdomain, rejecting lookalikes like api.cerebras.ai.evil.example, which this PR's own hostile-hostname test pins — so with ~8 hand-copies, any hardening of host matching (trailing-dot hostnames, IPv6 literals, IDN) must be applied to every copy in lockstep, and missing one silently misroutes or over-strips for that provider.

Witness:

cerebras buildRequest == mistral buildRequest: True
matcher sweep — new URL(...).hostname.toLowerCase() + exact-or-dot-suffix + try/catch:
  8 hand-copies total: mistral.ts, cerebras.ts, deepseek.ts, zai.ts, mimo.ts,
  openrouter.ts (regex sweep) + modelscope.ts, minimax.ts (direct read)

Extract a shared helper next to stripReasoningContent in provider/utils.ts and use it from cerebras.ts and mistral.ts — the two call sites this diff touches:

export function isProviderApiHost(
  config: ContentGeneratorConfig,
  host: string,
): boolean {
  const baseUrl = config.baseUrl ?? '';
  if (!baseUrl) return false;
  try {
    const hostname = new URL(baseUrl).hostname.toLowerCase();
    return hostname === host || hostname.endsWith(`.${host}`);
  } catch {
    return false;
  }
}

The rewiring must preserve Mistral's hostname-OR-model-marker routing — if (isMistralHostname(config)) return true; followed by the MISTRAL_MODEL_MARKERS check (mistral.ts:37-41). If applied, please confirm the extraction keeps the matching semantics: cerebras.test.ts's "does not treat hostile hostnames containing api.cerebras.ai as Cerebras" and "strips reasoning_content for Cerebras subdomains" pin the exact-or-dot-suffix semantics, and either goes red if the shared helper regresses to hostname.includes(host).

中文说明

R1-3:isCerebrasProviderisMistralHostname 几乎逐字相同——同样的 URL 解析、小写化、精确或点前缀子域名匹配、同样的 try/catch——只有 host 常量不同;本 PR 新增的 buildRequest 重写也与 Mistral 的逐字节一致。同一匹配逻辑在约 8 个 provider 文件中被手工复制。本 PR 已经把 stripReasoningContent 收敛到 provider/utils.ts;hostname 匹配器也应该放到同一个共享位置。该匹配器承载安全相关语义——精确匹配或点前缀子域名、拒绝形似域名(如 api.cerebras.ai.evil.example,本 PR 自己的恶意域名测试固定了这一点)——因此在约 8 份手工副本的情况下,任何对主机匹配的加固(尾点域名、IPv6 字面量、IDN)都必须同步应用到每一份,漏掉任何一份都会让对应 provider 悄悄误路由或过度 strip。

建议:在 provider/utils.ts 中、stripReasoningContent 旁边抽取共享辅助函数,并在 cerebras.tsmistral.ts——即本 diff 触及的两个调用点——中使用(代码见英文部分)。

修复约束:重接线必须保留 Mistral 的「hostname 或模型标记」路由——if (isMistralHostname(config)) return true; 之后是 MISTRAL_MODEL_MARKERS 检查(mistral.ts:37-41)。修复见证:如果应用此改动,请确认抽取没有改变匹配语义——cerebras.test.ts 的「does not treat hostile hostnames containing api.cerebras.ai as Cerebras」与「strips reasoning_content for Cerebras subdomains」固定了精确或点前缀后缀语义;若共享 helper 退化为 hostname.includes(host),这两个用例会变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking findings. Approved.

What I checked

Cross-file context:

  • mistral.ts at head — confirmed the refactoring is clean: stripReasoningContent body is identical to the removed version; MistralOpenAICompatibleProvider continues to import and use it correctly.
  • default.ts at head — confirmed DefaultOpenAICompatibleProvider.buildRequest does not add reasoning_content; the Cerebras buildRequest override (strip after super) is in the right order.
  • determineProvider full function — Cerebras check at line 115 sits after DeepSeek (line 75) and Mistral (line 107), before default; the ordering concern is correctly resolved.

Diff audit:

  • isCerebrasProvider: URL parse in try/catch, exact-or-dot-suffix hostname match, no mutation of input. Pattern mirrors isMistralHostname exactly.
  • buildRequest override chains super.buildRequestmap(stripReasoningContent); the history-non-mutation guarantee holds (shallow copy, delete).
  • Export surface: class re-exported from provider/index.ts and index.ts; standalone isCerebrasProvider function is not re-exported from either barrel.

Test audit:

  • Unit cases cover: strip fires for apex + subdomain; hostile suffix (api.cerebras.ai.evil.example) correctly falls through to default; non-Cerebras providers pass reasoning_content through; source history not mutated.
  • Integration test uses a real local HTTP server that 400s on any body containing reasoning_content, wiring the full session-history → converter → provider boundary → wire path. Not vacuous: with fix reverted, the follow-up turn receives a 400 and the response2 assertion fails.

Cross-check against existing reviews:

  • qwen-code-ci-bot R1-3 (cerebras.ts:28): isCerebrasProvider is a near-verbatim copy of isMistralHostname — extracting a shared matchesApiHost(config, host) helper would eliminate the duplication. Confirmed minor (Class 9 duplication). Already in the record; no new thread.
  • wenshao maintainer approval: live-CLI verification consistent with my findings.
  • qwen-code-review-bot: typecheck + 901 tests all passing at head. Consistent.

Scope

All 6 changed files reviewed. Not run: local build/test (no working tree); rung 3 not applicable (no platform-specific behavior changed).

Reviewed with AI assistance.

@wenshao
wenshao added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 8727061 Sep 5, 2026
135 of 138 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cerebras (OpenAI-compatible): every multi-turn request fails with '400 status code (no body)' — reasoning_content rejected on input

6 participants