diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c1e0f55..8d51cfa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "npm", "package": "@copilotkit/aimock", - "version": "^1.27.2" + "version": "^1.27.3" }, "description": "Fixture authoring skill for @copilotkit/aimock — LLM, multimedia (image/TTS/transcription/video), MCP, A2A, AG-UI, vector, embeddings, structured output, sequential responses, streaming physics, record/replay, agent loop patterns, and debugging" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ebcfd5e..ef930da 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "aimock", - "version": "1.27.2", + "version": "1.27.3", "description": "Fixture authoring guidance for @copilotkit/aimock — LLM, multimedia, MCP, A2A, AG-UI, vector, and service mocking", "author": { "name": "CopilotKit" diff --git a/CHANGELOG.md b/CHANGELOG.md index 289cc58..91d17e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.27.3] - 2026-05-27 + +### Fixed + +- correct OpenAI images endpoint path from `/v1/images/edit` to `/v1/images/edits` (closes [#221](https://github.com/CopilotKit/aimock/issues/221)) +- add Ollama `/api/embed` route as alias for `/api/embeddings`; `/api/embed` is the current documented endpoint ([ollama/ollama docs/api.md](https://github.com/ollama/ollama/blob/main/docs/api.md)) used by modern Ollama SDKs, while `/api/embeddings` is retained for backwards-compatibility. Both routes dispatch to the same handler. + ## [1.27.2] - 2026-05-26 ### Fixed diff --git a/DRIFT.md b/DRIFT.md index ef9c3fa..ab4c6af 100644 --- a/DRIFT.md +++ b/DRIFT.md @@ -79,9 +79,9 @@ When a `critical` drift is detected: - Google Gemini → `src/gemini.ts` (`buildGeminiTextResponse`, `buildGeminiToolCallResponse`, `buildGeminiTextStreamChunks`, `buildGeminiToolCallStreamChunks`) - Gemini embedContent → `src/gemini.ts` (embedContent response builder) - Gemini Interactions → `src/gemini-interactions.ts` (`buildInteractionsTextResponse`, `buildInteractionsToolCallResponse`, `buildInteractionsTextSSEEvents`, `buildInteractionsToolCallSSEEvents`) - - OpenAI Image Edit → `src/images.ts` (multipart `/v1/images/edit` handler) + - OpenAI Image Edit → `src/images.ts` (multipart `/v1/images/edits` handler) - OpenAI Audio Translation → `src/transcription.ts` (multipart `/v1/audio/translations` handler) - - Ollama Embeddings → `src/ollama.ts` (`/api/embeddings` response builder) + - Ollama Embeddings → `src/ollama.ts` (`/api/embed` + legacy `/api/embeddings` response builder) - Cohere Embed → `src/cohere.ts` (`/v2/embed` response builder) - ElevenLabs TTS → `src/elevenlabs-audio.ts` (`/v1/text-to-speech/{voice_id}` response builder) @@ -120,9 +120,9 @@ In addition to the 23 existing drift tests (20 HTTP response-shape + 3 model dep | Endpoint | Provider | Type | Status | | ---------------------------------------- | ------------- | ----------------- | ------- | | POST /v1beta/models/{model}:embedContent | Gemini | HTTP | Covered | -| POST /v1/images/edit | OpenAI | HTTP (multipart) | Covered | +| POST /v1/images/edits | OpenAI | HTTP (multipart) | Covered | | POST /v1/audio/translations | OpenAI | HTTP (multipart) | Covered | -| POST /api/embeddings | Ollama | HTTP | Covered | +| POST /api/embed, /api/embeddings | Ollama | HTTP | Covered | | POST /v2/embed | Cohere | HTTP | Covered | | POST /v1/text-to-speech/{voice_id} | ElevenLabs | HTTP | Covered | | stream_options.include_usage | OpenAI | Streaming feature | Covered | diff --git a/README.md b/README.md index 7bee4d7..0637fed 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or - **Timing-aware recording and replay** — Recorded fixtures capture per-frame arrival timestamps; replay uses recorded timings for approximate timing reproduction based on recorded TTFT and inter-frame cadence (replay chunk count may differ from recording — TTFT and average pace are preserved, not per-token fidelity) with configurable `--replay-speed` multiplier - **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `sequenceIndex`, `systemMessage` (gate on host-supplied agent context), or custom predicates - **[14 LLM Providers](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime (GA + Beta shim), Claude, Gemini (REST + embedContent), Gemini Live, Gemini Interactions, Azure, Bedrock, Vertex AI, Ollama (chat + embeddings), Cohere (chat + embed), ElevenLabs TTS — full streaming support -- **Multimedia APIs** — [image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [image editing](https://aimock.copilotkit.dev/images) (/v1/images/edit), [text-to-speech](https://aimock.copilotkit.dev/speech) (OpenAI + ElevenLabs), [audio transcription](https://aimock.copilotkit.dev/transcription), [audio translation](https://aimock.copilotkit.dev/transcription) (/v1/audio/translations), [video generation](https://aimock.copilotkit.dev/video), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle) +- **Multimedia APIs** — [image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [image editing](https://aimock.copilotkit.dev/images) (/v1/images/edits), [text-to-speech](https://aimock.copilotkit.dev/speech) (OpenAI + ElevenLabs), [audio transcription](https://aimock.copilotkit.dev/transcription), [audio translation](https://aimock.copilotkit.dev/transcription) (/v1/audio/translations), [video generation](https://aimock.copilotkit.dev/video), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle) - **[MCP](https://aimock.copilotkit.dev/mcp-mock) / [A2A](https://aimock.copilotkit.dev/a2a-mock) / [AG-UI](https://aimock.copilotkit.dev/agui-mock) / [Vector](https://aimock.copilotkit.dev/vector-mock)** — Mock every protocol your AI agents use - **[Chaos Testing](https://aimock.copilotkit.dev/chaos-testing)** — 500 errors, malformed JSON, mid-stream disconnects at any probability - **Per-Request Strict Mode** — `X-AIMock-Strict` header overrides the server-level `--strict` flag per request (`true`/`1` = strict, `false`/`0` = lenient) diff --git a/charts/aimock/Chart.yaml b/charts/aimock/Chart.yaml index a2c505b..79c178c 100644 --- a/charts/aimock/Chart.yaml +++ b/charts/aimock/Chart.yaml @@ -3,4 +3,4 @@ name: aimock description: Mock infrastructure for AI application testing (OpenAI, Anthropic, Gemini, MCP, A2A, vector) type: application version: 0.1.0 -appVersion: "1.27.2" +appVersion: "1.27.3" diff --git a/package.json b/package.json index 25d05b3..dba0ec2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/aimock", - "version": "1.27.2", + "version": "1.27.3", "description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, audio generation, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.", "license": "MIT", "keywords": [ diff --git a/scripts/update-competitive-matrix.ts b/scripts/update-competitive-matrix.ts index 688719c..3a344b9 100644 --- a/scripts/update-competitive-matrix.ts +++ b/scripts/update-competitive-matrix.ts @@ -117,7 +117,7 @@ const FEATURE_RULES: FeatureRule[] = [ }, { rowLabel: "Image editing", - keywords: ["/v1/images/edit", "image edit", "image editing", "inpainting", "edit.*image"], + keywords: ["/v1/images/edits", "image edit", "image editing", "inpainting", "edit.*image"], }, { rowLabel: "Text-to-Speech", diff --git a/src/__tests__/competitive-matrix.test.ts b/src/__tests__/competitive-matrix.test.ts index 3761fca..abfd0e1 100644 --- a/src/__tests__/competitive-matrix.test.ts +++ b/src/__tests__/competitive-matrix.test.ts @@ -59,7 +59,7 @@ const FEATURE_RULES: FeatureRule[] = [ }, { rowLabel: "Image editing", - keywords: ["/v1/images/edit", "image edit", "image editing", "inpainting", "edit.*image"], + keywords: ["/v1/images/edits", "image edit", "image editing", "inpainting", "edit.*image"], }, { rowLabel: "Non-speech audio", diff --git a/src/__tests__/image-edit.test.ts b/src/__tests__/image-edits.test.ts similarity index 83% rename from src/__tests__/image-edit.test.ts rename to src/__tests__/image-edits.test.ts index 3705c77..94c7742 100644 --- a/src/__tests__/image-edit.test.ts +++ b/src/__tests__/image-edits.test.ts @@ -26,7 +26,7 @@ describe("image edit endpoint", () => { formData.append("n", "1"); formData.append("size", "1024x1024"); - const res = await fetch(`${mock.url}/v1/images/edit`, { + const res = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -47,7 +47,7 @@ describe("image edit endpoint", () => { formData.append("image", new Blob(["fake"]), "image.png"); formData.append("model", "dall-e-2"); - const res = await fetch(`${mock.url}/v1/images/edit`, { + const res = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -72,7 +72,7 @@ describe("image edit endpoint", () => { formData.append("prompt", "remove background"); formData.append("model", "dall-e-2"); - const res = await fetch(`${mock.url}/v1/images/edit`, { + const res = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -95,7 +95,7 @@ describe("image edit endpoint", () => { formData.append("image", new Blob(["fake"]), "image.png"); formData.append("prompt", "enhance"); - const res = await fetch(`${mock.url}/v1/images/edit`, { + const res = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -124,7 +124,7 @@ describe("image edit endpoint", () => { formData.append("image", new Blob(["fake"]), "image.png"); formData.append("prompt", "test prompt"); - const editRes = await fetch(`${mock.url}/v1/images/edit`, { + const editRes = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -146,7 +146,7 @@ describe("image edit endpoint", () => { formData.append("image", new Blob(["fake"]), "image.png"); formData.append("prompt", "no match"); - const res = await fetch(`${mock.url}/v1/images/edit`, { + const res = await fetch(`${mock.url}/v1/images/edits`, { method: "POST", headers: { Authorization: "Bearer test" }, body: formData, @@ -156,6 +156,40 @@ describe("image edit endpoint", () => { const data = await res.json(); expect(data.error.code).toBe("no_fixture_match"); }); + + test("route path matches OpenAI: /v1/images/edits responds, /v1/images/edit is 404 (#221)", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "path check", endpoint: "image" }, + response: { image: { url: "https://example.com/ok.png" } }, + }); + await mock.start(); + + const makeForm = () => { + const fd = new FormData(); + fd.append("image", new Blob(["fake"]), "image.png"); + fd.append("prompt", "path check"); + return fd; + }; + + // Correct OpenAI path (plural) must succeed + const okRes = await fetch(`${mock.url}/v1/images/edits`, { + method: "POST", + headers: { Authorization: "Bearer test" }, + body: makeForm(), + }); + expect(okRes.status).toBe(200); + + // Legacy singular path must NOT be registered + const badRes = await fetch(`${mock.url}/v1/images/edit`, { + method: "POST", + headers: { Authorization: "Bearer test" }, + body: makeForm(), + }); + expect(badRes.status).toBe(404); + const badData = await badRes.json(); + expect(badData.error?.type).toBe("not_found"); + }); }); describe("image variations endpoint", () => { diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index 9ba2c91..7d60fb6 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -2138,3 +2138,21 @@ describe("POST /api/embeddings — Ollama Embeddings API", () => { expect(entry.response.status).toBe(200); }); }); + +// ─── POST /api/embed (current Ollama endpoint alias) ─────────────────────── + +describe("POST /api/embed — Ollama Embeddings API (current endpoint alias)", () => { + it("returns a valid embedding response identical to /api/embeddings", async () => { + instance = await createServer([]); + const res = await post(`${instance.url}/api/embed`, { + model: "nomic-embed-text", + prompt: "hello world", + }); + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.model).toBe("nomic-embed-text"); + expect(Array.isArray(body.embedding)).toBe(true); + expect(body.embedding.length).toBe(1536); + }); +}); diff --git a/src/images.ts b/src/images.ts index b5a7f2a..3850ba6 100644 --- a/src/images.ts +++ b/src/images.ts @@ -301,7 +301,7 @@ function serializeOpenAIImageResponse( } /** - * Handle POST /v1/images/edit — OpenAI Image Edit API. + * Handle POST /v1/images/edits — OpenAI Image Edit API. * * Request uses multipart/form-data. We extract text fields (`prompt`, `model`, * `n`, `size`, `response_format`) and ignore binary fields (`image`, `mask`) @@ -319,7 +319,7 @@ export async function handleImageEdit( setCorsHeaders: (res: http.ServerResponse) => void, ): Promise { setCorsHeaders(res); - const path = req.url ?? "/v1/images/edit"; + const path = req.url ?? "/v1/images/edits"; const method = req.method ?? "POST"; const contentType = Array.isArray(req.headers["content-type"]) @@ -412,7 +412,7 @@ export async function handleImageEdit( res, syntheticReq, "openai", - req.url ?? "/v1/images/edit", + req.url ?? "/v1/images/edits", fixtures, defaults, raw, diff --git a/src/server.ts b/src/server.ts index 8f63208..511b8fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -92,7 +92,7 @@ const SEARCH_PATH = "/search"; const RERANK_PATH = "/v2/rerank"; const MODERATIONS_PATH = "/v1/moderations"; const IMAGES_PATH = "/v1/images/generations"; -const IMAGES_EDIT_PATH = "/v1/images/edit"; +const IMAGES_EDIT_PATH = "/v1/images/edits"; const IMAGES_VARIATIONS_PATH = "/v1/images/variations"; const SPEECH_PATH = "/v1/audio/speech"; const TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"; @@ -123,7 +123,7 @@ const COMPAT_SUFFIXES = [ "/audio/transcriptions", "/audio/translations", "/images/generations", - "/images/edit", + "/images/edits", "/images/variations", ]; @@ -166,6 +166,7 @@ const VERTEX_AI_RE = const OLLAMA_CHAT_PATH = "/api/chat"; const OLLAMA_GENERATE_PATH = "/api/generate"; const OLLAMA_EMBEDDINGS_PATH = "/api/embeddings"; +const OLLAMA_EMBED_PATH = "/api/embed"; const OLLAMA_TAGS_PATH = "/api/tags"; const HEALTH_PATH = "/health"; @@ -1118,9 +1119,13 @@ export async function createServer( } // Ollama /api/* routes must be dispatched BEFORE normalizeCompatPath, which - // rewrites any path ending in /embeddings to /v1/embeddings. The /api/chat - // and /api/generate paths are unaffected (their suffixes aren't in - // COMPAT_SUFFIXES), but /api/embeddings would collide with the OpenAI handler. + // rewrites any path ending in /embeddings to /v1/embeddings. The /api/chat, + // /api/generate, and /api/embed paths are unaffected (their suffixes aren't + // in COMPAT_SUFFIXES), but /api/embeddings would collide with the OpenAI + // handler. /api/embed is the current Ollama endpoint + // (https://github.com/ollama/ollama/blob/main/docs/api.md); /api/embeddings + // is the legacy path kept for backwards-compatibility. Both route to the + // same handler. if (pathname === OLLAMA_CHAT_PATH && req.method === "POST") { try { const raw = await readBody(req); @@ -1159,7 +1164,10 @@ export async function createServer( return; } - if (pathname === OLLAMA_EMBEDDINGS_PATH && req.method === "POST") { + if ( + (pathname === OLLAMA_EMBEDDINGS_PATH || pathname === OLLAMA_EMBED_PATH) && + req.method === "POST" + ) { try { const raw = await readBody(req); await handleOllamaEmbeddings(req, res, raw, fixtures, journal, defaults, setCorsHeaders); @@ -1478,7 +1486,7 @@ export async function createServer( return; } - // POST /v1/images/edit — OpenAI Image Edit API (multipart/form-data) + // POST /v1/images/edits — OpenAI Image Edit API (multipart/form-data) if (pathname === IMAGES_EDIT_PATH && req.method === "POST") { try { const raw = await readBody(req);