diff --git a/src/__tests__/integration/api/admin/llm-models.test.ts b/src/__tests__/integration/api/admin/llm-models.test.ts index a9639aa594..e1ec45c365 100644 --- a/src/__tests__/integration/api/admin/llm-models.test.ts +++ b/src/__tests__/integration/api/admin/llm-models.test.ts @@ -573,6 +573,83 @@ describe("Admin LLM Models API", () => { const data = await response.json(); expect(data.model.provider).toBe("XAI"); }); + + it("should accept a slash-containing name on POST when provider is OTHER (OpenRouter)", async () => { + const request = createAuthenticatedPostRequest( + "/api/admin/llm-models", + superAdminUser, + { + name: "stealth/ox-alpha", + provider: "OTHER", + providerLabel: "OpenRouter", + inputPricePer1M: 1, + outputPricePer1M: 2, + }, + ); + const { POST } = await import("@/app/api/admin/llm-models/route"); + const response = await POST(request); + + expect(response.status).not.toBe(400); + const data = await response.json(); + expect(data.model.name).toBe("stealth/ox-alpha"); + }); + + it("should accept a slash-containing name on PATCH of an existing OTHER/OpenRouter model without resending provider", async () => { + const model = await createTestLlmModel({ + name: "stealth/ox-alpha-existing", + provider: "OTHER", + providerLabel: "OpenRouter", + }); + const request = createAuthenticatedPatchRequest( + `/api/admin/llm-models/${model.id}`, + { name: "x-ai/grok-4" }, + superAdminUser, + ); + const { PATCH } = await import("@/app/api/admin/llm-models/[id]/route"); + const response = await PATCH(request, { + params: Promise.resolve({ id: model.id }), + }); + + expect(response.status).not.toBe(400); + const data = await response.json(); + expect(data.model.name).toBe("x-ai/grok-4"); + }); + + it("should reject an invalid slash-containing name on POST even when provider is OTHER", async () => { + const request = createAuthenticatedPostRequest( + "/api/admin/llm-models", + superAdminUser, + { + name: "stealth//ox", + provider: "OTHER", + providerLabel: "OpenRouter", + inputPricePer1M: 1, + outputPricePer1M: 2, + }, + ); + const { POST } = await import("@/app/api/admin/llm-models/route"); + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it("should reject a name with a space in one of its segments on POST when provider is OTHER", async () => { + const request = createAuthenticatedPostRequest( + "/api/admin/llm-models", + superAdminUser, + { + name: "stealth/ox alpha", + provider: "OTHER", + providerLabel: "OpenRouter", + inputPricePer1M: 1, + outputPricePer1M: 2, + }, + ); + const { POST } = await import("@/app/api/admin/llm-models/route"); + const response = await POST(request); + + expect(response.status).toBe(400); + }); }); describe("Duplicate name conflict", () => { diff --git a/src/app/api/admin/llm-models/[id]/route.ts b/src/app/api/admin/llm-models/[id]/route.ts index eb9a7bcb85..74ec7b9cb1 100644 --- a/src/app/api/admin/llm-models/[id]/route.ts +++ b/src/app/api/admin/llm-models/[id]/route.ts @@ -10,16 +10,27 @@ import { Prisma } from "@prisma/client"; * in `src/lib/ai/models.ts`), and `getApiKeyForModel` derives the * credential from the *first* path segment. An unconstrained `name` * containing a `/` could make a row declared as one provider resolve - * to a different provider's key. Kept in sync with the sibling - * validator in `../route.ts`. + * to a different provider's key. + * + * `OTHER`/OpenRouter rows are the one exception: OpenRouter model ids + * are themselves `vendor/model` (e.g. "stealth/ox-alpha"), and + * `getModelValue()` prefixes them with `providerLabel/` (e.g. + * "openrouter/stealth/ox-alpha"), so `name` needs to allow one or more + * `/`-delimited safe segments there. First-class providers still + * forbid `/` in `name` entirely, since `getApiKeyForModel` keys off + * the first path segment. Kept in sync with the sibling validator in + * `../route.ts`. */ const SAFE_NAME_RE = /^[A-Za-z0-9._:-]+$/; +const SAFE_NAME_WITH_SLASHES_RE = /^[A-Za-z0-9._:-]+(\/[A-Za-z0-9._:-]+)*$/; function validateNameFields( name: unknown, providerLabel: unknown, + provider: unknown, ): NextResponse | null { - if (typeof name === "string" && !SAFE_NAME_RE.test(name)) { + const nameRe = provider === "OTHER" ? SAFE_NAME_WITH_SLASHES_RE : SAFE_NAME_RE; + if (typeof name === "string" && !nameRe.test(name)) { return NextResponse.json( { error: "name must match ^[A-Za-z0-9._:-]+$ (no slashes)" }, { status: 400 }, @@ -96,7 +107,12 @@ export async function PATCH( const { name, provider, providerLabel, inputPricePer1M, outputPricePer1M, cacheReadPer1MToken, cacheWritePer1MToken, dateStart, dateEnd, isPlanDefault, isTaskDefault, isPublic } = body; - const nameErr = validateNameFields(name, providerLabel); + // `provider` isn't always resent on a partial PATCH (e.g. renaming an + // OpenRouter model's `name`) — fall back to the existing row's + // provider so slash-containing OTHER names keep validating correctly + // without requiring the caller to also resend `provider`. + const effectiveProvider = provider !== undefined ? provider : existing.provider; + const nameErr = validateNameFields(name, providerLabel, effectiveProvider); if (nameErr) return nameErr; // Atomic: clearing the existing default and applying this row's diff --git a/src/app/api/admin/llm-models/route.ts b/src/app/api/admin/llm-models/route.ts index bd140d8d3e..d9e6628c4e 100644 --- a/src/app/api/admin/llm-models/route.ts +++ b/src/app/api/admin/llm-models/route.ts @@ -13,14 +13,25 @@ import { LlmProvider, Prisma } from "@prisma/client"; * to a different provider's key (e.g. an `XAI` row named * "anthropic/claude-x"). No slashes, and only characters that make * sense in a model id / display label. + * + * `OTHER`/OpenRouter rows are the one exception: OpenRouter model ids + * are themselves `vendor/model` (e.g. "stealth/ox-alpha"), and + * `getModelValue()` prefixes them with `providerLabel/` (e.g. + * "openrouter/stealth/ox-alpha"), so `name` needs to allow one or more + * `/`-delimited safe segments there. First-class providers still + * forbid `/` in `name` entirely, since `getApiKeyForModel` keys off + * the first path segment. */ const SAFE_NAME_RE = /^[A-Za-z0-9._:-]+$/; +const SAFE_NAME_WITH_SLASHES_RE = /^[A-Za-z0-9._:-]+(\/[A-Za-z0-9._:-]+)*$/; function validateNameFields( name: unknown, providerLabel: unknown, + provider: unknown, ): NextResponse | null { - if (typeof name === "string" && !SAFE_NAME_RE.test(name)) { + const nameRe = provider === "OTHER" ? SAFE_NAME_WITH_SLASHES_RE : SAFE_NAME_RE; + if (typeof name === "string" && !nameRe.test(name)) { return NextResponse.json( { error: "name must match ^[A-Za-z0-9._:-]+$ (no slashes)" }, { status: 400 }, @@ -88,7 +99,7 @@ export async function POST(request: NextRequest) { { status: 400 } ); } - const nameErr = validateNameFields(item.name, item.providerLabel); + const nameErr = validateNameFields(item.name, item.providerLabel, item.provider); if (nameErr) return nameErr; } @@ -147,7 +158,7 @@ export async function POST(request: NextRequest) { ); } - const nameErr = validateNameFields(name, providerLabel); + const nameErr = validateNameFields(name, providerLabel, provider); if (nameErr) return nameErr; // The default-flip (clear the existing default, then create the new