From 16f8837acfb5ed493ad2b94bf8d79b4e7f1bad74 Mon Sep 17 00:00:00 2001 From: phs1997 Date: Sat, 19 Sep 2026 05:05:34 +0200 Subject: [PATCH] fix(chatCore): only preserve tool_result blocks for Claude-native targets (#13972) * fix(chatCore): only preserve tool_result blocks for Claude-native targets When forwarding requests through the CC-bridge or Claude passthrough paths to OpenAI-compatible upstream targets (e.g. logfare, OpenRouter), Anthropic-shape tool_result content parts in user messages were preserved unchanged, causing upstream gateways to reject the request with HTTP 503. Gate preserveToolResultBlocks on targetFormat === FORMATS.CLAUDE so that OpenAI targets receive properly normalized content strings. Fixes #13971 * fix(chatCore): add regression coverage for the CC-bridge tool_result guard Adds a one-line clarifying comment on the isClaudePassthrough call site (the guard is a no-op there by construction) and two chatcore-translation-paths tests proving the CC-bridge branch strips raw tool_result blocks for OpenAI-compatible targets while still preserving them for Claude-native ones. --------- Co-authored-by: phs1997 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/handlers/chatCore.ts | 18 ++++- tests/unit/chatcore-translation-paths.test.ts | 65 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) 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();