1import { describe, expect, it } from "vitest";
2
3import { AnthropicAdapter } from "./providers/anthropic.js";
4import { GoogleAdapter } from "./providers/google.js";
5import { OpenAIAdapter, OssAdapter } from "./providers/openai-compatible.js";
6
7function sseResponse(events: string[]): Response {
8 const body = events.map((e) => `data: ${e}\n\n`).join("") + "data: [DONE]\n\n";
9 return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
10}
11
12function jsonResponse(value: unknown, status = 200): Response {
13 return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
14}
15
16function fakeFetch(handler: (url: string, init?: RequestInit) => Response): {
17 fn: typeof fetch;
18 calls: Array<{ url: string; body?: Record<string, unknown> }>;
19} {
20 const calls: Array<{ url: string; body?: Record<string, unknown> }> = [];
21 const fn = ((url: string, init?: RequestInit) => {
22 const body = init?.body ? (JSON.parse(init.body as string) as Record<string, unknown>) : undefined;
23 calls.push(body === undefined ? { url } : { url, body });
24 return Promise.resolve(handler(url, init));
25 }) as typeof fetch;
26 return { fn, calls };
27}
28
29describe("OpenAIAdapter", () => {
30 it("streams SSE deltas + tool calls + final usage", async () => {
31 const events = [
32 JSON.stringify({ choices: [{ delta: { role: "assistant", content: "Hel" } }] }),
33 JSON.stringify({ choices: [{ delta: { content: "lo " } }] }),
34 JSON.stringify({
35 choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "search", arguments: '{"q":' } }] } }],
36 }),
37 JSON.stringify({
38 choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"ai"}' } }] } }],
39 }),
40 JSON.stringify({
41 choices: [{ delta: {}, finish_reason: "tool_calls" }],
42 usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
43 }),
44 ];
45 const { fn } = fakeFetch(() => sseResponse(events));
46 const adapter = new OpenAIAdapter({ fetchImpl: fn });
47 const deltas: string[] = [];
48 const toolCalls: string[] = [];
49 let usage: { in: number; out: number; total: number } | undefined;
50 let finishReason: string | undefined;
51 for await (const chunk of adapter.chat({
52 model: "gpt-4o",
53 messages: [{ role: "user", content: "hi" }],
54 tools: [{ name: "search", description: "d", parameters: { type: "object" } }],
55 })) {
56 if (chunk.delta) deltas.push(chunk.delta);
57 if (chunk.toolCallDelta) toolCalls.push(chunk.toolCallDelta.name);
58 if (chunk.done) {
59 usage = chunk.usage;
60 finishReason = chunk.finishReason;
61 }
62 }
63 expect(deltas.join("")).toBe("Hello ");
64 expect(toolCalls).toEqual(["search"]);
65 expect(usage).toEqual({ in: 10, out: 5, total: 15 });
66 expect(finishReason).toBe("tool_calls");
67 });
68
69 it("sends Bearer auth and the OpenAI body shape", async () => {
70 let seenHeaders: Record<string, string> = {};
71 let seenBody: Record<string, unknown> = {};
72 const fn = ((url: string, init?: RequestInit) => {
73 seenHeaders = (init?.headers as Record<string, string>) ?? {};
74 seenBody = JSON.parse((init?.body as string) ?? "{}");
75 return Promise.resolve(
76 sseResponse([JSON.stringify({ choices: [{ delta: { content: "y" }, finish_reason: "stop" }] })]),
77 );
78 }) as typeof fetch;
79 const adapter = new OpenAIAdapter({ fetchImpl: fn, apiKey: "sk-test" });
80 for await (const _ of adapter.chat({ model: "gpt-4o", messages: [{ role: "user", content: "hi" }] })) {
81
82 }
83 expect(seenHeaders.authorization).toBe("Bearer sk-test");
84 expect(seenBody.stream).toBe(true);
85 expect(seenBody.stream_options).toEqual({ include_usage: true });
86 expect(seenBody.messages).toEqual([{ role: "user", content: "hi" }]);
87 });
88
89 it("maps non-ok responses to retryable ProviderHttpError", async () => {
90 const { fn } = fakeFetch(() => jsonResponse({ error: { message: "rate limited" } }, 429));
91 const adapter = new OpenAIAdapter({ fetchImpl: fn });
92 let caught: unknown;
93 try {
94 for await (const _ of adapter.chat({ model: "gpt-4o", messages: [{ role: "user", content: "hi" }] })) {
95
96 }
97 } catch (e) {
98 caught = e;
99 }
100 expect(caught).toBeDefined();
101 expect((caught as { status: number }).status).toBe(429);
102 expect((caught as { retryable: boolean }).retryable).toBe(true);
103 });
104
105 it("embed returns vectors in index order and the dimension", async () => {
106 const { fn } = fakeFetch(() =>
107 jsonResponse({
108 data: [
109 { index: 1, embedding: [0.2, 0.3] },
110 { index: 0, embedding: [0.1, 0.2] },
111 ],
112 model: "text-embedding-3-small",
113 usage: { prompt_tokens: 7, total_tokens: 7 },
114 }),
115 );
116 const adapter = new OpenAIAdapter({ fetchImpl: fn });
117 const result = await adapter.embed({ model: "text-embedding-3-small", texts: ["a", "b"] });
118 expect(result.vectors).toEqual([
119 [0.1, 0.2],
120 [0.2, 0.3],
121 ]);
122 expect(result.dimension).toBe(2);
123 expect(result.usage.tokens).toBe(7);
124 });
125
126 it("OssAdapter discovers the dimension at first use", async () => {
127 const { fn } = fakeFetch(() =>
128 jsonResponse({ data: [{ index: 0, embedding: [1, 0, 0] }], model: "nomic-embed-text", usage: { total_tokens: 1 } }),
129 );
130 const adapter = new OssAdapter({ fetchImpl: fn, baseUrl: "http://ollama:11434/v1" });
131 expect(adapter.dimension).toBeUndefined();
132 const result = await adapter.embed({ model: "nomic-embed-text", texts: ["x"] });
133 expect(result.dimension).toBe(3);
134 expect(adapter.dimension).toBe(3);
135 });
136});
137
138describe("AnthropicAdapter", () => {
139 it("maps system to the top-level param and streams text", async () => {
140 const events = [
141 JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5 } } }),
142 JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }),
143 JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " there" } }),
144 JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }),
145 ];
146 const { fn, calls } = fakeFetch(() => sseResponse(events));
147 const adapter = new AnthropicAdapter({ fetchImpl: fn, apiKey: "key" });
148 const text: string[] = [];
149 let usage: { in: number; out: number; total: number } | undefined;
150 for await (const chunk of adapter.chat({
151 model: "claude-sonnet-4-20250514",
152 messages: [
153 { role: "system", content: "be nice" },
154 { role: "user", content: "hello" },
155 ],
156 })) {
157 if (chunk.delta) text.push(chunk.delta);
158 if (chunk.done) usage = chunk.usage;
159 }
160 expect(text.join("")).toBe("Hi there");
161 expect(usage).toEqual({ in: 5, out: 3, total: 8 });
162 const body = calls[0]!.body!;
163 expect(body.system).toBe("be nice");
164 expect((body.messages as unknown[]).map((m) => (m as { role: string }).role)).toEqual(["user"]);
165 });
166
167 it("accumulates tool_use blocks (id/name + partial_json) into ToolCall deltas", async () => {
168 const events = [
169 JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5 } } }),
170 JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_1", name: "search" } }),
171 JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"q":' } }),
172 JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '"ai"}' } }),
173 JSON.stringify({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 9 } }),
174 ];
175 const { fn } = fakeFetch(() => sseResponse(events));
176 const adapter = new AnthropicAdapter({ fetchImpl: fn, apiKey: "key" });
177 const toolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown> }> = [];
178 let finishReason: string | undefined;
179 for await (const chunk of adapter.chat({
180 model: "claude-sonnet-4-20250514",
181 messages: [{ role: "user", content: "find ai" }],
182 tools: [{ name: "search", description: "d", parameters: { type: "object" } }],
183 })) {
184 if (chunk.toolCallDelta) toolCalls.push(chunk.toolCallDelta);
185 if (chunk.done) finishReason = chunk.finishReason;
186 }
187 expect(toolCalls).toEqual([{ id: "toolu_1", name: "search", arguments: { q: "ai" } }]);
188 expect(finishReason).toBe("tool_calls");
189 });
190
191 it("embed throws (no API) with a non-retryable 501", async () => {
192 const adapter = new AnthropicAdapter({ fetchImpl: (async () => new Response("{}")) as typeof fetch, apiKey: "k" });
193 const err = await adapter.embed({ model: "x", texts: ["y"] }).catch((e: unknown) => e);
194 expect((err as { status: number }).status).toBe(501);
195 expect((err as { retryable: boolean }).retryable).toBe(false);
196 });
197
198 it("sends x-api-key and anthropic-version headers", async () => {
199 let seen: Record<string, string> = {};
200 const fn = ((url: string, init?: RequestInit) => {
201 seen = init?.headers as Record<string, string>;
202 return Promise.resolve(
203 sseResponse([JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 1 } } })]),
204 );
205 }) as typeof fetch;
206 const adapter = new AnthropicAdapter({ fetchImpl: fn, apiKey: "secret" });
207 for await (const _ of adapter.chat({ model: "claude-3-5-haiku-20241022", messages: [{ role: "user", content: "x" }] })) {
208
209 }
210 expect(seen["x-api-key"]).toBe("secret");
211 expect(seen["anthropic-version"]).toBe("2023-06-01");
212 });
213});
214
215describe("GoogleAdapter", () => {
216 it("streams text + functionCall and maps usage", async () => {
217 const events = [
218 JSON.stringify({
219 candidates: [{ content: { role: "model", parts: [{ text: "Hello " }] }, finishReason: "STOP" }],
220 }),
221 JSON.stringify({
222 candidates: [{ content: { role: "model", parts: [{ text: "world" }] } }],
223 usageMetadata: { promptTokenCount: 4, candidatesTokenCount: 2, totalTokenCount: 6 },
224 }),
225 ];
226 const { fn, calls } = fakeFetch(() => sseResponse(events));
227 const adapter = new GoogleAdapter({ fetchImpl: fn, apiKey: "gkey" });
228 const text: string[] = [];
229 let usage: { in: number; out: number; total: number } | undefined;
230 for await (const chunk of adapter.chat({ model: "gemini-2.0-flash", messages: [{ role: "user", content: "hi" }] })) {
231 if (chunk.delta) text.push(chunk.delta);
232 if (chunk.done) usage = chunk.usage;
233 }
234 expect(text.join("")).toBe("Hello world");
235 expect(usage).toEqual({ in: 4, out: 2, total: 6 });
236 expect(calls[0]!.url).toContain("models/gemini-2.0-flash:streamGenerateContent");
237 expect(calls[0]!.url).toContain("alt=sse");
238 });
239
240 it("maps system → systemInstruction and assistant tool calls → functionCall parts", async () => {
241 let body: Record<string, unknown> = {};
242 const fn = ((url: string, init?: RequestInit) => {
243 body = JSON.parse(init!.body as string);
244 return Promise.resolve(
245 sseResponse([
246 JSON.stringify({
247 candidates: [{ content: { role: "model", parts: [{ functionCall: { name: "search", args: { q: "x" } } }] } }],
248 }),
249 ]),
250 );
251 }) as typeof fetch;
252 const adapter = new GoogleAdapter({ fetchImpl: fn, apiKey: "gkey" });
253 const toolCalls: string[] = [];
254 for await (const chunk of adapter.chat({
255 model: "gemini-2.0-flash",
256 messages: [
257 { role: "system", content: "sys" },
258 {
259 role: "assistant",
260 content: "",
261 toolCalls: [{ id: "c1", name: "search", arguments: { q: "x" } }],
262 },
263 { role: "user", content: "more" },
264 ],
265 })) {
266 if (chunk.toolCallDelta) toolCalls.push(chunk.toolCallDelta.name);
267 }
268 expect(toolCalls).toEqual(["search"]);
269 expect((body.systemInstruction as { parts: { text: string }[] }).parts[0]!.text).toBe("sys");
270 const contents = body.contents as Array<{ role: string; parts: Array<Record<string, unknown>> }>;
271 const assistant = contents.find((c) => c.role === "model")!;
272 expect(assistant.parts[0]!.functionCall).toEqual({ name: "search", args: { q: "x" } });
273 });
274
275 it("embed uses batchEmbedContents and reports the dimension", async () => {
276 const { fn } = fakeFetch(() =>
277 jsonResponse({ embeddings: [{ values: [0.1, 0.2, 0.3] }, { values: [0.4, 0.5, 0.6] }] }),
278 );
279 const adapter = new GoogleAdapter({ fetchImpl: fn, apiKey: "gkey" });
280 const result = await adapter.embed({ model: "text-embedding-004", texts: ["a", "b"] });
281 expect(result.dimension).toBe(3);
282 expect(result.vectors).toHaveLength(2);
283 expect(result.provider).toBe("google");
284 });
285});
286
287
288
289
291function hangingSseResponse(): Response {
292 const stream = new ReadableStream<Uint8Array>({
293 start(ctrl) {
294 const enc = new TextEncoder();
295 ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: "a" } }] })}\n\n`));
296 ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: "b" } }] })}\n\n`));
297
298 },
299 });
300 return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
301}
302
303describe("caller abort (TASK-0122)", () => {
304 it("aborts the provider stream mid-flight and surfaces AbortError (not a wrapped 502)", async () => {
305 const ac = new AbortController();
306 const { fn } = fakeFetch(() => hangingSseResponse());
307 const adapter = new OpenAIAdapter({ fetchImpl: fn });
308 const deltas: string[] = [];
309 const run = (async () => {
310 for await (const chunk of adapter.chat({
311 model: "gpt-4o",
312 messages: [{ role: "user", content: "hi" }],
313 stream: true,
314 signal: ac.signal,
315 })) {
316 if (chunk.delta) deltas.push(chunk.delta);
317 }
318 })();
319
320 await new Promise((r) => setTimeout(r, 15));
321 ac.abort();
322 let err: unknown;
323 try {
324 await run;
325 } catch (e) {
326 err = e;
327 }
328 expect(err).toBeDefined();
329 expect((err as Error).name).toBe("AbortError");
330 expect(deltas).toEqual(["a", "b"]);
331 });
332});
333