History

feat(web): edit usage prices across selected environments (#9861)

Julius Marminge committed 2 months ago

Commit 84b99f31 parent 7ee52b07 files · +1024 −283Browse files

Unified · Split

7 files
  1. apps/web/src/components/usage/UsagePage.tsx +28 −7
  2. apps/web/src/components/usage/UsagePriceOverrides.tsx +507 −271
  3. apps/web/src/components/usage/usagePriceTable.test.ts +170 −0
  4. apps/web/src/components/usage/usagePriceTable.ts +97 −0
  5. apps/web/src/components/usage/usagePriceTargets.test.ts +143 −0
  6. apps/web/src/components/usage/usagePriceTargets.ts +65 −0
  7. docs/user/usage.md +14 −5
apps/web/src/components/usage/UsagePage.tsx+28−7
@@ -4,7 +4,13 @@ import { · 3 unmodified lines
44 type EnvironmentId,
55 type UsageProviderKind,
66 } from "@t3tools/contracts";
7−import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react";
7+import {
8+ CircleAlertIcon,
9+ ChevronDownIcon,
10+ CircleDashedIcon,
11+ RefreshCwIcon,
12+ SlidersHorizontalIcon,
13+} from "lucide-react";
814 import { useMemo, useState } from "react";
915
1016 import {
@@ -32,7 +38,14 @@ import { · 21 unmodified lines
3238 makeWindow,
3339 } from "@t3tools/shared/usageFormat";
3440 import { Button } from "../ui/button";
35−import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
41+import {
42+ Menu,
43+ MenuCheckboxItem,
44+ MenuItem,
45+ MenuPopup,
46+ MenuSeparator,
47+ MenuTrigger,
48+} from "../ui/menu";
3649 import { ScrollArea } from "../ui/scroll-area";
3750 import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
3851 import { SidebarInset } from "../ui/sidebar";
@@ -287,11 +300,6 @@ export function UsagePage() { · 248 unmodified lines
287300
288301 <ScrollArea className="min-h-0 flex-1">
289302 <WorkspacePageContainer width="wide">
290− {!showingLimits ? (
291− <div className="flex justify-end">
292− <UsagePriceOverrides usage={environments} />
293− </div>
294− ) : null}
295303 {selectedEnvironments.length === 0 ? (
296304 <p className="text-sm text-muted-foreground">
297305 {environments.length === 0
@@ -629,6 +637,7 @@ function UsageEnvironmentFilter({ · 331 unmodified lines
629637 readonly duplicateSources: readonly string[];
630638 readonly staleEnvironments: readonly string[];
631639 }) {
640+ const [modelPricesOpen, setModelPricesOpen] = useState(false);
632641 const allSelected = selectedEnvironmentIds === null;
633642 const label = allSelected
634643 ? "All environments"
@@ -742,8 +751,20 @@ function UsageEnvironmentFilter({ · 107 unmodified lines
742751 staleEnvironments={staleEnvironments}
743752 />
744753 ) : null}
754+ <MenuSeparator />
755+ <MenuItem onClick={() => setModelPricesOpen(true)}>
756+ <SlidersHorizontalIcon aria-hidden />
757+ Model prices
758+ </MenuItem>
745759 </MenuPopup>
746760 </Menu>
761+ {modelPricesOpen ? (
762+ <UsagePriceOverrides
763+ usage={environments}
764+ initialSelectedEnvironmentIds={selectedEnvironmentIds}
765+ onOpenChange={setModelPricesOpen}
766+ />
767+ ) : null}
747768 </>
748769 );
749770 }
apps/web/src/components/usage/UsagePriceOverrides.tsx+507−271
@@ -1,15 +1,15 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import type { EnvironmentId, UsageModelPriceOverride } from "@t3tools/contracts";
3−import { useState } from "react";
2+import type { EnvironmentId } from "@t3tools/contracts";
3+import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
4+import * as Option from "effect/Option";
5+import { AsyncResult, Atom } from "effect/unstable/reactivity";
6+import { useRef, useState } from "react";
47
58 import { isElectron } from "../../env";
6−import {
7− type EnvironmentPresentation,
8− useEnvironments,
9− usePrimaryEnvironmentId,
10−} from "../../state/environments";
9+import { cn } from "../../lib/utils";
10+import { environmentPresentations } from "../../state/presentation";
1111 import { serverEnvironment } from "../../state/server";
12−import { useEnvironmentSessionState } from "../../state/session";
12+import { environmentSession } from "../../state/session";
1313 import { useAtomCommand } from "../../state/use-atom-command";
1414 import type { EnvironmentUsageStatus } from "../../state/usage";
1515 import {
@@ -20,6 +20,7 @@ import { Button } from "../ui/button"; · 4 unmodified lines
2020 import {
2121 Dialog,
2222 DialogDescription,
23+ DialogFooter,
2324 DialogHeader,
2425 DialogPanel,
2526 DialogPopup,
@@ -27,292 +28,527 @@ import { · 1 unmodified line
2728 } from "../ui/dialog";
2829 import { Input } from "../ui/input";
2930 import { Label } from "../ui/label";
30−import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
31−import { parseUsagePriceForm, USAGE_PRICE_FIELDS, usagePriceForm } from "./usagePriceForm";
31+import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
32+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
33+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
34+import { USAGE_PRICE_FIELDS } from "./usagePriceForm";
35+import {
36+ isEmptyUsagePriceDraft,
37+ usagePriceCell,
38+ usagePriceTableChanges,
39+ usagePriceTableErrors,
40+ type UsagePriceDraft,
41+ type UsagePriceField,
42+} from "./usagePriceTable";
43+import {
44+ writeUsagePrices,
45+ type UsagePriceTarget,
46+ type UsagePriceWriteResult,
47+} from "./usagePriceTargets";
3248
33−export function UsagePriceOverrides({
34− usage,
35−}: {
36− readonly usage: readonly EnvironmentUsageStatus[];
37−}) {
38− const [open, setOpen] = useState(false);
39− return (
40− <>
41− <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
42− Model prices
43− </Button>
44− {open ? <ModelPricesDialog usage={usage} onOpenChange={setOpen} /> : null}
45− </>
46− );
47−}
49+const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
50+ [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => {
51+ const settings = get(serverEnvironment.settingsValueAtom(environmentId));
52+ const session = get(environmentSession.sessionStateAtom(environmentId));
53+ const sessionAccess = {
54+ session: Option.getOrNull(AsyncResult.value(session)),
55+ isPending: session.waiting,
56+ hasError: session._tag === "Failure",
57+ };
58+ const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
59+ const access = isPrimary
60+ ? resolvePrimaryOperateAccess({ ...sessionAccess, isPrimary, hasDesktopBridge: isElectron })
61+ : resolveRemoteOperateAccess(sessionAccess);
62+ return {
63+ environmentId,
64+ label: environment.entry.target.label,
65+ prices: settings?.usagePriceOverrides ?? null,
66+ unavailable:
67+ environment.connection.phase !== "connected"
68+ ? "Offline"
69+ : settings === null
70+ ? "Prices not loaded"
71+ : environment.serverConfig?.environment.capabilities.usagePriceOverrides !== true
72+ ? "Update server to edit prices"
73+ : access === "pending"
74+ ? "Checking permissions…"
75+ : access === "denied"
76+ ? "Read-only access"
77+ : null,
78+ };
79+ }),
80+);
81+
82+type SaveAttempt = {
83+ readonly drafts: readonly UsagePriceDraft[];
84+ readonly destinations: readonly {
85+ readonly environmentId: EnvironmentId;
86+ readonly label: string;
87+ }[];
88+ readonly results: ReadonlyMap<
89+ EnvironmentId,
90+ UsagePriceWriteResult | { readonly status: "saving" }
91+ >;
92+};
4893
49−function ModelPricesDialog({
94+export function UsagePriceOverrides({
5095 usage,
96+ initialSelectedEnvironmentIds,
5197 onOpenChange,
5298 }: {
5399 readonly usage: readonly EnvironmentUsageStatus[];
100+ readonly initialSelectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
54101 readonly onOpenChange: (open: boolean) => void;
55102 }) {
56− const { environments } = useEnvironments();
57− const primaryEnvironmentId = usePrimaryEnvironmentId();
58− const [selectedId, setSelectedId] = useState<EnvironmentId | null>(primaryEnvironmentId);
59− const environment =
60− environments.find((entry) => entry.environmentId === selectedId) ?? environments[0];
61− const summary = usage.find(
62− (entry) => entry.environmentId === environment?.environmentId,
63− )?.summary;
64− const models = [...new Set(summary?.buckets.map((bucket) => bucket.model) ?? [])].sort();
103+ const environments = useAtomValue(priceTargetsAtom);
104+ const [selectedIds, setSelectedIds] = useState(initialSelectedEnvironmentIds);
105+ const selected = environments.filter(
106+ (environment) => selectedIds === null || selectedIds.has(environment.environmentId),
107+ );
108+ const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>([]);
109+ const [pending, setPending] = useState(false);
110+ const [attempt, setAttempt] = useState<SaveAttempt | null>(null);
111+ const focusRowRef = useRef<string | null>(null);
112+ const nextRowId = useRef(0);
113+ const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
114+ const customModels = [
115+ ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))),
116+ ].sort();
117+ const models = [
118+ ...new Set([
119+ ...customModels,
120+ ...usage
121+ .filter((environment) =>
122+ selected.some((target) => target.environmentId === environment.environmentId),
123+ )
124+ .flatMap((environment) => environment.summary?.buckets.map((bucket) => bucket.model) ?? []),
125+ ]),
126+ ].sort();
127+ // Keep new rows in place while successful environments publish their updated settings.
128+ const newModels = new Set(
129+ drafts.filter((draft) => draft.isNew).map((draft) => draft.model.trim()),
130+ );
131+ const rows: readonly UsagePriceDraft[] = [
132+ ...customModels
133+ .filter((model) => attempt === null || !newModels.has(model))
134+ .map(
135+ (model) =>
136+ drafts.find((draft) => draft.id === `model:${model}`) ?? {
137+ id: `model:${model}`,
138+ model,
139+ isNew: false,
140+ values: {},
141+ },
142+ ),
143+ ...drafts.filter((draft) => draft.isNew),
144+ ];
145+ const stagedDrafts = drafts.filter((draft) => !isEmptyUsagePriceDraft(draft));
146+ const errors = usagePriceTableErrors(selected, stagedDrafts);
147+ for (const draft of stagedDrafts) {
148+ if (!draft.isNew) continue;
149+ if (draft.model.trim() === "") errors.set(draft.id, "Enter a model ID.");
150+ else if (
151+ attempt === null &&
152+ (customModels.includes(draft.model.trim()) ||
153+ drafts.some((other) => other.id !== draft.id && other.model.trim() === draft.model.trim()))
154+ )
155+ errors.set(draft.id, "This model already has a row. Edit its prices there.");
156+ }
157+ const failedDestinations =
158+ attempt?.destinations.filter(
159+ (destination) => attempt.results.get(destination.environmentId)?.status === "failed",
160+ ) ?? [];
161+ const locked = pending || failedDestinations.length > 0;
162+ const hasChanges = stagedDrafts.length > 0;
163+ const destinationLabel =
164+ selected.length === 1 ? selected[0]!.label : `${selected.length} environments`;
165+ const selectionLabel = selectedIds === null ? "All environments" : destinationLabel;
166+ const discard = () => {
167+ setDrafts([]);
168+ setAttempt(null);
169+ };
170+ const selectEnvironments = (ids: ReadonlySet<EnvironmentId> | null) => {
171+ setSelectedIds(ids);
172+ setAttempt(null);
173+ };
174+ const updateDraft = (draft: UsagePriceDraft) => {
175+ setDrafts((previous) =>
176+ previous.some((entry) => entry.id === draft.id)
177+ ? previous.map((entry) => (entry.id === draft.id ? draft : entry))
178+ : [...previous, draft],
179+ );
180+ setAttempt(null);
181+ };
182+ const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => {
183+ const values = { ...row.values, [field]: value };
184+ const original = usagePriceCell(selected, row.model, field);
185+ if (
186+ !row.isNew &&
187+ original.placeholder !== "Mixed" &&
188+ original.placeholder !== "Unavailable" &&
189+ value === original.value
190+ )
191+ delete values[field];
192+ if (!row.isNew && Object.keys(values).length === 0)
193+ setDrafts((previous) => previous.filter((entry) => entry.id !== row.id));
194+ else updateDraft({ ...row, values });
195+ };
196+ const save = async (retry = false) => {
197+ if (pending) return;
198+ const destinations = retry ? failedDestinations : selected;
199+ const edits = retry && attempt ? attempt.drafts : stagedDrafts;
200+ if (destinations.length === 0 || edits.length === 0) return;
201+ const targets = destinations.map(
202+ (destination) =>
203+ environments.find(
204+ (environment) => environment.environmentId === destination.environmentId,
205+ ) ?? { ...destination, prices: null, unavailable: "Environment removed" },
206+ );
207+ const changes = new Map(
208+ targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]),
209+ );
210+ setPending(true);
211+ setAttempt((previous) => ({
212+ drafts: edits,
213+ destinations: retry && previous ? previous.destinations : targets,
214+ results: new Map([
215+ ...(retry && previous ? previous.results : []),
216+ ...targets.map((target) => [target.environmentId, { status: "saving" } as const] as const),
217+ ]),
218+ }));
219+ let failed = false;
220+ await writeUsagePrices({
221+ targets: targets.map((target) => ({
222+ ...target,
223+ unavailable:
224+ target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null,
225+ })),
226+ changes: new Map([...changes].map(([id, plan]) => [id, plan.changes])),
227+ write: updateSettings,
228+ onResult: (environmentId, result) => {
229+ if (result.status === "failed") failed = true;
230+ setAttempt((previous) =>
231+ previous === null
232+ ? null
233+ : { ...previous, results: new Map(previous.results).set(environmentId, result) },
234+ );
235+ },
236+ });
237+ setPending(false);
238+ if (!failed) setDrafts([]);
239+ };
65240
66241 return (
67− <Dialog open onOpenChange={onOpenChange}>
68− <DialogPopup className="max-w-xl">
242+ <Dialog
243+ open
244+ onOpenChange={(open) => {
245+ if (!pending) onOpenChange(open);
246+ }}
247+ >
248+ <DialogPopup className="max-w-3xl">
69249 <DialogHeader>
70− <DialogTitle>Model prices</DialogTitle>
250+ <DialogTitle>Custom model prices</DialogTitle>
71251 <DialogDescription>
72− Override estimated costs using USD per million tokens. Prices apply to all saved history
73− on the selected environment.
252+ Prices apply to all past and future usage on the environments you select.
74253 </DialogDescription>
75254 </DialogHeader>
76− <DialogPanel className="grid gap-5 pb-6">
77− {environment ? (
78− <>
79− <div className="grid gap-1.5">
80− <Label htmlFor="usage-prices-environment">Environment</Label>
81− <Select
82− value={environment.environmentId}
83− onValueChange={(value) => {
84− const next = environments.find((entry) => entry.environmentId === value);
85− if (next) setSelectedId(next.environmentId);
86− }}
255+ <DialogPanel className="grid gap-4">
256+ <div className="flex flex-wrap items-center justify-between gap-2">
257+ <div className="flex min-w-0 items-center gap-2">
258+ <Label id="usage-prices-apply-label" className="shrink-0">
259+ Apply to
260+ </Label>
261+ <Menu>
262+ <MenuTrigger
263+ render={<Button variant="outline" size="sm" className="min-w-0" />}
264+ aria-labelledby="usage-prices-apply-label usage-prices-selection"
265+ disabled={pending || hasChanges}
87266 >
88− <SelectTrigger id="usage-prices-environment">
89− <SelectValue>{environment.label}</SelectValue>
90− </SelectTrigger>
91− <SelectPopup>
92− {environments.map((entry) => (
93− <SelectItem key={entry.environmentId} value={entry.environmentId}>
94− {entry.label}
95− </SelectItem>
267+ <span id="usage-prices-selection" className="truncate">
268+ {selectionLabel}
269+ </span>
270+ <ChevronDownIcon className="size-3.5 shrink-0" aria-hidden />
271+ </MenuTrigger>
272+ <MenuPopup align="start" className="w-80 max-w-[calc(100vw-2rem)]">
273+ <MenuCheckboxItem
274+ checked={selectedIds === null}
275+ closeOnClick={false}
276+ onCheckedChange={(checked) => selectEnvironments(checked ? null : new Set())}
277+ >
278+ All environments
279+ </MenuCheckboxItem>
280+ <MenuSeparator />
281+ {environments.map((environment) => (
282+ <MenuCheckboxItem
283+ key={environment.environmentId}
284+ checked={selectedIds === null || selectedIds.has(environment.environmentId)}
285+ closeOnClick={false}
286+ onCheckedChange={(checked) => {
287+ const next = new Set(selected.map((target) => target.environmentId));
288+ if (checked) next.add(environment.environmentId);
289+ else next.delete(environment.environmentId);
290+ selectEnvironments(next.size === environments.length ? null : next);
291+ }}
292+ >
293+ <span className="flex min-w-0 items-center gap-3">
294+ <span className="min-w-0 flex-1 truncate">{environment.label}</span>
295+ {environment.unavailable ? (
296+ <span className="text-xs text-muted-foreground">
297+ {environment.unavailable}
298+ </span>
299+ ) : null}
300+ </span>
301+ </MenuCheckboxItem>
302+ ))}
303+ </MenuPopup>
304+ </Menu>
305+ </div>
306+ <span className="text-xs text-muted-foreground">USD / million tokens</span>
307+ </div>
308+ {selected.length === 0 ? (
309+ <p className="text-sm text-muted-foreground">
310+ {environments.length === 0
311+ ? "Connect an environment to set model prices."
312+ : "Select an environment to see and change its model prices."}
313+ </p>
314+ ) : (
315+ <>
316+ <div className="min-w-0 overflow-hidden rounded-lg border border-border">
317+ <Table className="min-w-160 table-fixed" aria-label="Custom model prices">
318+ <colgroup>
319+ <col className="w-[34%]" />
320+ {USAGE_PRICE_FIELDS.map((field) => (
321+ <col key={field.key} />
96322 ))}
97− </SelectPopup>
98− </Select>
323+ <col className="w-10" />
324+ </colgroup>
325+ <TableHeader>
326+ <TableRow className="hover:bg-transparent">
327+ <TableHead className="pl-3">Model ID</TableHead>
328+ {USAGE_PRICE_FIELDS.map((field) => (
329+ <TableHead key={field.key}>{field.label}</TableHead>
330+ ))}
331+ <TableHead className="px-1">
332+ <Button
333+ size="icon-xs"
334+ variant="ghost"
335+ aria-label="Add model price"
336+ disabled={locked}
337+ onClick={() => {
338+ const id = `new:${nextRowId.current++}`;
339+ focusRowRef.current = id;
340+ updateDraft({ id, model: "", isNew: true, values: {} });
341+ }}
342+ >
343+ <PlusIcon aria-hidden />
344+ </Button>
345+ </TableHead>
346+ </TableRow>
347+ </TableHeader>
348+ <TableBody>
349+ {rows.length === 0 ? (
350+ <TableRow className="hover:bg-transparent">
351+ <TableCell
352+ colSpan={6}
353+ className="py-8 text-center whitespace-normal text-muted-foreground"
354+ >
355+ {selected.some((environment) => environment.prices === null)
356+ ? "Some environment prices are unavailable."
357+ : "No custom prices. Add a row to override automatic pricing."}
358+ </TableCell>
359+ </TableRow>
360+ ) : (
361+ rows.map((row) => (
362+ <TableRow
363+ key={row.id}
364+ data-row-id={row.id}
365+ className="hover:bg-transparent"
366+ >
367+ <TableCell className="pl-3 whitespace-normal">
368+ {row.isNew ? (
369+ <Input
370+ size="compact"
371+ value={row.model}
372+ ref={(node) => {
373+ if (node && focusRowRef.current === row.id) {
374+ node.focus();
375+ focusRowRef.current = null;
376+ }
377+ }}
378+ aria-label="New model ID"
379+ aria-invalid={
380+ (row.model.trim() !== "" && errors.has(row.id)) || undefined
381+ }
382+ list="usage-price-models"
383+ placeholder="Model ID"
384+ autoComplete="off"
385+ spellCheck={false}
386+ disabled={locked}
387+ onChange={(event) =>
388+ updateDraft({ ...row, model: event.target.value })
389+ }
390+ />
391+ ) : (
392+ <span
393+ className={cn(
394+ "block break-all",
395+ row.removed && "text-muted-foreground line-through",
396+ )}
397+ >
398+ {row.model}
399+ </span>
400+ )}
401+ {errors.has(row.id) &&
402+ (row.model.trim() !== "" ||
403+ Object.values(row.values).some((value) => value !== "")) ? (
404+ <p role="alert" className="mt-1 text-xs text-destructive">
405+ {errors.get(row.id)}
406+ </p>
407+ ) : null}
408+ </TableCell>
409+ {row.removed ? (
410+ <TableCell colSpan={4} className="text-muted-foreground">
411+ Automatic pricing after saving
412+ </TableCell>
413+ ) : (
414+ USAGE_PRICE_FIELDS.map((field) => {
415+ const cell = usagePriceCell(selected, row.model, field.key);
416+ return (
417+ <TableCell key={field.key} className="px-1">
418+ <Input
419+ size="compact"
420+ inputMode="decimal"
421+ aria-label={`${field.label} price for ${row.model || "new model"}`}
422+ value={row.values[field.key] ?? (row.isNew ? "" : cell.value)}
423+ placeholder={
424+ row.isNew
425+ ? field.optional
426+ ? "Input rate"
427+ : "0.00"
428+ : cell.placeholder
429+ }
430+ autoComplete="off"
431+ disabled={locked}
432+ className="tabular-nums"
433+ onChange={(event) =>
434+ editCell(row, field.key, event.target.value)
435+ }
436+ />
437+ </TableCell>
438+ );
439+ })
440+ )}
441+ <TableCell className="px-1">
442+ <Tooltip>
443+ <TooltipTrigger
444+ render={<Button size="icon-xs" variant="ghost" />}
445+ disabled={locked}
446+ aria-label={
447+ row.removed
448+ ? `Undo reset for ${row.model}`
449+ : row.isNew
450+ ? "Remove new model"
451+ : `Reset price for ${row.model} to automatic`
452+ }
453+ onClick={() => {
454+ if (row.isNew)
455+ setDrafts((previous) =>
456+ previous.filter((entry) => entry.id !== row.id),
457+ );
458+ else if (row.removed) {
459+ if (Object.keys(row.values).length === 0)
460+ setDrafts((previous) =>
461+ previous.filter((entry) => entry.id !== row.id),
462+ );
463+ else updateDraft({ ...row, removed: false });
464+ } else updateDraft({ ...row, removed: true });
465+ }}
466+ >
467+ {row.isNew ? <XIcon aria-hidden /> : <RotateCcwIcon aria-hidden />}
468+ </TooltipTrigger>
469+ <TooltipPopup>
470+ {row.removed
471+ ? "Undo reset"
472+ : row.isNew
473+ ? "Remove row"
474+ : "Reset to automatic"}
475+ </TooltipPopup>
476+ </Tooltip>
477+ </TableCell>
478+ </TableRow>
479+ ))
480+ )}
481+ </TableBody>
482+ </Table>
99483 </div>
100− <EnvironmentModelPrices
101− key={environment.environmentId}
102− environment={environment}
103− models={models}
104− />
484+ <datalist id="usage-price-models">
485+ {models
486+ .filter((model) => !customModels.includes(model))
487+ .map((model) => (
488+ <option key={model} value={model} />
489+ ))}
490+ </datalist>
491+ <p className="text-xs text-muted-foreground">
492+ Blank cache rates use the input price. Enter 0 for free tokens.
493+ {selected.length > 1
494+ ? " Mixed cells keep each environment’s rate until you edit them."
495+ : ""}
496+ </p>
105497 </>
106− ) : (
107− <p className="text-sm text-muted-foreground">
108− Connect an environment to set model prices.
109− </p>
110498 )}
111− </DialogPanel>
112− </DialogPopup>
113− </Dialog>
114− );
115−}
116−
117−function EnvironmentModelPrices({
118− environment,
119− models,
120−}: {
121− readonly environment: EnvironmentPresentation;
122− readonly models: readonly string[];
123−}) {
124− const settings = useAtomValue(serverEnvironment.settingsValueAtom(environment.environmentId));
125− const session = useEnvironmentSessionState(environment.environmentId);
126− const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
127− const [form, setForm] = useState(usagePriceForm());
128− const [editingModel, setEditingModel] = useState<string | null>(null);
129− const [pending, setPending] = useState(false);
130− const [error, setError] = useState<string | null>(null);
131− const prices = settings?.usagePriceOverrides ?? {};
132− const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
133− const access = isPrimary
134− ? resolvePrimaryOperateAccess({
135− isPrimary,
136− hasDesktopBridge: isElectron,
137− session: session.data,
138− isPending: session.isPending,
139− hasError: session.hasError,
140− })
141− : resolveRemoteOperateAccess({
142− session: session.data,
143− isPending: session.isPending,
144− hasError: session.hasError,
145− });
146− const supportsOverrides =
147− environment.serverConfig?.environment.capabilities.usagePriceOverrides === true;
148− const unavailable =
149− environment.connection.phase !== "connected"
150− ? "Connect this environment to change its model prices."
151− : settings === null
152− ? "Loading model prices..."
153− : !supportsOverrides
154− ? "Update this environment's server to set model prices."
155− : access === "pending"
156− ? "Checking permissions..."
157− : access === "denied"
158− ? "This session can view model prices but cannot change them."
159− : null;
160− const readOnly = unavailable !== null || pending;
161− const parsed = parseUsagePriceForm(form);
162− const duplicate = editingModel === null && parsed !== null && Object.hasOwn(prices, parsed.model);
163−
164− const reset = () => {
165− setEditingModel(null);
166− setForm(usagePriceForm());
167− setError(null);
168− };
169− const save = async (model: string, price: UsageModelPriceOverride | null) => {
170− if (readOnly) return;
171− setPending(true);
172− setError(null);
173− try {
174− const result = await updateSettings({
175− environmentId: environment.environmentId,
176− input: { patch: { usagePriceOverrides: { [model]: price } } },
177− });
178− if (result._tag === "Failure") {
179− setError("Could not save model prices. Try again.");
180− } else if (price !== null || editingModel === model) {
181− reset();
182− }
183− } finally {
184− setPending(false);
185− }
186− };
187−
188− return (
189− <>
190− {Object.keys(prices).length > 0 ? (
191− <ul className="divide-y divide-border rounded-lg border border-border">
192− {Object.entries(prices)
193− .sort(([left], [right]) => left.localeCompare(right))
194− .map(([model, price]) => (
195− <li key={model} className="flex flex-wrap items-center gap-3 p-3">
196− <div className="min-w-0 flex-1">
197− <p className="break-all text-sm font-medium">{model}</p>
198− <p className="text-xs text-muted-foreground">
199− Input ${price.inputCostPerMillionTokens} · Output $
200− {price.outputCostPerMillionTokens}
201− <br />
202− Cache read $
203− {price.cacheReadCostPerMillionTokens ?? price.inputCostPerMillionTokens}
204− {" · "}Cache write $
205− {price.cacheWriteCostPerMillionTokens ?? price.inputCostPerMillionTokens}
206− </p>
207− </div>
208− <Button
209− variant="ghost"
210− size="xs"
211− disabled={readOnly}
212− aria-label={`Edit price for ${model}`}
213− onClick={() => {
214− setEditingModel(model);
215− setForm(usagePriceForm(model, price));
216− setError(null);
217− }}
218− >
219− Edit
220− </Button>
221− <Button
222− variant="ghost"
223− size="xs"
224− disabled={readOnly}
225− aria-label={`Remove price for ${model}`}
226− onClick={() => void save(model, null)}
227− >
228− Remove
229− </Button>
230− </li>
231− ))}
232− </ul>
233− ) : (
234− <p className="text-sm text-muted-foreground">
235− No custom prices. Public model pricing is used when available.
236− </p>
237− )}
238− {unavailable ? <p className="text-sm text-muted-foreground">{unavailable}</p> : null}
239− <form
240− className="grid gap-4"
241− onSubmit={(event) => {
242− event.preventDefault();
243− if (parsed && !duplicate) void save(parsed.model, parsed.price);
244− }}
245− >
246− <fieldset disabled={readOnly} className="grid min-w-0 gap-4">
247− <legend className="mb-3 text-sm font-medium">
248− {editingModel === null ? "Add model price" : "Edit model price"}
249− </legend>
250− <div className="grid gap-1.5">
251− <Label htmlFor="usage-price-model">Model ID</Label>
252− <Input
253− id="usage-price-model"
254− list="usage-price-models"
255− value={form.model}
256− disabled={editingModel !== null}
257− placeholder="Exact model ID from usage"
258− autoComplete="off"
259− spellCheck={false}
260− required
261− onChange={(event) => setForm({ ...form, model: event.target.value })}
262− />
263− <datalist id="usage-price-models">
264− {models.map((model) => (
265− <option key={model} value={model} />
266− ))}
267− </datalist>
268− {duplicate ? (
269− <p role="alert" className="text-xs text-destructive">
270− This model already has a price. Use Edit to change it.
271− </p>
272− ) : null}
273− </div>
274− <div className="grid grid-cols-2 gap-3">
275− {USAGE_PRICE_FIELDS.map((field) => (
276− <div key={field.key} className="grid gap-1.5">
277− <Label htmlFor={`usage-price-${field.key}`}>
278− {field.label}
279− {field.optional ? " (optional)" : ""}
280− </Label>
281− <Input
282− id={`usage-price-${field.key}`}
283− type="number"
284− min="0"
285− step="any"
286− inputMode="decimal"
287− required={!field.optional}
288− value={form[field.key]}
289− placeholder={field.optional ? "Use input rate" : "0.00"}
290− onChange={(event) => setForm({ ...form, [field.key]: event.target.value })}
291− />
292− </div>
293− ))}
294− </div>
295− <p className="text-xs text-muted-foreground">
296− Blank cache rates use the input price. Enter 0 for free tokens. Model IDs match exactly.
297− Remove a price to restore automatic pricing.
298− </p>
299− {error ? (
300− <p role="alert" className="text-sm text-destructive">
301− {error}
302− </p>
499+ {attempt ? (
500+ <div role="status" className="grid gap-1 text-xs">
501+ {attempt.destinations.map((destination) => {
502+ const result = attempt.results.get(destination.environmentId);
503+ return (
504+ <div key={destination.environmentId} className="flex justify-between gap-3">
505+ <span className="truncate text-muted-foreground">{destination.label}</span>
506+ <span
507+ className={
508+ result?.status === "failed"
509+ ? "text-right text-destructive"
510+ : "text-muted-foreground"
511+ }
512+ >
513+ {result?.status === "failed"
514+ ? `Not saved · ${result.error}`
515+ : result?.status === "saved"
516+ ? "Saved"
517+ : "Saving…"}
518+ </span>
519+ </div>
520+ );
521+ })}
522+ </div>
303523 ) : null}
304− <div className="flex justify-end gap-2">
305− {editingModel !== null ? (
306− <Button variant="outline" onClick={reset}>
307− Cancel
524+ </DialogPanel>
525+ <DialogFooter variant="bare" className="items-center sm:justify-between">
526+ <span className="text-xs text-muted-foreground">
527+ {hasChanges ? `Changes apply to ${destinationLabel}` : ""}
528+ </span>
529+ <div className="flex w-full flex-wrap justify-end gap-2 sm:w-auto">
530+ {hasChanges ? (
531+ <Button variant="ghost" disabled={pending} onClick={discard}>
532+ {failedDestinations.length > 0 ? "Discard pending changes" : "Discard changes"}
308533 </Button>
309534 ) : null}
310− <Button type="submit" disabled={parsed === null || duplicate || readOnly}>
311− {pending ? "Saving..." : editingModel === null ? "Add price" : "Save price"}
535+ <Button
536+ disabled={
537+ pending ||
538+ (!hasChanges && failedDestinations.length === 0) ||
539+ (failedDestinations.length === 0 && (errors.size > 0 || selected.length === 0))
540+ }
541+ onClick={() => void save(failedDestinations.length > 0)}
542+ >
543+ {pending
544+ ? "Saving…"
545+ : failedDestinations.length > 0
546+ ? "Retry failed saves"
547+ : "Save changes"}
312548 </Button>
313549 </div>
314− </fieldset>
315− </form>
316− </>
550+ </DialogFooter>
551+ </DialogPopup>
552+ </Dialog>
317553 );
318554 }
apps/web/src/components/usage/usagePriceTable.test.ts+170−0
@@ -0,0 +1,170 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+import {
4+ usagePriceCell,
5+ usagePriceTableChanges,
6+ usagePriceTableErrors,
7+ type UsagePriceDraft,
8+} from "./usagePriceTable";
9+import type { UsagePriceTarget } from "./usagePriceTargets";
10+
11+const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
12+const target = (name: string, prices: UsagePriceTarget["prices"]): UsagePriceTarget => ({
13+ environmentId: EnvironmentId.make(name),
14+ label: name,
15+ prices,
16+ unavailable: null,
17+});
18+const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({
19+ id: "model:example",
20+ model: "example",
21+ isNew: false,
22+ values,
23+});
24+
25+describe("price table edits", () => {
26+ it("shows shared and mixed cells separately, and never treats missing settings as automatic", () => {
27+ const targets = [
28+ target("a", { example: price }),
29+ target("b", { example: { ...price, outputCostPerMillionTokens: 10 } }),
30+ ];
31+ expect(usagePriceCell(targets, "example", "inputCostPerMillionTokens").value).toBe("2");
32+ expect(usagePriceCell(targets, "example", "outputCostPerMillionTokens")).toEqual({
33+ value: "",
34+ placeholder: "Mixed",
35+ });
36+ expect(usagePriceCell(targets, "example", "cacheReadCostPerMillionTokens")).toEqual({
37+ value: "",
38+ placeholder: "Input rate",
39+ });
40+ expect(
41+ usagePriceCell([target("a", {})], "example", "inputCostPerMillionTokens").placeholder,
42+ ).toBe("Automatic");
43+ expect(
44+ usagePriceCell([targets[0]!, target("b", null)], "example", "inputCostPerMillionTokens")
45+ .placeholder,
46+ ).toBe("Unavailable");
47+ });
48+
49+ it.each(["constructor", "toString", "__proto__"])(
50+ "handles an exact model ID named %s",
51+ (model) => {
52+ const environment = target("a", {});
53+ expect(usagePriceCell([environment], model, "inputCostPerMillionTokens")).toEqual({
54+ value: "",
55+ placeholder: "Automatic",
56+ });
57+ const result = usagePriceTableChanges(environment, [
58+ {
59+ ...draft({ inputCostPerMillionTokens: "2", outputCostPerMillionTokens: "8" }),
60+ model,
61+ isNew: true,
62+ },
63+ ]);
64+ expect(result.changes).toEqual([{ model, price }]);
65+ expect(
66+ usagePriceCell([target("a", { [model]: price })], model, "inputCostPerMillionTokens").value,
67+ ).toBe("2");
68+ },
69+ );
70+
71+ it("changes only edited columns while preserving each environment's other prices and models", () => {
72+ const edits = [draft({ inputCostPerMillionTokens: "3" })];
73+ const a = usagePriceTableChanges(
74+ target("a", { example: { ...price, cacheReadCostPerMillionTokens: 0 }, untouched: price }),
75+ edits,
76+ );
77+ const b = usagePriceTableChanges(
78+ target("b", { example: { ...price, outputCostPerMillionTokens: 10 } }),
79+ edits,
80+ );
81+ expect(a.changes).toEqual([
82+ {
83+ model: "example",
84+ price: { ...price, inputCostPerMillionTokens: 3, cacheReadCostPerMillionTokens: 0 },
85+ },
86+ ]);
87+ expect(b.changes).toEqual([
88+ { model: "example", price: { inputCostPerMillionTokens: 3, outputCostPerMillionTokens: 10 } },
89+ ]);
90+ });
91+
92+ it("batches new rows and resets, and distinguishes blank cache rates from explicit zero", () => {
93+ const plan = usagePriceTableChanges(target("a", { example: price }), [
94+ { ...draft({}), removed: true },
95+ {
96+ id: "new:1",
97+ model: " vendor/Model ",
98+ isNew: true,
99+ values: {
100+ inputCostPerMillionTokens: "2",
101+ outputCostPerMillionTokens: "8",
102+ cacheReadCostPerMillionTokens: "0",
103+ cacheWriteCostPerMillionTokens: "",
104+ },
105+ },
106+ ]);
107+ expect(plan.errors.size).toBe(0);
108+ expect(plan.changes).toEqual([
109+ { model: "example", price: null },
110+ { model: "vendor/Model", price: { ...price, cacheReadCostPerMillionTokens: 0 } },
111+ ]);
112+ });
113+
114+ it("requires complete prices before turning automatic pricing into an override", () => {
115+ const edits = [draft({ inputCostPerMillionTokens: "3" })];
116+ expect(usagePriceTableChanges(target("a", { example: price }), edits).errors.size).toBe(0);
117+ const missing = usagePriceTableChanges(target("b", {}), edits);
118+ expect(missing.changes).toEqual([]);
119+ expect(missing.errors.has("model:example")).toBe(true);
120+ });
121+
122+ it("ignores empty new rows without blocking other edits", () => {
123+ const environment = target("a", { example: price });
124+ const empty: UsagePriceDraft = {
125+ id: "new:1",
126+ model: " ",
127+ isNew: true,
128+ values: { inputCostPerMillionTokens: " " },
129+ };
130+ const plan = usagePriceTableChanges(environment, [
131+ draft({ inputCostPerMillionTokens: "3" }),
132+ empty,
133+ ]);
134+ expect(plan.errors.size).toBe(0);
135+ expect(plan.changes).toEqual([
136+ { model: "example", price: { ...price, inputCostPerMillionTokens: 3 } },
137+ ]);
138+ expect(usagePriceTableChanges(environment, [empty]).changes).toEqual([]);
139+ expect(
140+ usagePriceTableChanges(environment, [
141+ { ...empty, values: { inputCostPerMillionTokens: "0" } },
142+ ]).errors.get(empty.id),
143+ ).toBe("Enter a model ID.");
144+ });
145+
146+ it.each(["Offline", "Read-only access", "Update server to edit prices"])(
147+ "does not let %s destinations block a valid edit elsewhere",
148+ (unavailable) => {
149+ const edits = [draft({ inputCostPerMillionTokens: "3" })];
150+ const writable = target("writable", { example: price });
151+ const other = target("other", {});
152+ expect(usagePriceTableErrors([writable, { ...other, unavailable }], edits).size).toBe(0);
153+ expect(usagePriceTableErrors([writable, other], edits).has("model:example")).toBe(true);
154+ },
155+ );
156+
157+ it("does not write unchanged rates and rejects invalid edited cells", () => {
158+ const environment = target("a", { example: price });
159+ expect(
160+ usagePriceTableChanges(environment, [draft({ inputCostPerMillionTokens: "2.00" })]).changes,
161+ ).toEqual([]);
162+ expect(
163+ usagePriceTableChanges(environment, [draft({ cacheReadCostPerMillionTokens: "-1" })]).errors
164+ .size,
165+ ).toBe(1);
166+ expect(
167+ usagePriceTableChanges(environment, [draft({ outputCostPerMillionTokens: "" })]).errors.size,
168+ ).toBe(1);
169+ });
170+});
apps/web/src/components/usage/usagePriceTable.ts+97−0
@@ -0,0 +1,97 @@
1+import {
2+ parseUsagePriceForm,
3+ usagePriceForm,
4+ USAGE_PRICE_FIELDS,
5+ type UsagePriceForm,
6+} from "./usagePriceForm";
7+import type { UsagePriceChange, UsagePriceTarget } from "./usagePriceTargets";
8+
9+export type UsagePriceField = (typeof USAGE_PRICE_FIELDS)[number]["key"];
10+
11+export interface UsagePriceDraft {
12+ readonly id: string;
13+ readonly model: string;
14+ readonly isNew: boolean;
15+ readonly values: Partial<Pick<UsagePriceForm, UsagePriceField>>;
16+ readonly removed?: boolean;
17+}
18+
19+export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) {
20+ return (
21+ draft.isNew &&
22+ draft.model.trim() === "" &&
23+ Object.values(draft.values).every((value) => value.trim() === "")
24+ );
25+}
26+
27+function modelPrice(target: UsagePriceTarget, model: string) {
28+ return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;
29+}
30+
31+export function usagePriceCell(
32+ targets: readonly UsagePriceTarget[],
33+ model: string,
34+ field: UsagePriceField,
35+) {
36+ const optional = USAGE_PRICE_FIELDS.find((entry) => entry.key === field)!.optional;
37+ const values = targets.map((target) =>
38+ modelPrice(target, model) ? usagePriceForm(model, modelPrice(target, model))[field] : null,
39+ );
40+ if (targets.some((target) => target.prices === null))
41+ return { value: "", placeholder: "Unavailable" };
42+ if (values.some((value) => value !== values[0])) return { value: "", placeholder: "Mixed" };
43+ return {
44+ value: values[0] ?? "",
45+ placeholder: values[0] === null ? "Automatic" : optional ? "Input rate" : "0.00",
46+ };
47+}
48+
49+/** Only edited cells replace rates; untouched cells retain each environment's own values. */
50+export function usagePriceTableChanges(
51+ target: UsagePriceTarget,
52+ drafts: readonly UsagePriceDraft[],
53+) {
54+ const changes: UsagePriceChange[] = [];
55+ const errors = new Map<string, string>();
56+ for (const draft of drafts) {
57+ if (isEmptyUsagePriceDraft(draft)) continue;
58+ const model = draft.model.trim();
59+ if (draft.removed) {
60+ if (modelPrice(target, model)) changes.push({ model, price: null });
61+ continue;
62+ }
63+ const original = usagePriceForm(model, modelPrice(target, model));
64+ const form = { ...original, ...draft.values };
65+ const parsed = parseUsagePriceForm(form);
66+ if (parsed === null) {
67+ const missing = USAGE_PRICE_FIELDS.find(
68+ (field) => !field.optional && form[field.key].trim() === "",
69+ );
70+ errors.set(
71+ draft.id,
72+ model === ""
73+ ? "Enter a model ID."
74+ : missing
75+ ? `${missing.label} is required on ${target.label}.`
76+ : "Use non-negative numbers for prices.",
77+ );
78+ continue;
79+ }
80+ const next = usagePriceForm(model, parsed.price);
81+ if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key]))
82+ changes.push(parsed);
83+ }
84+ return { changes, errors };
85+}
86+
87+/** Unavailable destinations report a save failure without blocking writable environments. */
88+export function usagePriceTableErrors(
89+ targets: readonly UsagePriceTarget[],
90+ drafts: readonly UsagePriceDraft[],
91+) {
92+ return new Map(
93+ targets
94+ .filter((target) => target.unavailable === null && target.prices !== null)
95+ .flatMap((target) => [...usagePriceTableChanges(target, drafts).errors]),
96+ );
97+}
apps/web/src/components/usage/usagePriceTargets.test.ts+143−0
@@ -0,0 +1,143 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import { describe, expect, it, vi } from "vite-plus/test";
3+
4+import {
5+ writeUsagePrices,
6+ type UsagePriceTarget,
7+ type UsagePriceWriteResult,
8+} from "./usagePriceTargets";
9+
10+const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
11+const target = (id: string, overrides: Partial<UsagePriceTarget> = {}): UsagePriceTarget => ({
12+ environmentId: EnvironmentId.make(id),
13+ label: id,
14+ prices: { example: price },
15+ unavailable: null,
16+ ...overrides,
17+});
18+
19+describe("model price writes", () => {
20+ it("sends all table changes in one patch per environment and skips unchanged destinations", async () => {
21+ const write = vi.fn(async () => ({ _tag: "Success" as const }));
22+ const onResult = vi.fn();
23+ await writeUsagePrices({
24+ targets: [target("edited"), target("unchanged")],
25+ changes: new Map([
26+ [
27+ EnvironmentId.make("edited"),
28+ [
29+ { model: "new-model", price },
30+ { model: "example", price: null },
31+ ],
32+ ],
33+ ]),
34+ write,
35+ onResult,
36+ });
37+ expect(write).toHaveBeenCalledExactlyOnceWith({
38+ environmentId: "edited",
39+ input: { patch: { usagePriceOverrides: { "new-model": price, example: null } } },
40+ });
41+ expect(onResult).toHaveBeenCalledWith("unchanged", { status: "saved" });
42+ });
43+
44+ it("saves independently, skips unavailable targets, and retries failures without rewriting successes", async () => {
45+ let resolveSlow!: (result: { _tag: "Success" | "Failure" }) => void;
46+ const slow = new Promise<{ _tag: "Success" | "Failure" }>((resolve) => {
47+ resolveSlow = resolve;
48+ });
49+ let resolveFastSaved!: () => void;
50+ const fastSaved = new Promise<void>((resolve) => {
51+ resolveFastSaved = resolve;
52+ });
53+ const targets = [target("fast"), target("slow"), target("offline", { unavailable: "Offline" })];
54+ const results = new Map<EnvironmentId, UsagePriceWriteResult>();
55+ const write = vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) =>
56+ environmentId === "slow" ? slow : { _tag: "Success" as const },
57+ );
58+ const change = { model: "example", price };
59+ const running = writeUsagePrices({
60+ targets,
61+ changes: new Map(targets.map((target) => [target.environmentId, [change]])),
62+ write,
63+ onResult: (id, result) => {
64+ results.set(id, result);
65+ if (id === "fast") resolveFastSaved();
66+ },
67+ });
68+ await fastSaved;
69+ expect(results.get(EnvironmentId.make("fast"))).toEqual({ status: "saved" });
70+ expect(results.get(EnvironmentId.make("offline"))).toEqual({
71+ status: "failed",
72+ error: "Offline",
73+ });
74+ expect(results.has(EnvironmentId.make("slow"))).toBe(false);
75+ resolveSlow({ _tag: "Failure" });
76+ await running;
77+ expect(write).toHaveBeenCalledTimes(2);
78+ expect(write).toHaveBeenCalledWith({
79+ environmentId: "fast",
80+ input: { patch: { usagePriceOverrides: { example: price } } },
81+ });
82+ const retry = vi.fn(async () => ({ _tag: "Success" as const }));
83+ await writeUsagePrices({
84+ targets: targets
85+ .filter((entry) => results.get(entry.environmentId)?.status === "failed")
86+ .map((entry) => ({ ...entry, unavailable: null })),
87+ changes: new Map(targets.map((target) => [target.environmentId, [change]])),
88+ write: retry,
89+ onResult: (id, result) => {
90+ results.set(id, result);
91+ },
92+ });
93+ expect(retry.mock.calls).toHaveLength(2);
94+ expect(retry).not.toHaveBeenCalledWith(expect.objectContaining({ environmentId: "fast" }));
95+ expect([...results.values()].every((result) => result.status === "saved")).toBe(true);
96+ });
97+
98+ it("resets only the chosen model on the selected destination", async () => {
99+ const write = vi.fn(async () => ({ _tag: "Success" as const }));
100+ const onResult = vi.fn();
101+ await writeUsagePrices({
102+ targets: [target("selected")],
103+ changes: new Map([[EnvironmentId.make("selected"), [{ model: "example", price: null }]]]),
104+ write,
105+ onResult,
106+ });
107+ expect(write).toHaveBeenCalledExactlyOnceWith({
108+ environmentId: "selected",
109+ input: { patch: { usagePriceOverrides: { example: null } } },
110+ });
111+ expect(onResult).toHaveBeenCalledWith("selected", { status: "saved" });
112+ });
113+
114+ it("reports thrown writes and permission/version restrictions without aborting other saves", async () => {
115+ const onResult = vi.fn();
116+ const write = vi.fn(async () => {
117+ throw new Error("connection lost");
118+ });
119+ await writeUsagePrices({
120+ targets: [
121+ target("lost"),
122+ target("denied", { unavailable: "Read-only access" }),
123+ target("old", { unavailable: "Update server to edit prices" }),
124+ ],
125+ changes: new Map([[EnvironmentId.make("lost"), [{ model: "example", price }]]]),
126+ write,
127+ onResult,
128+ });
129+ expect(write).toHaveBeenCalledTimes(1);
130+ expect(onResult).toHaveBeenCalledWith("lost", {
131+ status: "failed",
132+ error: "Could not save. Try again.",
133+ });
134+ expect(onResult).toHaveBeenCalledWith("denied", {
135+ status: "failed",
136+ error: "Read-only access",
137+ });
138+ expect(onResult).toHaveBeenCalledWith("old", {
139+ status: "failed",
140+ error: "Update server to edit prices",
141+ });
142+ });
143+});
apps/web/src/components/usage/usagePriceTargets.ts+65−0
@@ -0,0 +1,65 @@
1+import type {
2+ EnvironmentId,
3+ ServerSettingsPatch,
4+ UsageModelPriceOverride,
5+} from "@t3tools/contracts";
6+
7+export interface UsagePriceTarget {
8+ readonly environmentId: EnvironmentId;
9+ readonly label: string;
10+ readonly prices: Readonly<Record<string, UsageModelPriceOverride>> | null;
11+ readonly unavailable: string | null;
12+}
13+
14+export interface UsagePriceChange {
15+ readonly model: string;
16+ readonly price: UsageModelPriceOverride | null;
17+}
18+
19+export type UsagePriceWriteResult =
20+ | { readonly status: "saved" }
21+ | { readonly status: "failed"; readonly error: string };
22+
23+/** Each destination settles independently; retry callers pass only the failed destinations. */
24+export async function writeUsagePrices(input: {
25+ readonly targets: readonly UsagePriceTarget[];
26+ readonly changes: ReadonlyMap<EnvironmentId, readonly UsagePriceChange[]>;
27+ readonly write: (input: {
28+ environmentId: EnvironmentId;
29+ input: { patch: ServerSettingsPatch };
30+ }) => Promise<{ readonly _tag: "Success" | "Failure" }>;
31+ readonly onResult: (environmentId: EnvironmentId, result: UsagePriceWriteResult) => void;
32+}) {
33+ await Promise.all(
34+ input.targets.map(async (target) => {
35+ let result: UsagePriceWriteResult;
36+ if (target.unavailable !== null) {
37+ result = { status: "failed", error: target.unavailable };
38+ } else {
39+ try {
40+ const changes = input.changes.get(target.environmentId) ?? [];
41+ const saved =
42+ changes.length === 0
43+ ? { _tag: "Success" as const }
44+ : await input.write({
45+ environmentId: target.environmentId,
46+ input: {
47+ patch: {
48+ usagePriceOverrides: Object.fromEntries(
49+ changes.map((change) => [change.model, change.price]),
50+ ),
51+ },
52+ },
53+ });
54+ result =
55+ saved._tag === "Success"
56+ ? { status: "saved" }
57+ : { status: "failed", error: "Could not save. Try again." };
58+ } catch {
59+ result = { status: "failed", error: "Could not save. Try again." };
60+ }
61+ }
62+ input.onResult(target.environmentId, result);
63+ }),
64+ );
65+}
docs/user/usage.md+14−5
@@ -18,15 +18,24 @@ update model pricing. · 17 unmodified lines
1818
1919 ## Set custom model prices
2020
21−On web or desktop, open **Usage → Model prices** to add, edit, or remove a model's estimated
22−price. Choose the environment whose history you want to price, then enter the exact model ID and
23−USD rates per million input and output tokens. You can enter any model ID, including models
21+On web or desktop, open the environment dropdown on **Usage**, then choose **Model prices** to add,
22+edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter;
23+choose all environments or select individual destinations. Enter the exact model ID and USD
24+rates per million input and output tokens. You can enter any model ID, including models
2425 without public pricing.
2526
2627 Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for
2728 tokens that are free. Saved prices replace automatic pricing for all of that environment's
28−history and are shared with clients connected to it. Set prices on each environment that needs
29−them. Removing a price restores automatic pricing.
29+history and are shared with clients connected to it. When environments have different prices,
30+cells show **Mixed**. Edit rates directly in the table, then choose **Save changes** to apply all
31+edited rows. Untouched cells keep each environment's rate. Select one environment to inspect its
32+prices. **Reset to automatic** marks a model's override for removal when you save; you can undo
33+it before saving.
34+
35+Each destination reports whether the change saved. Offline or unavailable environments are
36+marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change
37+without writing again to environments that already saved. Changes are not queued after you close
38+the dialog.
3039
3140 ## Track subscription limits
3241