From 5caa72f5fb41ee9f50d5b54ff9180b78303838c4 Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Thu, 3 Sep 2026 09:42:32 +0700 Subject: [PATCH] fix(models): support single model lookup Support single model lookup by replacing the one-segment models route with a catch-all route that preserves capability kind paths while allowing provider-prefixed IDs like cc/claude-sonnet-5. --- src/app/api/v1/models/[...model]/route.js | 78 +++++++++++++++++++++++ src/app/api/v1/models/[kind]/route.js | 55 ---------------- tests/unit/v1-model-lookup-3588.test.js | 71 +++++++++++++++++++++ 3 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 src/app/api/v1/models/[...model]/route.js delete mode 100644 src/app/api/v1/models/[kind]/route.js create mode 100644 tests/unit/v1-model-lookup-3588.test.js diff --git a/src/app/api/v1/models/[...model]/route.js b/src/app/api/v1/models/[...model]/route.js new file mode 100644 index 00000000..826d08e6 --- /dev/null +++ b/src/app/api/v1/models/[...model]/route.js @@ -0,0 +1,78 @@ +import { buildModelsList } from "../route.js"; + +// URL slug → service kind(s). `web` covers both webSearch and webFetch. +const KIND_SLUG_MAP = { + "image": ["image"], + "tts": ["tts"], + "stt": ["stt"], + "embedding": ["embedding"], + "image-to-text": ["imageToText"], + "web": ["webSearch", "webFetch"], +}; + +const LLM_KIND = "llm"; + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +function json(data, options = {}) { + return Response.json(data, { + ...options, + headers: { + "Access-Control-Allow-Origin": "*", + ...options.headers, + }, + }); +} + +/** + * GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability. + * GET /v1/models/{provider}/{model} - OpenAI-compatible single model lookup. + * Supported kinds: image, tts, stt, embedding, image-to-text, web. + */ +export async function GET(_request, { params }) { + try { + const { model } = await params; + const path = Array.isArray(model) ? model : [model]; + const identifier = path.filter(Boolean).join("/"); + const kindFilter = path.length === 1 ? KIND_SLUG_MAP[identifier] : null; + + if (kindFilter) { + const data = await buildModelsList(kindFilter); + return json({ object: "list", data }); + } + + // Match the same LLM catalog exposed by GET /v1/models. A catch-all + // parameter is required because provider-prefixed IDs contain a slash. + const models = await buildModelsList([LLM_KIND]); + const matchedModel = models.find((candidate) => candidate.id === identifier); + + if (!matchedModel) { + return json( + { + error: { + message: `The model '${identifier}' does not exist or you do not have access to it.`, + type: "invalid_request_error", + code: "model_not_found", + }, + }, + { status: 404 }, + ); + } + + return json(matchedModel); + } catch (error) { + console.log("Error fetching model:", error); + return json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/models/[kind]/route.js b/src/app/api/v1/models/[kind]/route.js deleted file mode 100644 index b0f715ba..00000000 --- a/src/app/api/v1/models/[kind]/route.js +++ /dev/null @@ -1,55 +0,0 @@ -import { buildModelsList } from "../route.js"; - -// URL slug → service kind(s). `web` covers both webSearch and webFetch. -const KIND_SLUG_MAP = { - "image": ["image"], - "tts": ["tts"], - "stt": ["stt"], - "embedding": ["embedding"], - "image-to-text": ["imageToText"], - "web": ["webSearch", "webFetch"], -}; - -export async function OPTIONS() { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "*", - }, - }); -} - -/** - * GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability. - * Supported kinds: image, tts, stt, embedding, image-to-text, web. - */ -export async function GET(_request, { params }) { - try { - const { kind } = await params; - const kindFilter = KIND_SLUG_MAP[kind]; - - if (!kindFilter) { - return Response.json( - { - error: { - message: `Unknown model kind: ${kind}. Supported: ${Object.keys(KIND_SLUG_MAP).join(", ")}`, - type: "invalid_request_error", - }, - }, - { status: 404, headers: { "Access-Control-Allow-Origin": "*" } } - ); - } - - const data = await buildModelsList(kindFilter); - return Response.json({ object: "list", data }, { - headers: { "Access-Control-Allow-Origin": "*" }, - }); - } catch (error) { - console.log("Error fetching models by kind:", error); - return Response.json( - { error: { message: error.message, type: "server_error" } }, - { status: 500 } - ); - } -} diff --git a/tests/unit/v1-model-lookup-3588.test.js b/tests/unit/v1-model-lookup-3588.test.js new file mode 100644 index 00000000..e27b3443 --- /dev/null +++ b/tests/unit/v1-model-lookup-3588.test.js @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildModelsList: vi.fn(), +})); + +vi.mock("../../src/app/api/v1/models/route.js", () => ({ + buildModelsList: mocks.buildModelsList, +})); + +const { GET } = await import("../../src/app/api/v1/models/[...model]/route.js"); + +const chatModel = { + id: "cc/claude-sonnet-5", + object: "model", + owned_by: "cc", + context_length: 1_000_000, +}; + +function params(model) { + return { params: Promise.resolve({ model }) }; +} + +describe("GET /v1/models/{id}", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retrieves a provider-prefixed model ID split across URL path segments", async () => { + mocks.buildModelsList.mockResolvedValue([chatModel]); + + const response = await GET(new Request("https://router.test/v1/models/cc/claude-sonnet-5"), params(["cc", "claude-sonnet-5"])); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(chatModel); + expect(mocks.buildModelsList).toHaveBeenCalledWith(["llm"]); + }); + + it("also handles a decoded slash in a single catch-all segment", async () => { + mocks.buildModelsList.mockResolvedValue([chatModel]); + + const response = await GET(new Request("https://router.test/v1/models/cc%2Fclaude-sonnet-5"), params(["cc/claude-sonnet-5"])); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(chatModel); + }); + + it("keeps capability-list routes unchanged", async () => { + const imageModel = { id: "image/gpt-image-1", object: "model", owned_by: "image" }; + mocks.buildModelsList.mockResolvedValue([imageModel]); + + const response = await GET(new Request("https://router.test/v1/models/image"), params(["image"])); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ object: "list", data: [imageModel] }); + expect(mocks.buildModelsList).toHaveBeenCalledWith(["image"]); + }); + + it("returns an OpenAI-style model_not_found response for an unknown model", async () => { + mocks.buildModelsList.mockResolvedValue([chatModel]); + + const response = await GET(new Request("https://router.test/v1/models/cc/missing-model"), params(["cc", "missing-model"])); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.error).toMatchObject({ + type: "invalid_request_error", + code: "model_not_found", + }); + }); +});