feat(runtimes): let users set custom prices for unmaintained models

The Runtime > Usage pricing diagnostic previously told users to "edit
packages/views/runtimes/utils.ts" when a model wasn't priced. That's
fine for us, useless for everyone else. We can't track every model
release, so let users supply their own per-million-token rates for
anything we don't ship a maintained rate for (e.g. gpt-5.5-mini today).

- Add a persisted Zustand store (custom-pricing-store) keyed by model
  name; rates live in localStorage so they survive reloads.
- resolvePricing consults the maintained MODEL_PRICING catalog first,
  then falls back to the store. Catalog still wins on overlap so a
  stale local override can't shadow a known rate.
- EmptyChartState gains a "Set custom prices" button when unmapped
  models exist; the dialog lists every unmapped model plus everything
  already overridden so users can edit / clear prior entries.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiang Bohan
2026-05-11 11:59:13 +08:00
parent b2b20b291b
commit 21b79cd281
9 changed files with 427 additions and 7 deletions

View File

@@ -46,6 +46,7 @@
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",

View File

@@ -0,0 +1,56 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
import { defaultStorage } from "../platform/storage";
// User-supplied pricing for models we don't ship a maintained rate for.
// We can't track every model OpenRouter / Codex / Hermes / Kimi etc. release,
// so the empty-state diagnostic lets users fill in their own rates. Stored
// globally (not workspace-scoped) because the rate of `gpt-5.5-mini` is the
// same regardless of which workspace you're viewing.
export interface CustomModelPricing {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface CustomPricingState {
pricings: Record<string, CustomModelPricing>;
setCustomPricing: (model: string, pricing: CustomModelPricing) => void;
removeCustomPricing: (model: string) => void;
}
// StorageAdapter (sync getItem returning string | null) is a structural subset
// of zustand's StateStorage, so it can be handed in directly via cast.
const stateStorage = defaultStorage as unknown as StateStorage;
export const useCustomPricingStore = create<CustomPricingState>()(
persist(
(set) => ({
pricings: {},
setCustomPricing: (model, pricing) =>
set((state) => ({
pricings: { ...state.pricings, [model]: pricing },
})),
removeCustomPricing: (model) =>
set((state) => {
if (!(model in state.pricings)) return state;
const next = { ...state.pricings };
delete next[model];
return { pricings: next };
}),
}),
{
name: "multica_runtime_custom_pricing",
storage: createJSONStorage(() => stateStorage),
},
),
);
// Vanilla accessor for non-React callers (the `resolvePricing` helper in
// packages/views/runtimes/utils.ts reads from here during cost estimation).
export function getCustomPricing(model: string): CustomModelPricing | undefined {
return useCustomPricingStore.getState().pricings[model];
}

View File

@@ -7,3 +7,4 @@ export * from "./types";
export * from "./derive-health";
export * from "./use-runtime-health";
export * from "./cli-version";
export * from "./custom-pricing-store";

View File

@@ -175,8 +175,22 @@
"legend_cache_write": "Cache write",
"empty_no_usage": "No usage in this period.",
"empty_pricing_missing": "Tokens recorded but pricing missing for:",
"empty_pricing_hint": "Add to MODEL_PRICING in packages/views/runtimes/utils.ts",
"empty_pricing_hint": "Set a custom rate to include them in cost totals.",
"empty_zero_cost": "Tokens recorded but cost calculation returned $0.",
"custom_pricing": {
"open_button": "Set custom prices",
"title": "Custom model prices",
"description": "Enter per-million-token rates so we can price models we don't ship a maintained rate for. Stored locally on this machine.",
"empty": "No models need a custom price right now.",
"field_input": "Input",
"field_output": "Output",
"field_cache_read": "Cache read",
"field_cache_write": "Cache write",
"unit_hint": "All rates are USD per 1M tokens. Leave every field blank to remove an override.",
"remove_aria": "Remove custom price",
"cancel": "Cancel",
"save": "Save"
},
"cost_by_title_agent": "Cost by agent",
"cost_by_title_model": "Cost by model",
"cost_by_tab_agent": "By agent",

View File

@@ -170,8 +170,22 @@
"legend_cache_write": "缓存写入",
"empty_no_usage": "此期间无使用记录。",
"empty_pricing_missing": "记录到 token但缺少这些模型的价格",
"empty_pricing_hint": "请在 packages/views/runtimes/utils.ts 的 MODEL_PRICING 中补充",
"empty_pricing_hint": "可以设置自定义价格,把它们也纳入费用统计。",
"empty_zero_cost": "记录到 token但费用计算结果为 $0。",
"custom_pricing": {
"open_button": "设置自定义价格",
"title": "自定义模型价格",
"description": "为我们未维护官方价格的模型填入每百万 token 的费率。仅保存在本机。",
"empty": "目前没有需要设置价格的模型。",
"field_input": "输入",
"field_output": "输出",
"field_cache_read": "缓存读取",
"field_cache_write": "缓存写入",
"unit_hint": "所有费率单位为美元 / 百万 token。清空所有字段即可移除该自定义价格。",
"remove_aria": "移除自定义价格",
"cancel": "取消",
"save": "保存"
},
"cost_by_title_agent": "按智能体分摊",
"cost_by_title_model": "按模型分摊",
"cost_by_tab_agent": "按智能体",

View File

@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import {
useCustomPricingStore,
type CustomModelPricing,
} from "@multica/core/runtimes/custom-pricing-store";
import { useT } from "../../i18n";
// Per-million-token rate fields. Stored as strings during editing so the
// user can briefly hold an empty field without us defaulting to 0 and
// hiding their work.
type DraftRow = {
input: string;
output: string;
cacheRead: string;
cacheWrite: string;
};
const EMPTY_DRAFT: DraftRow = { input: "", output: "", cacheRead: "", cacheWrite: "" };
function toDraft(p: CustomModelPricing | undefined): DraftRow {
if (!p) return EMPTY_DRAFT;
return {
input: String(p.input),
output: String(p.output),
cacheRead: String(p.cacheRead),
cacheWrite: String(p.cacheWrite),
};
}
function parseRow(draft: DraftRow): CustomModelPricing | null {
const values = [draft.input, draft.output, draft.cacheRead, draft.cacheWrite].map(
(s) => Number(s.trim()),
);
if (values.some((n) => !Number.isFinite(n) || n < 0)) return null;
const [input, output, cacheRead, cacheWrite] = values as [
number,
number,
number,
number,
];
return { input, output, cacheRead, cacheWrite };
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
// Models flagged as unmapped right now — these always appear in the form
// so users can price the model they came here to fix. Models that are
// already in the custom-pricing store also appear (so existing entries
// can be edited / removed), even if they're no longer "unmapped".
unmappedModels: readonly string[];
}
export function CustomPricingDialog({ open, onOpenChange, unmappedModels }: Props) {
const { t } = useT("runtimes");
const pricings = useCustomPricingStore((s) => s.pricings);
const setCustomPricing = useCustomPricingStore((s) => s.setCustomPricing);
const removeCustomPricing = useCustomPricingStore((s) => s.removeCustomPricing);
// Show every unmapped model plus everything already in the store, so a
// user revisiting the dialog after saving can still see / tweak / remove
// their prior entries.
const rows = Array.from(
new Set([...unmappedModels, ...Object.keys(pricings)]),
).sort();
const [drafts, setDrafts] = useState<Record<string, DraftRow>>({});
// Reset drafts whenever the dialog opens (or the visible row-set changes
// while it's open) so stale half-typed values from a previous session
// don't persist into a fresh edit.
useEffect(() => {
if (!open) return;
const fresh: Record<string, DraftRow> = {};
for (const model of rows) {
fresh[model] = toDraft(pricings[model]);
}
setDrafts(fresh);
// We intentionally don't depend on `rows` (a new array each render) —
// depending on `open` + the joined model list is enough and avoids
// resetting drafts on every parent re-render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, rows.join("\n")]);
const updateField = (model: string, field: keyof DraftRow, value: string) => {
setDrafts((d) => ({
...d,
[model]: { ...(d[model] ?? EMPTY_DRAFT), [field]: value },
}));
};
const handleSave = () => {
for (const model of rows) {
const draft = drafts[model] ?? EMPTY_DRAFT;
const parsed = parseRow(draft);
const allEmpty =
draft.input.trim() === "" &&
draft.output.trim() === "" &&
draft.cacheRead.trim() === "" &&
draft.cacheWrite.trim() === "";
if (allEmpty) {
// Treat clearing every field as "remove this override".
if (pricings[model]) removeCustomPricing(model);
continue;
}
if (parsed) setCustomPricing(model, parsed);
}
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t(($) => $.usage.custom_pricing.title)}</DialogTitle>
<DialogDescription>
{t(($) => $.usage.custom_pricing.description)}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{rows.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{t(($) => $.usage.custom_pricing.empty)}
</p>
) : (
rows.map((model) => {
const draft = drafts[model] ?? EMPTY_DRAFT;
const hasOverride = Boolean(pricings[model]);
return (
<div key={model} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<code className="truncate font-mono text-xs">{model}</code>
{hasOverride && (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => removeCustomPricing(model)}
aria-label={t(($) => $.usage.custom_pricing.remove_aria)}
title={t(($) => $.usage.custom_pricing.remove_aria)}
>
<Trash2 />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<PriceField
label={t(($) => $.usage.custom_pricing.field_input)}
value={draft.input}
onChange={(v) => updateField(model, "input", v)}
/>
<PriceField
label={t(($) => $.usage.custom_pricing.field_output)}
value={draft.output}
onChange={(v) => updateField(model, "output", v)}
/>
<PriceField
label={t(($) => $.usage.custom_pricing.field_cache_read)}
value={draft.cacheRead}
onChange={(v) => updateField(model, "cacheRead", v)}
/>
<PriceField
label={t(($) => $.usage.custom_pricing.field_cache_write)}
value={draft.cacheWrite}
onChange={(v) => updateField(model, "cacheWrite", v)}
/>
</div>
</div>
);
})
)}
<p className="text-[11px] text-muted-foreground">
{t(($) => $.usage.custom_pricing.unit_hint)}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t(($) => $.usage.custom_pricing.cancel)}
</Button>
<Button onClick={handleSave}>
{t(($) => $.usage.custom_pricing.save)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PriceField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{label}</Label>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="0.00"
/>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { BarChart3, ChevronRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import type { RuntimeUsage } from "@multica/core/types";
@@ -12,6 +13,7 @@ import {
runtimeUsageByAgentOptions,
runtimeUsageByHourOptions,
} from "@multica/core/runtimes/queries";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import {
formatTokens,
estimateCost,
@@ -31,6 +33,7 @@ import {
HourlyActivityChart,
ActivityHeatmap,
} from "./charts";
import { CustomPricingDialog } from "./custom-pricing-dialog";
import { useT } from "../../i18n";
// Single source of truth for the period selector. KPIs, the When-chart, the
@@ -108,6 +111,9 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
runtimeUsageOptions(runtimeId, 180),
);
const [days, setDays] = useState<TimeRange>(30);
// Subscribe so user-supplied prices flow through estimateCost on the next
// render. The ref itself is unused — the subscription is the point.
useCustomPricingStore((s) => s.pricings);
if (loading) return <UsageSkeleton />;
if (usage.length === 0) return <UsageEmpty />;
@@ -338,6 +344,7 @@ function HourlyTab({
function EmptyChartState({ usage }: { usage: RuntimeUsage[] }) {
const { t } = useT("runtimes");
const [dialogOpen, setDialogOpen] = useState(false);
const hasTokens = usage.some(
(u) =>
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens >
@@ -363,6 +370,20 @@ function EmptyChartState({ usage }: { usage: RuntimeUsage[] }) {
<p className="text-[11px] text-muted-foreground/70">
{t(($) => $.usage.empty_pricing_hint)}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => setDialogOpen(true)}
>
{t(($) => $.usage.custom_pricing.open_button)}
</Button>
<CustomPricingDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
unmappedModels={unmapped}
/>
</>
) : (
<p className="text-xs text-muted-foreground">

View File

@@ -1,7 +1,13 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach } from "vitest";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { collectUnmappedModels, estimateCost, isModelPriced } from "./utils";
afterEach(() => {
// Reset overrides so tests don't bleed pricing state into one another.
useCustomPricingStore.setState({ pricings: {} });
});
const zeroUsage = {
input_tokens: 0,
output_tokens: 0,
@@ -130,3 +136,71 @@ describe("collectUnmappedModels", () => {
expect(collectUnmappedModels(rows)).toEqual(["fictional-model-x"]);
});
});
describe("user-supplied custom pricing", () => {
it("prices a model the maintained catalog doesn't ship", () => {
useCustomPricingStore.getState().setCustomPricing("gpt-5.5-mini", {
input: 1,
output: 4,
cacheRead: 0.1,
cacheWrite: 1,
});
expect(isModelPriced("gpt-5.5-mini")).toBe(true);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.5-mini",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
}),
).toBeCloseTo(5, 5);
});
it("does NOT shadow the maintained catalog when both define the same model", () => {
// Catalog wins so a user can't accidentally over-charge themselves for
// a model we already track (and so a stale local override doesn't
// silently disagree with what the dashboard shows everyone else).
useCustomPricingStore.getState().setCustomPricing("claude-sonnet-4-6", {
input: 999,
output: 999,
cacheRead: 999,
cacheWrite: 999,
});
expect(
estimateCost({
...zeroUsage,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
}),
).toBeCloseTo(3, 5); // maintained input rate, not the 999 override
});
it("falls back to a stripped dated snapshot in the custom store", () => {
useCustomPricingStore.getState().setCustomPricing("brand-new-model", {
input: 2,
output: 8,
cacheRead: 0.2,
cacheWrite: 2,
});
expect(
estimateCost({
...zeroUsage,
model: "brand-new-model-2026-04-01",
input_tokens: 1_000_000,
}),
).toBeCloseTo(2, 5);
});
it("removeCustomPricing clears the override", () => {
const store = useCustomPricingStore.getState();
store.setCustomPricing("gpt-5.5-mini", {
input: 1,
output: 4,
cacheRead: 0.1,
cacheWrite: 1,
});
expect(isModelPriced("gpt-5.5-mini")).toBe(true);
useCustomPricingStore.getState().removeCustomPricing("gpt-5.5-mini");
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
});
});

View File

@@ -3,6 +3,7 @@ import type {
RuntimeUsageByAgent,
RuntimeUsageByHour,
} from "@multica/core/types";
import { getCustomPricing } from "@multica/core/runtimes/custom-pricing-store";
// ---------------------------------------------------------------------------
// Formatting helpers
@@ -182,10 +183,10 @@ const MODEL_PRICING: Record<
// tolerance: providers ship dated snapshots (`claude-sonnet-4-5-20250929`,
// `gpt-5-2025-08-07`) where the family is what we price and the date is
// volatile, so we strip a trailing date / "latest" tag and try again.
// Anything still unmapped after that is genuinely unknown; return
// undefined so callers can distinguish "$0 spend" from "spent but model
// not priced". No startsWith fallback: variants like `gpt-5.5-mini` must
// have their own row to be priced (otherwise they'd inherit `gpt-5.5`).
// Anything still unmapped in the maintained catalog falls back to the
// user-supplied custom pricing store before giving up. No startsWith
// fallback: variants like `gpt-5.5-mini` must have their own row to be
// priced (otherwise they'd inherit `gpt-5.5`).
function resolvePricing(model: string) {
if (!model) return undefined;
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
@@ -193,6 +194,16 @@ function resolvePricing(model: string) {
const stripped = model.replace(/-(20\d{2}-\d{2}-\d{2}|20\d{6}|latest)$/, "");
if (stripped !== model && MODEL_PRICING[stripped]) return MODEL_PRICING[stripped];
// User-supplied override for models we don't ship a maintained rate for.
// Checked exact-then-stripped to mirror the catalog lookup above, so a
// user can either pin a dated snapshot specifically or price the family.
const custom = getCustomPricing(model);
if (custom) return custom;
if (stripped !== model) {
const customStripped = getCustomPricing(stripped);
if (customStripped) return customStripped;
}
return undefined;
}