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:
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user