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:
@@ -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 };
|
||||
Reference in New Issue
Block a user