Skip to content

g1t/services/models/src/chat.test.ts

239 lines13,172 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

AI Gateway: OpenAI's format, open models, and your own providers1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import {
5 ChatStreamTranslator,
6 Untranslatable,
7 anthropicEffort,
8 anthropicToChat,
9 chatToAnthropic,
10 chatUsage,
11 finishReason,
12 openaiError,
13} from "./chat.ts";
14import { reasoningEffort, toChat } from "./openai.ts";
15
16test("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
70test("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
91test("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
97test("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
115test("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
156const 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]. */
159function 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
165test("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
215test("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
228test("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});