Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/__tests__/integration/api/admin/llm-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 20 additions & 4 deletions src/app/api/admin/llm-models/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions src/app/api/admin/llm-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Loading