diff --git a/src/__tests__/provider-compat.test.ts b/src/__tests__/provider-compat.test.ts index 7634f26..a6d9897 100644 --- a/src/__tests__/provider-compat.test.ts +++ b/src/__tests__/provider-compat.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import crypto from "node:crypto"; import { createServer, type ServerInstance } from "../server.js"; import type { Fixture } from "../types.js"; @@ -209,6 +211,248 @@ describe("Together AI compatibility", () => { }); }); +describe("OpenAI-compatible path prefix normalization", () => { + it("normalizes /v4/chat/completions to /v1/chat/completions", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status, body } = await httpPost(`${instance.url}/v4/chat/completions`, { + model: "bigmodel-4", + stream: false, + messages: [{ role: "user", content: "hello" }], + }); + + expect(status).toBe(200); + const parsed = JSON.parse(body); + expect(parsed.choices).toBeDefined(); + expect(parsed.choices[0].message.content).toBe("Hello from aimock!"); + expect(parsed.object).toBe("chat.completion"); + }); + + it("normalizes /api/coding/paas/v4/chat/completions to /v1/chat/completions", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status, body } = await httpPost(`${instance.url}/api/coding/paas/v4/chat/completions`, { + model: "bigmodel-4", + stream: false, + messages: [{ role: "user", content: "hello" }], + }); + + expect(status).toBe(200); + const parsed = JSON.parse(body); + expect(parsed.choices).toBeDefined(); + expect(parsed.choices[0].message.content).toBe("Hello from aimock!"); + expect(parsed.object).toBe("chat.completion"); + }); + + it("still handles standard /v1/chat/completions (regression)", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status, body } = await httpPost(`${instance.url}/v1/chat/completions`, { + model: "gpt-4o", + stream: false, + messages: [{ role: "user", content: "hello" }], + }); + + expect(status).toBe(200); + const parsed = JSON.parse(body); + expect(parsed.choices).toBeDefined(); + expect(parsed.choices[0].message.content).toBe("Hello from aimock!"); + expect(parsed.object).toBe("chat.completion"); + }); + + it("normalizes /custom/embeddings to /v1/embeddings", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status, body } = await httpPost(`${instance.url}/custom/embeddings`, { + model: "text-embedding-3-small", + input: "test embedding via custom prefix", + }); + + expect(status).toBe(200); + const parsed = JSON.parse(body); + expect(parsed.object).toBe("list"); + expect(parsed.data[0].embedding).toBeInstanceOf(Array); + }); + + it("combines /openai/ prefix strip with normalization for non-v1 paths", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + // /openai/v4/chat/completions — strip /openai/ then normalize /v4/ to /v1/ + const { status, body } = await httpPost( + `${instance.url}/openai/v4/chat/completions`, + { + model: "llama-3.3-70b-versatile", + stream: false, + messages: [{ role: "user", content: "hello" }], + }, + { Authorization: "Bearer mock-groq-key" }, + ); + + expect(status).toBe(200); + const parsed = JSON.parse(body); + expect(parsed.choices).toBeDefined(); + expect(parsed.choices[0].message.content).toBe("Hello from aimock!"); + }); + + it("normalizes /custom/responses to /v1/responses", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { body } = await httpPost(`${instance.url}/custom/responses`, { + model: "gpt-4o", + input: "hello", + stream: false, + }); + + // Normalization works: we get "No fixture matched" from the Responses handler + // (not "Not found" which would mean the path wasn't routed at all) + const parsed = JSON.parse(body); + expect(parsed.error.type).toBe("invalid_request_error"); + expect(parsed.error.code).toBe("no_fixture_match"); + }); + + it("normalizes /custom/audio/speech to /v1/audio/speech", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { body } = await httpPost(`${instance.url}/custom/audio/speech`, { + model: "tts-1", + input: "test speech", + voice: "alloy", + }); + + // Normalization works: handler reached (not "Not found") + const parsed = JSON.parse(body); + expect(parsed.error.type).toBe("invalid_request_error"); + }); + + it("normalizes /custom/audio/transcriptions to /v1/audio/transcriptions", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { body } = await httpPost(`${instance.url}/custom/audio/transcriptions`, { + model: "whisper-1", + file: "test", + }); + + // Normalization works: handler reached (not "Not found") + const parsed = JSON.parse(body); + expect(parsed.error.type).toBe("invalid_request_error"); + }); + + it("normalizes /custom/images/generations to /v1/images/generations", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { body } = await httpPost(`${instance.url}/custom/images/generations`, { + model: "dall-e-3", + prompt: "test", + }); + + // Normalization works: handler reached (not "Not found") + const parsed = JSON.parse(body); + expect(parsed.error.type).toBe("invalid_request_error"); + }); + + it("does NOT normalize /v2/chat/completions (/v2/ guard for Cohere convention)", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status } = await httpPost(`${instance.url}/v2/chat/completions`, { + model: "command-r-plus", + stream: false, + messages: [{ role: "user", content: "hello" }], + }); + + // /v2/chat/completions should NOT be rewritten to /v1/chat/completions + // — the /v2/ guard prevents normalization, so this falls through to 404 + expect(status).toBe(404); + }); + + it("routes /v2/chat to Cohere handler (not normalization concern)", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + // /v2/chat is Cohere's endpoint — reaches the Cohere handler directly + const { status } = await httpPost(`${instance.url}/v2/chat`, { + model: "command-r-plus", + stream: false, + messages: [{ role: "user", content: "hello" }], + }); + + expect(status).toBe(200); + }); + + it("returns 404 for unrecognized paths that don't match any suffix", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + const { status } = await httpPost(`${instance.url}/custom/foo/bar`, { + model: "test", + messages: [{ role: "user", content: "hello" }], + }); + + expect(status).toBe(404); + }); +}); + +describe("WebSocket path normalization", () => { + /** + * Send an HTTP upgrade request and return the resulting status code. + * 101 = upgrade succeeded (WebSocket), anything else = rejected. + */ + function wsUpgrade(url: string, path: string): Promise<{ statusCode: number }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request({ + hostname: parsed.hostname, + port: parsed.port, + path, + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Key": Buffer.from(crypto.randomBytes(16)).toString("base64"), + "Sec-WebSocket-Version": "13", + }, + }); + req.on("upgrade", (_res, socket) => { + socket.destroy(); + resolve({ statusCode: 101 }); + }); + req.on("response", (res) => { + resolve({ statusCode: res.statusCode ?? 0 }); + }); + req.on("error", reject); + req.end(); + }); + } + + it("WS upgrade to /custom/responses normalizes to /v1/responses", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + const { statusCode } = await wsUpgrade(instance.url, "/custom/responses"); + expect(statusCode).toBe(101); + }); + + it("WS upgrade to /openai/v1/responses works (/openai/ strip)", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + const { statusCode } = await wsUpgrade(instance.url, "/openai/v1/responses"); + expect(statusCode).toBe(101); + }); + + it("WS upgrade to /v2/responses is NOT normalized (returns 404)", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + const { statusCode } = await wsUpgrade(instance.url, "/v2/responses"); + expect(statusCode).toBe(404); + }); + + it("WS upgrade to Azure deployment path is NOT normalized", async () => { + instance = await createServer(CATCH_ALL_FIXTURES); + + // Azure deployment WebSocket path should NOT have /openai/ stripped + // or be normalized — it should 404 cleanly (Azure WS not supported) + const { statusCode } = await wsUpgrade( + instance.url, + "/openai/deployments/gpt-4o/chat/completions", + ); + + // Not upgraded (Azure deployment paths don't support WS) + expect(statusCode).toBe(404); + }); +}); + describe("vLLM compatibility", () => { // vLLM uses standard /v1/chat/completions with custom model names it("handles vLLM-style request via /v1/chat/completions", async () => { diff --git a/src/server.ts b/src/server.ts index f19f3b5..2337a2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -78,6 +78,46 @@ const VIDEOS_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; const GEMINI_PREDICT_RE = /^\/v1beta\/models\/([^:]+):predict$/; const DEFAULT_CHUNK_SIZE = 20; +// OpenAI-compatible endpoint suffixes for path prefix normalization. +// Providers like BigModel (/v4/) use non-standard base URL prefixes. +// Only includes endpoints that third-party OpenAI-compatible providers are +// likely to serve — excludes provider-specific paths (/messages, /realtime) +// and endpoints unlikely to appear behind non-standard prefixes +// (/moderations, /videos, /models). +const COMPAT_SUFFIXES = [ + "/chat/completions", + "/embeddings", + "/responses", + "/audio/speech", + "/audio/transcriptions", + "/images/generations", +]; + +/** + * Normalize OpenAI-compatible paths with arbitrary prefixes. + * Strips /openai/ prefix and rewrites paths ending in known suffixes to /v1/. + * Skips /v1/ (already standard) and /v2/ (Cohere convention). + */ +function normalizeCompatPath(pathname: string, logger?: Logger): string { + // Strip /openai/ prefix (Groq/OpenAI-compat alias) + if (pathname.startsWith("/openai/")) { + pathname = pathname.slice(7); + } + + // Normalize arbitrary prefixes to /v1/ + if (!pathname.startsWith("/v1/") && !pathname.startsWith("/v2/")) { + for (const suffix of COMPAT_SUFFIXES) { + if (pathname.endsWith(suffix)) { + if (logger) logger.debug(`Path normalized: ${pathname} → /v1${suffix}`); + pathname = "/v1" + suffix; + break; + } + } + } + + return pathname; +} + const GEMINI_PATH_RE = /^\/v1beta\/models\/([^:]+):(generateContent|streamGenerateContent)$/; const AZURE_DEPLOYMENT_RE = /^\/openai\/deployments\/([^/]+)\/(chat\/completions|embeddings)$/; const BEDROCK_INVOKE_RE = /^\/model\/([^/]+)\/invoke$/; @@ -691,12 +731,13 @@ export async function createServer( const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); let pathname = parsedUrl.pathname; - // Instrument response completion for metrics + // Instrument response completion for metrics. The finish callback reads + // pathname via closure after normalizeCompatPath has rewritten it, so + // metrics record the canonical /v1/... path. if (registry) { - const rawPathname = pathname; res.on("finish", () => { try { - const normalizedPath = normalizePathLabel(rawPathname); + const normalizedPath = normalizePathLabel(pathname); const method = req.method ?? "UNKNOWN"; const status = String(res.statusCode); registry.incrementCounter("aimock_requests_total", { @@ -743,10 +784,9 @@ export async function createServer( pathname = `/v1/${operation}`; } - // Groq/OpenAI-compatible alias: strip /openai prefix so that - // /openai/v1/chat/completions → /v1/chat/completions, etc. - if (!azureDeploymentId && pathname.startsWith("/openai/")) { - pathname = pathname.slice(7); // remove "/openai" prefix, keep the rest + // Normalize OpenAI-compatible paths (strip /openai/ prefix + rewrite arbitrary prefixes) + if (!azureDeploymentId) { + pathname = normalizeCompatPath(pathname, logger); } // Health / readiness probes @@ -1508,9 +1548,9 @@ export async function createServer( head: Buffer, ): Promise { const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); - const pathname = parsedUrl.pathname; + let pathname = parsedUrl.pathname; - // Dispatch to mounted services + // Dispatch to mounted services before any path rewrites if (mounts) { for (const { path: mountPath, handler } of mounts) { if ( @@ -1523,6 +1563,12 @@ export async function createServer( } } + // Normalize OpenAI-compatible paths (strip /openai/ prefix + rewrite arbitrary prefixes) + // Skip Azure deployment paths — they have their own rewrite in the HTTP handler + if (!pathname.match(AZURE_DEPLOYMENT_RE)) { + pathname = normalizeCompatPath(pathname, logger); + } + if ( pathname !== RESPONSES_PATH && pathname !== REALTIME_PATH &&