| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { |
| 5 | ChatStreamTranslator, |
| 6 | Untranslatable, |
| 7 | anthropicEffort, |
| 8 | anthropicToChat, |
| 9 | chatToAnthropic, |
| 10 | chatUsage, |
| 11 | finishReason, |
| 12 | openaiError, |
| 13 | } from "./chat.ts"; |
| 14 | import { reasoningEffort, toChat } from "./openai.ts"; |
| 15 | |
| 16 | test("a chat request with tools becomes an Anthropic message", () => { |
| 17 | const body = chatToAnthropic( |
| 18 | { |
| 19 | model: "anthropic/claude-haiku-5-5", |
| 20 | messages: [ |
| 21 | { role: "system", content: "You are terse." }, |
| 22 | { role: "developer", content: [{ type: "text", text: "Use the tools." }] }, |
| 23 | { role: "user", content: "Weather in Lisbon?" }, |
| 24 | { |
| 25 | role: "assistant", |
| 26 | content: null, |
| 27 | tool_calls: [{ id: "toolu_1", type: "function", function: { name: "weather", arguments: '{"city":"Lisbon"}' } }], |
| 28 | }, |
| 29 | { role: "tool", tool_call_id: "toolu_1", content: "21C, clear" }, |
| 30 | { role: "user", content: [{ type: "text", text: "And tomorrow?" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }] }, |
| 31 | ], |
| 32 | tools: [{ type: "function", function: { name: "weather", description: "Forecast", parameters: { type: "object", properties: { city: { type: "string" } } }, strict: true } }], |
| 33 | tool_choice: "auto", |
| 34 | parallel_tool_calls: false, |
| 35 | max_completion_tokens: 512, |
| 36 | temperature: 0.3, |
| 37 | stop: "END", |
| 38 | stream: true, |
| 39 | reasoning_effort: "minimal", |
| 40 | user: "ada", |
| 41 | }, |
| 42 | "claude-haiku-5-5", |
| 43 | ); |
| 44 | assert.deepEqual(body, { |
| 45 | model: "claude-haiku-5-5", |
| 46 | system: "You are terse.\n\nUse the tools.", |
| 47 | messages: [ |
| 48 | { role: "user", content: [{ type: "text", text: "Weather in Lisbon?" }] }, |
| 49 | { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "weather", input: { city: "Lisbon" } }] }, |
| 50 | { |
| 51 | role: "user", |
| 52 | content: [ |
| 53 | { type: "tool_result", tool_use_id: "toolu_1", content: "21C, clear" }, |
| 54 | { type: "text", text: "And tomorrow?" }, |
| 55 | { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } }, |
| 56 | ], |
| 57 | }, |
| 58 | ], |
| 59 | max_tokens: 512, |
| 60 | stream: true, |
| 61 | temperature: 0.3, |
| 62 | stop_sequences: ["END"], |
| 63 | metadata: { user_id: "ada" }, |
| 64 | output_config: { effort: "low" }, |
| 65 | tools: [{ name: "weather", description: "Forecast", input_schema: { type: "object", properties: { city: { type: "string" } } }, strict: true }], |
| 66 | tool_choice: { type: "auto", disable_parallel_tool_use: true }, |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | test("tool choice, structured output and the default max tokens", () => { |
| 71 | const forced = chatToAnthropic( |
| 72 | { |
| 73 | messages: [{ role: "user", content: "x" }], |
| 74 | tools: [{ type: "function", function: { name: "f", parameters: { type: "object" } } }], |
| 75 | tool_choice: { type: "function", function: { name: "f" } }, |
| 76 | response_format: { type: "json_schema", json_schema: { name: "a", schema: { type: "object" } } }, |
| 77 | }, |
| 78 | "m", |
| 79 | ); |
| 80 | assert.deepEqual(forced.tool_choice, { type: "tool", name: "f" }); |
| 81 | assert.deepEqual(forced.output_config, { format: { type: "json_schema", schema: { type: "object" } } }); |
| 82 | assert.equal(forced.max_tokens, 8192); |
| 83 | assert.deepEqual(chatToAnthropic({ messages: [{ role: "user", content: "x" }], tool_choice: "required" }, "m").tool_choice, { type: "any" }); |
| 84 | assert.deepEqual(chatToAnthropic({ messages: [{ role: "user", content: "x" }], tool_choice: "none" }, "m").tool_choice, { type: "none" }); |
| 85 | const json = chatToAnthropic({ messages: [{ role: "user", content: "x" }], response_format: { type: "json_object" } }, "m"); |
| 86 | assert.match(String(json.system), /JSON object/); |
| 87 | // Anthropic's own thinking settings pass through for a caller that knows them. |
| 88 | assert.deepEqual(chatToAnthropic({ messages: [{ role: "user", content: "x" }], thinking: { type: "adaptive" } }, "m").thinking, { type: "adaptive" }); |
| 89 | }); |
| 90 | |
| 91 | test("what cannot be said to Claude is refused, not dropped", () => { |
| 92 | assert.throws(() => chatToAnthropic({ messages: [{ role: "user", content: "x" }], n: 2 }, "m"), Untranslatable); |
| 93 | assert.throws(() => chatToAnthropic({ messages: [{ role: "user", content: [{ type: "input_audio" }] }] }, "m"), Untranslatable); |
| 94 | assert.throws(() => chatToAnthropic({ messages: [{ role: "user", content: "x" }], tools: [{ type: "web_search" }] }, "m"), Untranslatable); |
| 95 | }); |
| 96 | |
| 97 | test("effort maps between the two formats", () => { |
| 98 | assert.equal(anthropicEffort("minimal"), "low"); |
| 99 | assert.equal(anthropicEffort("none"), "low"); |
| 100 | assert.equal(anthropicEffort("high"), "high"); |
| 101 | assert.equal(anthropicEffort("bogus"), undefined); |
| 102 | assert.equal(reasoningEffort("max"), "high"); |
| 103 | assert.equal(reasoningEffort("xhigh"), "high"); |
| 104 | assert.equal(reasoningEffort("medium"), "medium"); |
| 105 | // Anthropic-format effort and schemas reach an OpenAI-shaped provider. |
| 106 | const body = toChat( |
| 107 | { messages: [{ role: "user", content: "x" }], output_config: { effort: "max", format: { type: "json_schema", schema: { type: "object" } } } }, |
| 108 | "gpt-oss", |
| 109 | { official: false }, |
| 110 | ); |
| 111 | assert.equal(body.reasoning_effort, "high"); |
| 112 | assert.deepEqual(body.response_format, { type: "json_schema", json_schema: { name: "answer", schema: { type: "object" }, strict: true } }); |
| 113 | }); |
| 114 | |
| 115 | test("an Anthropic answer becomes a chat completion, thinking carried with its tool call", () => { |
| 116 | const thinking = { type: "thinking", thinking: "Check the tool.", signature: "sig-abc" }; |
| 117 | const completion = anthropicToChat( |
| 118 | { |
| 119 | id: "msg_01", |
| 120 | content: [thinking, { type: "text", text: "Looking." }, { type: "tool_use", id: "toolu_9", name: "weather", input: { city: "Lisbon" } }], |
| 121 | stop_reason: "tool_use", |
| 122 | usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 900, cache_creation_input_tokens: 50 }, |
| 123 | }, |
| 124 | "anthropic/claude-sonnet-5-5", |
| 125 | ); |
| 126 | assert.equal(completion.object, "chat.completion"); |
| 127 | assert.equal(completion.model, "anthropic/claude-sonnet-5-5"); |
| 128 | const choice = (completion.choices as Record<string, unknown>[])[0]!; |
| 129 | assert.equal(choice.finish_reason, "tool_calls"); |
| 130 | const message = choice.message as Record<string, unknown>; |
| 131 | assert.equal(message.content, "Looking."); |
| 132 | assert.equal(message.reasoning_content, "Check the tool."); |
| 133 | const call = (message.tool_calls as { id: string; function: { name: string; arguments: string } }[])[0]!; |
| 134 | assert.equal(call.function.name, "weather"); |
| 135 | assert.deepEqual(JSON.parse(call.function.arguments), { city: "Lisbon" }); |
| 136 | assert.deepEqual(completion.usage, { prompt_tokens: 1050, completion_tokens: 20, total_tokens: 1070, prompt_tokens_details: { cached_tokens: 900 } }); |
| 137 | |
| 138 | // The next turn gives the call back; the thinking that led to it goes with it. |
| 139 | const next = chatToAnthropic( |
| 140 | { |
| 141 | messages: [ |
| 142 | { role: "user", content: "Weather?" }, |
| 143 | { role: "assistant", content: "Looking.", tool_calls: [call] }, |
| 144 | { role: "tool", tool_call_id: call.id, content: "21C" }, |
| 145 | ], |
| 146 | }, |
| 147 | "claude-sonnet-5-5", |
| 148 | ); |
| 149 | const assistant = (next.messages as { role: string; content: Record<string, unknown>[] }[])[1]!; |
| 150 | assert.deepEqual(assistant.content[0], thinking); |
| 151 | assert.deepEqual(assistant.content[2], { type: "tool_use", id: "toolu_9", name: "weather", input: { city: "Lisbon" } }); |
| 152 | const result = (next.messages as { content: Record<string, unknown>[] }[])[2]!.content[0]!; |
| 153 | assert.equal(result.tool_use_id, "toolu_9"); |
| 154 | }); |
| 155 | |
| 156 | const sse = (events: object[]) => events.map((e) => `event: ${(e as { type: string }).type}\ndata: ${JSON.stringify(e)}\n\n`).join(""); |
| 157 | |
| 158 | /** The chunks a translator sends, parsed, and whether it ended with [DONE]. */ |
| 159 | function chunks(out: string): { data: Record<string, unknown>[]; done: boolean } { |
| 160 | const lines = out.split("\n").filter((line) => line.startsWith("data: ")); |
| 161 | const done = lines.at(-1) === "data: [DONE]"; |
| 162 | return { data: lines.filter((line) => line !== "data: [DONE]").map((line) => JSON.parse(line.slice(6))), done }; |
| 163 | } |
| 164 | |
| 165 | test("Anthropic's stream becomes chat chunks: text, reasoning, a tool call, the stop and the usage", () => { |
| 166 | const stream = sse([ |
| 167 | { type: "message_start", message: { id: "msg_7", usage: { input_tokens: 10, cache_read_input_tokens: 300, output_tokens: 1 } } }, |
| 168 | { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, |
| 169 | { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Hmm." } }, |
| 170 | { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig" } }, |
| 171 | { type: "content_block_stop", index: 0 }, |
| 172 | { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, |
| 173 | { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Let me " } }, |
| 174 | { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "check." } }, |
| 175 | { type: "content_block_stop", index: 1 }, |
| 176 | { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "toolu_2", name: "weather", input: {} } }, |
| 177 | { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '{"city":' } }, |
| 178 | { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '"Lisbon"}' } }, |
| 179 | { type: "content_block_stop", index: 2 }, |
| 180 | { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 42 } }, |
| 181 | { type: "message_stop" }, |
| 182 | ]); |
| 183 | const translator = new ChatStreamTranslator("anthropic/claude-haiku-5-5", true); |
| 184 | // Split mid-line, as chunks arrive. |
| 185 | const out = translator.push(stream.slice(0, 101)) + translator.push(stream.slice(101)) + translator.finish(); |
| 186 | const { data, done } = chunks(out); |
| 187 | assert.ok(done); |
| 188 | assert.ok(data.every((chunk) => chunk.id === "chatcmpl-7" && chunk.object === "chat.completion.chunk")); |
| 189 | const deltas = data.filter((chunk) => (chunk.choices as unknown[]).length).map((chunk) => (chunk.choices as { delta: Record<string, unknown> }[])[0]!.delta); |
| 190 | assert.deepEqual(deltas[0], { role: "assistant", content: "" }); |
| 191 | assert.equal(deltas.map((d) => d.content ?? "").join(""), "Let me check."); |
| 192 | assert.equal(deltas.map((d) => d.reasoning_content ?? "").join(""), "Hmm."); |
| 193 | const calls = deltas.flatMap((d) => (d.tool_calls as Record<string, unknown>[] | undefined) ?? []); |
| 194 | assert.equal(calls[0]!.index, 0); |
| 195 | assert.equal((calls[0]!.function as { name: string }).name, "weather"); |
| 196 | assert.match(String(calls[0]!.id), /^toolu_2__g1t_/); |
| 197 | assert.equal(calls.map((c) => (c.function as { arguments: string }).arguments).join(""), '{"city":"Lisbon"}'); |
| 198 | const last = data.filter((chunk) => (chunk.choices as unknown[]).length).at(-1)!; |
| 199 | assert.equal((last.choices as { finish_reason: string }[])[0]!.finish_reason, "tool_calls"); |
| 200 | const usage = data.at(-1)!; |
| 201 | assert.deepEqual(usage.choices, []); |
| 202 | assert.deepEqual(usage.usage, { prompt_tokens: 310, completion_tokens: 42, total_tokens: 352, prompt_tokens_details: { cached_tokens: 300 } }); |
| 203 | // Finishing twice sends nothing more. |
| 204 | assert.equal(translator.finish(), ""); |
| 205 | |
| 206 | // The carried id brings the thinking back, signature and all. |
| 207 | const back = chatToAnthropic( |
| 208 | { messages: [{ role: "user", content: "x" }, { role: "assistant", content: "", tool_calls: [{ id: calls[0]!.id, type: "function", function: { name: "weather", arguments: "{}" } }] }] }, |
| 209 | "m", |
| 210 | ); |
| 211 | const assistant = (back.messages as { content: Record<string, unknown>[] }[])[1]!; |
| 212 | assert.deepEqual(assistant.content[0], { type: "thinking", thinking: "Hmm.", signature: "sig" }); |
| 213 | }); |
| 214 | |
| 215 | test("a stream with no usage asked for ends without it, and an error ends it", () => { |
| 216 | const plain = new ChatStreamTranslator("m", false); |
| 217 | const out = plain.push(sse([{ type: "message_start", message: { id: "msg_1", usage: { input_tokens: 3 } } }, { type: "message_stop" }])); |
| 218 | const { data, done } = chunks(out); |
| 219 | assert.ok(done); |
| 220 | assert.ok(data.every((chunk) => !("usage" in chunk))); |
| 221 | const failing = new ChatStreamTranslator("m", true); |
| 222 | const error = chunks(failing.push(sse([{ type: "error", error: { type: "overloaded_error", message: "Overloaded" } }]))); |
| 223 | assert.deepEqual(error.data[0], { error: { message: "Overloaded", type: "overloaded_error", param: null, code: null } }); |
| 224 | assert.ok(error.done); |
| 225 | assert.equal(failing.finish(), ""); |
| 226 | }); |
| 227 | |
| 228 | test("stop reasons, usage and errors in OpenAI's terms", async () => { |
| 229 | assert.equal(finishReason("end_turn"), "stop"); |
| 230 | assert.equal(finishReason("max_tokens"), "length"); |
| 231 | assert.equal(finishReason("refusal"), "content_filter"); |
| 232 | assert.deepEqual(chatUsage(null), { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }); |
| 233 | const refused = openaiError(402, "Out of AI credit."); |
| 234 | assert.equal(refused.status, 402); |
| 235 | assert.deepEqual(await refused.json(), { error: { message: "Out of AI credit.", type: "insufficient_quota", param: null, code: "insufficient_quota" } }); |
| 236 | assert.deepEqual(await openaiError(404, "No such model.").json(), { |
| 237 | error: { message: "No such model.", type: "invalid_request_error", param: null, code: "model_not_found" }, |
| 238 | }); |
| 239 | }); |