mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6278caef0e | ||
|
|
21b79cd281 |
@@ -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",
|
||||
|
||||
56
packages/core/runtimes/custom-pricing-store.ts
Normal file
56
packages/core/runtimes/custom-pricing-store.ts
Normal 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];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "按智能体",
|
||||
|
||||
@@ -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);
|
||||
|
||||
228
packages/views/runtimes/components/custom-pricing-dialog.tsx
Normal file
228
packages/views/runtimes/components/custom-pricing-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user