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:
@@ -1,82 +1,18 @@
|
|||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
import { needsTranslation } from "../../translator/index.js";
|
import { needsTranslation } from "../../translator/index.js";
|
||||||
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
|
|
||||||
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
||||||
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
||||||
import { createErrorResult } from "../../utils/error.js";
|
import { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
||||||
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.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 { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||||
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
||||||
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
||||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.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
|
* Convert an OpenAI Chat Completions non-streaming response body into the
|
||||||
* OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex)
|
* 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 { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
|
import { toClaudeMessageShape } from "../../translator/concerns/claudeShape.js";
|
||||||
import { PROVIDERS } from "../../config/providers.js";
|
import { PROVIDERS } from "../../config/providers.js";
|
||||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.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
|
// lost on the non-streaming return path. Inlined (not imported from
|
||||||
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
|
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
|
||||||
// already imports parseSSEToOpenAIResponse from this module.
|
// already imports parseSSEToOpenAIResponse from this module.
|
||||||
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
let finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||||
? chatCompletionToResponses(parsed, customToolNames)
|
? chatCompletionToResponses(parsed, customToolNames)
|
||||||
: parsed;
|
: 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": "*" } }) };
|
return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ChatCore] Chat Completions SSE→JSON failed:", 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 };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const { stripEosSentinel } = await import("../../open-sse/translator/concerns/eosStrip.js");
|
const { stripEosSentinel } = await import("../../open-sse/translator/concerns/eosStrip.js");
|
||||||
const { toClaudeMessageShape } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
|
const { toClaudeMessageShape } = await import("../../open-sse/translator/concerns/claudeShape.js");
|
||||||
|
|
||||||
// An OpenAI-shape body as returned by a claude-transport executor (opencode/
|
// An OpenAI-shape body as returned by a claude-transport executor (opencode/
|
||||||
// big-pickle): no type:"message", choices[] present. This is what an Anthropic
|
// big-pickle): no type:"message", choices[] present. This is what an Anthropic
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const { parseSSEToOpenAIResponse } = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
|
||||||
|
const { toClaudeMessageShape } = await import("../../open-sse/translator/concerns/claudeShape.js");
|
||||||
|
|
||||||
|
// SSE fixture of an OpenAI chat streaming provider (eg big-pickle forced
|
||||||
|
// streaming): text deltas + reasoning + tool_calls + usage.
|
||||||
|
const OPENAI_SSE = [
|
||||||
|
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"let me think\"},\"finish_reason\":null}]}",
|
||||||
|
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hel\"},\"finish_reason\":null}]}",
|
||||||
|
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":null}]}",
|
||||||
|
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Jakarta\\\"}\"}}]},\"finish_reason\":null}]}",
|
||||||
|
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20,\"total_tokens\":30}}",
|
||||||
|
"data: [DONE]"
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("parseSSEToOpenAIResponse (forced streaming → JSON)", () => {
|
||||||
|
it("reassembles content, reasoning, tool_calls, finish_reason, usage", () => {
|
||||||
|
const parsed = parseSSEToOpenAIResponse(OPENAI_SSE, "big-pickle");
|
||||||
|
expect(parsed.object).toBe("chat.completion");
|
||||||
|
expect(parsed.choices[0].message.content).toBe("Hello");
|
||||||
|
expect(parsed.choices[0].message.reasoning_content).toBe("let me think");
|
||||||
|
expect(parsed.choices[0].message.tool_calls).toHaveLength(1);
|
||||||
|
expect(parsed.choices[0].message.tool_calls[0].function.name).toBe("get_weather");
|
||||||
|
expect(parsed.choices[0].message.tool_calls[0].function.arguments).toContain("Jakarta");
|
||||||
|
expect(parsed.choices[0].finish_reason).toBe("tool_calls");
|
||||||
|
expect(parsed.usage.total_tokens).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty / malformed SSE", () => {
|
||||||
|
expect(parseSSEToOpenAIResponse("", "m")).toBeNull();
|
||||||
|
expect(parseSSEToOpenAIResponse("data: [DONE]", "m")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sseToJson Anthropic guard (sourceFormat=CLAUDE)", () => {
|
||||||
|
it("converts parsed OpenAI body to Claude message shape", () => {
|
||||||
|
const parsed = parseSSEToOpenAIResponse(OPENAI_SSE, "big-pickle");
|
||||||
|
const out = toClaudeMessageShape(parsed);
|
||||||
|
|
||||||
|
expect(out.type).toBe("message");
|
||||||
|
expect(out.role).toBe("assistant");
|
||||||
|
expect(Array.isArray(out.content)).toBe(true);
|
||||||
|
expect(out.content[0].type).toBe("thinking");
|
||||||
|
expect(out.content[0].thinking).toBe("let me think");
|
||||||
|
expect(out.content[1].type).toBe("text");
|
||||||
|
expect(out.content[1].text).toBe("Hello");
|
||||||
|
expect(out.content[2].type).toBe("tool_use");
|
||||||
|
expect(out.content[2].name).toBe("get_weather");
|
||||||
|
expect(out.stop_reason).toBe("tool_use");
|
||||||
|
expect(out.usage.input_tokens).toBe(10);
|
||||||
|
expect(out.usage.output_tokens).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user