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>
This commit is contained in:
@@ -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.
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-dimmed">Pricing Unit</label>
|
||||
<select
|
||||
name="pricingUnit"
|
||||
value={pricingUnit}
|
||||
onChange={(e) => setPricingUnit(e.target.value)}
|
||||
className="w-full rounded border border-grid-dimmed bg-charcoal-750 px-2 py-1.5 text-sm text-text-bright"
|
||||
>
|
||||
<option value="">(unset)</option>
|
||||
{PRICING_UNITS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-text-dimmed">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -425,6 +445,8 @@ type TierData = {
|
||||
prices: Record<string, number>;
|
||||
};
|
||||
|
||||
const PRICING_UNITS = ["tokens", "characters", "images", "minutes", "requests", "free", "not_findable"];
|
||||
|
||||
const COMMON_USAGE_TYPES = [
|
||||
"input",
|
||||
"output",
|
||||
|
||||
@@ -27,6 +27,7 @@ const CreateSchema = z.object({
|
||||
maxOutputTokens: z.string().optional(),
|
||||
capabilities: z.string().optional(),
|
||||
isHidden: z.string().optional(),
|
||||
pricingUnit: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action = dashboardAction(
|
||||
@@ -65,7 +66,7 @@ export const action = dashboardAction(
|
||||
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
|
||||
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden, pricingUnit } = parsed.data;
|
||||
|
||||
const model = await prisma.llmModel.create({
|
||||
data: {
|
||||
@@ -79,6 +80,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,6 +120,7 @@ export default function AdminLlmModelNewRoute() {
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [pricingUnit, setPricingUnit] = useState("tokens");
|
||||
const [testInput, setTestInput] = useState("");
|
||||
const [tiers, setTiers] = useState<TierData[]>([
|
||||
{ name: "Standard", isDefault: true, priority: 0, conditions: [], prices: { input: 0, output: 0 } },
|
||||
@@ -279,6 +282,23 @@ export default function AdminLlmModelNewRoute() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-dimmed">Pricing Unit</label>
|
||||
<select
|
||||
name="pricingUnit"
|
||||
value={pricingUnit}
|
||||
onChange={(e) => setPricingUnit(e.target.value)}
|
||||
className="w-full rounded border border-grid-dimmed bg-charcoal-750 px-2 py-1.5 text-sm text-text-bright"
|
||||
>
|
||||
<option value="">(unset)</option>
|
||||
{PRICING_UNITS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-text-dimmed">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -366,6 +386,8 @@ type TierData = {
|
||||
prices: Record<string, number>;
|
||||
};
|
||||
|
||||
const PRICING_UNITS = ["tokens", "characters", "images", "minutes", "requests", "free", "not_findable"];
|
||||
|
||||
const COMMON_USAGE_TYPES = [
|
||||
"input",
|
||||
"output",
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."llm_models" ADD COLUMN "pricing_unit" TEXT;
|
||||
@@ -2909,6 +2909,8 @@ model LlmModel {
|
||||
capabilities String[] @default([]) @map("capabilities")
|
||||
isHidden Boolean @default(false) @map("is_hidden")
|
||||
baseModelName String? @map("base_model_name")
|
||||
// "tokens", "images", "characters", "minutes", "requests", "free", "not_findable"; null until researched
|
||||
pricingUnit String? @map("pricing_unit")
|
||||
|
||||
pricingTiers LlmPricingTier[]
|
||||
prices LlmPrice[]
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function seedLlmPricing(prisma: PrismaClient): Promise<{
|
||||
capabilities: catalog?.capabilities ?? [],
|
||||
isHidden: catalog?.isHidden ?? false,
|
||||
baseModelName: catalog?.baseModelName ?? null,
|
||||
pricingUnit: "tokens",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function syncLlmCatalog(prisma: PrismaClient): Promise<{
|
||||
catalog?.baseModelName === undefined
|
||||
? existing.baseModelName
|
||||
: catalog.baseModelName,
|
||||
pricingUnit: existing.pricingUnit ?? "tokens",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user