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 <phs1997@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
phs1997
2026-09-19 05:05:34 +02:00
committed by GitHub
parent 233c66ffca
commit 16f8837acf
2 changed files with 80 additions and 3 deletions

View File

@@ -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})`);

View File

@@ -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();