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.
76 lines
2.7 KiB
JavaScript
76 lines
2.7 KiB
JavaScript
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");
|
|
});
|
|
}); |