mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d3b176d233
commit
5ff2cbd5f7
26
src/app/api/v1/models/[...model]/route.ts
Normal file
26
src/app/api/v1/models/[...model]/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
62
src/app/api/v1/models/modelById.ts
Normal file
62
src/app/api/v1/models/modelById.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
/** 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<string, string>) => Promise<Response>
|
||||
): Promise<Response> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
77
tests/unit/v1-models-by-id-4674.test.ts
Normal file
77
tests/unit/v1-models-by-id-4674.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user