diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 694600c7..54311d38 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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) diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 800c92a7..5755f75d 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -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); diff --git a/open-sse/translator/concerns/claudeShape.js b/open-sse/translator/concerns/claudeShape.js new file mode 100644 index 00000000..ddd38e39 --- /dev/null +++ b/open-sse/translator/concerns/claudeShape.js @@ -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 }; \ No newline at end of file diff --git a/tests/unit/anthropic-nonstream-shape.test.js b/tests/unit/anthropic-nonstream-shape.test.js index 1fafeb29..c94ec5c6 100644 --- a/tests/unit/anthropic-nonstream-shape.test.js +++ b/tests/unit/anthropic-nonstream-shape.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; 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/ // big-pickle): no type:"message", choices[] present. This is what an Anthropic diff --git a/tests/unit/sse-to-json-claude-shape.test.js b/tests/unit/sse-to-json-claude-shape.test.js new file mode 100644 index 00000000..24f26f0e --- /dev/null +++ b/tests/unit/sse-to-json-claude-shape.test.js @@ -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); + }); +}); \ No newline at end of file