Skip to content

Commit 359e250

Browse files
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>
1 parent 9818ad5 commit 359e250

9 files changed

Lines changed: 64 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

apps/webapp/app/routes/admin.api.v1.llm-models.$modelId.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const UpdateModelSchema = z.object({
3333
maxOutputTokens: z.number().int().nullable().optional(),
3434
capabilities: z.array(z.string()).optional(),
3535
isHidden: z.boolean().optional(),
36+
pricingUnit: z.string().nullable().optional(),
3637
pricingTiers: z
3738
.array(
3839
z.object({
@@ -86,7 +87,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
8687
return json({ error: "Invalid request body", details: parsed.error.issues }, { status: 400 });
8788
}
8889

89-
const { modelName, matchPattern, startDate, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
90+
const { modelName, matchPattern, startDate, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data;
9091

9192
// Validate regex if provided — strip (?i) POSIX flag since our registry handles it
9293
if (matchPattern) {
@@ -112,6 +113,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
112113
...(maxOutputTokens !== undefined && { maxOutputTokens }),
113114
...(capabilities !== undefined && { capabilities }),
114115
...(isHidden !== undefined && { isHidden }),
116+
...(pricingUnit !== undefined && { pricingUnit }),
115117
},
116118
});
117119

apps/webapp/app/routes/admin.api.v1.llm-models.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const CreateModelSchema = z.object({
4141
maxOutputTokens: z.number().int().optional(),
4242
capabilities: z.array(z.string()).optional(),
4343
isHidden: z.boolean().optional(),
44+
pricingUnit: z.string().optional(),
4445
pricingTiers: z.array(
4546
z.object({
4647
name: z.string().min(1),
@@ -80,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) {
8081
return json({ error: "Invalid request body", details: parsed.error.issues }, { status: 400 });
8182
}
8283

83-
const { modelName, matchPattern, startDate, source, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
84+
const { modelName, matchPattern, startDate, source, pricingTiers, provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data;
8485

8586
// Validate regex pattern — strip (?i) POSIX flag since our registry handles it
8687
try {
@@ -105,6 +106,7 @@ export async function action({ request }: ActionFunctionArgs) {
105106
maxOutputTokens: maxOutputTokens ?? null,
106107
capabilities: capabilities ?? [],
107108
isHidden: isHidden ?? false,
109+
pricingUnit: pricingUnit ?? null,
108110
},
109111
});
110112

apps/webapp/app/routes/admin.llm-models.$modelId.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const SaveSchema = z.object({
4949
maxOutputTokens: z.string().optional(),
5050
capabilities: z.string().optional(),
5151
isHidden: z.string().optional(),
52+
pricingUnit: z.string().optional(),
5253
});
5354

5455
export const action = dashboardAction(
@@ -101,7 +102,7 @@ export const action = dashboardAction(
101102
}
102103

103104
// Update model
104-
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
105+
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data;
105106
await prisma.llmModel.update({
106107
where: { id: modelId },
107108
data: {
@@ -113,6 +114,7 @@ export const action = dashboardAction(
113114
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
114115
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
115116
isHidden: isHidden === "on",
117+
pricingUnit: pricingUnit || null,
116118
},
117119
});
118120

@@ -158,6 +160,7 @@ export default function AdminLlmModelDetailRoute() {
158160
const [maxOutputTokens, setMaxOutputTokens] = useState(model.maxOutputTokens?.toString() ?? "");
159161
const [capabilities, setCapabilities] = useState(model.capabilities?.join(", ") ?? "");
160162
const [isHidden, setIsHidden] = useState(model.isHidden ?? false);
163+
const [pricingUnit, setPricingUnit] = useState(model.pricingUnit ?? "");
161164
const [testInput, setTestInput] = useState("");
162165
const [tiers, setTiers] = useState(() =>
163166
model.pricingTiers.map((t) => ({
@@ -325,6 +328,23 @@ export default function AdminLlmModelDetailRoute() {
325328
</div>
326329
</div>
327330

331+
<div className="space-y-1">
332+
<label className="text-xs font-medium text-text-dimmed">Pricing Unit</label>
333+
<select
334+
name="pricingUnit"
335+
value={pricingUnit}
336+
onChange={(e) => setPricingUnit(e.target.value)}
337+
className="w-full rounded border border-grid-dimmed bg-charcoal-750 px-2 py-1.5 text-sm text-text-bright"
338+
>
339+
<option value="">(unset)</option>
340+
{PRICING_UNITS.map((u) => (
341+
<option key={u} value={u}>
342+
{u}
343+
</option>
344+
))}
345+
</select>
346+
</div>
347+
328348
<label className="flex items-center gap-2 text-xs text-text-dimmed">
329349
<input
330350
type="checkbox"
@@ -425,6 +445,8 @@ type TierData = {
425445
prices: Record<string, number>;
426446
};
427447

448+
const PRICING_UNITS = ["tokens", "characters", "images", "minutes", "requests", "free", "not_findable"];
449+
428450
const COMMON_USAGE_TYPES = [
429451
"input",
430452
"output",

apps/webapp/app/routes/admin.llm-models.new.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const CreateSchema = z.object({
2727
maxOutputTokens: z.string().optional(),
2828
capabilities: z.string().optional(),
2929
isHidden: z.string().optional(),
30+
pricingUnit: z.string().optional(),
3031
});
3132

3233
export const action = dashboardAction(
@@ -65,7 +66,7 @@ export const action = dashboardAction(
6566
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
6667
}
6768

68-
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
69+
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data;
6970

7071
const model = await prisma.llmModel.create({
7172
data: {
@@ -79,6 +80,7 @@ export const action = dashboardAction(
7980
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
8081
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
8182
isHidden: isHidden === "on",
83+
pricingUnit: pricingUnit || null,
8284
},
8385
});
8486

@@ -118,6 +120,7 @@ export default function AdminLlmModelNewRoute() {
118120
const [maxOutputTokens, setMaxOutputTokens] = useState("");
119121
const [capabilities, setCapabilities] = useState("");
120122
const [isHidden, setIsHidden] = useState(false);
123+
const [pricingUnit, setPricingUnit] = useState("tokens");
121124
const [testInput, setTestInput] = useState("");
122125
const [tiers, setTiers] = useState<TierData[]>([
123126
{ name: "Standard", isDefault: true, priority: 0, conditions: [], prices: { input: 0, output: 0 } },
@@ -279,6 +282,23 @@ export default function AdminLlmModelNewRoute() {
279282
</div>
280283
</div>
281284

285+
<div className="space-y-1">
286+
<label className="text-xs font-medium text-text-dimmed">Pricing Unit</label>
287+
<select
288+
name="pricingUnit"
289+
value={pricingUnit}
290+
onChange={(e) => setPricingUnit(e.target.value)}
291+
className="w-full rounded border border-grid-dimmed bg-charcoal-750 px-2 py-1.5 text-sm text-text-bright"
292+
>
293+
<option value="">(unset)</option>
294+
{PRICING_UNITS.map((u) => (
295+
<option key={u} value={u}>
296+
{u}
297+
</option>
298+
))}
299+
</select>
300+
</div>
301+
282302
<label className="flex items-center gap-2 text-xs text-text-dimmed">
283303
<input
284304
type="checkbox"
@@ -366,6 +386,8 @@ type TierData = {
366386
prices: Record<string, number>;
367387
};
368388

389+
const PRICING_UNITS = ["tokens", "characters", "images", "minutes", "requests", "free", "not_findable"];
390+
369391
const COMMON_USAGE_TYPES = [
370392
"input",
371393
"output",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "public"."llm_models" ADD COLUMN "pricing_unit" TEXT;

internal-packages/database/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2909,6 +2909,8 @@ model LlmModel {
29092909
capabilities String[] @default([]) @map("capabilities")
29102910
isHidden Boolean @default(false) @map("is_hidden")
29112911
baseModelName String? @map("base_model_name")
2912+
// "tokens", "images", "characters", "minutes", "requests", "free", "not_findable"; null until researched
2913+
pricingUnit String? @map("pricing_unit")
29122914
29132915
pricingTiers LlmPricingTier[]
29142916
prices LlmPrice[]

internal-packages/llm-model-catalog/src/seed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export async function seedLlmPricing(prisma: PrismaClient): Promise<{
4646
capabilities: catalog?.capabilities ?? [],
4747
isHidden: catalog?.isHidden ?? false,
4848
baseModelName: catalog?.baseModelName ?? null,
49+
pricingUnit: "tokens",
4950
},
5051
});
5152

internal-packages/llm-model-catalog/src/sync.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export async function syncLlmCatalog(prisma: PrismaClient): Promise<{
7474
catalog?.baseModelName === undefined
7575
? existing.baseModelName
7676
: catalog.baseModelName,
77+
pricingUnit: existing.pricingUnit ?? "tokens",
7778
},
7879
});
7980

0 commit comments

Comments
 (0)