fix(ai): consult compat.whenThinking when gating cross-API thinking replay

The cross-API preservation predicate added in the parent commit read only
\`model.compat\`, so it missed the resolved \`compat.whenThinking\` view that
OpenCode-hosted reasoning models (Kimi/GLM/DeepSeek/Qwen/MiMo via \`opencode-go\`
/ \`opencode-zen\`) require:

- Base compat keeps \`requiresReasoningContentForToolCalls: false\` so
  thinking-disabled requests dodge \`Extra inputs are not permitted\` (#1071).
- \`compat.whenThinking\` reactivates the flag for thinking-engaged requests
  so they dodge \`thinking is enabled but reasoning_content is missing\` (#1484).

The old predicate read the base compat, so a cross-API switch into an
OpenCode reasoning model still demoted the prior thinking to text and the
next thinking-on request re-triggered #1484. The predicate now folds both
views (base + \`whenThinking\`) through a shared \`acceptsReasoningContentReplay\`
helper; if either view permits replay, the block is preserved.

Encoder companion fix in \`openai-completions.ts\`: when preserved cross-API
thinking reaches a request whose resolved compat can't surface
\`reasoning_content\` (most often OpenCode in thinking-disabled mode), fold
the reasoning into the visible content. Same shape as
\`requiresThinkingAsText\`, but gated on signature-stripped blocks only, so
same-model replays (which keep the streaming signature) are untouched. This
preserves the pre-#3434 text-demotion semantics for that branch without
silently dropping the reasoning chain.

Tests: \`packages/ai/test/issue-3434-repro.test.ts\` now also pins:
- OpenCode \`whenThinking\` compat surfaces \`reasoning_content\` from the
  preserved block (guards against re-opening #1484 on cross-API switch).
- OpenCode base compat (thinking off) folds the preserved block into
  content (guards against silently dropping the reasoning chain).

\`bun test packages/ai\`: 2296 pass / 1 fail; the lone failure
(\`proxy.test.ts > normalizes hyphenated provider ids to underscores\`)
is the same pre-existing \`Bun.env\` leak from a sibling test that fails on
\`main\`. \`bun check\` passes.

Addresses chatgpt-codex-connector review on #3439.
This commit is contained in:
roboomp
2026-06-25 04:11:30 +00:00
parent c30e647ec5
commit 8f7fb1d074
3 changed files with 119 additions and 1 deletions
@@ -1804,6 +1804,23 @@ export function convertMessages(
// signature on cross-API replays before the block reaches us.
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
} else if (nonEmptyThinkingBlocks.every(b => !b.thinkingSignature)) {
// Cross-API preserved thinking (signature stripped by
// `transform-messages`) that reached an encoder branch which can't
// surface `reasoning_content` for THIS request — most often an
// OpenCode-hosted reasoning model in thinking-disabled mode, where
// the resolved base compat must keep the field off to dodge the
// `Extra inputs are not permitted` 400 (#1071). Fold the reasoning
// into the visible content (same shape as `requiresThinkingAsText`)
// so the next turn at least sees the prior plan as conversation
// context, matching the pre-#3434 cross-API text-demotion behavior.
// Same-model replays preserve the streaming signature and never
// land here.
const thinkingText = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n\n");
assistantMsg.content =
typeof assistantMsg.content === "string" && assistantMsg.content.length > 0
? `${thinkingText}\n\n${assistantMsg.content}`
: thinkingText;
}
}
@@ -157,13 +157,29 @@ function isOpenAICompletionsModel(model: Model): model is Model<"openai-completi
* target on the same vendor must keep reasoning as structured
* `reasoning_content` instead of degrading it to conversation text.
*
* Considers both the base compat view AND `compat.whenThinking` because the
* latter is the resolved compat the request actually runs against when
* thinking is engaged on hosts like OpenCode (`opencode-go`/`opencode-zen`),
* where the base compat intentionally suppresses `requiresReasoningContentForToolCalls`
* to dodge the thinking-off `Extra inputs are not permitted` 400 (#1071) and
* the `whenThinking` policy reactivates it for thinking-on requests to dodge
* the `thinking is enabled but reasoning_content is missing` 400 (#1484).
* Ignoring `whenThinking` would re-open #1484 for every cross-API switch into
* an OpenCode-hosted reasoning model.
*
* The downstream encoder MUST then surface the preserved block on the wire
* via `reasoningContentField` — see `openai-completions.ts` for the matching
* branch.
*/
function openAICompletionsReplaysUnsignedThinking(model: Model<"openai-completions">): boolean {
if (!model.reasoning) return false;
const compat = model.compat;
const base = model.compat;
if (acceptsReasoningContentReplay(base)) return true;
const whenThinking = base.whenThinking;
return whenThinking !== undefined && acceptsReasoningContentReplay(whenThinking);
}
function acceptsReasoningContentReplay(compat: Model<"openai-completions">["compat"]): boolean {
if (compat.requiresThinkingAsText) return false;
// Hosts that REQUIRE `reasoning_content` on tool-call turns already accept
// it elsewhere; same for Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi,
+85
View File
@@ -115,6 +115,29 @@ function deepseekReasoningTarget(): Model<"openai-completions"> {
} satisfies ModelSpec<"openai-completions">);
}
function opencodeGoKimiTarget(): Model<"openai-completions"> {
// OpenCode Go's reasoning-enabled Kimi. Base compat keeps
// `requiresReasoningContentForToolCalls: false` to dodge the
// `Extra inputs are not permitted` 400 (#1071); only the resolved
// `whenThinking` policy reactivates it (#1484). The predicate that
// gates cross-API preservation MUST consult `whenThinking`, otherwise
// the prior thinking is demoted to text and the next thinking-on
// request 400s with `thinking is enabled but reasoning_content is
// missing in assistant tool call message at index N`.
return buildModel({
id: "kimi-k2.6",
name: "Kimi K2.6",
api: "openai-completions",
provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256_000,
maxTokens: 16_384,
} satisfies ModelSpec<"openai-completions">);
}
function openAIGpt4oTarget(): Model<"openai-completions"> {
// Official OpenAI Chat Completions, non-reasoning. Thinking blocks must
// still demote to text — this target can't usefully emit `reasoning_content`.
@@ -218,4 +241,66 @@ describe("cross-API 3p ↔ 3p thinking-block preservation (#3434)", () => {
expect(content).toContain("Explore the repo, then patch it.");
expect(content).toContain("Done.");
});
it("preserves cross-API thinking for OpenCode reasoning targets that gate replay via compat.whenThinking", () => {
// OpenCode (`opencode-go`, `opencode-zen`) reasoning models keep
// `requiresReasoningContentForToolCalls: false` on the base compat
// (dodges the thinking-off `Extra inputs are not permitted` 400 — #1071)
// and reactivate the flag on `compat.whenThinking` for thinking-engaged
// requests (dodges the `thinking is enabled but reasoning_content is
// missing` 400 — #1484). Before this guard, the cross-API preservation
// predicate only read the base compat, so prior thinking was demoted
// to text and the next thinking-on request re-triggered #1484.
const target = opencodeGoKimiTarget();
const messages: Message[] = [
userMessage("Plan it."),
zaiAnthropicMessage("Read README and answer."),
userMessage("Continue on OpenCode."),
];
// Resolve the thinking-engaged compat the way `streamOpenAICompletions`
// does for a request with reasoning effort set, then hand it to
// `convertMessages` directly so the test exercises the same encoder
// configuration the live wire would.
const compat = target.compat.whenThinking ?? target.compat;
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
const wire = convertMessages(target, { messages }, compat);
const assistant = findAssistantMessage(wire);
expect(assistant).toBeDefined();
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBe("Read README and answer.");
});
it("folds preserved thinking into content when the OpenCode base compat (thinking off) cannot surface reasoning_content", () => {
// Companion of the prior test: same OpenCode target, but the request
// runs against the BASE compat (thinking disabled, the path that bars
// `reasoning_content` per #1071). Without the encoder fallback,
// transform-messages would preserve the native thinking block and the
// encoder would silently drop it — a regression vs. the pre-#3434
// text-demotion behavior. The fallback folds the reasoning into the
// visible content so the next turn still sees the prior plan.
const target = opencodeGoKimiTarget();
const compat = target.compat;
expect(compat.requiresReasoningContentForToolCalls).toBe(false);
const messages: Message[] = [
userMessage("Plan it."),
zaiAnthropicMessage("Read README and answer."),
userMessage("Continue with thinking off."),
];
const wire = convertMessages(target, { messages }, compat);
const assistant = findAssistantMessage(wire);
expect(assistant).toBeDefined();
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBeUndefined();
const content = assistant.content;
expect(typeof content).toBe("string");
if (typeof content !== "string") throw new Error("content not a string");
expect(content).toContain("Read README and answer.");
expect(content).toContain("Done.");
});
});