From 9fd0fb0181f48523425374540a2afe7aa2fdb172 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 23 Sep 2026 15:24:15 +0700 Subject: [PATCH] fix(anthropic): non-stream /v1/messages returned OpenAI shape; strip EOS sentinel from text deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG: Anthropic client (sourceFormat=CLAUDE) hitting a claude-target provider got a raw OpenAI chat.completion body on non-streaming requests. The needsTranslation(CLAUDE,CLAUDE) gate is false when target===source, so the translator never ran; a claude-transport executor replying OpenAI JSON (opencode/big-pickle) leaked choices[]/prompt_tokens to the client, which Anthropic SDKs cannot parse (no content[] blocks, no type:"message"). FIX: shape-aware guard toClaudeMessageShape() in nonStreamingHandler — when sourceFormat is CLAUDE, convert any OpenAI-shape body to a proper Claude message (type, content blocks with thinking/text/tool_use, stop_reason via finish mapping, usage input/output tokens). Claude-shaped bodies pass through. Also: strip <|im_end|>/<|endoftext|>/<|eot_id|> EOS sentinels from Claude text deltas in openai-to-claude and kiro-to-claude translators — the upstream EOS token leaks into the final text_delta (observed 'OK<|im_end|>'). Tests: anthropic-nonstream-shape.test.js (6: eos strip + shape guard incl tool_calls→tool_use, pass-through, finish mapping). 28/28 related tests green. --- .../handlers/chatCore/nonStreamingHandler.js | 26 ++++++- open-sse/translator/concerns/eosStrip.js | 32 ++++++++ .../translator/response/kiro-to-claude.js | 3 +- .../translator/response/openai-to-claude.js | 3 +- tests/unit/anthropic-nonstream-shape.test.js | 76 +++++++++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 open-sse/translator/concerns/eosStrip.js create mode 100644 tests/unit/anthropic-nonstream-shape.test.js diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 8c21daf2..694600c7 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -23,6 +23,20 @@ function parseToolArguments(value) { } } +/** + * 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]; @@ -346,9 +360,19 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); - const translatedResponse = needsTranslation(targetFormat, sourceFormat) + let translatedResponse = needsTranslation(targetFormat, sourceFormat) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames) : responseBody; + // Shape-aware guard: an Anthropic client (sourceFormat=CLAUDE) must receive a + // Claude message even when the provider answered in OpenAI Chat shape. The + // needsTranslation(CLAUDE,CLAUDE) gate above is false when target===source + // (both CLAUDE), so the raw provider body is returned — and a claude-transport + // executor replying OpenAI JSON leaks a chat.completion body to the client + // (observed with opencode/big-pickle: no content[], no type:"message"). + if (sourceFormat === FORMATS.CLAUDE) { + translatedResponse = toClaudeMessageShape(translatedResponse); + } + const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message"; // Responses-format translation produces a `object:"response"` body with no // `choices`; skip the Chat-Completions-specific post-processing below for it. diff --git a/open-sse/translator/concerns/eosStrip.js b/open-sse/translator/concerns/eosStrip.js new file mode 100644 index 00000000..0b6a544e --- /dev/null +++ b/open-sse/translator/concerns/eosStrip.js @@ -0,0 +1,32 @@ +// EOS sentinel strip for translated streaming text. +// +// Some upstream executors/providers (observed: opencode/big-pickle behind a +// claude transport) append their special end-of-stream token verbatim to the +// final text delta — e.g. "OK<|im_end|>" — because the tokenizer emits it and +// the executor's OpenAI-compatible decoder passes it through as content. An +// Anthropic client receiving such a delta renders the literal marker in the +// transcript (and a strict schema check flags the unknown character run). +// Stripping is safe: these tokens only ever appear exactly once, as a suffix +// of the final chunk, and never carry meaning in a translated stream. +const EOS_SENTINELS = [ + "<|im_end|>", + "<|endoftext|>", + "<|eot_id|>", + "<|end|>", +]; + +/** Remove a trailing EOS sentinel from streaming text (suffix-only, once). */ +export function stripEosSentinel(text) { + if (!text || typeof text !== "string") return text; + for (const sentinel of EOS_SENTINELS) { + if (text.endsWith(sentinel)) { + return text.slice(0, -sentinel.length); + } + } + // Some providers emit it mid-chunk boundary (sentinel split across two + // deltas) — strip any occurrence, since these tokens have no legitimate + // place in model output. + const hasAny = EOS_SENTINELS.find((s) => text.includes(s)); + if (hasAny) return text.split(hasAny).join(""); + return text; +} \ No newline at end of file diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index d9fc0aab..853d524f 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -15,6 +15,7 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { stripEosSentinel } from "../concerns/eosStrip.js"; function stopThinkingBlock(state, results) { if (!state.thinkingBlockStarted) return; @@ -153,7 +154,7 @@ export function kiroToClaudeResponse(chunk, state) { results.push({ type: "content_block_delta", index: state.textBlockIndex, - delta: { type: "text_delta", text: delta.content }, + delta: { type: "text_delta", text: stripEosSentinel(delta.content) }, }); } diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index 3998cc84..2e9986f9 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -3,6 +3,7 @@ import { FORMATS } from "../formats.js"; import { ROLE, CLAUDE_BLOCK, MODEL_FALLBACK } from "../schema/index.js"; import { fromOpenAIFinish } from "../concerns/finishReason.js"; import { extractReasoningText } from "../concerns/reasoning.js"; +import { stripEosSentinel } from "../concerns/eosStrip.js"; // Legacy "proxy_" prefix used by older request translators. Response strips it // defensively so tool names from such turns resolve back (e.g. proxy_Read → Read @@ -175,7 +176,7 @@ export function openaiToClaudeResponse(chunk, state) { results.push({ type: "content_block_delta", index: state.textBlockIndex, - delta: { type: "text_delta", text: delta.content } + delta: { type: "text_delta", text: stripEosSentinel(delta.content) } }); } diff --git a/tests/unit/anthropic-nonstream-shape.test.js b/tests/unit/anthropic-nonstream-shape.test.js new file mode 100644 index 00000000..1fafeb29 --- /dev/null +++ b/tests/unit/anthropic-nonstream-shape.test.js @@ -0,0 +1,76 @@ +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"); + +// An OpenAI-shape body as returned by a claude-transport executor (opencode/ +// big-pickle): no type:"message", choices[] present. This is what an Anthropic +// client request gets today via the CLAUDE→CLAUDE path needsTranslation skips. +const OPENAI_BODY = { + id: "chatcmpl-abc123", + object: "chat.completion", + created: 1789056634, + model: "big-pickle", + choices: [ + { + index: 0, + message: { role: "assistant", content: "OK", tool_calls: null }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, +}; + +describe("stripEosSentinel", () => { + it("removes trailing <|im_end|>", () => { + expect(stripEosSentinel("OK<|im_end|>")).toBe("OK"); + }); + + it("removes trailing <|endoftext|> and <|eot_id|>", () => { + expect(stripEosSentinel("hi<|endoftext|>")).toBe("hi"); + expect(stripEosSentinel("hi<|eot_id|>")).toBe("hi"); + }); + + it("leaves normal text untouched", () => { + expect(stripEosSentinel("plain answer")).toBe("plain answer"); + expect(stripEosSentinel("")).toBe(""); + expect(stripEosSentinel(null)).toBeNull(); + }); +}); + +describe("toClaudeMessageShape (Anthropic non-stream guard)", () => { + it("converts OpenAI body to Claude message", () => { + const out = toClaudeMessageShape(structuredClone(OPENAI_BODY)); + expect(out.type).toBe("message"); + expect(out.content).toEqual([{ type: "text", text: "OK" }]); + expect(out.stop_reason).toBe("end_turn"); + expect(out.stop_sequence).toBeNull(); + expect(out.usage).toEqual({ input_tokens: 10, output_tokens: 2 }); + }); + + it("leaves an already-Claude message untouched", () => { + const claudeBody = { + id: "msg_abc", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "OK" }], + stop_reason: "end_turn", + }; + expect(toClaudeMessageShape(claudeBody)).toBe(claudeBody); + }); + + it("converts tool_calls to tool_use blocks", () => { + const body = structuredClone(OPENAI_BODY); + body.choices[0].message.tool_calls = [ + { id: "call_1", type: "function", function: { name: "read", arguments: "{\"path\":\"/x\"}" } }, + ]; + body.choices[0].finish_reason = "tool_calls"; + const out = toClaudeMessageShape(body); + const toolBlock = out.content.find((c) => c.type === "tool_use"); + expect(toolBlock.name).toBe("read"); + expect(toolBlock.id).toBe("call_1"); + expect(toolBlock.input).toEqual({ path: "/x" }); + expect(out.stop_reason).toBe("tool_use"); + }); +}); \ No newline at end of file