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.
74 lines
2.6 KiB
JavaScript
74 lines
2.6 KiB
JavaScript
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 }; |