diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 4c5a60e08b..43b1a73132 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -1,6 +1,7 @@ // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts"; +import { getModelTargetFormat } from "../../config/providerModels.ts"; // MiniMax exposes a Claude-compatible endpoint but rejects Anthropic's extended // `output_config` parameter (used to steer reasoning effort and structured output) @@ -220,7 +221,8 @@ function markMessageCacheControl(msg: ClaudeMessage, ttl?: string): boolean { export function prepareClaudeRequest( body: ClaudeRequestBody, provider: string | null = null, - preserveCacheControl = false + preserveCacheControl = false, + model: string | null = null ): ClaudeRequestBody { // 0. Strip Anthropic `output_config` for providers that reject it on their // Claude-compatible endpoints (MiniMax). Must run before any downstream @@ -243,7 +245,13 @@ export function prepareClaudeRequest( // We use the same allowlist as prompt-caching: only Anthropic-native // upstreams get redacted_thinking. Everything else gets plain thinking blocks // backed by reasoningCache (real text) or a placeholder (cache miss). - const supportsRedactedThinking = supportsPromptCaching; + // Mixed-format providers (e.g. opencode-go) may have some models targeting + // Anthropic's Messages API (targetFormat=claude) and others targeting OpenAI. + // When the specific model targets Claude format, it's hitting a real Anthropic + // endpoint that validates signatures — so it needs redacted_thinking too. + const modelTargetsClaude = + !!provider && !!model && getModelTargetFormat(provider, model) === "claude"; + const supportsRedactedThinking = supportsPromptCaching || modelTargetsClaude; const systemBlocks = body.system; if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) { diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8920568278..d22ddbb3f8 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -256,7 +256,7 @@ export function translateRequest( if (targetFormat === FORMATS.CLAUDE) { const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE; const preserveCache = isClaudePassthrough || options?.preserveCacheControl === true; - result = prepareClaudeRequest(result, provider, preserveCache); + result = prepareClaudeRequest(result, provider, preserveCache, model); } // Normalize openai-responses input shape for providers that require list input. diff --git a/tests/unit/translator-redacted-thinking-model-aware-4479.test.ts b/tests/unit/translator-redacted-thinking-model-aware-4479.test.ts new file mode 100644 index 0000000000..64a90c7be6 --- /dev/null +++ b/tests/unit/translator-redacted-thinking-model-aware-4479.test.ts @@ -0,0 +1,72 @@ +/** + * Regression for #4479 (port: model-aware supportsRedactedThinking for mixed-format providers). + * + * Mixed-format providers like `opencode-go` have some models that target Anthropic's + * Messages API (targetFormat="claude", e.g. minimax-m3) and others that target OpenAI's + * /chat/completions (default, e.g. glm-5.1). The Anthropic-format models hit a real + * Anthropic endpoint that validates signatures, so they need `redacted_thinking` blocks — + * but `supportsRedactedThinking` used to be decided by provider name alone, so those models + * received plain `thinking` blocks without a signature and got a 400. + * + * prepareClaudeRequest now takes a `model` param and also enables redacted_thinking when + * getModelTargetFormat(provider, model) === "claude". These tests pin both branches plus + * the backward-compatible no-model call. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts"); +const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import( + "../../open-sse/config/defaultThinkingSignature.ts" +); + +// Assistant turn with only a tool_use → prepareClaudeRequest injects a thinking block before +// it. Anthropic-format upstreams get redacted_thinking{data}; everyone else gets plain +// thinking{text}. Mirrors the shape in translator-claude-helper-thinking.test.ts. +function bodyWithToolUseNeedingThinking() { + return { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_x", name: "ls", input: { path: "." } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_x", content: "README.md" }], + }, + ], + }; +} + +test("opencode-go + claude-format model (minimax-m3) → redacted_thinking with synthetic data", () => { + const result = prepareClaudeRequest( + bodyWithToolUseNeedingThinking() as any, + "opencode-go", + false, + "minimax-m3" + ); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "redacted_thinking", "claude-targetFormat model needs redacted_thinking"); + assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(content[0].thinking, undefined); +}); + +test("opencode-go + OpenAI-format model (glm-5.1) → plain thinking (no redaction)", () => { + const result = prepareClaudeRequest( + bodyWithToolUseNeedingThinking() as any, + "opencode-go", + false, + "glm-5.1" + ); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "thinking", "OpenAI-targetFormat model must stay plain thinking"); + assert.equal(content[0].data, undefined, "no synthetic redacted data for non-Anthropic upstream"); +}); + +test("opencode-go without model param → plain thinking (backward-compatible)", () => { + const result = prepareClaudeRequest(bodyWithToolUseNeedingThinking() as any, "opencode-go"); + const content = (result as any).messages[1].content; + assert.equal(content[0].type, "thinking", "no model → old provider-name behavior preserved"); +});