Skip to content

Commit 372de39

Browse files
committed
fix: normalize OpenAI-compatible paths with arbitrary prefixes (#107)
Providers like BigModel (/v4/chat/completions), DeepSeek, and others use non-standard base URLs. Normalize any path ending in a known endpoint suffix to /v1/<endpoint> before routing. Closes #107
1 parent 0495773 commit 372de39

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

src/__tests__/provider-compat.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,92 @@ describe("Together AI compatibility", () => {
209209
});
210210
});
211211

212+
describe("OpenAI-compatible path prefix normalization", () => {
213+
it("normalizes /v4/chat/completions to /v1/chat/completions", async () => {
214+
instance = await createServer(CATCH_ALL_FIXTURES);
215+
216+
const { status, body } = await httpPost(`${instance.url}/v4/chat/completions`, {
217+
model: "bigmodel-4",
218+
stream: false,
219+
messages: [{ role: "user", content: "hello" }],
220+
});
221+
222+
expect(status).toBe(200);
223+
const parsed = JSON.parse(body);
224+
expect(parsed.choices).toBeDefined();
225+
expect(parsed.choices[0].message.content).toBe("Hello from aimock!");
226+
expect(parsed.object).toBe("chat.completion");
227+
});
228+
229+
it("normalizes /api/coding/paas/v4/chat/completions to /v1/chat/completions", async () => {
230+
instance = await createServer(CATCH_ALL_FIXTURES);
231+
232+
const { status, body } = await httpPost(`${instance.url}/api/coding/paas/v4/chat/completions`, {
233+
model: "bigmodel-4",
234+
stream: false,
235+
messages: [{ role: "user", content: "hello" }],
236+
});
237+
238+
expect(status).toBe(200);
239+
const parsed = JSON.parse(body);
240+
expect(parsed.choices).toBeDefined();
241+
expect(parsed.choices[0].message.content).toBe("Hello from aimock!");
242+
expect(parsed.object).toBe("chat.completion");
243+
});
244+
245+
it("still handles standard /v1/chat/completions (regression)", async () => {
246+
instance = await createServer(CATCH_ALL_FIXTURES);
247+
248+
const { status, body } = await httpPost(`${instance.url}/v1/chat/completions`, {
249+
model: "gpt-4o",
250+
stream: false,
251+
messages: [{ role: "user", content: "hello" }],
252+
});
253+
254+
expect(status).toBe(200);
255+
const parsed = JSON.parse(body);
256+
expect(parsed.choices).toBeDefined();
257+
expect(parsed.choices[0].message.content).toBe("Hello from aimock!");
258+
expect(parsed.object).toBe("chat.completion");
259+
});
260+
261+
it("normalizes /custom/embeddings to /v1/embeddings", async () => {
262+
instance = await createServer(CATCH_ALL_FIXTURES);
263+
264+
const { status, body } = await httpPost(`${instance.url}/custom/embeddings`, {
265+
model: "text-embedding-3-small",
266+
input: "test embedding via custom prefix",
267+
});
268+
269+
expect(status).toBe(200);
270+
const parsed = JSON.parse(body);
271+
expect(parsed.object).toBe("list");
272+
expect(parsed.data[0].embedding).toBeInstanceOf(Array);
273+
});
274+
275+
it("combines /openai/ prefix strip with path normalization", async () => {
276+
instance = await createServer(CATCH_ALL_FIXTURES);
277+
278+
// /openai/v1/chat/completions is the Groq-style path — the /openai/ strip
279+
// should still work alongside the new normalization logic
280+
const { status, body } = await httpPost(
281+
`${instance.url}/openai/v1/chat/completions`,
282+
{
283+
model: "llama-3.3-70b-versatile",
284+
stream: false,
285+
messages: [{ role: "user", content: "hello" }],
286+
},
287+
{ Authorization: "Bearer mock-groq-key" },
288+
);
289+
290+
expect(status).toBe(200);
291+
const parsed = JSON.parse(body);
292+
expect(parsed.choices).toBeDefined();
293+
expect(parsed.choices[0].message.content).toBe("Hello from aimock!");
294+
expect(parsed.object).toBe("chat.completion");
295+
});
296+
});
297+
212298
describe("vLLM compatibility", () => {
213299
// vLLM uses standard /v1/chat/completions with custom model names
214300
it("handles vLLM-style request via /v1/chat/completions", async () => {

src/server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,26 @@ export async function createServer(
749749
pathname = pathname.slice(7); // remove "/openai" prefix, keep the rest
750750
}
751751

752+
// Normalize OpenAI-compatible paths with arbitrary prefixes.
753+
// Providers like BigModel (/v4/chat/completions), DeepSeek, etc.
754+
// use non-standard base URLs. Normalize to /v1/<endpoint>.
755+
if (!azureDeploymentId && pathname !== "/v1/chat/completions") {
756+
const COMPAT_SUFFIXES = [
757+
"/chat/completions",
758+
"/embeddings",
759+
"/responses",
760+
"/audio/speech",
761+
"/audio/transcriptions",
762+
"/images/generations",
763+
];
764+
for (const suffix of COMPAT_SUFFIXES) {
765+
if (pathname.endsWith(suffix)) {
766+
pathname = "/v1" + suffix;
767+
break;
768+
}
769+
}
770+
}
771+
752772
// Health / readiness probes
753773
if (pathname === HEALTH_PATH && req.method === "GET") {
754774
setCorsHeaders(res);

0 commit comments

Comments
 (0)