authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 14:55:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 14:55:47-07:00
logb05adb70b00feac0883b2e364256100cfab4b6ca
tree58d7cca7670bb2edf6ece506e57a353bbc9f756b
parenta21702d0e58ad20263dc965d84472bb6d7eb38c8
signature Signed by PGP key B5690EEEBB952194

feat(usage): fold preview model IDs into the model they belong to (#15333)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

14 files changed, 471 insertions(+), 160 deletions(-)

apps/server/src/environment/ServerEnvironment.ts+1
......@@ -233,6 +233,7 @@ export const make = Effect.gen(function* () {
233233 environmentThemes: true,
234234 usageLimitSources: true,
235235 usagePriceOverrides: true,
236 usageModelAliases: true,
236237 threadPinning: true,
237238 threadPinReorder: true,
238239 threadActiveReorder: true,
apps/server/src/usage/UsageService.ts+8-10
......@@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn
5353import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
5454import { readAntigravityUsage } from "./antigravityUsageReader.ts";
5555import { readCursorAccountUsage } from "./cursorUsageReader.ts";
56import { UsageAggregator } from "./usageAggregation.ts";
56import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
5757import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
5858import {
5959 listTranscriptFiles,
......@@ -821,6 +821,7 @@ export const make = Effect.gen(function* () {
821821 ...hourlyWindow,
822822 rates,
823823 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
824 modelAliases: resolveModelAliases(settings.usageModelAliases),
824825 });
825826
826827 const sources: UsageSource[] = [];
......@@ -925,17 +926,13 @@ export const make = Effect.gen(function* () {
925926 });
926927
927928 /**
928 * In-flight scans by window and custom prices, so concurrent identical requests (the usage
929 * In-flight scans by window and usage settings, so concurrent identical requests (the usage
929930 * page open on two clients at once) share one scan instead of racing over
930931 * the same corpus twice.
931932 */
932933 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();
933934
934 const scanKey = (
935 input: UsageSummaryInput,
936 priceOverrides: ServerSettingsValue["usagePriceOverrides"],
937 cursorKeychainUsageEnabled: boolean,
938 ): string =>
935 const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string =>
939936 JSON.stringify([
940937 input.timeZone,
941938 input.sinceDay,
......@@ -943,13 +940,14 @@ export const make = Effect.gen(function* () {
943940 input.resolution ?? "day",
944941 input.sinceTime ?? null,
945942 input.untilTime ?? null,
946 priceOverrides,
947 cursorKeychainUsageEnabled,
943 settings.usagePriceOverrides,
944 settings.usageModelAliases,
945 settings.cursorKeychainUsageEnabled,
948946 ]);
949947
950948 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
951949 const settings = yield* readSettings;
952 const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled);
950 const key = scanKey(input, settings);
953951 const deferred = yield* Effect.uninterruptible(
954952 Effect.gen(function* () {
955953 const existing = inflightScans.get(key);
apps/server/src/usage/usageAggregation.test.ts+40-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "@effect/vitest";
22
3import { UsageAggregator } from "./usageAggregation.ts";
3import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
44import type { RateTable } from "./usagePricing.ts";
55import type { UsageRecord } from "./usageTranscripts.ts";
66
......@@ -253,6 +253,25 @@ describe("UsageAggregator", () => {
253253 expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);
254254 });
255255
256 it("folds a mapped model into its target and prices it there", () => {
257 const aggregator = new UsageAggregator({
258 timeZone: "UTC",
259 sinceDay: "2026-08-01",
260 untilDay: "2026-08-31",
261 rates,
262 modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }),
263 });
264 aggregator.add(record());
265 aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" }));
266 const result = aggregator.finish();
267
268 expect(result.buckets).toHaveLength(1);
269 expect(result.buckets[0]?.model).toBe("claude-fable-5");
270 expect(result.buckets[0]?.records).toBe(2);
271 expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9);
272 expect(result.buckets[0]?.unpricedRecords).toBe(0);
273 });
274
256275 it("separates providers and models into their own buckets", () => {
257276 const result = aggregate([
258277 record(),
......@@ -263,3 +282,23 @@ describe("UsageAggregator", () => {
263282 expect(result.buckets).toHaveLength(3);
264283 });
265284});
285
286describe("resolveModelAliases", () => {
287 it("follows chains to the final model and drops chains that enter a loop", () => {
288 expect(
289 resolveModelAliases({
290 "preview[1m]": "preview",
291 preview: "example-model",
292 loop: "back",
293 back: "loop",
294 intoLoop: "loop",
295 self: "self",
296 }),
297 ).toEqual(
298 new Map([
299 ["preview[1m]", "example-model"],
300 ["preview", "example-model"],
301 ]),
302 );
303 });
304});
apps/server/src/usage/usageAggregation.ts+34-1
......@@ -89,11 +89,35 @@ export interface AggregateOptions {
8989 readonly untilDay: string;
9090 readonly rates: RateTable;
9191 readonly priceOverrides?: RateTable;
92 /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */
93 readonly modelAliases?: ReadonlyMap<string, string>;
9294 readonly resolution?: UsageResolution;
9395 readonly sinceTimeMs?: number;
9496 readonly untilTimeMs?: number;
9597}
9698
99/**
100 * Resolves user model mappings to their final target, so `a -> b` and
101 * `b -> c` both land on `c`. A model whose chain enters a loop is left
102 * unmapped.
103 */
104export function resolveModelAliases(
105 aliases: Readonly<Record<string, string>>,
106): ReadonlyMap<string, string> {
107 const resolved = new Map<string, string>();
108 for (const model of Object.keys(aliases)) {
109 const seen = new Set([model]);
110 let target = aliases[model]!;
111 while (Object.hasOwn(aliases, target) && !seen.has(target)) {
112 seen.add(target);
113 target = aliases[target]!;
114 }
115 // Stopping on a mapped model means the chain entered a loop.
116 if (!Object.hasOwn(aliases, target)) resolved.set(model, target);
117 }
118 return resolved;
119}
120
97121export interface AggregateResult {
98122 readonly buckets: readonly UsageBucket[];
99123 /** Records dropped because an earlier record carried the same dedupe key. */
......@@ -147,7 +171,8 @@ export class UsageAggregator {
147171 * can derive per-window facts (distinct sessions, for one) from the records
148172 * that landed rather than everything the mtime prefilter happened to admit.
149173 */
150 add(record: UsageRecord, sourcePath?: string): boolean {
174 add(input: UsageRecord, sourcePath?: string): boolean {
175 const record = this.#mapModel(input);
151176 if (record.dedupeKey !== null) {
152177 if (this.#seen.has(record.dedupeKey)) {
153178 this.#duplicatesDropped += 1;
......@@ -217,6 +242,14 @@ export class UsageAggregator {
217242 return true;
218243 }
219244
245 /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */
246 #mapModel(record: UsageRecord): UsageRecord {
247 const model = this.#options.modelAliases?.get(record.model);
248 if (model === undefined) return record;
249 const { rateModel: _rateModel, ...rest } = record;
250 return { ...rest, model };
251 }
252
220253 /**
221254 * Records arrive file by file in time order, so most land in the bucket the
222255 * previous record used. Checking that first skips building and hashing a
apps/web/src/components/usage/UsagePriceOverrides.tsx+198-133
......@@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
3434import { USAGE_PRICE_FIELDS } from "./usagePriceForm";
3535import {
3636 isEmptyUsagePriceDraft,
37 usageAliasCell,
3738 usagePriceCell,
3839 usagePriceTableChanges,
3940 usagePriceTableErrors,
......@@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
6364 environmentId,
6465 label: environment.entry.target.label,
6566 prices: settings?.usagePriceOverrides ?? null,
67 aliases:
68 environment.serverConfig?.environment.capabilities.usageModelAliases === true
69 ? (settings?.usageModelAliases ?? null)
70 : null,
6671 unavailable:
6772 environment.connection.phase !== "connected"
6873 ? "Offline"
......@@ -91,7 +96,10 @@ type SaveAttempt = {
9196 >;
9297};
9398
94/** Edits custom model prices. `initialModel` opens with a new row for that model. */
99/**
100 * Edits custom model prices and mappings. A mapped model's usage counts as its
101 * target model. `initialModel` opens with a new row for that model.
102 */
95103export function UsagePriceOverrides({
96104 usage,
97105 initialSelectedEnvironmentIds,
......@@ -108,12 +116,13 @@ export function UsagePriceOverrides({
108116 const selected = environments.filter(
109117 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),
110118 );
111 // A model that already has a custom price somewhere is edited in its existing row.
119 // A model that already has a custom price or mapping is edited in its existing row.
112120 const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>(() =>
113121 initialModel === undefined ||
114122 selected.some(
115123 (environment) =>
116 environment.prices !== null && Object.hasOwn(environment.prices, initialModel),
124 Object.hasOwn(environment.prices ?? {}, initialModel) ||
125 Object.hasOwn(environment.aliases ?? {}, initialModel),
117126 )
118127 ? []
119128 : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }],
......@@ -124,7 +133,12 @@ export function UsagePriceOverrides({
124133 const nextRowId = useRef(0);
125134 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
126135 const customModels = [
127 ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))),
136 ...new Set(
137 selected.flatMap((environment) => [
138 ...Object.keys(environment.prices ?? {}),
139 ...Object.keys(environment.aliases ?? {}),
140 ]),
141 ),
128142 ].sort();
129143 const models = [
130144 ...new Set([
......@@ -191,19 +205,31 @@ export function UsagePriceOverrides({
191205 );
192206 setAttempt(null);
193207 };
208 // An existing row edited back to its saved values has nothing left to save.
209 const matchesSaved = (
210 row: UsagePriceDraft,
211 cell: { value: string; placeholder: string },
212 value: string,
213 ) =>
214 !row.isNew &&
215 cell.placeholder !== "Mixed" &&
216 cell.placeholder !== "Unavailable" &&
217 value === cell.value;
218 const updateOrDropDraft = (draft: UsagePriceDraft) => {
219 if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined)
220 setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id));
221 else updateDraft(draft);
222 };
194223 const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => {
195224 const values = { ...row.values, [field]: value };
196225 const original = usagePriceCell(selected, row.model, field);
197 if (
198 !row.isNew &&
199 original.placeholder !== "Mixed" &&
200 original.placeholder !== "Unavailable" &&
201 value === original.value
202 )
203 delete values[field];
204 if (!row.isNew && Object.keys(values).length === 0)
205 setDrafts((previous) => previous.filter((entry) => entry.id !== row.id));
206 else updateDraft({ ...row, values });
226 if (matchesSaved(row, original, value)) delete values[field];
227 updateOrDropDraft({ ...row, values });
228 };
229 const editAlias = (row: UsagePriceDraft, value: string) => {
230 const { alias: _alias, ...rest } = row;
231 const original = usageAliasCell(selected, row.model);
232 updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value });
207233 };
208234 const save = async (retry = false) => {
209235 if (pending) return;
......@@ -214,7 +240,7 @@ export function UsagePriceOverrides({
214240 (destination) =>
215241 environments.find(
216242 (environment) => environment.environmentId === destination.environmentId,
217 ) ?? { ...destination, prices: null, unavailable: "Environment removed" },
243 ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" },
218244 );
219245 const changes = new Map(
220246 targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]),
......@@ -257,11 +283,11 @@ export function UsagePriceOverrides({
257283 if (!pending) onOpenChange(open);
258284 }}
259285 >
260 <DialogPopup className="max-w-3xl">
286 <DialogPopup className="max-w-4xl">
261287 <DialogHeader>
262288 <DialogTitle>Custom model prices</DialogTitle>
263289 <DialogDescription>
264 Prices apply to all past and future usage on the environments you select.
290 Prices and mappings apply to all past and future usage on the environments you select.
265291 </DialogDescription>
266292 </DialogHeader>
267293 <DialogPanel>
......@@ -326,9 +352,10 @@ export function UsagePriceOverrides({
326352 ) : (
327353 <>
328354 <div className="min-w-0 overflow-hidden rounded-lg border border-border">
329 <Table className="min-w-160 table-fixed" aria-label="Custom model prices">
355 <Table className="min-w-180 table-fixed" aria-label="Custom model prices">
330356 <colgroup>
331 <col className="w-[34%]" />
357 <col className="w-[22%]" />
358 <col className="w-[18%]" />
332359 {USAGE_PRICE_FIELDS.map((field) => (
333360 <col key={field.key} />
334361 ))}
......@@ -337,6 +364,7 @@ export function UsagePriceOverrides({
337364 <TableHeader>
338365 <TableRow>
339366 <TableHead>Model ID</TableHead>
367 <TableHead>Map to</TableHead>
340368 {USAGE_PRICE_FIELDS.map((field) => (
341369 <TableHead key={field.key}>{field.label}</TableHead>
342370 ))}
......@@ -360,132 +388,157 @@ export function UsagePriceOverrides({
360388 <TableBody>
361389 {rows.length === 0 ? (
362390 <TableRow>
363 <TableCell colSpan={6} className="text-center whitespace-normal">
391 <TableCell colSpan={7} className="text-center whitespace-normal">
364392 <p className="py-6 text-muted-foreground">
365393 {selected.some((environment) => environment.prices === null)
366394 ? "Some environment prices are unavailable."
367 : "No custom prices. Add a row to override automatic pricing."}
395 : "No custom prices or mappings. Add a row to set one."}
368396 </p>
369397 </TableCell>
370398 </TableRow>
371399 ) : (
372 rows.map((row) => (
373 <TableRow key={row.id} data-row-id={row.id}>
374 <TableCell className="whitespace-normal">
375 {row.isNew ? (
376 <Input
377 size="compact"
378 value={row.model}
379 ref={(node) => {
380 if (node && focusRowRef.current === row.id) {
381 node.focus();
382 focusRowRef.current = null;
400 rows.map((row) => {
401 const aliasCell = usageAliasCell(selected, row.model);
402 const alias = (row.alias ?? aliasCell.value).trim();
403 return (
404 <TableRow key={row.id} data-row-id={row.id}>
405 <TableCell className="whitespace-normal">
406 {row.isNew ? (
407 <Input
408 size="compact"
409 value={row.model}
410 ref={(node) => {
411 if (node && focusRowRef.current === row.id) {
412 node.focus();
413 focusRowRef.current = null;
414 }
415 }}
416 aria-label="New model ID"
417 aria-invalid={
418 (row.model.trim() !== "" && errors.has(row.id)) || undefined
419 }
420 list="usage-price-models"
421 placeholder="Model ID"
422 autoComplete="off"
423 spellCheck={false}
424 disabled={locked}
425 onChange={(event) =>
426 updateDraft({ ...row, model: event.target.value })
383427 }
384 }}
385 aria-label="New model ID"
386 aria-invalid={
387 (row.model.trim() !== "" && errors.has(row.id)) || undefined
388 }
389 list="usage-price-models"
390 placeholder="Model ID"
391 autoComplete="off"
392 spellCheck={false}
393 disabled={locked}
394 onChange={(event) =>
395 updateDraft({ ...row, model: event.target.value })
396 }
397 />
428 />
429 ) : (
430 <span
431 className={cn(
432 "block break-all",
433 row.removed && "text-muted-foreground line-through",
434 )}
435 >
436 {row.model}
437 </span>
438 )}
439 {errors.has(row.id) &&
440 (row.model.trim() !== "" ||
441 Object.values(row.values).some((value) => value !== "")) ? (
442 <p role="alert" className="mt-1 text-xs text-destructive">
443 {errors.get(row.id)}
444 </p>
445 ) : null}
446 </TableCell>
447 {row.removed ? (
448 <TableCell colSpan={5}>
449 <span className="text-muted-foreground">
450 Automatic pricing after saving
451 </span>
452 </TableCell>
398453 ) : (
399 <span
400 className={cn(
401 "block break-all",
402 row.removed && "text-muted-foreground line-through",
403 )}
404 >
405 {row.model}
406 </span>
454 <TableCell>
455 <Input
456 size="compact"
457 value={row.alias ?? aliasCell.value}
458 aria-label={`Map ${row.model || "new model"} to model`}
459 list="usage-alias-models"
460 placeholder={row.isNew ? "Optional" : aliasCell.placeholder}
461 autoComplete="off"
462 spellCheck={false}
463 disabled={locked}
464 onChange={(event) => editAlias(row, event.target.value)}
465 />
466 </TableCell>
407467 )}
408 {errors.has(row.id) &&
409 (row.model.trim() !== "" ||
410 Object.values(row.values).some((value) => value !== "")) ? (
411 <p role="alert" className="mt-1 text-xs text-destructive">
412 {errors.get(row.id)}
413 </p>
414 ) : null}
415 </TableCell>
416 {row.removed ? (
417 <TableCell colSpan={4}>
418 <span className="text-muted-foreground">
419 Automatic pricing after saving
420 </span>
421 </TableCell>
422 ) : (
423 USAGE_PRICE_FIELDS.map((field) => {
424 const cell = usagePriceCell(selected, row.model, field.key);
425 return (
426 <TableCell key={field.key}>
427 <Input
428 size="compact"
429 inputMode="decimal"
430 aria-label={`${field.label} price for ${row.model || "new model"}`}
431 value={row.values[field.key] ?? (row.isNew ? "" : cell.value)}
432 placeholder={
433 row.isNew
434 ? field.optional
435 ? "Input rate"
436 : "0.00"
437 : cell.placeholder
438 }
439 autoComplete="off"
440 disabled={locked}
441 font="mono"
442 onChange={(event) =>
443 editCell(row, field.key, event.target.value)
444 }
445 />
446 </TableCell>
447 );
448 })
449 )}
450 <TableCell>
451 <Tooltip>
452 <TooltipTrigger
453 render={<Button size="icon-xs" variant="ghost" />}
454 disabled={locked}
455 aria-label={
456 row.removed
457 ? `Undo reset for ${row.model}`
458 : row.isNew
459 ? "Remove new model"
460 : `Reset price for ${row.model} to automatic`
461 }
462 onClick={() => {
463 if (row.isNew)
464 setDrafts((previous) =>
465 previous.filter((entry) => entry.id !== row.id),
466 );
467 else if (row.removed) {
468 if (Object.keys(row.values).length === 0)
468 {row.removed ? null : alias !== "" ? (
469 <TableCell colSpan={4} className="whitespace-normal">
470 <span className="break-all text-muted-foreground">
471 Counted as {alias}
472 </span>
473 </TableCell>
474 ) : (
475 USAGE_PRICE_FIELDS.map((field) => {
476 const cell = usagePriceCell(selected, row.model, field.key);
477 return (
478 <TableCell key={field.key}>
479 <Input
480 size="compact"
481 inputMode="decimal"
482 aria-label={`${field.label} price for ${row.model || "new model"}`}
483 value={row.values[field.key] ?? (row.isNew ? "" : cell.value)}
484 placeholder={
485 row.isNew
486 ? field.optional
487 ? "Input rate"
488 : "0.00"
489 : cell.placeholder
490 }
491 autoComplete="off"
492 disabled={locked}
493 font="mono"
494 onChange={(event) =>
495 editCell(row, field.key, event.target.value)
496 }
497 />
498 </TableCell>
499 );
500 })
501 )}
502 <TableCell>
503 <Tooltip>
504 <TooltipTrigger
505 render={<Button size="icon-xs" variant="ghost" />}
506 disabled={locked}
507 aria-label={
508 row.removed
509 ? `Undo reset for ${row.model}`
510 : row.isNew
511 ? "Remove new model"
512 : `Reset price for ${row.model} to automatic`
513 }
514 onClick={() => {
515 if (row.isNew)
469516 setDrafts((previous) =>
470517 previous.filter((entry) => entry.id !== row.id),
471518 );
472 else updateDraft({ ...row, removed: false });
473 } else updateDraft({ ...row, removed: true });
474 }}
475 >
476 {row.isNew ? <XIcon aria-hidden /> : <RotateCcwIcon aria-hidden />}
477 </TooltipTrigger>
478 <TooltipPopup>
479 {row.removed
480 ? "Undo reset"
481 : row.isNew
482 ? "Remove row"
483 : "Reset to automatic"}
484 </TooltipPopup>
485 </Tooltip>
486 </TableCell>
487 </TableRow>
488 ))
519 else if (row.removed)
520 updateOrDropDraft({ ...row, removed: false });
521 else updateDraft({ ...row, removed: true });
522 }}
523 >
524 {row.isNew ? (
525 <XIcon aria-hidden />
526 ) : (
527 <RotateCcwIcon aria-hidden />
528 )}
529 </TooltipTrigger>
530 <TooltipPopup>
531 {row.removed
532 ? "Undo reset"
533 : row.isNew
534 ? "Remove row"
535 : "Reset to automatic"}
536 </TooltipPopup>
537 </Tooltip>
538 </TableCell>
539 </TableRow>
540 );
541 })
489542 )}
490543 </TableBody>
491544 </Table>
......@@ -497,8 +550,20 @@ export function UsagePriceOverrides({
497550 <option key={model} value={model} />
498551 ))}
499552 </datalist>
553 <datalist id="usage-alias-models">
554 {models
555 .filter((model) =>
556 selected.every(
557 (environment) => !Object.hasOwn(environment.aliases ?? {}, model),
558 ),
559 )
560 .map((model) => (
561 <option key={model} value={model} />
562 ))}
563 </datalist>
500564 <p className="text-xs text-muted-foreground">
501 Blank cache rates use the input price. Enter 0 for free tokens.
565 Blank cache rates use the input price. Enter 0 for free tokens. A mapped model’s
566 usage counts as the model it maps to.
502567 {selected.length > 1
503568 ? " Mixed cells keep each environment’s rate until you edit them."
504569 : ""}
apps/web/src/components/usage/usagePriceTable.test.ts+58-1
......@@ -1,6 +1,7 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33import {
4 usageAliasCell,
45 usagePriceCell,
56 usagePriceTableChanges,
67 usagePriceTableErrors,
......@@ -9,10 +10,15 @@ import {
910import type { UsagePriceTarget } from "./usagePriceTargets";
1011
1112const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
12const target = (name: string, prices: UsagePriceTarget["prices"]): UsagePriceTarget => ({
13const target = (
14 name: string,
15 prices: UsagePriceTarget["prices"],
16 aliases: UsagePriceTarget["aliases"] = {},
17): UsagePriceTarget => ({
1318 environmentId: EnvironmentId.make(name),
1419 label: name,
1520 prices,
21 aliases,
1622 unavailable: null,
1723});
1824const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({
......@@ -167,4 +173,55 @@ describe("price table edits", () => {
167173 usagePriceTableChanges(environment, [draft({ outputCostPerMillionTokens: "" })]).errors.size,
168174 ).toBe(1);
169175 });
176
177 it("mapping a model drops its own price and resetting removes the mapping", () => {
178 const priced = target("a", { example: price });
179 expect(usagePriceTableChanges(priced, [{ ...draft({}), alias: " example-model " }])).toEqual({
180 changes: [
181 { model: "example", alias: "example-model" },
182 { model: "example", price: null },
183 ],
184 errors: new Map(),
185 });
186 const mapped = target("b", {}, { example: "example-model" });
187 expect(usagePriceTableChanges(mapped, [{ ...draft({}), removed: true }]).changes).toEqual([
188 { model: "example", alias: null },
189 ]);
190 expect(
191 usagePriceTableChanges(mapped, [{ ...draft({}), alias: "example" }]).errors.get(
192 "model:example",
193 ),
194 ).toBe("Map to a different model.");
195 });
196
197 it("clearing a mapping returns to automatic pricing unless prices are entered", () => {
198 const mapped = target("a", {}, { example: "example-model" });
199 expect(usagePriceTableChanges(mapped, [{ ...draft({}), alias: "" }]).changes).toEqual([
200 { model: "example", alias: null },
201 ]);
202 expect(
203 usagePriceTableChanges(mapped, [
204 {
205 ...draft({ inputCostPerMillionTokens: "2", outputCostPerMillionTokens: "8" }),
206 alias: "",
207 },
208 ]).changes,
209 ).toEqual([
210 { model: "example", alias: null },
211 { model: "example", price },
212 ]);
213 });
214
215 it("keeps each environment's mapping when the column is untouched", () => {
216 const edits = [draft({ inputCostPerMillionTokens: "3" })];
217 expect(
218 usagePriceTableChanges(target("a", {}, { example: "example-model" }), edits).changes,
219 ).toEqual([]);
220 expect(
221 usageAliasCell(
222 [target("a", {}, { example: "example-model" }), target("b", { example: price })],
223 "example",
224 ),
225 ).toEqual({ value: "", placeholder: "Mixed" });
226 });
170227});
apps/web/src/components/usage/usagePriceTable.ts+38
......@@ -13,6 +13,8 @@ export interface UsagePriceDraft {
1313 readonly model: string;
1414 readonly isNew: boolean;
1515 readonly values: Partial<Pick<UsagePriceForm, UsagePriceField>>;
16 /** Edited "Map to" model. Absent keeps each environment's mapping; blank removes it. */
17 readonly alias?: string;
1618 readonly removed?: boolean;
1719}
1820
......@@ -20,6 +22,7 @@ export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) {
2022 return (
2123 draft.isNew &&
2224 draft.model.trim() === "" &&
25 (draft.alias ?? "").trim() === "" &&
2326 Object.values(draft.values).every((value) => value.trim() === "")
2427 );
2528}
......@@ -28,6 +31,19 @@ function modelPrice(target: UsagePriceTarget, model: string) {
2831 return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;
2932}
3033
34function modelAlias(target: UsagePriceTarget, model: string) {
35 return target.aliases && Object.hasOwn(target.aliases, model) ? target.aliases[model] : undefined;
36}
37
38/** The "Map to" cell across the selected environments, read like a price cell. */
39export function usageAliasCell(targets: readonly UsagePriceTarget[], model: string) {
40 if (targets.some((target) => target.aliases === null))
41 return { value: "", placeholder: "Unavailable" };
42 const values = targets.map((target) => modelAlias(target, model) ?? "");
43 if (values.some((value) => value !== values[0])) return { value: "", placeholder: "Mixed" };
44 return { value: values[0] ?? "", placeholder: "None" };
45}
46
3147export function usagePriceCell(
3248 targets: readonly UsagePriceTarget[],
3349 model: string,
......@@ -56,10 +72,32 @@ export function usagePriceTableChanges(
5672 for (const draft of drafts) {
5773 if (isEmptyUsagePriceDraft(draft)) continue;
5874 const model = draft.model.trim();
75 const currentAlias = modelAlias(target, model);
5976 if (draft.removed) {
6077 if (modelPrice(target, model)) changes.push({ model, price: null });
78 if (currentAlias !== undefined) changes.push({ model, alias: null });
79 continue;
80 }
81 // A mapped model is priced as its target, so mapping it drops its own price.
82 const alias = (draft.alias ?? currentAlias ?? "").trim();
83 if (alias !== "") {
84 if (model === "") errors.set(draft.id, "Enter a model ID.");
85 else if (alias === model) errors.set(draft.id, "Map to a different model.");
86 else {
87 if (alias !== currentAlias) changes.push({ model, alias });
88 if (modelPrice(target, model)) changes.push({ model, price: null });
89 }
6190 continue;
6291 }
92 if (currentAlias !== undefined) {
93 changes.push({ model, alias: null });
94 // Clearing a mapping without entering prices returns to automatic pricing.
95 if (
96 !modelPrice(target, model) &&
97 Object.values(draft.values).every((value) => value.trim() === "")
98 )
99 continue;
100 }
63101 const original = usagePriceForm(model, modelPrice(target, model));
64102 const form = { ...original, ...draft.values };
65103 const parsed = parseUsagePriceForm(form);
apps/web/src/components/usage/usagePriceTargets.test.ts+32
......@@ -12,6 +12,7 @@ const target = (id: string, overrides: Partial<UsagePriceTarget> = {}): UsagePri
1212 environmentId: EnvironmentId.make(id),
1313 label: id,
1414 prices: { example: price },
15 aliases: {},
1516 unavailable: null,
1617 ...overrides,
1718});
......@@ -140,4 +141,35 @@ describe("model price writes", () => {
140141 error: "Update server to edit prices",
141142 });
142143 });
144
145 it("sends mappings beside prices and fails servers that cannot store them", async () => {
146 const write = vi.fn(async () => ({ _tag: "Success" as const }));
147 const onResult = vi.fn();
148 const changes = [
149 { model: "example", alias: "example-model" },
150 { model: "example", price: null },
151 ];
152 await writeUsagePrices({
153 targets: [target("current"), target("old", { aliases: null })],
154 changes: new Map([
155 [EnvironmentId.make("current"), changes],
156 [EnvironmentId.make("old"), changes],
157 ]),
158 write,
159 onResult,
160 });
161 expect(write).toHaveBeenCalledExactlyOnceWith({
162 environmentId: "current",
163 input: {
164 patch: {
165 usagePriceOverrides: { example: null },
166 usageModelAliases: { example: "example-model" },
167 },
168 },
169 });
170 expect(onResult).toHaveBeenCalledWith("old", {
171 status: "failed",
172 error: "Update server to map models",
173 });
174 });
143175});
apps/web/src/components/usage/usagePriceTargets.ts+25-9
......@@ -8,19 +8,24 @@ export interface UsagePriceTarget {
88 readonly environmentId: EnvironmentId;
99 readonly label: string;
1010 readonly prices: Readonly<Record<string, UsageModelPriceOverride>> | null;
11 /** Model mappings. `null` when they are not loaded or the server cannot store them. */
12 readonly aliases: Readonly<Record<string, string>> | null;
1113 readonly unavailable: string | null;
1214}
1315
14export interface UsagePriceChange {
15 readonly model: string;
16 readonly price: UsageModelPriceOverride | null;
17}
16/** `null` removes the model's custom price or mapping. */
17export type UsagePriceChange =
18 | { readonly model: string; readonly price: UsageModelPriceOverride | null }
19 | { readonly model: string; readonly alias: string | null };
1820
1921export type UsagePriceWriteResult =
2022 | { readonly status: "saved" }
2123 | { readonly status: "failed"; readonly error: string };
2224
23/** Each destination settles independently; retry callers pass only the failed destinations. */
25/**
26 * Each destination settles independently; retry callers pass only the failed destinations.
27 * Prices and mappings for one destination go out in a single patch.
28 */
2429export async function writeUsagePrices(input: {
2530 readonly targets: readonly UsagePriceTarget[];
2631 readonly changes: ReadonlyMap<EnvironmentId, readonly UsagePriceChange[]>;
......@@ -33,11 +38,19 @@ export async function writeUsagePrices(input: {
3338 await Promise.all(
3439 input.targets.map(async (target) => {
3540 let result: UsagePriceWriteResult;
41 const changes = input.changes.get(target.environmentId) ?? [];
42 const prices = changes.flatMap((change) =>
43 "price" in change ? [[change.model, change.price] as const] : [],
44 );
45 const aliases = changes.flatMap((change) =>
46 "alias" in change ? [[change.model, change.alias] as const] : [],
47 );
3648 if (target.unavailable !== null) {
3749 result = { status: "failed", error: target.unavailable };
50 } else if (aliases.length > 0 && target.aliases === null) {
51 result = { status: "failed", error: "Update server to map models" };
3852 } else {
3953 try {
40 const changes = input.changes.get(target.environmentId) ?? [];
4154 const saved =
4255 changes.length === 0
4356 ? { _tag: "Success" as const }
......@@ -45,9 +58,12 @@ export async function writeUsagePrices(input: {
4558 environmentId: target.environmentId,
4659 input: {
4760 patch: {
48 usagePriceOverrides: Object.fromEntries(
49 changes.map((change) => [change.model, change.price]),
50 ),
61 ...(prices.length > 0
62 ? { usagePriceOverrides: Object.fromEntries(prices) }
63 : {}),
64 ...(aliases.length > 0
65 ? { usageModelAliases: Object.fromEntries(aliases) }
66 : {}),
5167 },
5268 },
5369 });
docs/user/usage.md+5
......@@ -61,6 +61,11 @@ edited rows. Untouched cells keep each environment's rate. Select one environmen
6161prices. **Reset to automatic** marks a model's override for removal when you save; you can undo
6262it before saving.
6363
64To count one model as another, such as a preview model under its released name, enter the target
65model ID under **Map to**. The mapped model no longer appears on **Usage**: its tokens and cost
66move to the target model and use the target's price. Clear **Map to** or reset the row to show
67the model on its own again.
68
6469Each destination reports whether the change saved. Offline or unavailable environments are
6570marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change
6671without writing again to environments that already saved. Changes are not queued after you close
packages/client-runtime/src/state/server.ts+10-5
......@@ -936,12 +936,17 @@ export function createServerEnvironmentAtoms<R, E>(
936936 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),
937937 );
938938 const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) =>
939 Atom.make((get) =>
940 JSON.stringify([
939 Atom.make((get) => {
940 const settings = get(settingsValueAtom(environmentId));
941 const aliases = settings?.usageModelAliases ?? {};
942 return JSON.stringify([
941943 get(usagePricesAtom(environmentId)),
942 get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false,
943 ]),
944 ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),
944 Object.keys(aliases)
945 .sort()
946 .map((model) => [model, aliases[model]]),
947 settings?.cursorKeychainUsageEnabled ?? false,
948 ]);
949 }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),
945950 );
946951 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
947952 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
packages/contracts/src/environment.ts+2
......@@ -136,6 +136,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
136136 usageLimitSources: Schema.optionalKey(Schema.Boolean),
137137 /** Server persists custom model rates and applies them to usage summaries. */
138138 usagePriceOverrides: Schema.optionalKey(Schema.Boolean),
139 /** Server persists model mappings and folds mapped usage into the target model. */
140 usageModelAliases: Schema.optionalKey(Schema.Boolean),
139141 /** Server understands thread.pin / thread.unpin commands. Same
140142 version-skew contract as threadSettlement. */
141143 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.ts+11
......@@ -1404,6 +1404,13 @@ export const ServerSettings = Schema.Struct({
14041404 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
14051405 Schema.withDecodingDefault(Effect.succeed({})),
14061406 ),
1407 /**
1408 * Exact model ID to the model its usage counts as, such as a preview slug to
1409 * its released name. The mapped model is priced and reported as its target.
1410 */
1411 usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe(
1412 Schema.withDecodingDefault(Effect.succeed({})),
1413 ),
14071414});
14081415export type ServerSettings = typeof ServerSettings.Type;
14091416
......@@ -1700,6 +1707,10 @@ export const ServerSettingsPatch = Schema.Struct({
17001707 usagePriceOverrides: Schema.optionalKey(
17011708 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
17021709 ),
1710 /** Each entry replaces one model's mapping; `null` removes it. */
1711 usageModelAliases: Schema.optionalKey(
1712 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)),
1713 ),
17031714});
17041715export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
17051716
packages/shared/src/serverSettings.ts+9
......@@ -280,6 +280,7 @@ export function applyServerSettingsPatch(
280280 // Merged per entry below; its `null` removals must not reach deepMerge.
281281 usageLimitSources: usageLimitSourcesPatch,
282282 usagePriceOverrides: usagePriceOverridesPatch,
283 usageModelAliases: usageModelAliasesPatch,
283284 // Entry replacement: deepMerge would keep keys the client meant to clear.
284285 projectSettingsOverrides: projectSettingsOverridesPatch,
285286 // Already translated into `projectSettingsOverrides` above; the legacy
......@@ -394,6 +395,14 @@ export function applyServerSettingsPatch(
394395 ),
395396 }
396397 : {}),
398 ...(usageModelAliasesPatch !== undefined
399 ? {
400 usageModelAliases: mergeSettingsEntries(
401 current.usageModelAliases,
402 usageModelAliasesPatch,
403 ),
404 }
405 : {}),
397406 ...(patch.sourceControlWriterModelSelection !== undefined
398407 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
399408 : {}),