diff --git a/src/app/api/v1/models/[...model]/route.ts b/src/app/api/v1/models/[...model]/route.ts new file mode 100644 index 0000000000..cb3d22e8b0 --- /dev/null +++ b/src/app/api/v1/models/[...model]/route.ts @@ -0,0 +1,26 @@ +import { handleCorsOptions } from "@/shared/utils/cors"; +import { getUnifiedModelsResponse } from "../catalog"; +import { handleGetModelById } from "../modelById"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /v1/models/{model} — OpenAI-compatible single-model retrieval (#4674). + * + * Catch-all (`[...model]`) so provider-prefixed ids that contain a slash + * (e.g. `cgpt-web/gpt-5.5`, `claude/claude-sonnet-4-6`) are captured intact. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ model: string[] }> } +) { + const { model } = await params; + const segments = Array.isArray(model) ? model : [model]; + const requestedId = decodeURIComponent(segments.join("/")); + return handleGetModelById(request, requestedId, getUnifiedModelsResponse); +} diff --git a/src/app/api/v1/models/modelById.ts b/src/app/api/v1/models/modelById.ts new file mode 100644 index 0000000000..5a8b2eadb8 --- /dev/null +++ b/src/app/api/v1/models/modelById.ts @@ -0,0 +1,62 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; + +/** + * #4674 — Shared logic for `GET /v1/models/{model}`. + * + * Before this route existed, a request for a single model fell through to the + * Next.js catch-all and returned the HTML dashboard, which broke Claude Code's + * model-validation probe (it expects the OpenAI `{ id, object: "model", ... }` + * shape). Kept in a sibling module (not the route file) so the lookup stays + * unit-testable without the DB-backed catalog. + */ + +type CatalogModel = { id?: unknown } & Record; + +/** Find a model entry in the unified catalog `data` array by its exact id. */ +export function findModelById( + data: CatalogModel[] | null | undefined, + requestedId: string +): CatalogModel | null { + if (!Array.isArray(data)) return null; + return data.find((m) => typeof m?.id === "string" && m.id === requestedId) ?? null; +} + +/** + * Resolve a single model from the unified catalog response. + * + * @param getModels Returns the `{ object: "list", data: [...] }` Response — in + * the route this is `getUnifiedModelsResponse`; tests inject a fake. + */ +export async function handleGetModelById( + request: Request, + requestedId: string, + getModels: (request: Request, corsHeaders?: Record) => Promise +): Promise { + const listResp = await getModels(request, CORS_HEADERS); + // Propagate auth rejections / 5xx (already JSON) unchanged. + if (!listResp.ok) return listResp; + + let data: CatalogModel[] | undefined; + try { + const body = (await listResp.json()) as { data?: CatalogModel[] }; + data = body?.data; + } catch { + data = undefined; + } + + const found = findModelById(data, requestedId); + if (found) { + return Response.json(found, { headers: CORS_HEADERS }); + } + + return Response.json( + { + error: { + message: `The model '${requestedId}' does not exist`, + type: "invalid_request_error", + code: "model_not_found", + }, + }, + { status: 404, headers: CORS_HEADERS } + ); +} diff --git a/tests/unit/v1-models-by-id-4674.test.ts b/tests/unit/v1-models-by-id-4674.test.ts new file mode 100644 index 0000000000..4af0bb7193 --- /dev/null +++ b/tests/unit/v1-models-by-id-4674.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + findModelById, + handleGetModelById, +} from "@/app/api/v1/models/modelById"; + +// #4674 — GET /v1/models/{model} previously had no route handler, so the request +// fell through to the Next.js catch-all and returned the HTML dashboard instead of +// JSON, breaking Claude Code's model-validation probe. These tests pin the JSON +// contract for both the found and not-found paths. + +const CATALOG = [ + { id: "claude/claude-sonnet-4-6", object: "model", owned_by: "claude" }, + { id: "cgpt-web/gpt-5.5", object: "model", owned_by: "chatgpt-web" }, + { id: "gpt-5", object: "model", owned_by: "openai" }, +]; + +function listResponse() { + return Response.json({ object: "list", data: CATALOG }); +} + +test("findModelById returns the exact-id match", () => { + const found = findModelById(CATALOG, "claude/claude-sonnet-4-6"); + assert.ok(found); + assert.equal(found.id, "claude/claude-sonnet-4-6"); + assert.equal(found.object, "model"); +}); + +test("findModelById handles provider-prefixed ids containing a slash", () => { + const found = findModelById(CATALOG, "cgpt-web/gpt-5.5"); + assert.ok(found); + assert.equal(found.id, "cgpt-web/gpt-5.5"); +}); + +test("findModelById returns null for an unknown model", () => { + assert.equal(findModelById(CATALOG, "does-not-exist"), null); +}); + +test("findModelById tolerates a non-array catalog", () => { + assert.equal(findModelById(undefined, "gpt-5"), null); + assert.equal(findModelById(null, "gpt-5"), null); +}); + +test("handleGetModelById returns 200 JSON for an existing model", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const res = await handleGetModelById(req, "gpt-5", listResponse); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /application\/json/); + const body = await res.json(); + assert.equal(body.id, "gpt-5"); + assert.equal(body.object, "model"); +}); + +test("handleGetModelById returns 404 JSON (not HTML) for an unknown model", async () => { + const req = new Request("http://localhost:20128/v1/models/ghost"); + const res = await handleGetModelById(req, "ghost", listResponse); + assert.equal(res.status, 404); + // The whole point of #4674: never serve the HTML dashboard here. + const ct = res.headers.get("content-type") || ""; + assert.match(ct, /application\/json/); + assert.doesNotMatch(ct, /text\/html/); + const body = await res.json(); + assert.equal(body.error.code, "model_not_found"); + assert.match(body.error.message, /ghost/); +}); + +test("handleGetModelById propagates an upstream auth/error response unchanged", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const rejection = async () => + Response.json({ error: { message: "unauthorized" } }, { status: 401 }); + const res = await handleGetModelById(req, "gpt-5", rejection); + assert.equal(res.status, 401); + const body = await res.json(); + assert.equal(body.error.message, "unauthorized"); +});