From 359e2503c96927e3b8510524259cd4264d54e7cb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 3 Jun 2026 16:08:36 +0100 Subject: [PATCH] feat(database,webapp): add LlmModel pricing_unit column and admin selector (#3820) ## Summary Adds a nullable `pricing_unit` column to the LLM model registry's `llm_models` table, recording how each model is billed ("tokens", "characters", "images", "minutes", "requests", "free", "not_findable"). It lets pricing-coverage reporting exclude models that aren't priced per-token (image/video/audio models currently drag the "% priced" number down even though they can never carry a per-token price), and lays the groundwork for non-token pricing. The default model catalog is entirely per-token, so `seed` and `syncLlmCatalog` set `pricing_unit="tokens"` on those rows. The admin LLM model form (create + edit) and the admin API get a pricing-unit selector so admin-curated models can set it; existing rows can stay unset. Auto-discovered models get their unit from the model-registry pipeline, which lands separately. --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .server-changes/llm-model-pricing-unit.md | 6 +++++ .../admin.api.v1.llm-models.$modelId.ts | 4 +++- .../app/routes/admin.api.v1.llm-models.ts | 4 +++- .../app/routes/admin.llm-models.$modelId.tsx | 24 ++++++++++++++++++- .../app/routes/admin.llm-models.new.tsx | 24 ++++++++++++++++++- .../migration.sql | 2 ++ .../database/prisma/schema.prisma | 2 ++ .../llm-model-catalog/src/seed.ts | 1 + .../llm-model-catalog/src/sync.ts | 1 + 9 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .server-changes/llm-model-pricing-unit.md create mode 100644 internal-packages/database/prisma/migrations/20260603132613_add_pricing_unit_to_llm_models/migration.sql diff --git a/.server-changes/llm-model-pricing-unit.md b/.server-changes/llm-model-pricing-unit.md new file mode 100644 index 000000000..6cb557545 --- /dev/null +++ b/.server-changes/llm-model-pricing-unit.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Track a pricing unit (tokens, images, characters, etc.) per LLM model in the model registry, seeded for the default catalog and selectable in the admin model form. diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts index e473c2c72..7e5f559a4 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts @@ -33,6 +33,7 @@ const UpdateModelSchema = z.object({ maxOutputTokens: z.number().int().nullable().optional(), capabilities: z.array(z.string()).optional(), isHidden: z.boolean().optional(), + pricingUnit: z.string().nullable().optional(), pricingTiers: z .array( z.object({ @@ -86,7 +87,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Invalid request body", details: parsed.error.issues }, { status: 400 }); } - const { modelName, matchPattern, startDate, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; + const { modelName, matchPattern, startDate, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data; // Validate regex if provided — strip (?i) POSIX flag since our registry handles it if (matchPattern) { @@ -112,6 +113,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ...(maxOutputTokens !== undefined && { maxOutputTokens }), ...(capabilities !== undefined && { capabilities }), ...(isHidden !== undefined && { isHidden }), + ...(pricingUnit !== undefined && { pricingUnit }), }, }); diff --git a/apps/webapp/app/routes/admin.api.v1.llm-models.ts b/apps/webapp/app/routes/admin.api.v1.llm-models.ts index 7c8a3d8ca..6753f37dc 100644 --- a/apps/webapp/app/routes/admin.api.v1.llm-models.ts +++ b/apps/webapp/app/routes/admin.api.v1.llm-models.ts @@ -41,6 +41,7 @@ const CreateModelSchema = z.object({ maxOutputTokens: z.number().int().optional(), capabilities: z.array(z.string()).optional(), isHidden: z.boolean().optional(), + pricingUnit: z.string().optional(), pricingTiers: z.array( z.object({ name: z.string().min(1), @@ -80,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Invalid request body", details: parsed.error.issues }, { status: 400 }); } - const { modelName, matchPattern, startDate, source, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; + const { modelName, matchPattern, startDate, source, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data; // Validate regex pattern — strip (?i) POSIX flag since our registry handles it try { @@ -105,6 +106,7 @@ export async function action({ request }: ActionFunctionArgs) { maxOutputTokens: maxOutputTokens ?? null, capabilities: capabilities ?? [], isHidden: isHidden ?? false, + pricingUnit: pricingUnit ?? null, }, }); diff --git a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx index e90752fb2..f048853c4 100644 --- a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx +++ b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx @@ -49,6 +49,7 @@ const SaveSchema = z.object({ maxOutputTokens: z.string().optional(), capabilities: z.string().optional(), isHidden: z.string().optional(), + pricingUnit: z.string().optional(), }); export const action = dashboardAction( @@ -101,7 +102,7 @@ export const action = dashboardAction( } // Update model - const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; + const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data; await prisma.llmModel.update({ where: { id: modelId }, data: { @@ -113,6 +114,7 @@ export const action = dashboardAction( maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], isHidden: isHidden === "on", + pricingUnit: pricingUnit || null, }, }); @@ -158,6 +160,7 @@ export default function AdminLlmModelDetailRoute() { const [maxOutputTokens, setMaxOutputTokens] = useState(model.maxOutputTokens?.toString() ?? ""); const [capabilities, setCapabilities] = useState(model.capabilities?.join(", ") ?? ""); const [isHidden, setIsHidden] = useState(model.isHidden ?? false); + const [pricingUnit, setPricingUnit] = useState(model.pricingUnit ?? ""); const [testInput, setTestInput] = useState(""); const [tiers, setTiers] = useState(() => model.pricingTiers.map((t) => ({ @@ -325,6 +328,23 @@ export default function AdminLlmModelDetailRoute() { +
+ + +
+