diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7434983c79..c67b2b4dd4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2403,7 +2403,12 @@ export async function handleChatCore({ extractSystemRoleMessages(translatedBody); } else { // Non-CC path: full normalization including content type conversion. - normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true }); + // Preserve tool_result blocks only when the upstream target speaks the + // Anthropic Messages format — OpenAI-compatible gateways reject them + // and return 503. See issue #13971. + normalizeClaudeUpstreamMessages(translatedBody, { + preserveToolResultBlocks: targetFormat === FORMATS.CLAUDE, + }); } } else if (isClaudePassthrough) { // Pure passthrough: forward the body as-is without OpenAI round-trip. @@ -2454,7 +2459,16 @@ export async function handleChatCore({ ensureCacheControlOnLastUserMessage(translatedBody); } } else { - normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true }); + // Same guard as the CC-bridge path: only preserve tool_result blocks + // for Anthropic-native targets. See issue #13971. This branch only runs + // under isClaudePassthrough (sourceFormat === targetFormat === CLAUDE, + // defined above), so targetFormat === FORMATS.CLAUDE always holds here — + // the guard is a no-op on this call site, kept for symmetry with the + // CC-bridge one above rather than a change to code the issue said not + // to touch. + normalizeClaudeUpstreamMessages(translatedBody, { + preserveToolResultBlocks: targetFormat === FORMATS.CLAUDE, + }); } log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 12bab97f0f..fd6221adf2 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -379,6 +379,7 @@ async function invokeChatCore({ reasoningTransportFallback = "drop", managedLease = null, cachedSettings = null, + modelTargetFormat = undefined, }: any = {}) { const calls: any[] = []; @@ -408,7 +409,10 @@ async function invokeChatCore({ const requestBody = structuredClone(body); const result = await handleChatCore({ body: requestBody, - modelInfo: { provider, model, extendedContext: false }, + modelInfo: + modelTargetFormat !== undefined + ? { provider, model, extendedContext: false, targetFormat: modelTargetFormat } + : { provider, model, extendedContext: false }, credentials: credentials || { apiKey: "sk-test", // #13452/#13798: buildUrl() refuses an `*-compatible-*` node with no baseUrl @@ -1565,6 +1569,65 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true) assert.equal(call.body.messages[2].content[0].type, "tool_result"); }); + +// Issue #13971: the CC-bridge unconditionally preserved raw tool_result blocks even when the +// target speaks OpenAI-compatible (503 on those gateways). Fix: gate preserveToolResultBlocks +// on targetFormat === FORMATS.CLAUDE. userAgent is plain (non-Claude-Code) so both requests hit +// the CC-bridge's normalizeClaudeUpstreamMessages branch (chatCore.ts:2377-2385), not the +// Claude-Code semantic-passthrough branch above it, which this fix does not touch. +function ccBridgeToolResultCall(modelTargetFormat?: string) { + return invokeChatCore({ + provider: "anthropic-compatible-cc-test", + model: "claude-sonnet-4-6", + endpoint: "/v1/messages", + credentials: { + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" }, + }, + body: { + model: "claude-sonnet-4-6", + max_tokens: 64, + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_x", name: "Read", input: {} }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_x", content: "file contents" }], + }, + ], + tools: [{ name: "Read", input_schema: { type: "object", properties: {} } }], + }, + userAgent: "unit-test", + responseFormat: "claude", + modelTargetFormat, + }); +} +test("chatCore strips tool_result blocks on the CC-bridge path when the target is OpenAI-compatible", async () => { + const { call, result } = await ccBridgeToolResultCall("openai"); + assert.equal(result.success, true); + // No block may be raw tool_result/tool_use — that shape 503'd on #13971; the + // orphan-tool-use cleanup also drops the now-unmatched assistant turn, a stronger guard. + for (const message of call.body.messages) { + for (const block of message.content) { + assert.notEqual(block.type, "tool_result"); + assert.notEqual(block.type, "tool_use"); + } + } + const flattened = call.body.messages + .flatMap((m: { content: Array<{ text?: string }> }) => m.content) + .map((b: { text?: string }) => b.text) + .join("\n"); + assert.match(flattened, /file contents/); +}); +// Same branch, real (Claude-native) target format — tool_result stays preserved raw. +test("chatCore still preserves tool_result blocks on the CC-bridge path when the target is Claude-native", async () => { + const { call, result } = await ccBridgeToolResultCall(); + assert.equal(result.success, true); + assert.equal(call.body.messages[0].content[0].type, "tool_use"); + assert.equal(call.body.messages[1].content[0].type, "tool_result"); +}); test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => { await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" }); invalidateCacheControlSettingsCache();