fix(translator): shape Anthropic non-stream responses from forced-streaming providers

The non-streaming Anthropic path leaked OpenAI chat.completion bodies to
Claude clients when the upstream provider forces streaming (forced
SSE->JSON path): parseSSEToOpenAIResponse yields choices[] which Claude
Code cannot parse. Convert to Claude Messages shape for sourceFormat
CLAUDE in sseToJsonHandler.

Shared toClaudeMessageShape/openAICompletionToClaudeMessage moved to
translator/concerns/claudeShape.js to avoid circular import between
nonStreamingHandler and sseToJsonHandler. 5 new tests: sse-to-json
reassembly + Claude guard, plus existing anthropic-nonstream-shape
updated to import from the concern.
This commit is contained in:
asepharyana
2026-09-23 15:36:24 +07:00
parent b239b2a8a9
commit 3de32a10f3
5 changed files with 139 additions and 67 deletions
@@ -1,82 +1,18 @@
import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js";
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
import { toClaudeMessageShape, openAICompletionToClaudeMessage } from "../../translator/concerns/claudeShape.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
/**
* Convert an OpenAI Chat Completions body into the Claude Messages shape when
* the client speaks Anthropic (sourceFormat=CLAUDE) but the provider replied
* OpenAI JSON. This is the shape-aware guard for the non-streaming path: the
* needsTranslation(CLAUDE,CLAUDE) gate is false when target===source, so the
* raw provider body would otherwise leak to the Anthropic client with no
* content[] blocks and no type:"message".
*/
export function toClaudeMessageShape(responseBody) {
if (!responseBody || responseBody.type === "message") return responseBody;
if (responseBody?.choices) return openAICompletionToClaudeMessage(responseBody);
return responseBody;
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/**
* Convert an OpenAI Chat Completions non-streaming response body into the
* OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex)
@@ -3,6 +3,7 @@ import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { toClaudeMessageShape } from "../../translator/concerns/claudeShape.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
@@ -356,10 +357,17 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
// lost on the non-streaming return path. Inlined (not imported from
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
// already imports parseSSEToOpenAIResponse from this module.
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
let finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
? chatCompletionToResponses(parsed, customToolNames)
: parsed;
// Anthropic client behind a forced-streaming chat provider: the SSE was
// OpenAI-shaped, so hand the client the Claude Messages shape (type:
// "message", content[] blocks, stop_reason) instead of leaking choices[].
if (sourceFormat === FORMATS.CLAUDE) {
finalBody = toClaudeMessageShape(finalBody);
}
return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
@@ -0,0 +1,74 @@
import { FORMATS } from "../formats.js";
import { fromOpenAIFinish } from "./finishReason.js";
// Shared OpenAI-Chat → Claude-Messages shape conversion.
//
// Lives in `concerns/` (not chatCore/) because BOTH the non-streaming handler
// and the forced-SSE→JSON handler need it, and importing it from
// nonStreamingHandler.js into sseToJsonHandler.js would create a circular
// import (nonStreamingHandler already imports parseSSEToOpenAIResponse from
// sseToJsonHandler).
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/**
* Convert an OpenAI Chat Completions body into the Claude Messages shape when
* the client speaks Anthropic (sourceFormat=CLAUDE) but the provider replied
* OpenAI JSON. Shape-aware guard: Claude-shaped bodies pass through untouched.
*/
export function toClaudeMessageShape(responseBody) {
if (!responseBody || responseBody.type === "message") return responseBody;
if (responseBody?.choices) return openAICompletionToClaudeMessage(responseBody);
return responseBody;
}
/** Export the raw converter for tests that want it directly. */
export { openAICompletionToClaudeMessage };