authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 19:45:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:45:14-07:00
log84b99f3fb7bbc759fcbb589d031920e76edf4746
treeb4133a89a6f8cf1cd9948f24c42640805d5806b7
parent7ee52b077399463323e859f9451a65d31b96a27f
signature Signed by PGP key B5690EEEBB952194

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


7 files changed, 1024 insertions(+), 283 deletions(-)

apps/web/src/components/usage/UsagePage.tsx+28-7
......@@ -4,7 +4,13 @@ import {
44 type EnvironmentId,
55 type UsageProviderKind,
66} from "@t3tools/contracts";
7import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react";
7import {
8 CircleAlertIcon,
9 ChevronDownIcon,
10 CircleDashedIcon,
11 RefreshCwIcon,
12 SlidersHorizontalIcon,
13} from "lucide-react";
814import { useMemo, useState } from "react";
915
1016import {
......@@ -32,7 +38,14 @@ import {
3238 makeWindow,
3339} from "@t3tools/shared/usageFormat";
3440import { Button } from "../ui/button";
35import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
41import {
42 Menu,
43 MenuCheckboxItem,
44 MenuItem,
45 MenuPopup,
46 MenuSeparator,
47 MenuTrigger,
48} from "../ui/menu";
3649import { ScrollArea } from "../ui/scroll-area";
3750import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
3851import { SidebarInset } from "../ui/sidebar";
......@@ -287,11 +300,6 @@ export function UsagePage() {
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({
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({
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 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, UsageModelPriceOverride } from "@t3tools/contracts";
3import { useState } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useRef, useState } from "react";
47
58import { isElectron } from "../../env";
6import {
7 type EnvironmentPresentation,
8 useEnvironments,
9 usePrimaryEnvironmentId,
10} from "../../state/environments";
9import { cn } from "../../lib/utils";
10import { environmentPresentations } from "../../state/presentation";
1111import { serverEnvironment } from "../../state/server";
12import { useEnvironmentSessionState } from "../../state/session";
12import { environmentSession } from "../../state/session";
1313import { useAtomCommand } from "../../state/use-atom-command";
1414import type { EnvironmentUsageStatus } from "../../state/usage";
1515import {
......@@ -20,6 +20,7 @@ import { Button } from "../ui/button";
2020import {
2121 Dialog,
2222 DialogDescription,
23 DialogFooter,
2324 DialogHeader,
2425 DialogPanel,
2526 DialogPopup,
......@@ -27,292 +28,527 @@ import {
2728} from "../ui/dialog";
2829import { Input } from "../ui/input";
2930import { Label } from "../ui/label";
30import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
31import { parseUsagePriceForm, USAGE_PRICE_FIELDS, usagePriceForm } from "./usagePriceForm";
31import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
32import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
33import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
34import { USAGE_PRICE_FIELDS } from "./usagePriceForm";
35import {
36 isEmptyUsagePriceDraft,
37 usagePriceCell,
38 usagePriceTableChanges,
39 usagePriceTableErrors,
40 type UsagePriceDraft,
41 type UsagePriceField,
42} from "./usagePriceTable";
43import {
44 writeUsagePrices,
45 type UsagePriceTarget,
46 type UsagePriceWriteResult,
47} from "./usagePriceTargets";
3248
33export 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}
49const 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
82type 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
49function ModelPricesDialog({
94export 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
117function 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 created+170
......@@ -0,0 +1,170 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 usagePriceCell,
5 usagePriceTableChanges,
6 usagePriceTableErrors,
7 type UsagePriceDraft,
8} from "./usagePriceTable";
9import type { UsagePriceTarget } from "./usagePriceTargets";
10
11const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
12const target = (name: string, prices: UsagePriceTarget["prices"]): UsagePriceTarget => ({
13 environmentId: EnvironmentId.make(name),
14 label: name,
15 prices,
16 unavailable: null,
17});
18const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({
19 id: "model:example",
20 model: "example",
21 isNew: false,
22 values,
23});
24
25describe("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 created+97
......@@ -0,0 +1,97 @@
1import {
2 parseUsagePriceForm,
3 usagePriceForm,
4 USAGE_PRICE_FIELDS,
5 type UsagePriceForm,
6} from "./usagePriceForm";
7import type { UsagePriceChange, UsagePriceTarget } from "./usagePriceTargets";
8
9export type UsagePriceField = (typeof USAGE_PRICE_FIELDS)[number]["key"];
10
11export 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
19export 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
27function modelPrice(target: UsagePriceTarget, model: string) {
28 return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;
29}
30
31export 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. */
50export 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. */
88export 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 created+143
......@@ -0,0 +1,143 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 writeUsagePrices,
6 type UsagePriceTarget,
7 type UsagePriceWriteResult,
8} from "./usagePriceTargets";
9
10const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
11const 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
19describe("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 created+65
......@@ -0,0 +1,65 @@
1import type {
2 EnvironmentId,
3 ServerSettingsPatch,
4 UsageModelPriceOverride,
5} from "@t3tools/contracts";
6
7export 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
14export interface UsagePriceChange {
15 readonly model: string;
16 readonly price: UsageModelPriceOverride | null;
17}
18
19export 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. */
24export 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.
1818
1919## Set custom model prices
2020
21On web or desktop, open **Usage → Model prices** to add, edit, or remove a model's estimated
22price. Choose the environment whose history you want to price, then enter the exact model ID and
23USD rates per million input and output tokens. You can enter any model ID, including models
21On web or desktop, open the environment dropdown on **Usage**, then choose **Model prices** to add,
22edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter;
23choose all environments or select individual destinations. Enter the exact model ID and USD
24rates per million input and output tokens. You can enter any model ID, including models
2425without public pricing.
2526
2627Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for
2728tokens that are free. Saved prices replace automatic pricing for all of that environment's
28history and are shared with clients connected to it. Set prices on each environment that needs
29them. Removing a price restores automatic pricing.
29history and are shared with clients connected to it. When environments have different prices,
30cells show **Mixed**. Edit rates directly in the table, then choose **Save changes** to apply all
31edited rows. Untouched cells keep each environment's rate. Select one environment to inspect its
32prices. **Reset to automatic** marks a model's override for removal when you save; you can undo
33it before saving.
34
35Each destination reports whether the change saved. Offline or unavailable environments are
36marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change
37without writing again to environments that already saved. Changes are not queued after you close
38the dialog.
3039
3140## Track subscription limits
3241