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* () {...@@ -233,6 +233,7 @@ export const make = Effect.gen(function* () {
233 environmentThemes: true,233 environmentThemes: true,
234 usageLimitSources: true,234 usageLimitSources: true,
235 usagePriceOverrides: true,235 usagePriceOverrides: true,
236 usageModelAliases: true,
236 threadPinning: true,237 threadPinning: true,
237 threadPinReorder: true,238 threadPinReorder: true,
238 threadActiveReorder: true,239 threadActiveReorder: true,
apps/server/src/usage/UsageService.ts+8-10
...@@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn...@@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn
53import { readOpenCodeUsage } from "./opencodeUsageReader.ts";53import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
54import { readAntigravityUsage } from "./antigravityUsageReader.ts";54import { readAntigravityUsage } from "./antigravityUsageReader.ts";
55import { readCursorAccountUsage } from "./cursorUsageReader.ts";55import { readCursorAccountUsage } from "./cursorUsageReader.ts";
56import { UsageAggregator } from "./usageAggregation.ts";56import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
57import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";57import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
58import {58import {
59 listTranscriptFiles,59 listTranscriptFiles,
...@@ -821,6 +821,7 @@ export const make = Effect.gen(function* () {...@@ -821,6 +821,7 @@ export const make = Effect.gen(function* () {
821 ...hourlyWindow,821 ...hourlyWindow,
822 rates,822 rates,
823 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),823 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
824 modelAliases: resolveModelAliases(settings.usageModelAliases),
824 });825 });
825826
826 const sources: UsageSource[] = [];827 const sources: UsageSource[] = [];
...@@ -925,17 +926,13 @@ export const make = Effect.gen(function* () {...@@ -925,17 +926,13 @@ export const make = Effect.gen(function* () {
925 });926 });
926927
927 /**928 /**
928 * In-flight scans by window and custom prices, so concurrent identical requests (the usage929 * In-flight scans by window and usage settings, so concurrent identical requests (the usage
929 * page open on two clients at once) share one scan instead of racing over930 * page open on two clients at once) share one scan instead of racing over
930 * the same corpus twice.931 * the same corpus twice.
931 */932 */
932 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();933 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();
933934
934 const scanKey = (935 const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string =>
935 input: UsageSummaryInput,
936 priceOverrides: ServerSettingsValue["usagePriceOverrides"],
937 cursorKeychainUsageEnabled: boolean,
938 ): string =>
939 JSON.stringify([936 JSON.stringify([
940 input.timeZone,937 input.timeZone,
941 input.sinceDay,938 input.sinceDay,
...@@ -943,13 +940,14 @@ export const make = Effect.gen(function* () {...@@ -943,13 +940,14 @@ export const make = Effect.gen(function* () {
943 input.resolution ?? "day",940 input.resolution ?? "day",
944 input.sinceTime ?? null,941 input.sinceTime ?? null,
945 input.untilTime ?? null,942 input.untilTime ?? null,
946 priceOverrides,943 settings.usagePriceOverrides,
947 cursorKeychainUsageEnabled,944 settings.usageModelAliases,
945 settings.cursorKeychainUsageEnabled,
948 ]);946 ]);
949947
950 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {948 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
951 const settings = yield* readSettings;949 const settings = yield* readSettings;
952 const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled);950 const key = scanKey(input, settings);
953 const deferred = yield* Effect.uninterruptible(951 const deferred = yield* Effect.uninterruptible(
954 Effect.gen(function* () {952 Effect.gen(function* () {
955 const existing = inflightScans.get(key);953 const existing = inflightScans.get(key);
apps/server/src/usage/usageAggregation.test.ts+40-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { UsageAggregator } from "./usageAggregation.ts";3import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
4import type { RateTable } from "./usagePricing.ts";4import type { RateTable } from "./usagePricing.ts";
5import type { UsageRecord } from "./usageTranscripts.ts";5import type { UsageRecord } from "./usageTranscripts.ts";
66
...@@ -253,6 +253,25 @@ describe("UsageAggregator", () => {...@@ -253,6 +253,25 @@ describe("UsageAggregator", () => {
253 expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);253 expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);
254 });254 });
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
256 it("separates providers and models into their own buckets", () => {275 it("separates providers and models into their own buckets", () => {
257 const result = aggregate([276 const result = aggregate([
258 record(),277 record(),
...@@ -263,3 +282,23 @@ describe("UsageAggregator", () => {...@@ -263,3 +282,23 @@ describe("UsageAggregator", () => {
263 expect(result.buckets).toHaveLength(3);282 expect(result.buckets).toHaveLength(3);
264 });283 });
265});284});
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 {...@@ -89,11 +89,35 @@ export interface AggregateOptions {
89 readonly untilDay: string;89 readonly untilDay: string;
90 readonly rates: RateTable;90 readonly rates: RateTable;
91 readonly priceOverrides?: RateTable;91 readonly priceOverrides?: RateTable;
92 /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */
93 readonly modelAliases?: ReadonlyMap<string, string>;
92 readonly resolution?: UsageResolution;94 readonly resolution?: UsageResolution;
93 readonly sinceTimeMs?: number;95 readonly sinceTimeMs?: number;
94 readonly untilTimeMs?: number;96 readonly untilTimeMs?: number;
95}97}
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
97export interface AggregateResult {121export interface AggregateResult {
98 readonly buckets: readonly UsageBucket[];122 readonly buckets: readonly UsageBucket[];
99 /** Records dropped because an earlier record carried the same dedupe key. */123 /** Records dropped because an earlier record carried the same dedupe key. */
...@@ -147,7 +171,8 @@ export class UsageAggregator {...@@ -147,7 +171,8 @@ export class UsageAggregator {
147 * can derive per-window facts (distinct sessions, for one) from the records171 * can derive per-window facts (distinct sessions, for one) from the records
148 * that landed rather than everything the mtime prefilter happened to admit.172 * that landed rather than everything the mtime prefilter happened to admit.
149 */173 */
150 add(record: UsageRecord, sourcePath?: string): boolean {174 add(input: UsageRecord, sourcePath?: string): boolean {
175 const record = this.#mapModel(input);
151 if (record.dedupeKey !== null) {176 if (record.dedupeKey !== null) {
152 if (this.#seen.has(record.dedupeKey)) {177 if (this.#seen.has(record.dedupeKey)) {
153 this.#duplicatesDropped += 1;178 this.#duplicatesDropped += 1;
...@@ -217,6 +242,14 @@ export class UsageAggregator {...@@ -217,6 +242,14 @@ export class UsageAggregator {
217 return true;242 return true;
218 }243 }
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
220 /**253 /**
221 * Records arrive file by file in time order, so most land in the bucket the254 * Records arrive file by file in time order, so most land in the bucket the
222 * previous record used. Checking that first skips building and hashing a255 * 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";...@@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
34import { USAGE_PRICE_FIELDS } from "./usagePriceForm";34import { USAGE_PRICE_FIELDS } from "./usagePriceForm";
35import {35import {
36 isEmptyUsagePriceDraft,36 isEmptyUsagePriceDraft,
37 usageAliasCell,
37 usagePriceCell,38 usagePriceCell,
38 usagePriceTableChanges,39 usagePriceTableChanges,
39 usagePriceTableErrors,40 usagePriceTableErrors,
...@@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>...@@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
63 environmentId,64 environmentId,
64 label: environment.entry.target.label,65 label: environment.entry.target.label,
65 prices: settings?.usagePriceOverrides ?? null,66 prices: settings?.usagePriceOverrides ?? null,
67 aliases:
68 environment.serverConfig?.environment.capabilities.usageModelAliases === true
69 ? (settings?.usageModelAliases ?? null)
70 : null,
66 unavailable:71 unavailable:
67 environment.connection.phase !== "connected"72 environment.connection.phase !== "connected"
68 ? "Offline"73 ? "Offline"
...@@ -91,7 +96,10 @@ type SaveAttempt = {...@@ -91,7 +96,10 @@ type SaveAttempt = {
91 >;96 >;
92};97};
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 */
95export function UsagePriceOverrides({103export function UsagePriceOverrides({
96 usage,104 usage,
97 initialSelectedEnvironmentIds,105 initialSelectedEnvironmentIds,
...@@ -108,12 +116,13 @@ export function UsagePriceOverrides({...@@ -108,12 +116,13 @@ export function UsagePriceOverrides({
108 const selected = environments.filter(116 const selected = environments.filter(
109 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),117 (environment) => selectedIds === null || selectedIds.has(environment.environmentId),
110 );118 );
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.
112 const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>(() =>120 const [drafts, setDrafts] = useState<readonly UsagePriceDraft[]>(() =>
113 initialModel === undefined ||121 initialModel === undefined ||
114 selected.some(122 selected.some(
115 (environment) =>123 (environment) =>
116 environment.prices !== null && Object.hasOwn(environment.prices, initialModel),124 Object.hasOwn(environment.prices ?? {}, initialModel) ||
125 Object.hasOwn(environment.aliases ?? {}, initialModel),
117 )126 )
118 ? []127 ? []
119 : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }],128 : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }],
...@@ -124,7 +133,12 @@ export function UsagePriceOverrides({...@@ -124,7 +133,12 @@ export function UsagePriceOverrides({
124 const nextRowId = useRef(0);133 const nextRowId = useRef(0);
125 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });134 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
126 const customModels = [135 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 ),
128 ].sort();142 ].sort();
129 const models = [143 const models = [
130 ...new Set([144 ...new Set([
...@@ -191,19 +205,31 @@ export function UsagePriceOverrides({...@@ -191,19 +205,31 @@ export function UsagePriceOverrides({
191 );205 );
192 setAttempt(null);206 setAttempt(null);
193 };207 };
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 };
194 const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => {223 const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => {
195 const values = { ...row.values, [field]: value };224 const values = { ...row.values, [field]: value };
196 const original = usagePriceCell(selected, row.model, field);225 const original = usagePriceCell(selected, row.model, field);
197 if (226 if (matchesSaved(row, original, value)) delete values[field];
198 !row.isNew &&227 updateOrDropDraft({ ...row, values });
199 original.placeholder !== "Mixed" &&228 };
200 original.placeholder !== "Unavailable" &&229 const editAlias = (row: UsagePriceDraft, value: string) => {
201 value === original.value230 const { alias: _alias, ...rest } = row;
202 )231 const original = usageAliasCell(selected, row.model);
203 delete values[field];232 updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value });
204 if (!row.isNew && Object.keys(values).length === 0)
205 setDrafts((previous) => previous.filter((entry) => entry.id !== row.id));
206 else updateDraft({ ...row, values });
207 };233 };
208 const save = async (retry = false) => {234 const save = async (retry = false) => {
209 if (pending) return;235 if (pending) return;
...@@ -214,7 +240,7 @@ export function UsagePriceOverrides({...@@ -214,7 +240,7 @@ export function UsagePriceOverrides({
214 (destination) =>240 (destination) =>
215 environments.find(241 environments.find(
216 (environment) => environment.environmentId === destination.environmentId,242 (environment) => environment.environmentId === destination.environmentId,
217 ) ?? { ...destination, prices: null, unavailable: "Environment removed" },243 ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" },
218 );244 );
219 const changes = new Map(245 const changes = new Map(
220 targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]),246 targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]),
...@@ -257,11 +283,11 @@ export function UsagePriceOverrides({...@@ -257,11 +283,11 @@ export function UsagePriceOverrides({
257 if (!pending) onOpenChange(open);283 if (!pending) onOpenChange(open);
258 }}284 }}
259 >285 >
260 <DialogPopup className="max-w-3xl">286 <DialogPopup className="max-w-4xl">
261 <DialogHeader>287 <DialogHeader>
262 <DialogTitle>Custom model prices</DialogTitle>288 <DialogTitle>Custom model prices</DialogTitle>
263 <DialogDescription>289 <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.
265 </DialogDescription>291 </DialogDescription>
266 </DialogHeader>292 </DialogHeader>
267 <DialogPanel>293 <DialogPanel>
...@@ -326,9 +352,10 @@ export function UsagePriceOverrides({...@@ -326,9 +352,10 @@ export function UsagePriceOverrides({
326 ) : (352 ) : (
327 <>353 <>
328 <div className="min-w-0 overflow-hidden rounded-lg border border-border">354 <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">
330 <colgroup>356 <colgroup>
331 <col className="w-[34%]" />357 <col className="w-[22%]" />
358 <col className="w-[18%]" />
332 {USAGE_PRICE_FIELDS.map((field) => (359 {USAGE_PRICE_FIELDS.map((field) => (
333 <col key={field.key} />360 <col key={field.key} />
334 ))}361 ))}
...@@ -337,6 +364,7 @@ export function UsagePriceOverrides({...@@ -337,6 +364,7 @@ export function UsagePriceOverrides({
337 <TableHeader>364 <TableHeader>
338 <TableRow>365 <TableRow>
339 <TableHead>Model ID</TableHead>366 <TableHead>Model ID</TableHead>
367 <TableHead>Map to</TableHead>
340 {USAGE_PRICE_FIELDS.map((field) => (368 {USAGE_PRICE_FIELDS.map((field) => (
341 <TableHead key={field.key}>{field.label}</TableHead>369 <TableHead key={field.key}>{field.label}</TableHead>
342 ))}370 ))}
...@@ -360,132 +388,157 @@ export function UsagePriceOverrides({...@@ -360,132 +388,157 @@ export function UsagePriceOverrides({
360 <TableBody>388 <TableBody>
361 {rows.length === 0 ? (389 {rows.length === 0 ? (
362 <TableRow>390 <TableRow>
363 <TableCell colSpan={6} className="text-center whitespace-normal">391 <TableCell colSpan={7} className="text-center whitespace-normal">
364 <p className="py-6 text-muted-foreground">392 <p className="py-6 text-muted-foreground">
365 {selected.some((environment) => environment.prices === null)393 {selected.some((environment) => environment.prices === null)
366 ? "Some environment prices are unavailable."394 ? "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."}
368 </p>396 </p>
369 </TableCell>397 </TableCell>
370 </TableRow>398 </TableRow>
371 ) : (399 ) : (
372 rows.map((row) => (400 rows.map((row) => {
373 <TableRow key={row.id} data-row-id={row.id}>401 const aliasCell = usageAliasCell(selected, row.model);
374 <TableCell className="whitespace-normal">402 const alias = (row.alias ?? aliasCell.value).trim();
375 {row.isNew ? (403 return (
376 <Input404 <TableRow key={row.id} data-row-id={row.id}>
377 size="compact"405 <TableCell className="whitespace-normal">
378 value={row.model}406 {row.isNew ? (
379 ref={(node) => {407 <Input
380 if (node && focusRowRef.current === row.id) {408 size="compact"
381 node.focus();409 value={row.model}
382 focusRowRef.current = null;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 })
383 }427 }
384 }}428 />
385 aria-label="New model ID"429 ) : (
386 aria-invalid={430 <span
387 (row.model.trim() !== "" && errors.has(row.id)) || undefined431 className={cn(
388 }432 "block break-all",
389 list="usage-price-models"433 row.removed && "text-muted-foreground line-through",
390 placeholder="Model ID"434 )}
391 autoComplete="off"435 >
392 spellCheck={false}436 {row.model}
393 disabled={locked}437 </span>
394 onChange={(event) =>438 )}
395 updateDraft({ ...row, model: event.target.value })439 {errors.has(row.id) &&
396 }440 (row.model.trim() !== "" ||
397 />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>
398 ) : (453 ) : (
399 <span454 <TableCell>
400 className={cn(455 <Input
401 "block break-all",456 size="compact"
402 row.removed && "text-muted-foreground line-through",457 value={row.alias ?? aliasCell.value}
403 )}458 aria-label={`Map ${row.model || "new model"} to model`}
404 >459 list="usage-alias-models"
405 {row.model}460 placeholder={row.isNew ? "Optional" : aliasCell.placeholder}
406 </span>461 autoComplete="off"
462 spellCheck={false}
463 disabled={locked}
464 onChange={(event) => editAlias(row, event.target.value)}
465 />
466 </TableCell>
407 )}467 )}
408 {errors.has(row.id) &&468 {row.removed ? null : alias !== "" ? (
409 (row.model.trim() !== "" ||469 <TableCell colSpan={4} className="whitespace-normal">
410 Object.values(row.values).some((value) => value !== "")) ? (470 <span className="break-all text-muted-foreground">
411 <p role="alert" className="mt-1 text-xs text-destructive">471 Counted as {alias}
412 {errors.get(row.id)}472 </span>
413 </p>473 </TableCell>
414 ) : null}474 ) : (
415 </TableCell>475 USAGE_PRICE_FIELDS.map((field) => {
416 {row.removed ? (476 const cell = usagePriceCell(selected, row.model, field.key);
417 <TableCell colSpan={4}>477 return (
418 <span className="text-muted-foreground">478 <TableCell key={field.key}>
419 Automatic pricing after saving479 <Input
420 </span>480 size="compact"
421 </TableCell>481 inputMode="decimal"
422 ) : (482 aria-label={`${field.label} price for ${row.model || "new model"}`}
423 USAGE_PRICE_FIELDS.map((field) => {483 value={row.values[field.key] ?? (row.isNew ? "" : cell.value)}
424 const cell = usagePriceCell(selected, row.model, field.key);484 placeholder={
425 return (485 row.isNew
426 <TableCell key={field.key}>486 ? field.optional
427 <Input487 ? "Input rate"
428 size="compact"488 : "0.00"
429 inputMode="decimal"489 : cell.placeholder
430 aria-label={`${field.label} price for ${row.model || "new model"}`}490 }
431 value={row.values[field.key] ?? (row.isNew ? "" : cell.value)}491 autoComplete="off"
432 placeholder={492 disabled={locked}
433 row.isNew493 font="mono"
434 ? field.optional494 onChange={(event) =>
435 ? "Input rate"495 editCell(row, field.key, event.target.value)
436 : "0.00"496 }
437 : cell.placeholder497 />
438 }498 </TableCell>
439 autoComplete="off"499 );
440 disabled={locked}500 })
441 font="mono"501 )}
442 onChange={(event) =>502 <TableCell>
443 editCell(row, field.key, event.target.value)503 <Tooltip>
444 }504 <TooltipTrigger
445 />505 render={<Button size="icon-xs" variant="ghost" />}
446 </TableCell>506 disabled={locked}
447 );507 aria-label={
448 })508 row.removed
449 )}509 ? `Undo reset for ${row.model}`
450 <TableCell>510 : row.isNew
451 <Tooltip>511 ? "Remove new model"
452 <TooltipTrigger512 : `Reset price for ${row.model} to automatic`
453 render={<Button size="icon-xs" variant="ghost" />}513 }
454 disabled={locked}514 onClick={() => {
455 aria-label={515 if (row.isNew)
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)
469 setDrafts((previous) =>516 setDrafts((previous) =>
470 previous.filter((entry) => entry.id !== row.id),517 previous.filter((entry) => entry.id !== row.id),
471 );518 );
472 else updateDraft({ ...row, removed: false });519 else if (row.removed)
473 } else updateDraft({ ...row, removed: true });520 updateOrDropDraft({ ...row, removed: false });
474 }}521 else updateDraft({ ...row, removed: true });
475 >522 }}
476 {row.isNew ? <XIcon aria-hidden /> : <RotateCcwIcon aria-hidden />}523 >
477 </TooltipTrigger>524 {row.isNew ? (
478 <TooltipPopup>525 <XIcon aria-hidden />
479 {row.removed526 ) : (
480 ? "Undo reset"527 <RotateCcwIcon aria-hidden />
481 : row.isNew528 )}
482 ? "Remove row"529 </TooltipTrigger>
483 : "Reset to automatic"}530 <TooltipPopup>
484 </TooltipPopup>531 {row.removed
485 </Tooltip>532 ? "Undo reset"
486 </TableCell>533 : row.isNew
487 </TableRow>534 ? "Remove row"
488 ))535 : "Reset to automatic"}
536 </TooltipPopup>
537 </Tooltip>
538 </TableCell>
539 </TableRow>
540 );
541 })
489 )}542 )}
490 </TableBody>543 </TableBody>
491 </Table>544 </Table>
...@@ -497,8 +550,20 @@ export function UsagePriceOverrides({...@@ -497,8 +550,20 @@ export function UsagePriceOverrides({
497 <option key={model} value={model} />550 <option key={model} value={model} />
498 ))}551 ))}
499 </datalist>552 </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>
500 <p className="text-xs text-muted-foreground">564 <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.
502 {selected.length > 1567 {selected.length > 1
503 ? " Mixed cells keep each environment’s rate until you edit them."568 ? " Mixed cells keep each environment’s rate until you edit them."
504 : ""}569 : ""}
apps/web/src/components/usage/usagePriceTable.test.ts+58-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 usageAliasCell,
4 usagePriceCell,5 usagePriceCell,
5 usagePriceTableChanges,6 usagePriceTableChanges,
6 usagePriceTableErrors,7 usagePriceTableErrors,
...@@ -9,10 +10,15 @@ import {...@@ -9,10 +10,15 @@ import {
9import type { UsagePriceTarget } from "./usagePriceTargets";10import type { UsagePriceTarget } from "./usagePriceTargets";
1011
11const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };12const 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 => ({
13 environmentId: EnvironmentId.make(name),18 environmentId: EnvironmentId.make(name),
14 label: name,19 label: name,
15 prices,20 prices,
21 aliases,
16 unavailable: null,22 unavailable: null,
17});23});
18const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({24const draft = (values: UsagePriceDraft["values"]): UsagePriceDraft => ({
...@@ -167,4 +173,55 @@ describe("price table edits", () => {...@@ -167,4 +173,55 @@ describe("price table edits", () => {
167 usagePriceTableChanges(environment, [draft({ outputCostPerMillionTokens: "" })]).errors.size,173 usagePriceTableChanges(environment, [draft({ outputCostPerMillionTokens: "" })]).errors.size,
168 ).toBe(1);174 ).toBe(1);
169 });175 });
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 });
170});227});
apps/web/src/components/usage/usagePriceTable.ts+38
...@@ -13,6 +13,8 @@ export interface UsagePriceDraft {...@@ -13,6 +13,8 @@ export interface UsagePriceDraft {
13 readonly model: string;13 readonly model: string;
14 readonly isNew: boolean;14 readonly isNew: boolean;
15 readonly values: Partial<Pick<UsagePriceForm, UsagePriceField>>;15 readonly values: Partial<Pick<UsagePriceForm, UsagePriceField>>;
16 /** Edited "Map to" model. Absent keeps each environment's mapping; blank removes it. */
17 readonly alias?: string;
16 readonly removed?: boolean;18 readonly removed?: boolean;
17}19}
1820
...@@ -20,6 +22,7 @@ export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) {...@@ -20,6 +22,7 @@ export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) {
20 return (22 return (
21 draft.isNew &&23 draft.isNew &&
22 draft.model.trim() === "" &&24 draft.model.trim() === "" &&
25 (draft.alias ?? "").trim() === "" &&
23 Object.values(draft.values).every((value) => value.trim() === "")26 Object.values(draft.values).every((value) => value.trim() === "")
24 );27 );
25}28}
...@@ -28,6 +31,19 @@ function modelPrice(target: UsagePriceTarget, model: string) {...@@ -28,6 +31,19 @@ function modelPrice(target: UsagePriceTarget, model: string) {
28 return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;31 return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;
29}32}
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
31export function usagePriceCell(47export function usagePriceCell(
32 targets: readonly UsagePriceTarget[],48 targets: readonly UsagePriceTarget[],
33 model: string,49 model: string,
...@@ -56,10 +72,32 @@ export function usagePriceTableChanges(...@@ -56,10 +72,32 @@ export function usagePriceTableChanges(
56 for (const draft of drafts) {72 for (const draft of drafts) {
57 if (isEmptyUsagePriceDraft(draft)) continue;73 if (isEmptyUsagePriceDraft(draft)) continue;
58 const model = draft.model.trim();74 const model = draft.model.trim();
75 const currentAlias = modelAlias(target, model);
59 if (draft.removed) {76 if (draft.removed) {
60 if (modelPrice(target, model)) changes.push({ model, price: null });77 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 }
61 continue;90 continue;
62 }91 }
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 }
63 const original = usagePriceForm(model, modelPrice(target, model));101 const original = usagePriceForm(model, modelPrice(target, model));
64 const form = { ...original, ...draft.values };102 const form = { ...original, ...draft.values };
65 const parsed = parseUsagePriceForm(form);103 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...@@ -12,6 +12,7 @@ const target = (id: string, overrides: Partial<UsagePriceTarget> = {}): UsagePri
12 environmentId: EnvironmentId.make(id),12 environmentId: EnvironmentId.make(id),
13 label: id,13 label: id,
14 prices: { example: price },14 prices: { example: price },
15 aliases: {},
15 unavailable: null,16 unavailable: null,
16 ...overrides,17 ...overrides,
17});18});
...@@ -140,4 +141,35 @@ describe("model price writes", () => {...@@ -140,4 +141,35 @@ describe("model price writes", () => {
140 error: "Update server to edit prices",141 error: "Update server to edit prices",
141 });142 });
142 });143 });
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 });
143});175});
apps/web/src/components/usage/usagePriceTargets.ts+25-9
...@@ -8,19 +8,24 @@ export interface UsagePriceTarget {...@@ -8,19 +8,24 @@ export interface UsagePriceTarget {
8 readonly environmentId: EnvironmentId;8 readonly environmentId: EnvironmentId;
9 readonly label: string;9 readonly label: string;
10 readonly prices: Readonly<Record<string, UsageModelPriceOverride>> | null;10 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;
11 readonly unavailable: string | null;13 readonly unavailable: string | null;
12}14}
1315
14export interface UsagePriceChange {16/** `null` removes the model's custom price or mapping. */
15 readonly model: string;17export type UsagePriceChange =
16 readonly price: UsageModelPriceOverride | null;18 | { readonly model: string; readonly price: UsageModelPriceOverride | null }
17}19 | { readonly model: string; readonly alias: string | null };
1820
19export type UsagePriceWriteResult =21export type UsagePriceWriteResult =
20 | { readonly status: "saved" }22 | { readonly status: "saved" }
21 | { readonly status: "failed"; readonly error: string };23 | { 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 */
24export async function writeUsagePrices(input: {29export async function writeUsagePrices(input: {
25 readonly targets: readonly UsagePriceTarget[];30 readonly targets: readonly UsagePriceTarget[];
26 readonly changes: ReadonlyMap<EnvironmentId, readonly UsagePriceChange[]>;31 readonly changes: ReadonlyMap<EnvironmentId, readonly UsagePriceChange[]>;
...@@ -33,11 +38,19 @@ export async function writeUsagePrices(input: {...@@ -33,11 +38,19 @@ export async function writeUsagePrices(input: {
33 await Promise.all(38 await Promise.all(
34 input.targets.map(async (target) => {39 input.targets.map(async (target) => {
35 let result: UsagePriceWriteResult;40 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 );
36 if (target.unavailable !== null) {48 if (target.unavailable !== null) {
37 result = { status: "failed", error: target.unavailable };49 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" };
38 } else {52 } else {
39 try {53 try {
40 const changes = input.changes.get(target.environmentId) ?? [];
41 const saved =54 const saved =
42 changes.length === 055 changes.length === 0
43 ? { _tag: "Success" as const }56 ? { _tag: "Success" as const }
...@@ -45,9 +58,12 @@ export async function writeUsagePrices(input: {...@@ -45,9 +58,12 @@ export async function writeUsagePrices(input: {
45 environmentId: target.environmentId,58 environmentId: target.environmentId,
46 input: {59 input: {
47 patch: {60 patch: {
48 usagePriceOverrides: Object.fromEntries(61 ...(prices.length > 0
49 changes.map((change) => [change.model, change.price]),62 ? { usagePriceOverrides: Object.fromEntries(prices) }
50 ),63 : {}),
64 ...(aliases.length > 0
65 ? { usageModelAliases: Object.fromEntries(aliases) }
66 : {}),
51 },67 },
52 },68 },
53 });69 });
docs/user/usage.md+5
...@@ -61,6 +61,11 @@ edited rows. Untouched cells keep each environment's rate. Select one environmen...@@ -61,6 +61,11 @@ edited rows. Untouched cells keep each environment's rate. Select one environmen
61prices. **Reset to automatic** marks a model's override for removal when you save; you can undo61prices. **Reset to automatic** marks a model's override for removal when you save; you can undo
62it before saving.62it 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
64Each destination reports whether the change saved. Offline or unavailable environments are69Each destination reports whether the change saved. Offline or unavailable environments are
65marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change70marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change
66without writing again to environments that already saved. Changes are not queued after you close71without 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>(...@@ -936,12 +936,17 @@ export function createServerEnvironmentAtoms<R, E>(
936 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),936 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),
937 );937 );
938 const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) =>938 const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) =>
939 Atom.make((get) =>939 Atom.make((get) => {
940 JSON.stringify([940 const settings = get(settingsValueAtom(environmentId));
941 const aliases = settings?.usageModelAliases ?? {};
942 return JSON.stringify([
941 get(usagePricesAtom(environmentId)),943 get(usagePricesAtom(environmentId)),
942 get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false,944 Object.keys(aliases)
943 ]),945 .sort()
944 ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),946 .map((model) => [model, aliases[model]]),
947 settings?.cursorKeychainUsageEnabled ?? false,
948 ]);
949 }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),
945 );950 );
946 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>951 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
947 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(952 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
packages/contracts/src/environment.ts+2
...@@ -136,6 +136,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -136,6 +136,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
136 usageLimitSources: Schema.optionalKey(Schema.Boolean),136 usageLimitSources: Schema.optionalKey(Schema.Boolean),
137 /** Server persists custom model rates and applies them to usage summaries. */137 /** Server persists custom model rates and applies them to usage summaries. */
138 usagePriceOverrides: Schema.optionalKey(Schema.Boolean),138 usagePriceOverrides: Schema.optionalKey(Schema.Boolean),
139 /** Server persists model mappings and folds mapped usage into the target model. */
140 usageModelAliases: Schema.optionalKey(Schema.Boolean),
139 /** Server understands thread.pin / thread.unpin commands. Same141 /** Server understands thread.pin / thread.unpin commands. Same
140 version-skew contract as threadSettlement. */142 version-skew contract as threadSettlement. */
141 threadPinning: Schema.optionalKey(Schema.Boolean),143 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.ts+11
...@@ -1404,6 +1404,13 @@ export const ServerSettings = Schema.Struct({...@@ -1404,6 +1404,13 @@ export const ServerSettings = Schema.Struct({
1404 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(1404 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
1405 Schema.withDecodingDefault(Effect.succeed({})),1405 Schema.withDecodingDefault(Effect.succeed({})),
1406 ),1406 ),
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 ),
1407});1414});
1408export type ServerSettings = typeof ServerSettings.Type;1415export type ServerSettings = typeof ServerSettings.Type;
14091416
...@@ -1700,6 +1707,10 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1700,6 +1707,10 @@ export const ServerSettingsPatch = Schema.Struct({
1700 usagePriceOverrides: Schema.optionalKey(1707 usagePriceOverrides: Schema.optionalKey(
1701 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),1708 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
1702 ),1709 ),
1710 /** Each entry replaces one model's mapping; `null` removes it. */
1711 usageModelAliases: Schema.optionalKey(
1712 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)),
1713 ),
1703});1714});
1704export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;1715export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
17051716
packages/shared/src/serverSettings.ts+9
...@@ -280,6 +280,7 @@ export function applyServerSettingsPatch(...@@ -280,6 +280,7 @@ export function applyServerSettingsPatch(
280 // Merged per entry below; its `null` removals must not reach deepMerge.280 // Merged per entry below; its `null` removals must not reach deepMerge.
281 usageLimitSources: usageLimitSourcesPatch,281 usageLimitSources: usageLimitSourcesPatch,
282 usagePriceOverrides: usagePriceOverridesPatch,282 usagePriceOverrides: usagePriceOverridesPatch,
283 usageModelAliases: usageModelAliasesPatch,
283 // Entry replacement: deepMerge would keep keys the client meant to clear.284 // Entry replacement: deepMerge would keep keys the client meant to clear.
284 projectSettingsOverrides: projectSettingsOverridesPatch,285 projectSettingsOverrides: projectSettingsOverridesPatch,
285 // Already translated into `projectSettingsOverrides` above; the legacy286 // Already translated into `projectSettingsOverrides` above; the legacy
...@@ -394,6 +395,14 @@ export function applyServerSettingsPatch(...@@ -394,6 +395,14 @@ export function applyServerSettingsPatch(
394 ),395 ),
395 }396 }
396 : {}),397 : {}),
398 ...(usageModelAliasesPatch !== undefined
399 ? {
400 usageModelAliases: mergeSettingsEntries(
401 current.usageModelAliases,
402 usageModelAliasesPatch,
403 ),
404 }
405 : {}),
397 ...(patch.sourceControlWriterModelSelection !== undefined406 ...(patch.sourceControlWriterModelSelection !== undefined
398 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }407 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
399 : {}),408 : {}),