Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
6278caef0e fix(runtimes): show pricing-gap notice for partial unmapping; invalidate cost memos on price save
Two bugs surfaced in review:

1. The "Set custom prices" CTA only showed inside EmptyChartState, which
   only fires when Daily / Hourly total cost is exactly 0. Mixed windows
   (some priced + some unpriced models) rendered the chart normally and
   left no entry point — the unpriced tokens silently contributed \$0
   to totals.

   Add a permanent UnmappedPricingNotice above the KPI grid that appears
   whenever collectUnmappedModels(filtered) is non-empty, regardless of
   chart state. EmptyChartState keeps the diagnostic text but the CTA
   button moves to the notice so the two surfaces don't duplicate.

2. The aggregate useMemo blocks (WhenChart's dailyCostStack / hourlyCost,
   CostByBlock's byAgent / byModel, ActivityHeatmap's cells) keyed only
   on their query data. After a price save the parent re-rendered, but
   the memos returned cached pre-save totals because their deps were
   identical. The KPI cards updated; the charts did not.

   Subscribe to the pricing store in each aggregating component and
   list `pricings` as a memo dependency. The store returns a stable
   reference until setCustomPricing fires, so memos only invalidate
   on real changes.

New unit tests cover both: a mixed priced/unpriced aggregate produces
mixed costs (and surfaces the unpriced names), and aggregateCostByModel
called twice on the same input array reflects a freshly-saved override.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 12:11:41 +08:00
Jiang Bohan
21b79cd281 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>
2026-05-11 11:59:13 +08:00
10 changed files with 565 additions and 14 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,24 @@
"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.",
"unmapped_notice_one": "{{count}} model has no maintained price — its tokens are excluded from cost totals.",
"unmapped_notice_other": "{{count}} models have no maintained price — their tokens are excluded from cost totals.",
"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,23 @@
"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。",
"unmapped_notice_other": "有 {{count}} 个模型没有维护价格,相关 token 未计入费用统计。",
"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

@@ -1,5 +1,6 @@
import { useMemo } from "react";
import type { RuntimeUsage } from "@multica/core/types";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { estimateCost } from "../../utils";
import { useT } from "../../../i18n";
@@ -48,6 +49,9 @@ interface Insights {
export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {
const { t } = useT("runtimes");
// Memo dep — estimateCost (called inside the body below) consults the
// user-override store, so saving a custom rate must invalidate the cells.
const pricings = useCustomPricingStore((s) => s.pricings);
const { cells, monthLabels, insights } = useMemo(() => {
// Sum priced cost per day. Cost (not tokens) gives the colour scale a
// financial meaning that lines up with the rest of the page — a "hot"
@@ -167,7 +171,7 @@ export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {
};
return { cells: cellsWithLevel, monthLabels: months, insights };
}, [usage]);
}, [usage, pricings]);
const labelWidth = 28;
const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP);

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

@@ -1,9 +1,10 @@
"use client";
import { useMemo, useState } from "react";
import { BarChart3, ChevronRight } from "lucide-react";
import { BarChart3, ChevronRight, AlertCircle } 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,11 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
runtimeUsageOptions(runtimeId, 180),
);
const [days, setDays] = useState<TimeRange>(30);
// Subscribe so the KPI cards (which call estimateCost at render-time, not
// through a memo) re-evaluate when the user saves a custom rate. The
// aggregate sub-components (WhenChart, CostByBlock, ActivityHeatmap) each
// subscribe on their own and pass pricings as a memo dep there.
useCustomPricingStore((s) => s.pricings);
if (loading) return <UsageSkeleton />;
if (usage.length === 0) return <UsageEmpty />;
@@ -149,6 +157,13 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
/>
</div>
{/* Pricing-gap banner. Sits above the KPI grid so a *partial* unmapping
(some priced + some unpriced models in the same window) still has
a visible entry point into the manual-pricing dialog — otherwise
the chart would render normally and the unmapped tokens would silently
contribute $0 to totals. */}
<UnmappedPricingNotice usage={filtered} />
<div className="grid grid-cols-3 divide-x rounded-lg border bg-card">
<KpiCard
label={t(($) => $.usage.kpi_cost_label, { days })}
@@ -244,6 +259,10 @@ function WhenChart({
}) {
const { t } = useT("runtimes");
const [tab, setTab] = useState<WhenTab>("daily");
// Memo dep — the aggregates below run `estimateCost`, which now consults
// the user override store. Without listing pricings here the memos cache
// pre-override totals when query data hasn't changed.
const pricings = useCustomPricingStore((s) => s.pricings);
// Lazy-fetch hourly cost — only needed when its tab is active. Daily and
// heatmap derive from the already-cached 90d usage prop.
@@ -252,14 +271,17 @@ function WhenChart({
enabled: tab === "hourly",
});
const { dailyCostStack } = useMemo(() => aggregateByDate(filtered), [filtered]);
const { dailyCostStack } = useMemo(
() => aggregateByDate(filtered),
[filtered, pricings],
);
const hourlyCost = useMemo(
() =>
aggregateCostByHour(byHourRows).map((row) => ({
hour: Number(row.key),
cost: row.cost,
})),
[byHourRows],
[byHourRows, pricings],
);
return (
@@ -353,6 +375,8 @@ function EmptyChartState({ usage }: { usage: RuntimeUsage[] }) {
{t(($) => $.usage.empty_no_usage)}
</p>
) : unmapped.length > 0 ? (
// CTA lives in the page-level UnmappedPricingNotice above. Keep the
// chart-area copy descriptive only so the two surfaces don't bicker.
<>
<p className="text-xs text-muted-foreground">
{t(($) => $.usage.empty_pricing_missing)}
@@ -373,6 +397,50 @@ function EmptyChartState({ usage }: { usage: RuntimeUsage[] }) {
);
}
// ---------------------------------------------------------------------------
// UnmappedPricingNotice — always-visible banner shown above the KPI grid
// whenever the selected window contains any model that isn't priced. Covers
// the partial-unmapping case where the chart still renders (so EmptyChartState
// never fires) but some tokens are silently contributing $0 to totals.
// ---------------------------------------------------------------------------
function UnmappedPricingNotice({ usage }: { usage: RuntimeUsage[] }) {
const { t } = useT("runtimes");
const [dialogOpen, setDialogOpen] = useState(false);
const unmapped = collectUnmappedModels(usage);
if (unmapped.length === 0) return null;
return (
<div
role="alert"
className="flex flex-wrap items-center gap-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs"
>
<AlertCircle className="h-4 w-4 shrink-0 text-warning" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground">
{t(($) => $.usage.unmapped_notice, { count: unmapped.length })}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{unmapped.join(", ")}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
>
{t(($) => $.usage.custom_pricing.open_button)}
</Button>
<CustomPricingDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
unmappedModels={unmapped}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Chart legend — three coloured dots + labels, rendered in WhenChart's
// header so the chart body keeps its full vertical real estate.
@@ -417,6 +485,9 @@ function CostByBlock({
}) {
const { t } = useT("runtimes");
const [tab, setTab] = useState<"agent" | "model">("agent");
// Memo dep — same reason as WhenChart: aggregateCostBy{Agent,Model} call
// estimateCost, which now reads the override store.
const pricings = useCustomPricingStore((s) => s.pricings);
// by-agent is server-side aggregation (fetched lazily on tab activation).
// by-model derives from the daily cache the parent already has — free.
@@ -428,8 +499,14 @@ function CostByBlock({
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const byAgent = useMemo(() => aggregateCostByAgent(byAgentRows), [byAgentRows]);
const byModel = useMemo(() => aggregateCostByModel(usage), [usage]);
const byAgent = useMemo(
() => aggregateCostByAgent(byAgentRows),
[byAgentRows, pricings],
);
const byModel = useMemo(
() => aggregateCostByModel(usage),
[usage, pricings],
);
const caption =
tab === "agent"

View File

@@ -1,6 +1,17 @@
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";
import {
aggregateCostByModel,
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,
@@ -130,3 +141,134 @@ 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);
});
it("priced + unpriced models in the same window produce a mixed-cost aggregate", () => {
// The partial-unmapping case: chart renders normally because some
// models are priced, but the unmapped ones silently contribute $0 if
// we don't surface them. Confirm aggregateCostByModel exposes both
// sides so the UI can show a notice for the gap.
const rows = [
{
...zeroUsage,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "anthropic",
agent_count: 1,
},
{
...zeroUsage,
model: "fictional-model-x",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "fictional",
agent_count: 1,
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const byModel = aggregateCostByModel(rows as any);
const sonnet = byModel.find((r) => r.key === "claude-sonnet-4-6");
const fictional = byModel.find((r) => r.key === "fictional-model-x");
expect(sonnet?.cost).toBeCloseTo(3, 5);
expect(fictional?.cost).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(collectUnmappedModels(rows as any)).toEqual(["fictional-model-x"]);
});
it("aggregateCostByModel reflects a newly-saved custom price on re-call with the same input", () => {
// Regression for the memo-dependency bug GPT-Boy flagged: aggregate
// helpers must give different answers before vs after a price save,
// otherwise child components (WhenChart / CostByBlock / ActivityHeatmap)
// that memo on query data alone keep showing pre-save totals.
const rows = [
{
...zeroUsage,
model: "fictional-model-x",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "fictional",
agent_count: 1,
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const before = aggregateCostByModel(rows as any);
expect(before[0]?.cost).toBe(0);
useCustomPricingStore.getState().setCustomPricing("fictional-model-x", {
input: 2,
output: 8,
cacheRead: 0.2,
cacheWrite: 2,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const after = aggregateCostByModel(rows as any);
expect(after[0]?.cost).toBeCloseTo(2, 5);
});
});

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;
}