From b5380f680913724fcffe64d4abefe573e9fd8290 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 09:47:14 -0800 Subject: [PATCH 001/121] Make husky prepare script graceful for fresh installs --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5d190c..546f9f5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "lint": "eslint .", "format:check": "prettier --check .", "release": "pnpm build && changeset publish", - "prepare": "husky" + "prepare": "husky || true" }, "lint-staged": { "*.{ts,mts,js,mjs,cjs,json,html,css,md}": "prettier --write", From 8c8bd853907c23fb7a07776f9c31f35f34ba13c2 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Tue, 3 Mar 2026 13:02:27 -0500 Subject: [PATCH 002/121] chore: release 1.0.0 Signed-off-by: Tyler Slaton --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5d190c..6f15651 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/mock-openai", - "version": "0.1.0", + "version": "1.0.0", "description": "Deterministic mock OpenAI server for testing", "license": "MIT", "packageManager": "pnpm@10.28.2", From b90dfa500dccb11c6798b3b7b260c1399179db84 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 10:19:13 -0800 Subject: [PATCH 003/121] docs: add unit tests badge to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c39ec9..a9939bd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @copilotkit/mock-openai +# @copilotkit/mock-openai [![Unit Tests](https://github.com/CopilotKit/mock-openai/actions/workflows/test-unit.yml/badge.svg)](https://github.com/CopilotKit/mock-openai/actions/workflows/test-unit.yml) Deterministic mock OpenAI server for testing. Streams SSE responses in real OpenAI Chat Completions and Responses API format, driven entirely by fixtures. Zero runtime dependencies — built on Node.js builtins only. From fb983aa14e43acef59f524b3c3a14a93667f911d Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 10:20:48 -0800 Subject: [PATCH 004/121] docs: update CLAUDE.md to reflect conventional commit requirement --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 595c3a9..666789d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,5 +36,5 @@ entire repo, not just staged files. ## Commit Messages -- Plain English, no conventional commit prefixes (no feat:, fix:, chore:, etc.) +- This repo enforces conventional commit prefixes via commitlint: `fix:`, `feat:`, `docs:`, `test:`, `chore:`, `refactor:`, etc. - No Co-Authored-By lines From 131ef6cac01b43adb60398bbcff47506b2bf1ac0 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 10:28:55 -0800 Subject: [PATCH 005/121] docs: add CopilotKit kite favicon --- docs/favicon.svg | 30 ++++++++++++++++++++++++++++++ docs/index.html | 2 ++ 2 files changed, 32 insertions(+) create mode 100644 docs/favicon.svg diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..93121b9 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.html b/docs/index.html index 39fbb43..327d123 100644 --- a/docs/index.html +++ b/docs/index.html @@ -9,6 +9,8 @@ content="Real HTTP server. Real SSE streams. Fixture-driven. Zero dependencies. Drop-in replacement for OpenAI in your test suite." /> + + From 5f0c18bf35467a334802276bbec359cd641d923e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 11:32:57 -0800 Subject: [PATCH 006/121] feat: add Anthropic Claude and Google Gemini provider support Add handler modules for two new LLM provider APIs, both following the established pattern from responses.ts: convert inbound request to ChatCompletionRequest, match fixtures, convert response back to provider-specific format. Claude Messages API (/v1/messages): - Streaming via event: type / data: json SSE format - Non-streaming JSON responses - Full message lifecycle: message_start through message_stop - Tool use with input_json_delta streaming - msg_ and toolu_ ID prefixes Google Gemini GenerateContent API: - /v1beta/models/{model}:generateContent (non-streaming) - /v1beta/models/{model}:streamGenerateContent (streaming) - data-only SSE format (no event prefix, no [DONE]) - functionCall/functionResponse round-trips with synthetic IDs - FUNCTION_CALL finishReason for tool call responses Also adds generateMessageId() and generateToolUseId() helpers, server routes for both providers, and comprehensive tests. --- src/__tests__/gemini.test.ts | 665 ++++++++++++++++++++++++++++++ src/__tests__/helpers.test.ts | 28 ++ src/__tests__/messages.test.ts | 711 +++++++++++++++++++++++++++++++++ src/__tests__/server.test.ts | 40 ++ src/gemini.ts | 472 ++++++++++++++++++++++ src/helpers.ts | 8 + src/messages.ts | 531 ++++++++++++++++++++++++ src/server.ts | 70 +++- 8 files changed, 2524 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/gemini.test.ts create mode 100644 src/__tests__/messages.test.ts create mode 100644 src/gemini.ts create mode 100644 src/messages.ts diff --git a/src/__tests__/gemini.test.ts b/src/__tests__/gemini.test.ts new file mode 100644 index 0000000..7f87ce7 --- /dev/null +++ b/src/__tests__/gemini.test.ts @@ -0,0 +1,665 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import type { Fixture } from "../types.js"; +import { createServer, type ServerInstance } from "../server.js"; +import { geminiToCompletionRequest } from "../gemini.js"; + +// --- helpers --- + +function post( + url: string, + body: unknown, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +function postRaw(url: string, raw: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(raw), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(raw); + req.end(); + }); +} + +function parseGeminiSSEChunks(body: string): unknown[] { + const chunks: unknown[] = []; + for (const line of body.split("\n")) { + if (line.startsWith("data: ")) { + chunks.push(JSON.parse(line.slice(6))); + } + } + return chunks; +} + +// --- fixtures --- + +const textFixture: Fixture = { + match: { userMessage: "hello" }, + response: { content: "Hi there!" }, +}; + +const toolFixture: Fixture = { + match: { userMessage: "weather" }, + response: { + toolCalls: [ + { + name: "get_weather", + arguments: '{"city":"NYC"}', + }, + ], + }, +}; + +const multiToolFixture: Fixture = { + match: { userMessage: "multi-tool" }, + response: { + toolCalls: [ + { name: "get_weather", arguments: '{"city":"NYC"}' }, + { name: "get_time", arguments: '{"tz":"EST"}' }, + ], + }, +}; + +const errorFixture: Fixture = { + match: { userMessage: "fail" }, + response: { + error: { + message: "Rate limited", + type: "rate_limit_error", + code: "rate_limit", + }, + status: 429, + }, +}; + +const badResponseFixture: Fixture = { + match: { userMessage: "badtype" }, + response: { content: 42 } as unknown as Fixture["response"], +}; + +const allFixtures: Fixture[] = [ + textFixture, + toolFixture, + multiToolFixture, + errorFixture, + badResponseFixture, +]; + +// --- tests --- + +let instance: ServerInstance | null = null; + +afterEach(async () => { + if (instance) { + await new Promise((resolve) => { + instance!.server.close(() => resolve()); + }); + instance = null; + } +}); + +// ─── Unit tests: input conversion ──────────────────────────────────────────── + +describe("geminiToCompletionRequest", () => { + it("converts user text message", () => { + const result = geminiToCompletionRequest( + { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages).toEqual([{ role: "user", content: "hello" }]); + expect(result.model).toBe("gemini-2.0-flash"); + expect(result.stream).toBe(false); + }); + + it("converts systemInstruction to system message", () => { + const result = geminiToCompletionRequest( + { + systemInstruction: { parts: [{ text: "Be helpful" }] }, + contents: [{ role: "user", parts: [{ text: "hi" }] }], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages).toEqual([ + { role: "system", content: "Be helpful" }, + { role: "user", content: "hi" }, + ]); + }); + + it("converts model (assistant) messages", () => { + const result = geminiToCompletionRequest( + { + contents: [ + { role: "user", parts: [{ text: "hi" }] }, + { role: "model", parts: [{ text: "hello" }] }, + ], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages[1]).toEqual({ role: "assistant", content: "hello" }); + }); + + it("converts functionCall parts to tool_calls", () => { + const result = geminiToCompletionRequest( + { + contents: [ + { + role: "model", + parts: [ + { + functionCall: { + name: "get_weather", + args: { city: "NYC" }, + }, + }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("assistant"); + expect(result.messages[0].content).toBeNull(); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe("call_gemini_get_weather_0"); + expect(result.messages[0].tool_calls![0].function.name).toBe("get_weather"); + expect(result.messages[0].tool_calls![0].function.arguments).toBe('{"city":"NYC"}'); + }); + + it("converts functionResponse parts to tool messages", () => { + const result = geminiToCompletionRequest( + { + contents: [ + { + role: "user", + parts: [ + { + functionResponse: { + name: "get_weather", + response: { temp: 72 }, + }, + }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe('{"temp":72}'); + expect(result.messages[0].tool_call_id).toBe("call_gemini_get_weather_0"); + }); + + it("extracts model from function parameter, not request body", () => { + const result = geminiToCompletionRequest( + { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + }, + "gemini-1.5-pro", + true, + ); + expect(result.model).toBe("gemini-1.5-pro"); + expect(result.stream).toBe(true); + }); + + it("converts functionDeclarations to ToolDefinition", () => { + const result = geminiToCompletionRequest( + { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { + functionDeclarations: [ + { + name: "get_weather", + description: "Get weather", + parameters: { type: "object" }, + }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + expect(result.tools).toEqual([ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object" }, + }, + }, + ]); + }); + + it("passes through generationConfig temperature", () => { + const result = geminiToCompletionRequest( + { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + generationConfig: { temperature: 0.7 }, + }, + "gemini-2.0-flash", + false, + ); + expect(result.temperature).toBe(0.7); + }); + + it("converts multiple functionResponse parts with unique tool_call_ids", () => { + const result = geminiToCompletionRequest( + { + contents: [ + { + role: "user", + parts: [ + { + functionResponse: { + name: "search", + response: { results: ["cats"] }, + }, + }, + { + functionResponse: { + name: "search", + response: { results: ["dogs"] }, + }, + }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[1].role).toBe("tool"); + // IDs should be unique even for same function name + expect(result.messages[0].tool_call_id).toBe("call_gemini_search_0"); + expect(result.messages[1].tool_call_id).toBe("call_gemini_search_1"); + expect(result.messages[0].tool_call_id).not.toBe(result.messages[1].tool_call_id); + }); + + it("aligns functionCall and functionResponse IDs across a round trip", () => { + // Model turn: two functionCall parts + const modelTurn = geminiToCompletionRequest( + { + contents: [ + { + role: "model", + parts: [ + { functionCall: { name: "search", args: { q: "cats" } } }, + { functionCall: { name: "search", args: { q: "dogs" } } }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + + // User turn: two functionResponse parts in same order + const userTurn = geminiToCompletionRequest( + { + contents: [ + { + role: "user", + parts: [ + { functionResponse: { name: "search", response: { r: "cats" } } }, + { functionResponse: { name: "search", response: { r: "dogs" } } }, + ], + }, + ], + }, + "gemini-2.0-flash", + false, + ); + + // IDs should align: call[0] matches response[0], call[1] matches response[1] + expect(modelTurn.messages[0].tool_calls![0].id).toBe(userTurn.messages[0].tool_call_id); + expect(modelTurn.messages[0].tool_calls![1].id).toBe(userTurn.messages[1].tool_call_id); + }); +}); + +// ─── Integration tests: Gemini non-streaming ──────────────────────────────── + +describe("POST /v1beta/models/{model}:generateContent (non-streaming)", () => { + it("returns text response as JSON", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("application/json"); + + const body = JSON.parse(res.body); + expect(body.candidates).toHaveLength(1); + expect(body.candidates[0].content.role).toBe("model"); + expect(body.candidates[0].content.parts[0].text).toBe("Hi there!"); + expect(body.candidates[0].finishReason).toBe("STOP"); + expect(body.candidates[0].index).toBe(0); + expect(body.usageMetadata).toBeDefined(); + }); + + it("returns tool call response with functionCall parts", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "weather" }] }], + }); + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.candidates[0].content.parts[0].functionCall).toBeDefined(); + expect(body.candidates[0].content.parts[0].functionCall.name).toBe("get_weather"); + expect(body.candidates[0].content.parts[0].functionCall.args).toEqual({ city: "NYC" }); + expect(body.candidates[0].finishReason).toBe("FUNCTION_CALL"); + }); + + it("returns multiple tool calls as multiple parts", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "multi-tool" }] }], + }); + + const body = JSON.parse(res.body); + expect(body.candidates[0].content.parts).toHaveLength(2); + expect(body.candidates[0].content.parts[0].functionCall.name).toBe("get_weather"); + expect(body.candidates[0].content.parts[1].functionCall.name).toBe("get_time"); + }); +}); + +// ─── Integration tests: Gemini streaming ──────────────────────────────────── + +describe("POST /v1beta/models/{model}:streamGenerateContent (streaming)", () => { + it("streams text response as SSE", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/event-stream"); + + const chunks = parseGeminiSSEChunks(res.body) as { + candidates: { + content: { role: string; parts: { text?: string }[] }; + finishReason?: string; + }[]; + usageMetadata?: unknown; + }[]; + + expect(chunks.length).toBeGreaterThan(0); + + // All chunks have model role + for (const chunk of chunks) { + expect(chunk.candidates[0].content.role).toBe("model"); + } + + // Reconstruct content from text parts + const fullText = chunks.map((c) => c.candidates[0].content.parts[0].text ?? "").join(""); + expect(fullText).toBe("Hi there!"); + + // Only last chunk has finishReason + const lastChunk = chunks[chunks.length - 1]; + expect(lastChunk.candidates[0].finishReason).toBe("STOP"); + expect(lastChunk.usageMetadata).toBeDefined(); + + // Non-last chunks have no finishReason + if (chunks.length > 1) { + expect(chunks[0].candidates[0].finishReason).toBeUndefined(); + } + + // No [DONE] or event: prefix + expect(res.body).not.toContain("[DONE]"); + expect(res.body).not.toContain("event:"); + }); + + it("streams tool calls as SSE", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { + contents: [{ role: "user", parts: [{ text: "weather" }] }], + }); + + expect(res.status).toBe(200); + + const chunks = parseGeminiSSEChunks(res.body) as { + candidates: { + content: { + parts: { functionCall?: { name: string; args: unknown } }[]; + }; + finishReason?: string; + }[]; + }[]; + + // Tool calls come as a single chunk + expect(chunks).toHaveLength(1); + expect(chunks[0].candidates[0].content.parts[0].functionCall).toBeDefined(); + expect(chunks[0].candidates[0].content.parts[0].functionCall!.name).toBe("get_weather"); + expect(chunks[0].candidates[0].content.parts[0].functionCall!.args).toEqual({ + city: "NYC", + }); + expect(chunks[0].candidates[0].finishReason).toBe("FUNCTION_CALL"); + }); + + it("uses fixture chunkSize for text streaming", async () => { + const bigChunkFixture: Fixture = { + match: { userMessage: "bigchunk" }, + response: { content: "ABCDEFGHIJ" }, + chunkSize: 5, + }; + instance = await createServer([bigChunkFixture], { chunkSize: 2 }); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { + contents: [{ role: "user", parts: [{ text: "bigchunk" }] }], + }); + + const chunks = parseGeminiSSEChunks(res.body) as { + candidates: { content: { parts: { text: string }[] } }[]; + }[]; + // 10 chars / chunkSize 5 = 2 chunks + expect(chunks).toHaveLength(2); + expect(chunks[0].candidates[0].content.parts[0].text).toBe("ABCDE"); + expect(chunks[1].candidates[0].content.parts[0].text).toBe("FGHIJ"); + }); +}); + +// ─── Error handling ───────────────────────────────────────────────────────── + +describe("Gemini error handling", () => { + it("returns error fixture with correct status", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "fail" }] }], + }); + + expect(res.status).toBe(429); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("Rate limited"); + }); + + it("returns 404 when no fixture matches", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "unknown" }] }], + }); + + expect(res.status).toBe(404); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("No fixture matched"); + }); + + it("returns 400 for malformed JSON", async () => { + instance = await createServer(allFixtures); + const res = await postRaw( + `${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, + "{not valid", + ); + + expect(res.status).toBe(400); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("Malformed JSON"); + }); + + it("returns 500 for unknown response type", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "badtype" }] }], + }); + + expect(res.status).toBe(500); + const body = JSON.parse(res.body); + expect(body.error.message).toContain("did not match any known type"); + }); +}); + +// ─── Routing ──────────────────────────────────────────────────────────────── + +describe("Gemini routing", () => { + it("returns 404 for GET on Gemini endpoint", async () => { + instance = await createServer(allFixtures); + const res = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const parsed = new URL(instance!.url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: "/v1beta/models/gemini-2.0-flash:generateContent", + method: "GET", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(res.status).toBe(404); + }); + + it("returns 404 for unknown Gemini-like path", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:unknownAction`, { + contents: [], + }); + expect(res.status).toBe(404); + }); + + it("extracts model name from URL path", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1beta/models/gemini-1.5-pro:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + + const entry = instance.journal.getLast(); + expect(entry).not.toBeNull(); + expect(entry!.body.model).toBe("gemini-1.5-pro"); + }); +}); + +// ─── Journal ──────────────────────────────────────────────────────────────── + +describe("Gemini journal", () => { + it("records successful text response", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + + expect(instance.journal.size).toBe(1); + const entry = instance.journal.getLast(); + expect(entry!.path).toBe("/v1beta/models/gemini-2.0-flash:generateContent"); + expect(entry!.response.status).toBe(200); + expect(entry!.response.fixture).toBe(textFixture); + }); + + it("records unmatched response with null fixture", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "nomatch" }] }], + }); + + const entry = instance.journal.getLast(); + expect(entry!.response.status).toBe(404); + expect(entry!.response.fixture).toBeNull(); + }); +}); + +// ─── CORS ─────────────────────────────────────────────────────────────────── + +describe("Gemini CORS", () => { + it("includes CORS headers", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); +}); diff --git a/src/__tests__/helpers.test.ts b/src/__tests__/helpers.test.ts index 8c27c02..8e38418 100644 --- a/src/__tests__/helpers.test.ts +++ b/src/__tests__/helpers.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { generateId, generateToolCallId, + generateMessageId, + generateToolUseId, isTextResponse, isToolCallResponse, isErrorResponse, @@ -36,6 +38,32 @@ describe("generateToolCallId", () => { }); }); +describe("generateMessageId", () => { + it("generates message IDs with msg_ prefix", () => { + const id = generateMessageId(); + expect(id).toMatch(/^msg_/); + expect(id.length).toBeGreaterThan(5); + }); + + it("generates unique IDs", () => { + const ids = new Set(Array.from({ length: 100 }, () => generateMessageId())); + expect(ids.size).toBe(100); + }); +}); + +describe("generateToolUseId", () => { + it("generates tool use IDs with toolu_ prefix", () => { + const id = generateToolUseId(); + expect(id).toMatch(/^toolu_/); + expect(id.length).toBeGreaterThan(7); + }); + + it("generates unique IDs", () => { + const ids = new Set(Array.from({ length: 100 }, () => generateToolUseId())); + expect(ids.size).toBe(100); + }); +}); + describe("type guards", () => { it("isTextResponse identifies text responses", () => { expect(isTextResponse({ content: "hello" })).toBe(true); diff --git a/src/__tests__/messages.test.ts b/src/__tests__/messages.test.ts new file mode 100644 index 0000000..573a884 --- /dev/null +++ b/src/__tests__/messages.test.ts @@ -0,0 +1,711 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import type { Fixture } from "../types.js"; +import { createServer, type ServerInstance } from "../server.js"; +import { claudeToCompletionRequest } from "../messages.js"; + +// --- helpers --- + +function post( + url: string, + body: unknown, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +function postRaw(url: string, raw: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(raw), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(raw); + req.end(); + }); +} + +interface SSEEvent { + type: string; + [key: string]: unknown; +} + +function parseClaudeSSEEvents(body: string): SSEEvent[] { + const events: SSEEvent[] = []; + const lines = body.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + events.push(JSON.parse(line.slice(6)) as SSEEvent); + } + } + return events; +} + +// --- fixtures --- + +const textFixture: Fixture = { + match: { userMessage: "hello" }, + response: { content: "Hi there!" }, +}; + +const toolFixture: Fixture = { + match: { userMessage: "weather" }, + response: { + toolCalls: [ + { + name: "get_weather", + arguments: '{"city":"NYC"}', + }, + ], + }, +}; + +const multiToolFixture: Fixture = { + match: { userMessage: "multi-tool" }, + response: { + toolCalls: [ + { name: "get_weather", arguments: '{"city":"NYC"}' }, + { name: "get_time", arguments: '{"tz":"EST"}' }, + ], + }, +}; + +const errorFixture: Fixture = { + match: { userMessage: "fail" }, + response: { + error: { + message: "Rate limited", + type: "rate_limit_error", + code: "rate_limit", + }, + status: 429, + }, +}; + +const badResponseFixture: Fixture = { + match: { userMessage: "badtype" }, + response: { content: 42 } as unknown as Fixture["response"], +}; + +const allFixtures: Fixture[] = [ + textFixture, + toolFixture, + multiToolFixture, + errorFixture, + badResponseFixture, +]; + +// --- tests --- + +let instance: ServerInstance | null = null; + +afterEach(async () => { + if (instance) { + await new Promise((resolve) => { + instance!.server.close(() => resolve()); + }); + instance = null; + } +}); + +// ─── Unit tests: input conversion ──────────────────────────────────────────── + +describe("claudeToCompletionRequest", () => { + it("converts user message with string content", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + expect(result.messages).toEqual([{ role: "user", content: "hello" }]); + expect(result.model).toBe("claude-3-5-sonnet-20241022"); + }); + + it("converts user message with content blocks", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "hello " }, + { type: "text", text: "world" }, + ], + }, + ], + }); + expect(result.messages).toEqual([{ role: "user", content: "hello world" }]); + }); + + it("converts system string to system message", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + system: "Be helpful", + messages: [{ role: "user", content: "hi" }], + }); + expect(result.messages).toEqual([ + { role: "system", content: "Be helpful" }, + { role: "user", content: "hi" }, + ]); + }); + + it("converts system content blocks to system message", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + system: [{ type: "text", text: "System prompt" }], + messages: [{ role: "user", content: "hi" }], + }); + expect(result.messages).toEqual([ + { role: "system", content: "System prompt" }, + { role: "user", content: "hi" }, + ]); + }); + + it("converts assistant message with string content", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + }); + expect(result.messages[1]).toEqual({ role: "assistant", content: "hello" }); + }); + + it("handles assistant message with null content", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { + role: "assistant", + content: null as unknown as string, + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("assistant"); + expect(result.messages[0].content).toBeNull(); + }); + + it("converts assistant tool_use blocks to tool_calls", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "get_weather", + input: { city: "NYC" }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("assistant"); + expect(result.messages[0].content).toBeNull(); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe("toolu_123"); + expect(result.messages[0].tool_calls![0].function.name).toBe("get_weather"); + expect(result.messages[0].tool_calls![0].function.arguments).toBe('{"city":"NYC"}'); + }); + + it("converts tool_result blocks to tool messages", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: '{"temp":72}', + }, + ], + }, + ], + }); + expect(result.messages).toEqual([ + { role: "tool", content: '{"temp":72}', tool_call_id: "toolu_123" }, + ]); + }); + + it("converts tool_result with nested text content blocks", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_456", + content: [{ type: "text", text: "result data" }], + }, + ], + }, + ], + }); + expect(result.messages).toEqual([ + { role: "tool", content: "result data", tool_call_id: "toolu_456" }, + ]); + }); + + it("converts tools with input_schema to ToolDefinition", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hi" }], + tools: [ + { + name: "get_weather", + description: "Get weather info", + input_schema: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + expect(result.tools).toEqual([ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather info", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + }, + }, + }, + ]); + }); + + it("returns undefined tools when none provided", () => { + const result = claudeToCompletionRequest({ + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hi" }], + }); + expect(result.tools).toBeUndefined(); + }); +}); + +// ─── Integration tests: POST /v1/messages ─────────────────────────────────── + +describe("POST /v1/messages (streaming)", () => { + it("streams text response with correct event types", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/event-stream"); + + const events = parseClaudeSSEEvents(res.body); + const types = events.map((e) => e.type); + + expect(types[0]).toBe("message_start"); + expect(types).toContain("content_block_start"); + expect(types).toContain("content_block_delta"); + expect(types).toContain("content_block_stop"); + expect(types).toContain("message_delta"); + expect(types[types.length - 1]).toBe("message_stop"); + + // No [DONE] sentinel + expect(res.body).not.toContain("[DONE]"); + }); + + it("message_start contains msg_ prefixed id", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const msgStart = events.find((e) => e.type === "message_start") as SSEEvent & { + message: { id: string; role: string; model: string }; + }; + expect(msgStart).toBeDefined(); + expect(msgStart.message.id).toMatch(/^msg_/); + expect(msgStart.message.role).toBe("assistant"); + expect(msgStart.message.model).toBe("claude-3-5-sonnet-20241022"); + }); + + it("text deltas reconstruct full content", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const deltas = events.filter((e) => e.type === "content_block_delta") as (SSEEvent & { + delta: { type: string; text: string }; + })[]; + const fullText = deltas.map((d) => d.delta.text).join(""); + expect(fullText).toBe("Hi there!"); + }); + + it("message_delta has stop_reason end_turn for text", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const msgDelta = events.find((e) => e.type === "message_delta") as SSEEvent & { + delta: { stop_reason: string }; + }; + expect(msgDelta).toBeDefined(); + expect(msgDelta.delta.stop_reason).toBe("end_turn"); + }); + + it("streams tool call response with tool_use blocks", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "weather" }], + }); + + expect(res.status).toBe(200); + + const events = parseClaudeSSEEvents(res.body); + const types = events.map((e) => e.type); + + expect(types[0]).toBe("message_start"); + expect(types).toContain("content_block_start"); + expect(types).toContain("content_block_delta"); + expect(types).toContain("content_block_stop"); + expect(types).toContain("message_delta"); + expect(types[types.length - 1]).toBe("message_stop"); + + // content_block_start should have tool_use type + const blockStart = events.find( + (e) => + e.type === "content_block_start" && + (e.content_block as { type: string })?.type === "tool_use", + ) as SSEEvent & { + content_block: { type: string; id: string; name: string }; + }; + expect(blockStart).toBeDefined(); + expect(blockStart.content_block.id).toMatch(/^toolu_/); + expect(blockStart.content_block.name).toBe("get_weather"); + }); + + it("tool call deltas use input_json_delta and reconstruct arguments", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "weather" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const deltas = events.filter( + (e) => + e.type === "content_block_delta" && + (e.delta as { type: string })?.type === "input_json_delta", + ) as (SSEEvent & { delta: { type: string; partial_json: string } })[]; + + expect(deltas.length).toBeGreaterThan(0); + const fullJson = deltas.map((d) => d.delta.partial_json).join(""); + const parsed = JSON.parse(fullJson); + expect(parsed).toEqual({ city: "NYC" }); + }); + + it("message_delta has stop_reason tool_use for tool calls", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "weather" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const msgDelta = events.find((e) => e.type === "message_delta") as SSEEvent & { + delta: { stop_reason: string }; + }; + expect(msgDelta.delta.stop_reason).toBe("tool_use"); + }); + + it("streams multiple tool calls with correct indices", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "multi-tool" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const blockStarts = events.filter( + (e) => + e.type === "content_block_start" && + (e.content_block as { type: string })?.type === "tool_use", + ); + expect(blockStarts).toHaveLength(2); + expect(blockStarts[0].index).toBe(0); + expect(blockStarts[1].index).toBe(1); + }); + + it("uses fixture chunkSize for text streaming", async () => { + const bigChunkFixture: Fixture = { + match: { userMessage: "bigchunk" }, + response: { content: "ABCDEFGHIJ" }, + chunkSize: 5, + }; + instance = await createServer([bigChunkFixture], { chunkSize: 2 }); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "bigchunk" }], + }); + + const events = parseClaudeSSEEvents(res.body); + const deltas = events.filter( + (e) => + e.type === "content_block_delta" && (e.delta as { type: string })?.type === "text_delta", + ) as (SSEEvent & { delta: { text: string } })[]; + // 10 chars / chunkSize 5 = 2 deltas + expect(deltas).toHaveLength(2); + expect(deltas[0].delta.text).toBe("ABCDE"); + expect(deltas[1].delta.text).toBe("FGHIJ"); + }); +}); + +describe("POST /v1/messages (non-streaming)", () => { + it("returns text response as JSON", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + stream: false, + }); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("application/json"); + + const body = JSON.parse(res.body); + expect(body.type).toBe("message"); + expect(body.role).toBe("assistant"); + expect(body.id).toMatch(/^msg_/); + expect(body.content).toHaveLength(1); + expect(body.content[0].type).toBe("text"); + expect(body.content[0].text).toBe("Hi there!"); + expect(body.stop_reason).toBe("end_turn"); + }); + + it("returns tool call response as JSON with object input", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "weather" }], + stream: false, + }); + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.type).toBe("message"); + expect(body.stop_reason).toBe("tool_use"); + expect(body.content).toHaveLength(1); + expect(body.content[0].type).toBe("tool_use"); + expect(body.content[0].name).toBe("get_weather"); + // Claude uses object input, not string + expect(body.content[0].input).toEqual({ city: "NYC" }); + expect(body.content[0].id).toBeDefined(); + }); + + it("returns multiple tool calls as JSON", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "multi-tool" }], + stream: false, + }); + + const body = JSON.parse(res.body); + expect(body.content).toHaveLength(2); + expect(body.content[0].name).toBe("get_weather"); + expect(body.content[1].name).toBe("get_time"); + }); +}); + +describe("POST /v1/messages (error handling)", () => { + it("returns error fixture with correct status", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "fail" }], + }); + + expect(res.status).toBe(429); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("Rate limited"); + }); + + it("returns 404 when no fixture matches", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "unknown" }], + }); + + expect(res.status).toBe(404); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("No fixture matched"); + }); + + it("returns 400 for malformed JSON", async () => { + instance = await createServer(allFixtures); + const res = await postRaw(`${instance.url}/v1/messages`, "{not valid"); + + expect(res.status).toBe(400); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("Malformed JSON"); + }); + + it("returns 500 for unknown response type", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "badtype" }], + }); + + expect(res.status).toBe(500); + const body = JSON.parse(res.body); + expect(body.error.message).toContain("did not match any known type"); + }); +}); + +describe("POST /v1/messages (journal)", () => { + it("records successful text response", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + expect(instance.journal.size).toBe(1); + const entry = instance.journal.getLast(); + expect(entry!.path).toBe("/v1/messages"); + expect(entry!.response.status).toBe(200); + expect(entry!.response.fixture).toBe(textFixture); + }); + + it("records unmatched response with null fixture", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "nomatch" }], + }); + + const entry = instance.journal.getLast(); + expect(entry!.response.status).toBe(404); + expect(entry!.response.fixture).toBeNull(); + }); + + it("journal body contains converted ChatCompletionRequest", async () => { + instance = await createServer(allFixtures); + await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + system: "Be nice", + messages: [{ role: "user", content: "hello" }], + }); + + const entry = instance.journal.getLast(); + expect(entry!.body.model).toBe("claude-3-5-sonnet-20241022"); + expect(entry!.body.messages).toEqual([ + { role: "system", content: "Be nice" }, + { role: "user", content: "hello" }, + ]); + }); +}); + +describe("POST /v1/messages (CORS)", () => { + it("includes CORS headers", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 40ebba2..5d3fdc9 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -414,6 +414,46 @@ describe("routing", () => { const res = await post(`${instance.url}/other/path`, { model: "gpt-4", messages: [] }); expect(res.status).toBe(404); }); + + it("routes POST /v1/messages to Claude handler", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + expect(res.status).toBe(200); + }); + + it("returns 404 for GET /v1/messages", async () => { + instance = await createServer(allFixtures); + const res = await get(`${instance.url}/v1/messages`); + expect(res.status).toBe(404); + }); + + it("routes POST to Gemini generateContent", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + expect(res.status).toBe(200); + }); + + it("routes POST to Gemini streamGenerateContent", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + expect(res.status).toBe(200); + }); + + it("returns 404 for unknown Gemini-like path", async () => { + instance = await createServer(allFixtures); + const res = await post(`${instance.url}/v1beta/models/gemini-2.0-flash:unknownAction`, { + contents: [], + }); + expect(res.status).toBe(404); + }); }); describe("CORS", () => { diff --git a/src/gemini.ts b/src/gemini.ts new file mode 100644 index 0000000..88c51bc --- /dev/null +++ b/src/gemini.ts @@ -0,0 +1,472 @@ +/** + * Google Gemini GenerateContent API support. + * + * Translates incoming Gemini requests into the ChatCompletionRequest format + * used by the fixture router, and converts fixture responses back into the + * Gemini GenerateContent streaming (or non-streaming) format. + */ + +import type * as http from "node:http"; +import type { + ChatCompletionRequest, + ChatMessage, + Fixture, + ToolCall, + ToolDefinition, +} from "./types.js"; +import { isTextResponse, isToolCallResponse, isErrorResponse } from "./helpers.js"; +import { matchFixture } from "./router.js"; +import { writeErrorResponse } from "./sse-writer.js"; +import type { Journal } from "./journal.js"; + +// ─── Gemini request types ─────────────────────────────────────────────────── + +interface GeminiPart { + text?: string; + functionCall?: { name: string; args: Record }; + functionResponse?: { name: string; response: unknown }; +} + +interface GeminiContent { + role?: string; + parts: GeminiPart[]; +} + +interface GeminiFunctionDeclaration { + name: string; + description?: string; + parameters?: object; +} + +interface GeminiToolDef { + functionDeclarations?: GeminiFunctionDeclaration[]; +} + +interface GeminiRequest { + contents?: GeminiContent[]; + systemInstruction?: GeminiContent; + tools?: GeminiToolDef[]; + generationConfig?: { + temperature?: number; + maxOutputTokens?: number; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +// ─── Input conversion: Gemini → ChatCompletions messages ──────────────────── + +export function geminiToCompletionRequest( + req: GeminiRequest, + model: string, + stream: boolean, +): ChatCompletionRequest { + const messages: ChatMessage[] = []; + + // systemInstruction → system message + if (req.systemInstruction) { + const text = req.systemInstruction.parts + .filter((p) => p.text !== undefined) + .map((p) => p.text!) + .join(""); + if (text) { + messages.push({ role: "system", content: text }); + } + } + + if (req.contents) { + for (const content of req.contents) { + const role = content.role ?? "user"; + + if (role === "user") { + // Check for functionResponse parts + const funcResponses = content.parts.filter((p) => p.functionResponse); + const textParts = content.parts.filter((p) => p.text !== undefined); + + if (funcResponses.length > 0) { + // functionResponse → tool message + for (let i = 0; i < funcResponses.length; i++) { + const part = funcResponses[i]; + messages.push({ + role: "tool", + content: + typeof part.functionResponse!.response === "string" + ? part.functionResponse!.response + : JSON.stringify(part.functionResponse!.response), + tool_call_id: `call_gemini_${part.functionResponse!.name}_${i}`, + }); + } + // Any text parts alongside → user message + if (textParts.length > 0) { + messages.push({ + role: "user", + content: textParts.map((p) => p.text!).join(""), + }); + } + } else { + // Regular user text + const text = textParts.map((p) => p.text!).join(""); + messages.push({ role: "user", content: text }); + } + } else if (role === "model") { + // Check for functionCall parts + const funcCalls = content.parts.filter((p) => p.functionCall); + const textParts = content.parts.filter((p) => p.text !== undefined); + + if (funcCalls.length > 0) { + messages.push({ + role: "assistant", + content: null, + tool_calls: funcCalls.map((p, i) => ({ + id: `call_gemini_${p.functionCall!.name}_${i}`, + type: "function" as const, + function: { + name: p.functionCall!.name, + arguments: JSON.stringify(p.functionCall!.args), + }, + })), + }); + } else { + const text = textParts.map((p) => p.text!).join(""); + messages.push({ role: "assistant", content: text }); + } + } + } + } + + // Convert tools + let tools: ToolDefinition[] | undefined; + if (req.tools && req.tools.length > 0) { + const decls = req.tools.flatMap((t) => t.functionDeclarations ?? []); + if (decls.length > 0) { + tools = decls.map((d) => ({ + type: "function" as const, + function: { + name: d.name, + description: d.description, + parameters: d.parameters, + }, + })); + } + } + + return { + model, + messages, + stream, + temperature: req.generationConfig?.temperature, + tools, + }; +} + +// ─── Response building: fixture → Gemini format ───────────────────────────── + +interface GeminiResponseChunk { + candidates: { + content: { role: string; parts: GeminiPart[] }; + finishReason?: string; + index: number; + }[]; + usageMetadata?: { + promptTokenCount: number; + candidatesTokenCount: number; + totalTokenCount: number; + }; +} + +function buildGeminiTextStreamChunks(content: string, chunkSize: number): GeminiResponseChunk[] { + const chunks: GeminiResponseChunk[] = []; + + // Content chunks + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + const isLast = i + chunkSize >= content.length; + const chunk: GeminiResponseChunk = { + candidates: [ + { + content: { role: "model", parts: [{ text: slice }] }, + index: 0, + ...(isLast ? { finishReason: "STOP" } : {}), + }, + ], + ...(isLast + ? { + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + } + : {}), + }; + chunks.push(chunk); + } + + // Handle empty content + if (content.length === 0) { + chunks.push({ + candidates: [ + { + content: { role: "model", parts: [{ text: "" }] }, + finishReason: "STOP", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + }); + } + + return chunks; +} + +function buildGeminiToolCallStreamChunks(toolCalls: ToolCall[]): GeminiResponseChunk[] { + const parts: GeminiPart[] = toolCalls.map((tc) => { + let argsObj: Record; + try { + argsObj = JSON.parse(tc.arguments || "{}") as Record; + } catch { + argsObj = {}; + } + return { + functionCall: { name: tc.name, args: argsObj }, + }; + }); + + // Gemini sends all tool calls in a single response chunk + return [ + { + candidates: [ + { + content: { role: "model", parts }, + finishReason: "FUNCTION_CALL", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + }, + ]; +} + +// Non-streaming response builders + +function buildGeminiTextResponse(content: string): GeminiResponseChunk { + return { + candidates: [ + { + content: { role: "model", parts: [{ text: content }] }, + finishReason: "STOP", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + }; +} + +function buildGeminiToolCallResponse(toolCalls: ToolCall[]): GeminiResponseChunk { + const parts: GeminiPart[] = toolCalls.map((tc) => { + let argsObj: Record; + try { + argsObj = JSON.parse(tc.arguments || "{}") as Record; + } catch { + argsObj = {}; + } + return { + functionCall: { name: tc.name, args: argsObj }, + }; + }); + + return { + candidates: [ + { + content: { role: "model", parts }, + finishReason: "FUNCTION_CALL", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + }; +} + +// ─── SSE writer for Gemini streaming ──────────────────────────────────────── + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function writeGeminiSSEStream( + res: http.ServerResponse, + chunks: GeminiResponseChunk[], + latency = 0, +): Promise { + if (res.writableEnded) return; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + for (const chunk of chunks) { + if (latency > 0) await delay(latency); + if (res.writableEnded) return; + // Gemini uses data-only SSE (no event: prefix, no [DONE]) + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + + if (!res.writableEnded) { + res.end(); + } +} + +// ─── Request handler ──────────────────────────────────────────────────────── + +export async function handleGemini( + req: http.IncomingMessage, + res: http.ServerResponse, + raw: string, + model: string, + streaming: boolean, + fixtures: Fixture[], + journal: Journal, + defaults: { latency: number; chunkSize: number }, + setCorsHeaders: (res: http.ServerResponse) => void, +): Promise { + setCorsHeaders(res); + + let geminiReq: GeminiRequest; + try { + geminiReq = JSON.parse(raw) as GeminiRequest; + } catch { + writeErrorResponse( + res, + 400, + JSON.stringify({ + error: { + message: "Malformed JSON", + code: 400, + status: "INVALID_ARGUMENT", + }, + }), + ); + return; + } + + // Convert to ChatCompletionRequest for fixture matching + const completionReq = geminiToCompletionRequest(geminiReq, model, streaming); + + const fixture = matchFixture(fixtures, completionReq); + const path = req.url ?? `/v1beta/models/${model}:generateContent`; + + if (!fixture) { + journal.add({ + method: req.method ?? "POST", + path, + headers: {}, + body: completionReq, + response: { status: 404, fixture: null }, + }); + writeErrorResponse( + res, + 404, + JSON.stringify({ + error: { + message: "No fixture matched", + code: 404, + status: "NOT_FOUND", + }, + }), + ); + return; + } + + const response = fixture.response; + const latency = fixture.latency ?? defaults.latency; + const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize); + + // Error response + if (isErrorResponse(response)) { + const status = response.status ?? 500; + journal.add({ + method: req.method ?? "POST", + path, + headers: {}, + body: completionReq, + response: { status, fixture }, + }); + writeErrorResponse(res, status, JSON.stringify(response)); + return; + } + + // Text response + if (isTextResponse(response)) { + journal.add({ + method: req.method ?? "POST", + path, + headers: {}, + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildGeminiTextResponse(response.content); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const chunks = buildGeminiTextStreamChunks(response.content, chunkSize); + await writeGeminiSSEStream(res, chunks, latency); + } + return; + } + + // Tool call response + if (isToolCallResponse(response)) { + journal.add({ + method: req.method ?? "POST", + path, + headers: {}, + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildGeminiToolCallResponse(response.toolCalls); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const chunks = buildGeminiToolCallStreamChunks(response.toolCalls); + await writeGeminiSSEStream(res, chunks, latency); + } + return; + } + + // Unknown response type + journal.add({ + method: req.method ?? "POST", + path, + headers: {}, + body: completionReq, + response: { status: 500, fixture }, + }); + writeErrorResponse( + res, + 500, + JSON.stringify({ + error: { + message: "Fixture response did not match any known type", + code: 500, + status: "INTERNAL", + }, + }), + ); +} diff --git a/src/helpers.ts b/src/helpers.ts index 37939d4..faabaaa 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -17,6 +17,14 @@ export function generateToolCallId(): string { return `call_${randomBytes(12).toString("base64url")}`; } +export function generateMessageId(): string { + return `msg_${randomBytes(12).toString("base64url")}`; +} + +export function generateToolUseId(): string { + return `toolu_${randomBytes(12).toString("base64url")}`; +} + export function isTextResponse(r: FixtureResponse): r is TextResponse { return "content" in r && typeof (r as TextResponse).content === "string"; } diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..a401220 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,531 @@ +/** + * Anthropic Claude Messages API support. + * + * Translates incoming /v1/messages requests into the ChatCompletionRequest + * format used by the fixture router, and converts fixture responses back into + * the Claude Messages API streaming (or non-streaming) format. + */ + +import type * as http from "node:http"; +import type { + ChatCompletionRequest, + ChatMessage, + Fixture, + ToolCall, + ToolDefinition, +} from "./types.js"; +import { + generateMessageId, + generateToolUseId, + isTextResponse, + isToolCallResponse, + isErrorResponse, +} from "./helpers.js"; +import { matchFixture } from "./router.js"; +import { writeErrorResponse } from "./sse-writer.js"; +import type { Journal } from "./journal.js"; + +// ─── Claude Messages API request types ────────────────────────────────────── + +interface ClaudeContentBlock { + type: string; + text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string | ClaudeContentBlock[]; + is_error?: boolean; +} + +interface ClaudeMessage { + role: "user" | "assistant"; + content: string | ClaudeContentBlock[]; +} + +interface ClaudeToolDef { + name: string; + description?: string; + input_schema?: object; +} + +interface ClaudeRequest { + model: string; + messages: ClaudeMessage[]; + system?: string | ClaudeContentBlock[]; + tools?: ClaudeToolDef[]; + tool_choice?: unknown; + stream?: boolean; + max_tokens: number; + temperature?: number; + [key: string]: unknown; +} + +// ─── Input conversion: Claude → ChatCompletions messages ──────────────────── + +function extractClaudeTextContent(content: string | ClaudeContentBlock[]): string { + if (typeof content === "string") return content; + return content + .filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join(""); +} + +export function claudeToCompletionRequest(req: ClaudeRequest): ChatCompletionRequest { + const messages: ChatMessage[] = []; + + // system field → system message + if (req.system) { + const systemText = + typeof req.system === "string" + ? req.system + : req.system + .filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join(""); + if (systemText) { + messages.push({ role: "system", content: systemText }); + } + } + + for (const msg of req.messages) { + if (msg.role === "user") { + // Check for tool_result blocks + if (typeof msg.content !== "string" && Array.isArray(msg.content)) { + const toolResults = msg.content.filter((b) => b.type === "tool_result"); + const textBlocks = msg.content.filter((b) => b.type === "text"); + + if (toolResults.length > 0) { + // Each tool_result → tool message + for (const tr of toolResults) { + const resultContent = + typeof tr.content === "string" + ? tr.content + : Array.isArray(tr.content) + ? tr.content + .filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join("") + : ""; + messages.push({ + role: "tool", + content: resultContent, + tool_call_id: tr.tool_use_id, + }); + } + // Any accompanying text blocks → user message + if (textBlocks.length > 0) { + messages.push({ + role: "user", + content: textBlocks.map((b) => b.text ?? "").join(""), + }); + } + continue; + } + } + // Regular user message + messages.push({ + role: "user", + content: extractClaudeTextContent(msg.content), + }); + } else if (msg.role === "assistant") { + if (typeof msg.content === "string") { + messages.push({ role: "assistant", content: msg.content }); + } else if (Array.isArray(msg.content)) { + const toolUseBlocks = msg.content.filter((b) => b.type === "tool_use"); + const textContent = extractClaudeTextContent(msg.content); + + if (toolUseBlocks.length > 0) { + messages.push({ + role: "assistant", + content: textContent || null, + tool_calls: toolUseBlocks.map((b) => ({ + id: b.id ?? generateToolUseId(), + type: "function" as const, + function: { + name: b.name ?? "", + arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}), + }, + })), + }); + } else { + messages.push({ role: "assistant", content: textContent || null }); + } + } else { + // null/undefined content — tool-only assistant turn + messages.push({ role: "assistant", content: null }); + } + } + } + + // Convert tools + let tools: ToolDefinition[] | undefined; + if (req.tools && req.tools.length > 0) { + tools = req.tools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.input_schema, + }, + })); + } + + return { + model: req.model, + messages, + stream: req.stream, + temperature: req.temperature, + tools, + }; +} + +// ─── Response building: fixture → Claude Messages API format ──────────────── + +interface ClaudeSSEEvent { + type: string; + [key: string]: unknown; +} + +function buildClaudeTextStreamEvents( + content: string, + model: string, + chunkSize: number, +): ClaudeSSEEvent[] { + const msgId = generateMessageId(); + const events: ClaudeSSEEvent[] = []; + + // message_start + events.push({ + type: "message_start", + message: { + id: msgId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + + // content_block_start + events.push({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }); + + // content_block_delta — text chunks + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + events.push({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: slice }, + }); + } + + // content_block_stop + events.push({ + type: "content_block_stop", + index: 0, + }); + + // message_delta + events.push({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 0 }, + }); + + // message_stop + events.push({ type: "message_stop" }); + + return events; +} + +function buildClaudeToolCallStreamEvents( + toolCalls: ToolCall[], + model: string, + chunkSize: number, +): ClaudeSSEEvent[] { + const msgId = generateMessageId(); + const events: ClaudeSSEEvent[] = []; + + // message_start + events.push({ + type: "message_start", + message: { + id: msgId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + + for (let idx = 0; idx < toolCalls.length; idx++) { + const tc = toolCalls[idx]; + const toolUseId = tc.id || generateToolUseId(); + + // Parse arguments to JSON object (Claude uses objects, not strings) + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + argsObj = {}; + } + const argsJson = JSON.stringify(argsObj); + + // content_block_start + events.push({ + type: "content_block_start", + index: idx, + content_block: { + type: "tool_use", + id: toolUseId, + name: tc.name, + input: {}, + }, + }); + + // content_block_delta — input_json_delta chunks + for (let i = 0; i < argsJson.length; i += chunkSize) { + const slice = argsJson.slice(i, i + chunkSize); + events.push({ + type: "content_block_delta", + index: idx, + delta: { type: "input_json_delta", partial_json: slice }, + }); + } + + // content_block_stop + events.push({ + type: "content_block_stop", + index: idx, + }); + } + + // message_delta + events.push({ + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { output_tokens: 0 }, + }); + + // message_stop + events.push({ type: "message_stop" }); + + return events; +} + +// Non-streaming response builders + +function buildClaudeTextResponse(content: string, model: string): object { + return { + id: generateMessageId(), + type: "message", + role: "assistant", + content: [{ type: "text", text: content }], + model, + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + +function buildClaudeToolCallResponse(toolCalls: ToolCall[], model: string): object { + return { + id: generateMessageId(), + type: "message", + role: "assistant", + content: toolCalls.map((tc) => { + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + argsObj = {}; + } + return { + type: "tool_use", + id: tc.id || generateToolUseId(), + name: tc.name, + input: argsObj, + }; + }), + model, + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + +// ─── SSE writer for Claude Messages API ───────────────────────────────────── + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function writeClaudeSSEStream( + res: http.ServerResponse, + events: ClaudeSSEEvent[], + latency = 0, +): Promise { + if (res.writableEnded) return; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + for (const event of events) { + if (latency > 0) await delay(latency); + if (res.writableEnded) return; + res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + } + + if (!res.writableEnded) { + res.end(); + } +} + +// ─── Request handler ──────────────────────────────────────────────────────── + +export async function handleMessages( + req: http.IncomingMessage, + res: http.ServerResponse, + raw: string, + fixtures: Fixture[], + journal: Journal, + defaults: { latency: number; chunkSize: number }, + setCorsHeaders: (res: http.ServerResponse) => void, +): Promise { + setCorsHeaders(res); + + let claudeReq: ClaudeRequest; + try { + claudeReq = JSON.parse(raw) as ClaudeRequest; + } catch { + writeErrorResponse( + res, + 400, + JSON.stringify({ + error: { + message: "Malformed JSON", + type: "invalid_request_error", + }, + }), + ); + return; + } + + // Convert to ChatCompletionRequest for fixture matching + const completionReq = claudeToCompletionRequest(claudeReq); + + const fixture = matchFixture(fixtures, completionReq); + + if (!fixture) { + journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: {}, + body: completionReq, + response: { status: 404, fixture: null }, + }); + writeErrorResponse( + res, + 404, + JSON.stringify({ + error: { + message: "No fixture matched", + type: "invalid_request_error", + }, + }), + ); + return; + } + + const response = fixture.response; + const latency = fixture.latency ?? defaults.latency; + const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize); + + // Error response + if (isErrorResponse(response)) { + const status = response.status ?? 500; + journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: {}, + body: completionReq, + response: { status, fixture }, + }); + writeErrorResponse(res, status, JSON.stringify(response)); + return; + } + + // Text response + if (isTextResponse(response)) { + journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: {}, + body: completionReq, + response: { status: 200, fixture }, + }); + if (claudeReq.stream === false) { + const body = buildClaudeTextResponse(response.content, completionReq.model); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildClaudeTextStreamEvents(response.content, completionReq.model, chunkSize); + await writeClaudeSSEStream(res, events, latency); + } + return; + } + + // Tool call response + if (isToolCallResponse(response)) { + journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: {}, + body: completionReq, + response: { status: 200, fixture }, + }); + if (claudeReq.stream === false) { + const body = buildClaudeToolCallResponse(response.toolCalls, completionReq.model); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildClaudeToolCallStreamEvents( + response.toolCalls, + completionReq.model, + chunkSize, + ); + await writeClaudeSSEStream(res, events, latency); + } + return; + } + + // Unknown response type + journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: {}, + body: completionReq, + response: { status: 500, fixture }, + }); + writeErrorResponse( + res, + 500, + JSON.stringify({ + error: { + message: "Fixture response did not match any known type", + type: "server_error", + }, + }), + ); +} diff --git a/src/server.ts b/src/server.ts index 93b5c61..93ada39 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,8 @@ import { isErrorResponse, } from "./helpers.js"; import { handleResponses } from "./responses.js"; +import { handleMessages } from "./messages.js"; +import { handleGemini } from "./gemini.js"; export interface ServerInstance { server: http.Server; @@ -22,8 +24,11 @@ export interface ServerInstance { const COMPLETIONS_PATH = "/v1/chat/completions"; const RESPONSES_PATH = "/v1/responses"; +const MESSAGES_PATH = "/v1/messages"; const DEFAULT_CHUNK_SIZE = 20; +const GEMINI_PATH_RE = /^\/v1beta\/models\/([^:]+):(generateContent|streamGenerateContent)$/; + const REQUESTS_PATH = "/v1/_requests"; const CORS_HEADERS: Record = { @@ -229,7 +234,7 @@ function flattenHeaders(headers: http.IncomingHttpHeaders): Record handleMessages(req, res, raw, fixtures, journal, defaults, setCorsHeaders)) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Internal error"; + if (!res.headersSent) { + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + try { + res.write(`event: error\ndata: ${JSON.stringify({ error: { message: msg } })}\n\n`); + } catch { + /* */ + } + res.end(); + } + }); + return; + } + + // POST /v1beta/models/{model}:(generateContent|streamGenerateContent) — Google Gemini + const geminiMatch = pathname.match(GEMINI_PATH_RE); + if (geminiMatch && req.method === "POST") { + const geminiModel = geminiMatch[1]; + const streaming = geminiMatch[2] === "streamGenerateContent"; + readBody(req) + .then((raw) => + handleGemini( + req, + res, + raw, + geminiModel, + streaming, + fixtures, + journal, + defaults, + setCorsHeaders, + ), + ) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Internal error"; + if (!res.headersSent) { + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + try { + res.write(`data: ${JSON.stringify({ error: { message: msg } })}\n\n`); + } catch { + /* */ + } + res.end(); + } + }); + return; + } + // POST /v1/chat/completions — Chat Completions API if (pathname !== COMPLETIONS_PATH) { handleNotFound(res, "Not found"); From b67c9138ed9bb01a652ad22cbe96d1c47dec75ea Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 11:33:12 -0800 Subject: [PATCH 007/121] refactor: rename MockOpenAI to LLMock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the project from @copilotkit/mock-openai to @copilotkit/llmock to reflect multi-provider scope (OpenAI, Anthropic, Google Gemini). - Class: MockOpenAI → LLMock - Files: mock-openai.ts → llmock.ts, mock-openai.test.ts → llmock.test.ts - Package: @copilotkit/mock-openai → @copilotkit/llmock - CLI: "Usage: mock-openai" → "Usage: llmock" - Binary: mock-openai → llmock - All imports, tests, and docs updated - Clean break — no backward-compat alias --- CLAUDE.md | 2 +- docs/CNAME | 2 +- package.json | 6 +- src/__tests__/cli.test.ts | 2 +- src/__tests__/integration.test.ts | 47 ++++++- .../{mock-openai.test.ts => llmock.test.ts} | 128 +++++++++--------- src/cli.ts | 4 +- src/index.ts | 16 ++- src/{mock-openai.ts => llmock.ts} | 6 +- src/responses.ts | 2 +- 10 files changed, 134 insertions(+), 81 deletions(-) rename src/__tests__/{mock-openai.test.ts => llmock.test.ts} (90%) rename src/{mock-openai.ts => llmock.ts} (97%) diff --git a/CLAUDE.md b/CLAUDE.md index 666789d..be295bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# mock-openai +# llmock ## Before Every Commit diff --git a/docs/CNAME b/docs/CNAME index 3ce79fb..bd52770 100644 --- a/docs/CNAME +++ b/docs/CNAME @@ -1 +1 @@ -mock-openai.copilotkit.dev +llmock.copilotkit.dev diff --git a/package.json b/package.json index 6d7a1aa..3143d89 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@copilotkit/mock-openai", + "name": "@copilotkit/llmock", "version": "1.0.0", - "description": "Deterministic mock OpenAI server for testing", + "description": "Deterministic mock LLM server for testing (OpenAI, Anthropic, Gemini)", "license": "MIT", "packageManager": "pnpm@10.28.2", "type": "module", @@ -21,7 +21,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "mock-openai": "./dist/cli.js" + "llmock": "./dist/cli.js" }, "files": [ "dist", diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 1005679..09d9188 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -94,7 +94,7 @@ function writeFixture(dir: string, name: string): string { describe.skipIf(!CLI_AVAILABLE)("CLI: --help", () => { it("prints usage text and exits with code 0", async () => { const { stdout, code } = await runCli(["--help"]); - expect(stdout).toContain("Usage: mock-openai"); + expect(stdout).toContain("Usage: llmock"); expect(stdout).toContain("--port"); expect(stdout).toContain("--fixtures"); expect(code).toBe(0); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index aa5c63c..5a8cdb7 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -3,7 +3,7 @@ import http from "node:http"; import { resolve } from "node:path"; import { createServer, type ServerInstance } from "../server.js"; import { loadFixturesFromDir } from "../fixture-loader.js"; -import { MockOpenAI } from "../mock-openai.js"; +import { LLMock } from "../llmock.js"; import type { Fixture, SSEChunk, ChatCompletionRequest } from "../types.js"; // --------------------------------------------------------------------------- @@ -476,7 +476,7 @@ describe("integration: server options", () => { }); describe("integration: onToolResult", () => { - let mock: MockOpenAI | null = null; + let mock: LLMock | null = null; afterEach(async () => { if (mock) { @@ -492,7 +492,7 @@ describe("integration: onToolResult", () => { }); it("matches a tool result message and streams the expected response", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onToolResult("call_abc", { content: "result text" }); await mock.start(); @@ -548,6 +548,47 @@ describe("integration: onToolResult", () => { }); }); +describe("integration: cross-provider fixture sharing", () => { + it("same fixture works across all 4 endpoints", async () => { + const fixtures: Fixture[] = [ + { + match: { userMessage: "hello" }, + response: { content: "Hello from fixture!" }, + }, + ]; + + instance = await createServer(fixtures, { port: 0, chunkSize: 100 }); + + // OpenAI Chat Completions + const r1 = await httpPost(`${instance.url}/v1/chat/completions`, chatRequest("hello")); + expect(r1.status).toBe(200); + + // OpenAI Responses API + const r2 = await httpPost(`${instance.url}/v1/responses`, { + model: "gpt-4", + input: [{ role: "user", content: "hello" }], + }); + expect(r2.status).toBe(200); + + // Anthropic Claude Messages API + const r3 = await httpPost(`${instance.url}/v1/messages`, { + model: "claude-3-5-sonnet-20241022", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + }); + expect(r3.status).toBe(200); + + // Google Gemini generateContent + const r4 = await httpPost(`${instance.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + }); + expect(r4.status).toBe(200); + + // Journal should have 4 entries + expect(instance.journal.size).toBe(4); + }); +}); + describe("integration: large payload streaming", () => { it("streams and reassembles a large (50KB+) text response", async () => { const largeContent = "x".repeat(50000); diff --git a/src/__tests__/mock-openai.test.ts b/src/__tests__/llmock.test.ts similarity index 90% rename from src/__tests__/mock-openai.test.ts rename to src/__tests__/llmock.test.ts index 43e6b1f..6e1ef50 100644 --- a/src/__tests__/mock-openai.test.ts +++ b/src/__tests__/llmock.test.ts @@ -3,7 +3,7 @@ import * as http from "node:http"; import { resolve, join } from "node:path"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { MockOpenAI } from "../mock-openai.js"; +import { LLMock } from "../llmock.js"; import { Journal } from "../journal.js"; // ---- Helpers ---- @@ -46,13 +46,13 @@ function chatBody(userMessage: string, stream = true) { } function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "mock-openai-test-")); + return mkdtempSync(join(tmpdir(), "llmock-test-")); } // ---- Tests ---- -describe("MockOpenAI", () => { - let mock: MockOpenAI | null = null; +describe("LLMock", () => { + let mock: LLMock | null = null; afterEach(async () => { if (mock) { @@ -70,23 +70,23 @@ describe("MockOpenAI", () => { describe("constructor", () => { it("creates an instance with default options", () => { - mock = new MockOpenAI(); - expect(mock).toBeInstanceOf(MockOpenAI); + mock = new LLMock(); + expect(mock).toBeInstanceOf(LLMock); }); it("accepts custom options", () => { - mock = new MockOpenAI({ + mock = new LLMock({ port: 0, host: "127.0.0.1", latency: 50, }); - expect(mock).toBeInstanceOf(MockOpenAI); + expect(mock).toBeInstanceOf(LLMock); }); }); describe("fixture management", () => { it("addFixture adds a fixture and returns this", () => { - mock = new MockOpenAI(); + mock = new LLMock(); const result = mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -95,7 +95,7 @@ describe("MockOpenAI", () => { }); it("addFixtures adds multiple fixtures and returns this", () => { - mock = new MockOpenAI(); + mock = new LLMock(); const result = mock.addFixtures([ { match: { userMessage: "a" }, @@ -110,7 +110,7 @@ describe("MockOpenAI", () => { }); it("chaining API works across multiple calls", () => { - mock = new MockOpenAI(); + mock = new LLMock(); const result = mock .addFixture({ match: { userMessage: "hello" }, @@ -126,7 +126,7 @@ describe("MockOpenAI", () => { }); it("clearFixtures empties all fixtures and returns this", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -142,7 +142,7 @@ describe("MockOpenAI", () => { }); it("on() shorthand adds a fixture", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.on({ userMessage: "on-test" }, { content: "on response" }); await mock.start(); @@ -152,7 +152,7 @@ describe("MockOpenAI", () => { }); it("on() shorthand passes latency and chunkSize opts", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.on({ userMessage: "opts-test" }, { content: "response" }, { latency: 0, chunkSize: 5 }); await mock.start(); @@ -163,7 +163,7 @@ describe("MockOpenAI", () => { describe("loadFixtureFile", () => { it("loads fixtures from a JSON file", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.loadFixtureFile(join(FIXTURES_DIR, "example-greeting.json")); await mock.start(); @@ -173,7 +173,7 @@ describe("MockOpenAI", () => { }); it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); const result = mock.loadFixtureFile(join(FIXTURES_DIR, "example-greeting.json")); expect(result).toBe(mock); }); @@ -181,7 +181,7 @@ describe("MockOpenAI", () => { describe("loadFixtureDir", () => { it("loads all JSON fixtures from a directory", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.loadFixtureDir(FIXTURES_DIR); await mock.start(); @@ -193,7 +193,7 @@ describe("MockOpenAI", () => { }); it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); const result = mock.loadFixtureDir(FIXTURES_DIR); expect(result).toBe(mock); }); @@ -213,7 +213,7 @@ describe("MockOpenAI", () => { }), ); - mock = new MockOpenAI(); + mock = new LLMock(); mock.loadFixtureDir(tmpDir); await mock.start(); @@ -228,7 +228,7 @@ describe("MockOpenAI", () => { describe("server lifecycle", () => { it("start returns a URL", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -239,13 +239,13 @@ describe("MockOpenAI", () => { }); it("start throws if server already started", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); await expect(mock.start()).rejects.toThrow("Server already started"); }); it("stop closes the server", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -261,12 +261,12 @@ describe("MockOpenAI", () => { }); it("stop throws if server not started", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await expect(mock.stop()).rejects.toThrow("Server not started"); }); it("stop rejects when server.close() errors", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); // Access the underlying http.Server via the private serverInstance field @@ -290,7 +290,7 @@ describe("MockOpenAI", () => { }); it("can restart after stop", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -300,7 +300,7 @@ describe("MockOpenAI", () => { await mock.stop(); mock = null; // clear for safety - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi again!" }, @@ -315,12 +315,12 @@ describe("MockOpenAI", () => { describe("url getter", () => { it("throws before server is started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(() => mock!.url).toThrow("Server not started"); }); it("returns url after server is started", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(mock.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); }); @@ -328,18 +328,18 @@ describe("MockOpenAI", () => { describe("journal getter", () => { it("throws before server is started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(() => mock!.journal).toThrow("Server not started"); }); it("returns a Journal instance after start", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(mock.journal).toBeInstanceOf(Journal); }); it("journal records requests", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "journal-test" }, response: { content: "recorded" }, @@ -357,7 +357,7 @@ describe("MockOpenAI", () => { describe("request handling", () => { it("serves a streaming text response", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "stream" }, response: { content: "streamed content" }, @@ -371,7 +371,7 @@ describe("MockOpenAI", () => { }); it("returns 404 when no fixture matches", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.addFixture({ match: { userMessage: "hello" }, response: { content: "Hi!" }, @@ -383,7 +383,7 @@ describe("MockOpenAI", () => { }); it("fixtures added after start are visible", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); // No fixtures yet — should 404 @@ -405,7 +405,7 @@ describe("MockOpenAI", () => { describe("onMessage convenience", () => { it("registers a fixture matching a string userMessage", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("greet", { content: "Hi!" }); await mock.start(); @@ -415,7 +415,7 @@ describe("MockOpenAI", () => { }); it("registers a fixture matching a regex userMessage", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage(/hel+o/, { content: "Matched!" }); await mock.start(); @@ -425,14 +425,14 @@ describe("MockOpenAI", () => { }); it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(mock.onMessage("x", { content: "y" })).toBe(mock); }); }); describe("onToolCall convenience", () => { it("registers a fixture matching a tool name", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onToolCall("get_weather", { content: "sunny" }); await mock.start(); @@ -451,25 +451,25 @@ describe("MockOpenAI", () => { }); // The fixture match for toolName is checked against the last assistant message's tool_calls // This may or may not match depending on router logic, but the fixture should be registered - expect(mock).toBeInstanceOf(MockOpenAI); + expect(mock).toBeInstanceOf(LLMock); }); it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(mock.onToolCall("fn", { content: "r" })).toBe(mock); }); }); describe("onToolResult convenience", () => { it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(mock.onToolResult("call_123", { content: "r" })).toBe(mock); }); }); describe("nextRequestError", () => { it("returns an error on the next request then removes itself", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hello", { content: "Hi!" }); await mock.start(); @@ -488,7 +488,7 @@ describe("MockOpenAI", () => { }); it("uses default error message when none provided", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hello", { content: "Hi!" }); await mock.start(); @@ -501,12 +501,12 @@ describe("MockOpenAI", () => { }); it("returns this for chaining", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(mock.nextRequestError(500)).toBe(mock); }); it("stacks multiple one-shot errors (last pushed fires first)", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hello", { content: "Normal response" }); await mock.start(); @@ -535,7 +535,7 @@ describe("MockOpenAI", () => { describe("journal proxies", () => { it("getRequests returns journal entries", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); await mock.start(); @@ -547,7 +547,7 @@ describe("MockOpenAI", () => { }); it("getLastRequest returns last entry", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("a", { content: "A" }); mock.onMessage("b", { content: "B" }); await mock.start(); @@ -561,13 +561,13 @@ describe("MockOpenAI", () => { }); it("getLastRequest returns null when no requests", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(mock.getLastRequest()).toBeNull(); }); it("clearRequests empties the journal", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); await mock.start(); @@ -579,14 +579,14 @@ describe("MockOpenAI", () => { }); it("getRequests throws when server not started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(() => mock!.getRequests()).toThrow("Server not started"); }); }); describe("reset", () => { it("clears fixtures and journal", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); await mock.start(); @@ -602,19 +602,19 @@ describe("MockOpenAI", () => { }); it("returns this for chaining", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(mock.reset()).toBe(mock); }); it("works even before server starts (just clears fixtures)", () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); expect(mock.reset()).toBe(mock); }); it("is idempotent — calling reset() twice causes no error", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); await mock.start(); @@ -636,7 +636,7 @@ describe("MockOpenAI", () => { }); it("after reset, only newly added fixtures are active", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("old", { content: "Old response" }); mock.onMessage("new", { content: "New response" }); await mock.start(); @@ -661,7 +661,7 @@ describe("MockOpenAI", () => { }); it("clearFixtures works before server is started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); mock.onMessage("hi", { content: "Hello" }); // clearFixtures alone should not throw before start expect(mock.clearFixtures()).toBe(mock); @@ -670,47 +670,47 @@ describe("MockOpenAI", () => { describe("baseUrl getter", () => { it("returns same value as url", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(mock.baseUrl).toBe(mock.url); }); it("throws before server is started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(() => mock!.baseUrl).toThrow("Server not started"); }); }); describe("port getter", () => { it("returns a number", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); expect(typeof mock.port).toBe("number"); expect(mock.port).toBeGreaterThan(0); }); it("matches the port in the URL", async () => { - mock = new MockOpenAI(); + mock = new LLMock(); await mock.start(); const urlPort = parseInt(new URL(mock.url).port, 10); expect(mock.port).toBe(urlPort); }); it("throws before server is started", () => { - mock = new MockOpenAI(); + mock = new LLMock(); expect(() => mock!.port).toThrow("Server not started"); }); }); describe("static create()", () => { it("creates and starts a server", async () => { - mock = await MockOpenAI.create(); + mock = await LLMock.create(); expect(mock.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(mock.journal).toBeInstanceOf(Journal); }); it("accepts options", async () => { - mock = await MockOpenAI.create({ + mock = await LLMock.create({ host: "127.0.0.1", port: 0, }); @@ -718,7 +718,7 @@ describe("MockOpenAI", () => { }); it("allows adding fixtures after creation", async () => { - mock = await MockOpenAI.create(); + mock = await LLMock.create(); mock.addFixture({ match: { userMessage: "factory-test" }, response: { content: "factory response" }, diff --git a/src/cli.ts b/src/cli.ts index 0cf8663..e9abeb3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import { createServer } from "./server.js"; import { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js"; const HELP = ` -Usage: mock-openai [options] +Usage: llmock [options] Options: -p, --port Port to listen on (default: 4010) @@ -79,7 +79,7 @@ async function main() { chunkSize, }); - console.log(`Mock OpenAI server listening on ${instance.url}`); + console.log(`llmock server listening on ${instance.url}`); function shutdown() { console.log("\nShutting down..."); diff --git a/src/index.ts b/src/index.ts index 623acaf..9cb90da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ // Main class -export { MockOpenAI } from "./mock-openai.js"; +export { LLMock } from "./llmock.js"; // Server export { createServer, type ServerInstance } from "./server.js"; @@ -13,8 +13,20 @@ export { Journal } from "./journal.js"; // Router export { matchFixture } from "./router.js"; +// Provider handlers +export { handleResponses } from "./responses.js"; +export { handleMessages } from "./messages.js"; +export { handleGemini } from "./gemini.js"; + // Helpers -export { generateId, generateToolCallId, buildTextChunks, buildToolCallChunks } from "./helpers.js"; +export { + generateId, + generateToolCallId, + generateMessageId, + generateToolUseId, + buildTextChunks, + buildToolCallChunks, +} from "./helpers.js"; // SSE export { writeSSEStream, writeErrorResponse } from "./sse-writer.js"; diff --git a/src/mock-openai.ts b/src/llmock.ts similarity index 97% rename from src/mock-openai.ts rename to src/llmock.ts index d75dcb2..f70e9d2 100644 --- a/src/mock-openai.ts +++ b/src/llmock.ts @@ -3,7 +3,7 @@ import { createServer, type ServerInstance } from "./server.js"; import { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js"; import { Journal } from "./journal.js"; -export class MockOpenAI { +export class LLMock { private fixtures: Fixture[] = []; private serverInstance: ServerInstance | null = null; private options: MockServerOptions; @@ -194,8 +194,8 @@ export class MockOpenAI { // ---- Static factory ---- - static async create(options?: MockServerOptions): Promise { - const instance = new MockOpenAI(options); + static async create(options?: MockServerOptions): Promise { + const instance = new LLMock(options); await instance.start(); return instance; } diff --git a/src/responses.ts b/src/responses.ts index 17952af..2f3d9cf 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -1,5 +1,5 @@ /** - * OpenAI Responses API support for MockOpenAI. + * OpenAI Responses API support for LLMock. * * Translates incoming /v1/responses requests into the ChatCompletionRequest * format used by the fixture router, and converts fixture responses back into From 93fc51b860969192c94fe9e8ced9a6eab5756e3b Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 3 Mar 2026 11:58:57 -0800 Subject: [PATCH 008/121] docs: rename mock-openai to llmock and add multi-provider documentation Update README.md and docs/index.html to reflect the rename from mock-openai/MockOpenAI to llmock/LLMock throughout. Add documentation for Claude Messages API and Gemini GenerateContent endpoints, update the MSW comparison table with multi-provider rows, and add ANTHROPIC_BASE_URL/Gemini base URL examples. --- README.md | 77 +++++++++++++++++++++++++++++-------------------- docs/index.html | 72 ++++++++++++++++++++++++++------------------- 2 files changed, 88 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 2c39ec9..eddf449 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,38 @@ -# @copilotkit/mock-openai +# @copilotkit/llmock -Deterministic mock OpenAI server for testing. Streams SSE responses in real OpenAI Chat Completions and Responses API format, driven entirely by fixtures. Zero runtime dependencies — built on Node.js builtins only. +Deterministic multi-provider mock LLM server for testing. Streams SSE responses in real OpenAI, Claude, and Gemini API formats, driven entirely by fixtures. Zero runtime dependencies — built on Node.js builtins only. -Supports both streaming (SSE) and non-streaming JSON responses, text completions, tool calls, and error injection. Point any process at it via `OPENAI_BASE_URL` and get reproducible, instant responses. +Supports both streaming (SSE) and non-streaming JSON responses across OpenAI (Chat Completions + Responses), Anthropic Claude (Messages), and Google Gemini (GenerateContent) APIs. Text completions, tool calls, and error injection. Point any process at it via `OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`, or Gemini base URL and get reproducible, instant responses. ## Install ```bash -npm install @copilotkit/mock-openai +npm install @copilotkit/llmock ``` ## When to Use This vs MSW [MSW (Mock Service Worker)](https://mswjs.io/) is a popular API mocking library, but it solves a different problem. -**The key difference is architecture.** mock-openai runs a real HTTP server on a port. MSW patches `http`/`https`/`fetch` modules inside a single Node.js process. MSW can only intercept requests from the process that calls `server.listen()` — child processes, separate services, and workers are unaffected. +**The key difference is architecture.** llmock runs a real HTTP server on a port. MSW patches `http`/`https`/`fetch` modules inside a single Node.js process. MSW can only intercept requests from the process that calls `server.listen()` — child processes, separate services, and workers are unaffected. -This matters for E2E tests where multiple processes make OpenAI calls: +This matters for E2E tests where multiple processes make LLM API calls: ``` Playwright test runner (Node) └─ controls browser → Next.js app (separate process) - └─ OPENAI_BASE_URL → mock-openai :5555 + └─ OPENAI_BASE_URL → llmock :5555 ├─ Mastra agent workers ├─ LangGraph workers └─ CopilotKit runtime ``` -MSW can't intercept any of those calls. mock-openai can — it's a real server on a real port. +MSW can't intercept any of those calls. llmock can — it's a real server on a real port. -**Use mock-openai when:** +**Use llmock when:** - Multiple processes need to hit the same mock (E2E tests, agent frameworks, microservices) -- You want OpenAI-specific SSE format out of the box (Chat Completions + Responses API) +- You want multi-provider SSE format out of the box (OpenAI, Claude, Gemini) - You prefer defining fixtures as JSON files rather than code - You need a standalone CLI server @@ -42,11 +42,13 @@ MSW can't intercept any of those calls. mock-openai can — it's a real server o - You're mocking many different APIs, not just OpenAI - You want in-process interception without running a server -| Capability | mock-openai | MSW | +| Capability | llmock | MSW | | ---------------------------- | --------------------- | ------------------------------------------------------------------------- | | Cross-process interception | **Yes** (real server) | **No** (in-process only) | | OpenAI Chat Completions SSE | **Built-in** | Manual — build `data: {json}\n\n` + `[DONE]` yourself | | OpenAI Responses API SSE | **Built-in** | Manual — MSW's `sse()` sends `data:` events, not OpenAI's `event:` format | +| Claude Messages API SSE | **Built-in** | Manual — build `event:`/`data:` SSE yourself | +| Gemini streaming | **Built-in** | Manual — build `data:` SSE yourself | | Fixture file loading (JSON) | **Yes** | **No** — handlers are code-only | | Request journal / inspection | **Yes** | **No** — track requests manually | | Non-streaming responses | **Yes** | **Yes** | @@ -57,9 +59,9 @@ MSW can't intercept any of those calls. mock-openai can — it's a real server o ## Quick Start ```typescript -import { MockOpenAI } from "@copilotkit/mock-openai"; +import { LLMock } from "@copilotkit/llmock"; -const mock = new MockOpenAI({ port: 5555 }); +const mock = new LLMock({ port: 5555 }); mock.onMessage("hello", { content: "Hi there!" }); @@ -73,21 +75,21 @@ await mock.stop(); ## E2E Test Patterns -Real-world patterns from using mock-openai in Playwright E2E tests with CopilotKit, Mastra, LangGraph, and Agno agent frameworks. +Real-world patterns from using llmock in Playwright E2E tests with CopilotKit, Mastra, LangGraph, and Agno agent frameworks. ### Global Setup/Teardown Start the mock server once for the entire test suite. All child processes (Next.js, agent workers) inherit the URL via environment variable. ```typescript -// e2e/mock-openai-setup.ts -import { MockOpenAI } from "@copilotkit/mock-openai"; +// e2e/llmock-setup.ts +import { LLMock } from "@copilotkit/llmock"; import * as path from "node:path"; -let mockServer: MockOpenAI | null = null; +let mockServer: LLMock | null = null; -export async function setupMockOpenAI(): Promise { - mockServer = new MockOpenAI({ port: 5555 }); +export async function setupLLMock(): Promise { + mockServer = new LLMock({ port: 5555 }); // Load JSON fixtures from a directory mockServer.loadFixtureDir(path.join(__dirname, "fixtures", "openai")); @@ -95,10 +97,10 @@ export async function setupMockOpenAI(): Promise { const url = await mockServer.start(); // Child processes use this to find the mock - process.env.MOCK_OPENAI_URL = `${url}/v1`; + process.env.LLMOCK_URL = `${url}/v1`; } -export async function teardownMockOpenAI(): Promise { +export async function teardownLLMock(): Promise { if (mockServer) { await mockServer.stop(); mockServer = null; @@ -111,6 +113,12 @@ The Next.js app (or any other service) just needs: ```env OPENAI_BASE_URL=http://localhost:5555/v1 OPENAI_API_KEY=mock-key + +# Or for Anthropic Claude: +ANTHROPIC_BASE_URL=http://localhost:5555/v1 + +# Or for Google Gemini — point at the base URL: +# http://localhost:5555/v1beta ``` ### JSON Fixture Files @@ -260,7 +268,7 @@ mockServer.addFixture({ ## Programmatic API -### `new MockOpenAI(options?)` +### `new LLMock(options?)` Create a new mock server instance. @@ -271,9 +279,9 @@ Create a new mock server instance. | `latency` | `number` | `0` | Default ms delay between SSE chunks | | `chunkSize` | `number` | `20` | Default characters per SSE chunk | -### `MockOpenAI.create(options?)` +### `LLMock.create(options?)` -Static factory — creates an instance and starts it in one call. Returns `Promise`. +Static factory — creates an instance and starts it in one call. Returns `Promise`. ### Server Lifecycle @@ -354,7 +362,7 @@ mock.nextRequestError(429, { ### Request Journal -Every request to `/v1/chat/completions` and `/v1/responses` is recorded in a journal. +Every request to all API endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`, and Gemini endpoints) is recorded in a journal. #### Programmatic Access @@ -440,14 +448,19 @@ Streams as SSE chunks, splitting `content` by `chunkSize`. With `stream: false`, The server handles: - **POST `/v1/chat/completions`** — OpenAI Chat Completions API (streaming and non-streaming) -- **POST `/v1/responses`** — OpenAI Responses API (streaming and non-streaming). Requests are translated to the Chat Completions fixture format internally, so the same fixtures work for both endpoints. +- **POST `/v1/responses`** — OpenAI Responses API (streaming and non-streaming) +- **POST `/v1/messages`** — Anthropic Claude Messages API (streaming and non-streaming) +- **POST `/v1beta/models/{model}:generateContent`** — Google Gemini (non-streaming) +- **POST `/v1beta/models/{model}:streamGenerateContent`** — Google Gemini (streaming) + +All endpoints share the same fixture pool — the same fixtures work across all providers. Requests are translated to a common format internally for fixture matching. ## CLI The package includes a standalone server binary: ```bash -mock-openai [options] +llmock [options] ``` | Option | Short | Default | Description | @@ -461,23 +474,23 @@ mock-openai [options] ```bash # Start with bundled example fixtures -mock-openai +llmock # Custom fixtures on a specific port -mock-openai -p 8080 -f ./my-fixtures +llmock -p 8080 -f ./my-fixtures # Simulate slow responses -mock-openai --latency 100 --chunk-size 5 +llmock --latency 100 --chunk-size 5 ``` ## Advanced Usage ### Low-level Server -If you need the raw HTTP server without the `MockOpenAI` wrapper: +If you need the raw HTTP server without the `LLMock` wrapper: ```typescript -import { createServer } from "@copilotkit/mock-openai"; +import { createServer } from "@copilotkit/llmock"; const fixtures = [{ match: { userMessage: "hi" }, response: { content: "Hello!" } }]; diff --git a/docs/index.html b/docs/index.html index 39fbb43..ecdcb66 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,10 +3,10 @@ - mock-openai — Deterministic OpenAI mock server for testing + llmock — Deterministic mock LLM server for testing @@ -839,13 +839,13 @@