From 21b79cd28159b66cf05995eebf9902f91d5cdb90 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 11 May 2026 11:59:13 +0800 Subject: [PATCH] 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 --- packages/core/package.json | 1 + .../core/runtimes/custom-pricing-store.ts | 56 +++++ packages/core/runtimes/index.ts | 1 + packages/views/locales/en/runtimes.json | 16 +- packages/views/locales/zh-Hans/runtimes.json | 16 +- .../components/custom-pricing-dialog.tsx | 228 ++++++++++++++++++ .../runtimes/components/usage-section.tsx | 21 ++ packages/views/runtimes/utils.test.ts | 76 +++++- packages/views/runtimes/utils.ts | 19 +- 9 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 packages/core/runtimes/custom-pricing-store.ts create mode 100644 packages/views/runtimes/components/custom-pricing-dialog.tsx diff --git a/packages/core/package.json b/packages/core/package.json index 05c6448eb..083bf5b7a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/runtimes/custom-pricing-store.ts b/packages/core/runtimes/custom-pricing-store.ts new file mode 100644 index 000000000..c70046134 --- /dev/null +++ b/packages/core/runtimes/custom-pricing-store.ts @@ -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; + 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()( + 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]; +} diff --git a/packages/core/runtimes/index.ts b/packages/core/runtimes/index.ts index e16b10bef..bcb40bd5b 100644 --- a/packages/core/runtimes/index.ts +++ b/packages/core/runtimes/index.ts @@ -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"; diff --git a/packages/views/locales/en/runtimes.json b/packages/views/locales/en/runtimes.json index c9d4575bf..5c2829f9d 100644 --- a/packages/views/locales/en/runtimes.json +++ b/packages/views/locales/en/runtimes.json @@ -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", diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json index f053ecc99..69871fe59 100644 --- a/packages/views/locales/zh-Hans/runtimes.json +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -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": "按智能体", diff --git a/packages/views/runtimes/components/custom-pricing-dialog.tsx b/packages/views/runtimes/components/custom-pricing-dialog.tsx new file mode 100644 index 000000000..b2dd8200d --- /dev/null +++ b/packages/views/runtimes/components/custom-pricing-dialog.tsx @@ -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>({}); + + // 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 = {}; + 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 ( + + + + {t(($) => $.usage.custom_pricing.title)} + + {t(($) => $.usage.custom_pricing.description)} + + + +
+ {rows.length === 0 ? ( +

+ {t(($) => $.usage.custom_pricing.empty)} +

+ ) : ( + rows.map((model) => { + const draft = drafts[model] ?? EMPTY_DRAFT; + const hasOverride = Boolean(pricings[model]); + return ( +
+
+ {model} + {hasOverride && ( + + )} +
+
+ $.usage.custom_pricing.field_input)} + value={draft.input} + onChange={(v) => updateField(model, "input", v)} + /> + $.usage.custom_pricing.field_output)} + value={draft.output} + onChange={(v) => updateField(model, "output", v)} + /> + $.usage.custom_pricing.field_cache_read)} + value={draft.cacheRead} + onChange={(v) => updateField(model, "cacheRead", v)} + /> + $.usage.custom_pricing.field_cache_write)} + value={draft.cacheWrite} + onChange={(v) => updateField(model, "cacheWrite", v)} + /> +
+
+ ); + }) + )} +

+ {t(($) => $.usage.custom_pricing.unit_hint)} +

+
+ + + + + +
+
+ ); +} + +function PriceField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder="0.00" + /> +
+ ); +} diff --git a/packages/views/runtimes/components/usage-section.tsx b/packages/views/runtimes/components/usage-section.tsx index 407afa7e0..9e2166aab 100644 --- a/packages/views/runtimes/components/usage-section.tsx +++ b/packages/views/runtimes/components/usage-section.tsx @@ -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(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 ; if (usage.length === 0) return ; @@ -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[] }) {

{t(($) => $.usage.empty_pricing_hint)}

+ + ) : (

diff --git a/packages/views/runtimes/utils.test.ts b/packages/views/runtimes/utils.test.ts index 64b16729f..7e1c16759 100644 --- a/packages/views/runtimes/utils.test.ts +++ b/packages/views/runtimes/utils.test.ts @@ -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); + }); +}); diff --git a/packages/views/runtimes/utils.ts b/packages/views/runtimes/utils.ts index 1d6de5bcf..67f25832c 100644 --- a/packages/views/runtimes/utils.ts +++ b/packages/views/runtimes/utils.ts @@ -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; }