authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 14:22:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 14:22:21-07:00
log394e8470c89927a06575e08c434006b2320f1781
tree6f28eb29747c13099cd516707513b92b02380e53
parent5e1c23d769b182018f5bd6d3623a7edec13d26bc
signature Signed by PGP key B5690EEEBB952194

feat(usage): support custom model prices (#9774)


22 files changed, 1165 insertions(+), 44 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -166,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -166,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
166 expect(second.capabilities.attachmentUploads).toBe(true);166 expect(second.capabilities.attachmentUploads).toBe(true);
167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
168 expect(second.capabilities.pullRequests).toBe(true);168 expect(second.capabilities.pullRequests).toBe(true);
169 expect(second.capabilities.usagePriceOverrides).toBe(true);
169 expect(second.capabilities.threadTitleRegeneration).toBe(true);170 expect(second.capabilities.threadTitleRegeneration).toBe(true);
170 expect(second.capabilities.threadPullRequestLinking).toBe(true);171 expect(second.capabilities.threadPullRequestLinking).toBe(true);
171 expect(second.capabilities.agentActivityPublishing).toBe(false);172 expect(second.capabilities.agentActivityPublishing).toBe(false);
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {...@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222 threadSnooze: true,222 threadSnooze: true,
223 environmentThemes: true,223 environmentThemes: true,
224 usageLimitSources: true,224 usageLimitSources: true,
225 usagePriceOverrides: true,
225 threadPinning: true,226 threadPinning: true,
226 threadPinReorder: true,227 threadPinReorder: true,
227 threadTitleRegeneration: true,228 threadTitleRegeneration: true,
apps/server/src/serverSettings.test.ts+26
...@@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => {
272 ).pipe(Effect.provide(makeServerSettingsLayer())),272 ).pipe(Effect.provide(makeServerSettingsLayer())),
273 );273 );
274274
275 it.effect("persists custom usage prices and removes them from the settings file", () =>
276 Effect.scoped(
277 Effect.gen(function* () {
278 const serverConfig = yield* ServerConfig.ServerConfig;
279 const fileSystem = yield* FileSystem.FileSystem;
280 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
281 const prices = {
282 inputCostPerMillionTokens: 2,
283 outputCostPerMillionTokens: 8,
284 cacheReadCostPerMillionTokens: 0,
285 };
286 const readPersisted = fileSystem
287 .readFileString(serverConfig.settingsPath)
288 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))));
289
290 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": prices } });
291 const persisted = yield* readPersisted;
292 assert.deepStrictEqual(persisted.usagePriceOverrides, { "example-model": prices });
293
294 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": null } });
295 const restored = yield* readPersisted;
296 assert.deepStrictEqual(restored.usagePriceOverrides, {});
297 }),
298 ).pipe(Effect.provide(makeServerSettingsLayer())),
299 );
300
275 it.effect("persists and broadcasts thread settlement settings", () =>301 it.effect("persists and broadcasts thread settlement settings", () =>
276 Effect.scoped(302 Effect.scoped(
277 Effect.gen(function* () {303 Effect.gen(function* () {
apps/server/src/usage/UsageService.test.ts+106-2
...@@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
10import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";10import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
11import * as Duration from "effect/Duration";11import * as Duration from "effect/Duration";
12import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";14import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
16import * as Scheduler from "effect/Scheduler";18import * as Scheduler from "effect/Scheduler";
17import * as TestClock from "effect/testing/TestClock";19import * as TestClock from "effect/testing/TestClock";
...@@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts";...@@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts";
21import * as ServerSettings from "../serverSettings.ts";23import * as ServerSettings from "../serverSettings.ts";
22import * as UsageService from "./UsageService.ts";24import * as UsageService from "./UsageService.ts";
2325
24function claudeLine(id: number, outputTokens: number): string {26function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
25 return `${JSON.stringify({27 return `${JSON.stringify({
26 type: "assistant",28 type: "assistant",
27 timestamp: "2026-08-01T10:00:00Z",29 timestamp: "2026-08-01T10:00:00Z",
...@@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string {...@@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string {
29 sessionId: "session-1",31 sessionId: "session-1",
30 message: {32 message: {
31 id: `msg_${id}`,33 id: `msg_${id}`,
32 model: "claude-fable-5",34 model,
33 usage: { input_tokens: 10, output_tokens: outputTokens },35 usage: { input_tokens: 10, output_tokens: outputTokens },
34 },36 },
35 })}\n`;37 })}\n`;
...@@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens...@@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
96}98}
9799
98describe("UsageService", () => {100describe("UsageService", () => {
101 it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () =>
102 Effect.gen(function* () {
103 const { transcript, settings, home } = yield* setup;
104 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model")));
105
106 yield* Effect.gen(function* () {
107 const settingsService = yield* ServerSettings.ServerSettingsService;
108 const service = yield* UsageService.make;
109
110 const original = yield* service.readSummary(WINDOW);
111 assert.strictEqual(original.buckets[0]?.costUsd, 0);
112 assert.strictEqual(original.buckets[0]?.unpricedRecords, 1);
113
114 yield* settingsService.updateSettings({
115 usagePriceOverrides: {
116 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },
117 },
118 });
119 const overridden = yield* service.readSummary(WINDOW);
120 assert.closeTo(overridden.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12);
121 assert.strictEqual(overridden.buckets[0]?.costSource, "modelPriced");
122 assert.strictEqual(overridden.buckets[0]?.unpricedRecords, 0);
123 assert.deepStrictEqual(overridden.buckets[0]?.totals, original.buckets[0]?.totals);
124
125 yield* settingsService.updateSettings({
126 usagePriceOverrides: {
127 "example-model": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 16 },
128 },
129 });
130 const edited = yield* service.readSummary(WINDOW);
131 assert.closeTo(edited.buckets[0]?.costUsd ?? -1, 0.00012, 1e-12);
132
133 yield* settingsService.updateSettings({ usagePriceOverrides: { "example-model": null } });
134 const restored = yield* service.readSummary(WINDOW);
135 assert.deepStrictEqual(restored.buckets, original.buckets);
136 }).pipe(
137 Effect.provide(
138 serviceLayers({ prefix: "usage-service-price-overrides-test", home, settings }),
139 ),
140 );
141 }).pipe(Effect.scoped),
142 );
143
99 it.live("counts appended usage on a rescan of a grown transcript", () =>144 it.live("counts appended usage on a rescan of a grown transcript", () =>
100 Effect.gen(function* () {145 Effect.gen(function* () {
101 const { transcript, settings, home } = yield* setup;146 const { transcript, settings, home } = yield* setup;
...@@ -114,6 +159,65 @@ describe("UsageService", () => {...@@ -114,6 +159,65 @@ describe("UsageService", () => {
114 }).pipe(Effect.scoped),159 }).pipe(Effect.scoped),
115 );160 );
116161
162 it.live("does not share an in-flight scan after custom prices change", () =>
163 Effect.gen(function* () {
164 const { transcript, settings, home } = yield* setup;
165 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model")));
166
167 yield* Effect.gen(function* () {
168 const settingsService = yield* ServerSettings.ServerSettingsService;
169 const fileSystem = yield* FileSystem.FileSystem;
170 const firstScanStarted = yield* Deferred.make<void>();
171 const secondScanStarted = yield* Deferred.make<void>();
172 const releaseRates = yield* Deferred.make<void>();
173 let homeProbes = 0;
174 const service = yield* UsageService.make.pipe(
175 Effect.provideService(FileSystem.FileSystem, {
176 ...fileSystem,
177 exists: (path) =>
178 fileSystem.exists(path).pipe(
179 Effect.tap(() => {
180 if (path !== NodePath.join(home, "claude", ".claude", "projects"))
181 return Effect.void;
182 homeProbes += 1;
183 return Deferred.succeed(
184 homeProbes === 1 ? firstScanStarted : secondScanStarted,
185 undefined,
186 );
187 }),
188 ),
189 }),
190 Effect.provideService(
191 HttpClient.HttpClient,
192 HttpClient.make((request) =>
193 Deferred.await(releaseRates).pipe(
194 Effect.as(HttpClientResponse.fromWeb(request, Response.json({}))),
195 ),
196 ),
197 ),
198 );
199
200 const first = yield* service.readSummary(WINDOW).pipe(Effect.forkChild);
201 yield* Deferred.await(firstScanStarted);
202 yield* settingsService.updateSettings({
203 usagePriceOverrides: {
204 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },
205 },
206 });
207 const second = yield* service.readSummary(WINDOW).pipe(Effect.forkChild);
208 yield* Deferred.await(secondScanStarted);
209 yield* Deferred.succeed(releaseRates, undefined);
210
211 const original = yield* Fiber.join(first);
212 const updated = yield* Fiber.join(second);
213 assert.strictEqual(original.buckets[0]?.costUsd, 0);
214 assert.closeTo(updated.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12);
215 }).pipe(
216 Effect.provide(serviceLayers({ prefix: "usage-service-price-race-test", home, settings })),
217 );
218 }).pipe(Effect.scoped),
219 );
220
117 it.live("shares one scan between concurrent identical requests", () =>221 it.live("shares one scan between concurrent identical requests", () =>
118 Effect.gen(function* () {222 Effect.gen(function* () {
119 const { transcript, settings, home } = yield* setup;223 const { transcript, settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+42-28
...@@ -16,6 +16,7 @@ import * as NodeOS from "node:os";...@@ -16,6 +16,7 @@ import * as NodeOS from "node:os";
1616
17import {17import {
18 USAGE_CONTRACT_VERSION,18 USAGE_CONTRACT_VERSION,
19 type ServerSettings as ServerSettingsValue,
19 type UsageProviderKind,20 type UsageProviderKind,
20 type UsageSource,21 type UsageSource,
21 type UsagePricing,22 type UsagePricing,
...@@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts";
44import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";45import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
45import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";46import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
46import { UsageAggregator } from "./usageAggregation.ts";47import { UsageAggregator } from "./usageAggregation.ts";
47import { parseRateTable, type RateTable } from "./usagePricing.ts";48import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
48import {49import {
49 listTranscriptFiles,50 listTranscriptFiles,
50 readDirectoryVolumeId,51 readDirectoryVolumeId,
...@@ -232,24 +233,22 @@ export const make = Effect.gen(function* () {...@@ -232,24 +233,22 @@ export const make = Effect.gen(function* () {
232 return nestedExists ? nested : path.join(homePath, "projects");233 return nestedExists ? nested : path.join(homePath, "projects");
233 });234 });
234235
235 /** Resolves the transcript directory for each provider. */236 // A settings failure must not silently discard custom rates or transcript homes.
236 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {237 const readSettings = settingsService.getSettings.pipe(
237 // A settings failure must surface as an error: swallowing it here would238 Effect.catchCause(
238 // present "zero usage from every provider" as a valid answer.239 (cause) =>
239 const settings = yield* settingsService.getSettings.pipe(240 new UsageReadError({
240 Effect.catchCause(241 reason: "scanFailed",
241 (cause) =>242 detail: "Server settings could not be read.",
242 new UsageReadError({243 cause: Cause.squash(cause),
243 reason: "scanFailed",244 }),
244 // Bounded description; the squashed failure travels as the cause.245 ),
245 // Squashed, not the Cause tree: a full tree in a Defect field is246 );
246 // the unbounded wire payload the bounded detail exists to avoid.
247 detail: "Server settings could not be read.",
248 cause: Cause.squash(cause),
249 }),
250 ),
251 );
252247
248 /** Resolves the transcript directory for each provider. */
249 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
250 settings: ServerSettingsValue,
251 ) {
253 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);252 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
254 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);253 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
255 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);254 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
...@@ -379,10 +378,15 @@ export const make = Effect.gen(function* () {...@@ -379,10 +378,15 @@ export const make = Effect.gen(function* () {
379 | null;378 | null;
380 }379 }
381380
382 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) {381 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
382 windowStartMs: number,
383 settings: ServerSettingsValue,
384 ) {
383 // The home resolvers ask for `Path` themselves; satisfy them from the385 // The home resolvers ask for `Path` themselves; satisfy them from the
384 // instance we already hold so the scan stays context-free.386 // instance we already hold so the scan stays context-free.
385 const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path));387 const dirs = yield* resolveTranscriptDirs(settings).pipe(
388 Effect.provideService(Path.Path, path),
389 );
386 const scanned: ScannedDir[] = [];390 const scanned: ScannedDir[] = [];
387 for (const { provider, dir, fileName } of dirs) {391 for (const { provider, dir, fileName } of dirs) {
388 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));392 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
...@@ -406,7 +410,10 @@ export const make = Effect.gen(function* () {...@@ -406,7 +410,10 @@ export const make = Effect.gen(function* () {
406 return scanned;410 return scanned;
407 });411 });
408412
409 const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) {413 const scanSummary = Effect.fn("UsageService.scanSummary")(function* (
414 input: UsageSummaryInput,
415 settings: ServerSettingsValue,
416 ) {
410 if (input.sinceDay > input.untilDay) {417 if (input.sinceDay > input.untilDay) {
411 return yield* new UsageReadError({418 return yield* new UsageReadError({
412 reason: "invalidWindow",419 reason: "invalidWindow",
...@@ -455,9 +462,10 @@ export const make = Effect.gen(function* () {...@@ -455,9 +462,10 @@ export const make = Effect.gen(function* () {
455 // Pricing only matters once records are aggregated, so the rate table462 // Pricing only matters once records are aggregated, so the rate table
456 // loads while transcripts stream instead of gating them: a cold rates463 // loads while transcripts stream instead of gating them: a cold rates
457 // fetch on a slow network no longer delays the scan by its own timeout.464 // fetch on a slow network no longer delays the scan by its own timeout.
458 const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], {465 const [, scannedDirs] = yield* Effect.all(
459 concurrency: 2,466 [ensureRates(false), collectDirs(windowStartMs, settings)],
460 });467 { concurrency: 2 },
468 );
461469
462 const aggregator = new UsageAggregator({470 const aggregator = new UsageAggregator({
463 timeZone: input.timeZone,471 timeZone: input.timeZone,
...@@ -466,6 +474,7 @@ export const make = Effect.gen(function* () {...@@ -466,6 +474,7 @@ export const make = Effect.gen(function* () {
466 resolution: input.resolution ?? "day",474 resolution: input.resolution ?? "day",
467 ...hourlyWindow,475 ...hourlyWindow,
468 rates,476 rates,
477 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
469 });478 });
470479
471 const sources: UsageSource[] = [];480 const sources: UsageSource[] = [];
...@@ -547,13 +556,16 @@ export const make = Effect.gen(function* () {...@@ -547,13 +556,16 @@ export const make = Effect.gen(function* () {
547 });556 });
548557
549 /**558 /**
550 * In-flight scans by window, so concurrent identical requests (the usage559 * In-flight scans by window and custom prices, so concurrent identical requests (the usage
551 * page open on two clients at once) share one scan instead of racing over560 * page open on two clients at once) share one scan instead of racing over
552 * the same corpus twice.561 * the same corpus twice.
553 */562 */
554 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();563 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();
555564
556 const scanKey = (input: UsageSummaryInput): string =>565 const scanKey = (
566 input: UsageSummaryInput,
567 priceOverrides: ServerSettingsValue["usagePriceOverrides"],
568 ): string =>
557 JSON.stringify([569 JSON.stringify([
558 input.timeZone,570 input.timeZone,
559 input.sinceDay,571 input.sinceDay,
...@@ -561,10 +573,12 @@ export const make = Effect.gen(function* () {...@@ -561,10 +573,12 @@ export const make = Effect.gen(function* () {
561 input.resolution ?? "day",573 input.resolution ?? "day",
562 input.sinceTime ?? null,574 input.sinceTime ?? null,
563 input.untilTime ?? null,575 input.untilTime ?? null,
576 priceOverrides,
564 ]);577 ]);
565578
566 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {579 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
567 const key = scanKey(input);580 const settings = yield* readSettings;
581 const key = scanKey(input, settings.usagePriceOverrides);
568 const deferred = yield* Effect.uninterruptible(582 const deferred = yield* Effect.uninterruptible(
569 Effect.gen(function* () {583 Effect.gen(function* () {
570 const existing = inflightScans.get(key);584 const existing = inflightScans.get(key);
...@@ -576,7 +590,7 @@ export const make = Effect.gen(function* () {...@@ -576,7 +590,7 @@ export const make = Effect.gen(function* () {
576 inflightScans.set(key, created);590 inflightScans.set(key, created);
577 // Detached so one departing client cannot tear the scan out from under591 // Detached so one departing client cannot tear the scan out from under
578 // the fibers awaiting it; a finished scan warms the cache either way.592 // the fibers awaiting it; a finished scan warms the cache either way.
579 yield* scanSummary(input).pipe(593 yield* scanSummary(input, settings).pipe(
580 Effect.onExit((exit) =>594 Effect.onExit((exit) =>
581 Effect.sync(() => inflightScans.delete(key)).pipe(595 Effect.sync(() => inflightScans.delete(key)).pipe(
582 Effect.andThen(Deferred.done(created, exit)),596 Effect.andThen(Deferred.done(created, exit)),
apps/server/src/usage/usageAggregation.ts+8-1
...@@ -61,6 +61,7 @@ export interface AggregateOptions {...@@ -61,6 +61,7 @@ export interface AggregateOptions {
61 readonly sinceDay: string;61 readonly sinceDay: string;
62 readonly untilDay: string;62 readonly untilDay: string;
63 readonly rates: RateTable;63 readonly rates: RateTable;
64 readonly priceOverrides?: RateTable;
64 readonly resolution?: UsageResolution;65 readonly resolution?: UsageResolution;
65 readonly sinceTimeMs?: number;66 readonly sinceTimeMs?: number;
66 readonly untilTimeMs?: number;67 readonly untilTimeMs?: number;
...@@ -165,11 +166,17 @@ export class UsageAggregator {...@@ -165,11 +166,17 @@ export class UsageAggregator {
165 record.model,166 record.model,
166 record.totals,167 record.totals,
167 record.reportedCostUsd,168 record.reportedCostUsd,
169 this.#options.priceOverrides,
168 );170 );
169171
170 bucket.totals = addTotals(bucket.totals, record.totals);172 bucket.totals = addTotals(bucket.totals, record.totals);
171 bucket.costUsd += priced.costUsd;173 bucket.costUsd += priced.costUsd;
172 bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals);174 bucket.cacheSavingsUsd += cacheSavingsUsd(
175 this.#options.rates,
176 record.model,
177 record.totals,
178 this.#options.priceOverrides,
179 );
173 bucket.records += 1;180 bucket.records += 1;
174 if (priced.costSource === "unpriced") bucket.unpricedRecords += 1;181 if (priced.costSource === "unpriced") bucket.unpricedRecords += 1;
175 if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;182 if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;
apps/server/src/usage/usagePricing.test.ts+75-1
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts";3import {
4 cacheSavingsUsd,
5 createOverrideRateTable,
6 lookupRate,
7 normalizeModelName,
8 parseRateTable,
9 priceUsage,
10} from "./usagePricing.ts";
411
5const rate = (input: number, cacheRead?: number) => ({12const rate = (input: number, cacheRead?: number) => ({
6 input_cost_per_token: input,13 input_cost_per_token: input,
...@@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({...@@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({
9});16});
1017
11describe("usage pricing", () => {18describe("usage pricing", () => {
19 const totals = {
20 uncachedInputTokens: 1_000_000,
21 cachedInputTokens: 1_000_000,
22 cacheCreationTokens: 1_000_000,
23 outputTokens: 1_000_000,
24 reasoningTokens: 500_000,
25 };
26
27 it("uses custom token rates ahead of public and provider-reported costs", () => {
28 const table = parseRateTable({ "example-model": rate(1) });
29 const overrides = createOverrideRateTable({
30 "example-model": {
31 inputCostPerMillionTokens: 2,
32 outputCostPerMillionTokens: 8,
33 cacheReadCostPerMillionTokens: 0.5,
34 cacheWriteCostPerMillionTokens: 3,
35 },
36 });
37
38 for (const reportedCostUsd of [null, 99]) {
39 expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({
40 costUsd: 13.5,
41 costSource: "modelPriced",
42 });
43 }
44 expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5);
45 });
46
47 it("prices unknown models offline and uses input prices for omitted cache rates", () => {
48 const table = parseRateTable({});
49 const overrides = createOverrideRateTable({
50 "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 },
51 });
52
53 expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({
54 costUsd: 14,
55 costSource: "modelPriced",
56 });
57 expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0);
58 });
59
60 it("preserves explicit zero rates and matches only the exact trimmed model ID", () => {
61 const table = parseRateTable({});
62 const overrides = createOverrideRateTable({
63 " vendor/example-model[1m] ": {
64 inputCostPerMillionTokens: 0,
65 outputCostPerMillionTokens: 0,
66 },
67 });
68 expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({
69 costUsd: 0,
70 costSource: "modelPriced",
71 });
72 for (const model of [
73 "example-model[1m]",
74 "vendor/example-model",
75 "vendor/Example-model[1m]",
76 "other/example-model[1m]",
77 ]) {
78 expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced");
79 expect(priceUsage(table, model, totals, 99, overrides)).toEqual({
80 costUsd: 99,
81 costSource: "providerReported",
82 });
83 }
84 });
85
12 it("keeps the existing model-name normalization contract", () => {86 it("keeps the existing model-name normalization contract", () => {
13 expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5");87 expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5");
14 });88 });
apps/server/src/usage/usagePricing.ts+35-5
...@@ -7,7 +7,11 @@...@@ -7,7 +7,11 @@
7 *7 *
8 * @module usagePricing8 * @module usagePricing
9 */9 */
10import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts";10import type {
11 UsageCostSource,
12 UsageModelPriceOverride,
13 UsageTokenTotals,
14} from "@t3tools/contracts";
1115
12/**16/**
13 * The subset of a LiteLLM entry we price against. All values are USD per token.17 * The subset of a LiteLLM entry we price against. All values are USD per token.
...@@ -26,6 +30,25 @@ export interface ModelRate {...@@ -26,6 +30,25 @@ export interface ModelRate {
2630
27export type RateTable = ReadonlyMap<string, ModelRate>;31export type RateTable = ReadonlyMap<string, ModelRate>;
2832
33/** Custom IDs keep their case, provider prefix, and variant suffix. */
34export function createOverrideRateTable(
35 overrides: Readonly<Record<string, UsageModelPriceOverride>>,
36): RateTable {
37 return new Map(
38 Object.entries(overrides).map(([model, prices]) => [
39 model.trim(),
40 {
41 inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000,
42 outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000,
43 cacheReadCostPerToken:
44 (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
45 cacheCreationCostPerToken:
46 (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
47 },
48 ]),
49 );
50}
51
29/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */52/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
30interface LiteLlmEntry {53interface LiteLlmEntry {
31 readonly input_cost_per_token?: unknown;54 readonly input_cost_per_token?: unknown;
...@@ -168,12 +191,14 @@ export function priceUsage(...@@ -168,12 +191,14 @@ export function priceUsage(
168 model: string,191 model: string,
169 totals: UsageTokenTotals,192 totals: UsageTokenTotals,
170 reportedCostUsd: number | null,193 reportedCostUsd: number | null,
194 overrides?: RateTable,
171): PricedUsage {195): PricedUsage {
172 if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {196 const override = overrides?.get(model.trim());
197 if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
173 return { costUsd: reportedCostUsd, costSource: "providerReported" };198 return { costUsd: reportedCostUsd, costSource: "providerReported" };
174 }199 }
175200
176 const rate = lookupRate(table, model);201 const rate = override ?? lookupRate(table, model);
177 if (rate === null) return { costUsd: 0, costSource: "unpriced" };202 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
178203
179 const costUsd =204 const costUsd =
...@@ -189,8 +214,13 @@ export function priceUsage(...@@ -189,8 +214,13 @@ export function priceUsage(
189 * What the cached input would have cost at full input rates, minus what it214 * What the cached input would have cost at full input rates, minus what it
190 * actually cost. Drives the "cache savings" figure.215 * actually cost. Drives the "cache savings" figure.
191 */216 */
192export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {217export function cacheSavingsUsd(
193 const rate = lookupRate(table, model);218 table: RateTable,
219 model: string,
220 totals: UsageTokenTotals,
221 overrides?: RateTable,
222): number {
223 const rate = overrides?.get(model.trim()) ?? lookupRate(table, model);
194 if (rate === null) return 0;224 if (rate === null) return 0;
195 return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);225 return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
196}226}
apps/web/src/components/usage/UsagePage.test.tsx+1
...@@ -57,6 +57,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({...@@ -57,6 +57,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({
57vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));57vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
58vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));58vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
59vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));59vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
60vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
60vi.mock("./usageProviders", async (importOriginal) => {61vi.mock("./usageProviders", async (importOriginal) => {
61 const actual = await importOriginal<typeof import("./usageProviders")>();62 const actual = await importOriginal<typeof import("./usageProviders")>();
62 return {63 return {
apps/web/src/components/usage/UsagePage.tsx+6
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36import { WorkspacePageContainer } from "../WorkspacePageContainer";36import { WorkspacePageContainer } from "../WorkspacePageContainer";
37import { WorkspacePageHeader } from "../WorkspacePageHeader";37import { WorkspacePageHeader } from "../WorkspacePageHeader";
38import { UsageLimitsSection } from "./UsageLimits";38import { UsageLimitsSection } from "./UsageLimits";
39import { UsagePriceOverrides } from "./UsagePriceOverrides";
39import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";40import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
40import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";41import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
4142
...@@ -264,6 +265,11 @@ export function UsagePage() {...@@ -264,6 +265,11 @@ export function UsagePage() {
264265
265 <ScrollArea className="min-h-0 flex-1">266 <ScrollArea className="min-h-0 flex-1">
266 <WorkspacePageContainer width="wide">267 <WorkspacePageContainer width="wide">
268 {!showingLimits ? (
269 <div className="flex justify-end">
270 <UsagePriceOverrides usage={environments} />
271 </div>
272 ) : null}
267 {showingLimits ? (273 {showingLimits ? (
268 <UsageLimitsSection />274 <UsageLimitsSection />
269 ) : settling ? (275 ) : settling ? (
apps/web/src/components/usage/UsagePriceOverrides.tsx created+318
...@@ -0,0 +1,318 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, UsageModelPriceOverride } from "@t3tools/contracts";
3import { useState } from "react";
4
5import { isElectron } from "../../env";
6import {
7 type EnvironmentPresentation,
8 useEnvironments,
9 usePrimaryEnvironmentId,
10} from "../../state/environments";
11import { serverEnvironment } from "../../state/server";
12import { useEnvironmentSessionState } from "../../state/session";
13import { useAtomCommand } from "../../state/use-atom-command";
14import type { EnvironmentUsageStatus } from "../../state/usage";
15import {
16 resolvePrimaryOperateAccess,
17 resolveRemoteOperateAccess,
18} from "../settings/ProviderSettingsPanel.logic";
19import { Button } from "../ui/button";
20import {
21 Dialog,
22 DialogDescription,
23 DialogHeader,
24 DialogPanel,
25 DialogPopup,
26 DialogTitle,
27} from "../ui/dialog";
28import { Input } from "../ui/input";
29import { Label } from "../ui/label";
30import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
31import { parseUsagePriceForm, USAGE_PRICE_FIELDS, usagePriceForm } from "./usagePriceForm";
32
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}
48
49function ModelPricesDialog({
50 usage,
51 onOpenChange,
52}: {
53 readonly usage: readonly EnvironmentUsageStatus[];
54 readonly onOpenChange: (open: boolean) => void;
55}) {
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();
65
66 return (
67 <Dialog open onOpenChange={onOpenChange}>
68 <DialogPopup className="max-w-xl">
69 <DialogHeader>
70 <DialogTitle>Model prices</DialogTitle>
71 <DialogDescription>
72 Override estimated costs using USD per million tokens. Prices apply to all saved history
73 on the selected environment.
74 </DialogDescription>
75 </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 }}
87 >
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>
96 ))}
97 </SelectPopup>
98 </Select>
99 </div>
100 <EnvironmentModelPrices
101 key={environment.environmentId}
102 environment={environment}
103 models={models}
104 />
105 </>
106 ) : (
107 <p className="text-sm text-muted-foreground">
108 Connect an environment to set model prices.
109 </p>
110 )}
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>
303 ) : null}
304 <div className="flex justify-end gap-2">
305 {editingModel !== null ? (
306 <Button variant="outline" onClick={reset}>
307 Cancel
308 </Button>
309 ) : null}
310 <Button type="submit" disabled={parsed === null || duplicate || readOnly}>
311 {pending ? "Saving..." : editingModel === null ? "Add price" : "Save price"}
312 </Button>
313 </div>
314 </fieldset>
315 </form>
316 </>
317 );
318}
apps/web/src/components/usage/usagePriceForm.test.ts created+73
...@@ -0,0 +1,73 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseUsagePriceForm, usagePriceForm } from "./usagePriceForm.ts";
4
5describe("model price entry", () => {
6 it("preserves exact model IDs and fractional rates", () => {
7 expect(
8 parseUsagePriceForm({
9 ...usagePriceForm(),
10 model: " vendor/Example-Model ",
11 inputCostPerMillionTokens: "2.5",
12 outputCostPerMillionTokens: "10",
13 }),
14 ).toEqual({
15 model: "vendor/Example-Model",
16 price: { inputCostPerMillionTokens: 2.5, outputCostPerMillionTokens: 10 },
17 });
18 });
19
20 it("keeps free cache tokens distinct from blank cache prices", () => {
21 const parsed = parseUsagePriceForm({
22 ...usagePriceForm(),
23 model: "example-model",
24 inputCostPerMillionTokens: "2",
25 outputCostPerMillionTokens: "8",
26 cacheReadCostPerMillionTokens: "0",
27 cacheWriteCostPerMillionTokens: " ",
28 });
29 expect(parsed?.price).toEqual({
30 inputCostPerMillionTokens: 2,
31 outputCostPerMillionTokens: 8,
32 cacheReadCostPerMillionTokens: 0,
33 });
34 });
35
36 it("preserves all prices when editing, including small fractional prices", () => {
37 const price = {
38 inputCostPerMillionTokens: 0,
39 outputCostPerMillionTokens: 8,
40 cacheReadCostPerMillionTokens: 0.0000001,
41 cacheWriteCostPerMillionTokens: 3.5,
42 };
43 expect(parseUsagePriceForm(usagePriceForm("example-model", price))).toEqual({
44 model: "example-model",
45 price,
46 });
47 });
48
49 it.each(["", " ", "-1", "Infinity", "NaN", "1e999", "$2", "0x10"])(
50 "rejects invalid required prices: %j",
51 (inputCostPerMillionTokens) => {
52 expect(
53 parseUsagePriceForm({
54 ...usagePriceForm("example-model", {
55 inputCostPerMillionTokens: 2,
56 outputCostPerMillionTokens: 8,
57 }),
58 inputCostPerMillionTokens,
59 }),
60 ).toBeNull();
61 },
62 );
63
64 it("rejects invalid optional rates and missing model IDs", () => {
65 const form = usagePriceForm("example-model", {
66 inputCostPerMillionTokens: 2,
67 outputCostPerMillionTokens: 8,
68 });
69 expect(parseUsagePriceForm({ ...form, cacheReadCostPerMillionTokens: "-1" })).toBeNull();
70 expect(parseUsagePriceForm({ ...form, cacheWriteCostPerMillionTokens: "invalid" })).toBeNull();
71 expect(parseUsagePriceForm({ ...form, model: " " })).toBeNull();
72 });
73});
apps/web/src/components/usage/usagePriceForm.ts created+62
...@@ -0,0 +1,62 @@
1import type { UsageModelPriceOverride } from "@t3tools/contracts";
2
3export const USAGE_PRICE_FIELDS = [
4 { key: "inputCostPerMillionTokens", label: "Input", optional: false },
5 { key: "outputCostPerMillionTokens", label: "Output", optional: false },
6 { key: "cacheReadCostPerMillionTokens", label: "Cache read", optional: true },
7 { key: "cacheWriteCostPerMillionTokens", label: "Cache write", optional: true },
8] as const;
9
10export type UsagePriceForm = { model: string } & Record<
11 (typeof USAGE_PRICE_FIELDS)[number]["key"],
12 string
13>;
14
15export function usagePriceForm(model = "", price?: UsageModelPriceOverride): UsagePriceForm {
16 return {
17 model,
18 inputCostPerMillionTokens: price?.inputCostPerMillionTokens.toString() ?? "",
19 outputCostPerMillionTokens: price?.outputCostPerMillionTokens.toString() ?? "",
20 cacheReadCostPerMillionTokens: price?.cacheReadCostPerMillionTokens?.toString() ?? "",
21 cacheWriteCostPerMillionTokens: price?.cacheWriteCostPerMillionTokens?.toString() ?? "",
22 };
23}
24
25/** Blank cache prices use the input rate; explicit zero means free. */
26export function parseUsagePriceForm(
27 form: UsagePriceForm,
28): { model: string; price: UsageModelPriceOverride } | null {
29 const model = form.model.trim();
30 if (model.length === 0) return null;
31 const rates: Partial<Record<(typeof USAGE_PRICE_FIELDS)[number]["key"], number>> = {};
32 for (const field of USAGE_PRICE_FIELDS) {
33 const raw = form[field.key].trim();
34 if (raw === "") {
35 if (field.optional) continue;
36 return null;
37 }
38 if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) return null;
39 const value = Number(raw);
40 if (!Number.isFinite(value) || value < 0) return null;
41 rates[field.key] = value;
42 }
43 if (
44 rates.inputCostPerMillionTokens === undefined ||
45 rates.outputCostPerMillionTokens === undefined
46 ) {
47 return null;
48 }
49 return {
50 model,
51 price: {
52 inputCostPerMillionTokens: rates.inputCostPerMillionTokens,
53 outputCostPerMillionTokens: rates.outputCostPerMillionTokens,
54 ...(rates.cacheReadCostPerMillionTokens === undefined
55 ? {}
56 : { cacheReadCostPerMillionTokens: rates.cacheReadCostPerMillionTokens }),
57 ...(rates.cacheWriteCostPerMillionTokens === undefined
58 ? {}
59 : { cacheWriteCostPerMillionTokens: rates.cacheWriteCostPerMillionTokens }),
60 },
61 };
62}
docs/user/usage.md+12
...@@ -12,6 +12,18 @@ record are missing from the totals....@@ -12,6 +12,18 @@ record are missing from the totals.
12If recent work is missing or a new model shows no cost, refresh to rescan session history and12If recent work is missing or a new model shows no cost, refresh to rescan session history and
13update model pricing.13update model pricing.
1414
15## Set custom model prices
16
17On web or desktop, open **Usage → Model prices** to add, edit, or remove a model's estimated
18price. Choose the environment whose history you want to price, then enter the exact model ID and
19USD rates per million input and output tokens. You can enter any model ID, including models
20without public pricing.
21
22Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for
23tokens that are free. Saved prices replace automatic pricing for all of that environment's
24history and are shared with clients connected to it. Set prices on each environment that needs
25them. Removing a price restores automatic pricing.
26
15## Track subscription limits27## Track subscription limits
1628
17**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also29**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also
packages/client-runtime/src/state/server.ts+22
...@@ -807,6 +807,27 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -807,6 +807,27 @@ export function createServerEnvironmentAtoms<R, E>(
807 Atom.withLabel(`environment-data:server:settings:${environmentId}`),807 Atom.withLabel(`environment-data:server:settings:${environmentId}`),
808 ),808 ),
809 );809 );
810 const usagePricesAtom = Atom.family((environmentId: EnvironmentId) =>
811 Atom.make((get) => {
812 const overrides = get(settingsValueAtom(environmentId))?.usagePriceOverrides ?? {};
813 // Only changed prices should trigger another transcript scan. Settings
814 // snapshots can recreate the same mapping in a different property order.
815 return JSON.stringify(
816 Object.keys(overrides)
817 .sort()
818 .map((model) => {
819 const price = overrides[model]!;
820 return [
821 model,
822 price.inputCostPerMillionTokens,
823 price.outputCostPerMillionTokens,
824 price.cacheReadCostPerMillionTokens,
825 price.cacheWriteCostPerMillionTokens,
826 ];
827 }),
828 );
829 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),
830 );
810 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>831 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
811 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(832 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
812 Atom.withLabel(`environment-data:server:providers:${environmentId}`),833 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
...@@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>(
892 label: "environment-data:server:usage-summary",913 label: "environment-data:server:usage-summary",
893 tag: WS_METHODS.serverGetUsageSummary,914 tag: WS_METHODS.serverGetUsageSummary,
894 staleTimeMs: 60_000,915 staleTimeMs: 60_000,
916 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),
895 }),917 }),
896 configProjection,918 configProjection,
897 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {919 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
packages/client-runtime/src/state/serverUsage.test.ts created+263
...@@ -0,0 +1,263 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 UsageDay,
5 USAGE_CONTRACT_VERSION,
6 WS_METHODS,
7 type ServerConfig,
8 type ServerConfigStreamEvent,
9 type ServerSettings,
10 type UsageSummary,
11 type UsageSummaryInput,
12} from "@t3tools/contracts";
13import { expect, it } from "@effect/vitest";
14import * as Deferred from "effect/Deferred";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Queue from "effect/Queue";
19import * as Stream from "effect/Stream";
20import * as SubscriptionRef from "effect/SubscriptionRef";
21import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
22
23import {
24 AVAILABLE_CONNECTION_STATE,
25 PrimaryConnectionTarget,
26 type PreparedConnection,
27 type SupervisorConnectionState,
28} from "../connection/model.ts";
29import { EnvironmentRegistry } from "../connection/registry.ts";
30import { EnvironmentSupervisor } from "../connection/supervisor.ts";
31import { EnvironmentCacheStore } from "../platform/persistence.ts";
32import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
33import type { RpcSession } from "../rpc/session.ts";
34import { createServerEnvironmentAtoms } from "./server.ts";
35
36const TARGET = new PrimaryConnectionTarget({
37 environmentId: EnvironmentId.make("usage-environment"),
38 label: "Usage environment",
39 httpBaseUrl: "https://environment.example.test",
40 wsBaseUrl: "wss://environment.example.test",
41});
42
43const INPUT: UsageSummaryInput = {
44 sinceDay: UsageDay.make("2026-09-01"),
45 untilDay: UsageDay.make("2026-09-04"),
46 timeZone: "UTC",
47};
48
49const CONFIG = {
50 settings: DEFAULT_SERVER_SETTINGS,
51 environment: { serverVersion: "0.0.1", capabilities: {} },
52} as ServerConfig;
53
54const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
55 beforeRead: (request: number) => Effect.Effect<void> = () => Effect.void,
56) {
57 const events = yield* Queue.unbounded<ServerConfigStreamEvent>();
58 let settings = DEFAULT_SERVER_SETTINGS;
59 let requests = 0;
60 const client = {
61 [WS_METHODS.subscribeServerConfig]: () =>
62 Stream.concat(
63 Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }),
64 Stream.fromQueue(events),
65 ),
66 [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) =>
67 Effect.gen(function* () {
68 requests += 1;
69 const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0;
70 yield* beforeRead(requests);
71 return {
72 contractVersion: USAGE_CONTRACT_VERSION,
73 readAt: "2026-09-04T12:00:00Z",
74 ...input,
75 buckets: [
76 {
77 day: input.sinceDay,
78 provider: "codex",
79 model: "custom-model",
80 totals: {
81 uncachedInputTokens: 1_000_000,
82 cachedInputTokens: 0,
83 cacheCreationTokens: 0,
84 outputTokens: 0,
85 reasoningTokens: 0,
86 },
87 costUsd: price,
88 cacheSavingsUsd: 0,
89 costSource: price === 0 ? "unpriced" : "modelPriced",
90 records: 1,
91 unpricedRecords: price === 0 ? 1 : 0,
92 sessions: 1,
93 },
94 ],
95 sources: [],
96 pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 },
97 scanDurationMs: 0,
98 } satisfies UsageSummary;
99 }),
100 } as unknown as WsRpcProtocolClient;
101 const session: RpcSession = {
102 client,
103 initialConfig: Effect.succeed(CONFIG),
104 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
105 ready: Effect.void,
106 probe: Effect.void,
107 closed: Effect.never,
108 };
109 const supervisor = EnvironmentSupervisor.of({
110 target: TARGET,
111 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
112 ...AVAILABLE_CONNECTION_STATE,
113 phase: "connected",
114 }),
115 session: yield* SubscriptionRef.make(Option.some(session)),
116 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
117 connect: Effect.void,
118 disconnect: Effect.void,
119 retryNow: Effect.void,
120 });
121 const environments = EnvironmentRegistry.of({
122 run: (_environmentId, effect) =>
123 Effect.provideService(effect, EnvironmentSupervisor, supervisor),
124 followStream: (_environmentId, stream) =>
125 Stream.provideService(stream, EnvironmentSupervisor, supervisor),
126 } as EnvironmentRegistry["Service"]);
127 const cache = EnvironmentCacheStore.of({
128 loadShell: () => Effect.succeed(Option.none()),
129 saveShell: () => Effect.void,
130 loadThread: () => Effect.succeed(Option.none()),
131 saveThread: () => Effect.void,
132 removeThread: () => Effect.void,
133 loadServerConfig: () => Effect.succeed(Option.none()),
134 saveServerConfig: () => Effect.void,
135 loadVcsRefs: () => Effect.succeed(Option.none()),
136 saveVcsRefs: () => Effect.void,
137 removeVcsRefs: () => Effect.void,
138 clearVcsRefs: () => Effect.void,
139 clear: () => Effect.void,
140 });
141 const runtime = Atom.runtime(
142 Layer.merge(
143 Layer.succeed(EnvironmentRegistry, environments),
144 Layer.succeed(EnvironmentCacheStore, cache),
145 ),
146 );
147 const initialConfigValueAtom = Atom.make(CONFIG);
148 const atoms = createServerEnvironmentAtoms(runtime, {
149 initialConfigValueAtom: () => initialConfigValueAtom,
150 });
151 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
152 Effect.sync(() => registry.dispose()),
153 );
154 const updateSettings = Effect.fn("ServerUsageTest.updateSettings")(function* (
155 next: ServerSettings,
156 ) {
157 settings = next;
158 yield* Queue.offer(events, {
159 version: 1,
160 type: "settingsUpdated",
161 payload: { settings },
162 });
163 yield* AtomRegistry.toStream(registry, atoms.settingsValueAtom(TARGET.environmentId)).pipe(
164 Stream.filter((current) => current === next),
165 Stream.runHead,
166 );
167 });
168 return {
169 registry,
170 requests: () => requests,
171 updateSettings,
172 summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }),
173 };
174});
175
176function waitForCost<E>(
177 registry: AtomRegistry.AtomRegistry,
178 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,
179 cost: number,
180) {
181 return AtomRegistry.toStream(registry, atom).pipe(
182 Stream.filter(
183 (result) =>
184 AsyncResult.isSuccess(result) &&
185 !result.waiting &&
186 result.value.buckets[0]?.costUsd === cost,
187 ),
188 Stream.runHead,
189 );
190}
191
192it.effect("refreshes cached usage windows only when override prices change", () =>
193 Effect.scoped(
194 Effect.gen(function* () {
195 const harness = yield* makeHarness();
196 const current = harness.summary();
197 const previous = harness.summary({ ...INPUT, sinceDay: UsageDay.make("2026-08-01") });
198 const unmountCurrent = harness.registry.mount(current);
199 const unmountPrevious = harness.registry.mount(previous);
200 yield* waitForCost(harness.registry, current, 0);
201 yield* waitForCost(harness.registry, previous, 0);
202 unmountPrevious();
203 expect(harness.requests()).toBe(2);
204
205 const prices = {
206 "custom-model": { inputCostPerMillionTokens: 3, outputCostPerMillionTokens: 9 },
207 "other-model": { inputCostPerMillionTokens: 1, outputCostPerMillionTokens: 2 },
208 };
209 yield* harness.updateSettings({ ...DEFAULT_SERVER_SETTINGS, usagePriceOverrides: prices });
210 yield* waitForCost(harness.registry, current, 3);
211 yield* waitForCost(harness.registry, previous, 3);
212 expect(harness.requests()).toBe(4);
213
214 yield* harness.updateSettings({
215 ...DEFAULT_SERVER_SETTINGS,
216 usagePriceOverrides: {
217 "other-model": { outputCostPerMillionTokens: 2, inputCostPerMillionTokens: 1 },
218 "custom-model": { outputCostPerMillionTokens: 9, inputCostPerMillionTokens: 3 },
219 },
220 });
221 yield* harness.updateSettings({
222 ...DEFAULT_SERVER_SETTINGS,
223 usagePriceOverrides: prices,
224 defaultTheme: "custom-theme",
225 });
226 yield* harness.updateSettings(DEFAULT_SERVER_SETTINGS);
227 yield* waitForCost(harness.registry, current, 0);
228 yield* waitForCost(harness.registry, previous, 0);
229 expect(harness.requests()).toBe(6);
230 unmountCurrent();
231 }),
232 ),
233);
234
235it.effect("restarts a pending usage read after a price change", () =>
236 Effect.scoped(
237 Effect.gen(function* () {
238 const started = yield* Deferred.make<void>();
239 const interrupted = yield* Deferred.make<void>();
240 const harness = yield* makeHarness((request) =>
241 request === 1
242 ? Deferred.succeed(started, undefined).pipe(
243 Effect.andThen(Effect.never),
244 Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
245 )
246 : Effect.void,
247 );
248 const summary = harness.summary();
249 const unmount = harness.registry.mount(summary);
250 yield* Deferred.await(started);
251 yield* harness.updateSettings({
252 ...DEFAULT_SERVER_SETTINGS,
253 usagePriceOverrides: {
254 "custom-model": { inputCostPerMillionTokens: 5, outputCostPerMillionTokens: 10 },
255 },
256 });
257 yield* waitForCost(harness.registry, summary, 5);
258 yield* Deferred.await(interrupted);
259 expect(harness.requests()).toBe(2);
260 unmount();
261 }),
262 ),
263);
packages/contracts/src/environment.ts+2
...@@ -105,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -105,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
105 /** Server streams quota from configured usage-limit sources. Same105 /** Server streams quota from configured usage-limit sources. Same
106 version-skew contract as environmentThemes. */106 version-skew contract as environmentThemes. */
107 usageLimitSources: Schema.optionalKey(Schema.Boolean),107 usageLimitSources: Schema.optionalKey(Schema.Boolean),
108 /** Server persists custom model rates and applies them to usage summaries. */
109 usagePriceOverrides: Schema.optionalKey(Schema.Boolean),
108 /** Server understands thread.pin / thread.unpin commands. Same110 /** Server understands thread.pin / thread.unpin commands. Same
109 version-skew contract as threadSettlement. */111 version-skew contract as threadSettlement. */
110 threadPinning: Schema.optionalKey(Schema.Boolean),112 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.test.ts+52
...@@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);...@@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
21const encodeServerSettings = Schema.encodeSync(ServerSettings);21const encodeServerSettings = Schema.encodeSync(ServerSettings);
22const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);22const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
2323
24describe("ServerSettings usage price overrides", () => {
25 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
26
27 it("defaults to automatic pricing and round-trips arbitrary model IDs", () => {
28 expect(decodeServerSettings({}).usagePriceOverrides).toEqual({});
29 const settings = decodeServerSettings({
30 usagePriceOverrides: { " vendor/example-model ": prices },
31 });
32 expect(encodeServerSettings(settings).usagePriceOverrides).toEqual({
33 "vendor/example-model": prices,
34 });
35 });
36
37 it("accepts zero rates, optional cache rates, and per-model deletion", () => {
38 const overrides = {
39 "example-model": {
40 inputCostPerMillionTokens: 0,
41 outputCostPerMillionTokens: 0,
42 cacheReadCostPerMillionTokens: 0.5,
43 cacheWriteCostPerMillionTokens: 3,
44 },
45 "removed-model": null,
46 };
47 expect(
48 decodeServerSettingsPatch({ usagePriceOverrides: overrides }).usagePriceOverrides,
49 ).toEqual(overrides);
50 });
51
52 it.each([
53 "inputCostPerMillionTokens",
54 "outputCostPerMillionTokens",
55 "cacheReadCostPerMillionTokens",
56 "cacheWriteCostPerMillionTokens",
57 ])("rejects invalid %s rates at the settings boundary", (field) => {
58 for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, "2"]) {
59 const usagePriceOverrides = { "example-model": { ...prices, [field]: value } };
60 expect(() => decodeServerSettings({ usagePriceOverrides })).toThrow();
61 expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
62 }
63 });
64
65 it("rejects empty model IDs and incomplete input/output pricing", () => {
66 for (const usagePriceOverrides of [
67 { " ": prices },
68 { "example-model": { inputCostPerMillionTokens: 2 } },
69 { "example-model": { outputCostPerMillionTokens: 8 } },
70 ]) {
71 expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
72 }
73 });
74});
75
24describe("ClaudeSettings auto-compaction", () => {76describe("ClaudeSettings auto-compaction", () => {
25 it("uses Claude's default threshold when no override is configured", () => {77 it("uses Claude's default threshold when no override is configured", () => {
26 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");78 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
packages/contracts/src/settings.ts+22
...@@ -358,6 +358,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS...@@ -358,6 +358,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS
358358
359// ── Server Settings (server-authoritative) ────────────────────359// ── Server Settings (server-authoritative) ────────────────────
360360
361const UsageModelTokenPrice = Schema.Number.check(
362 Schema.isFinite(),
363 Schema.isGreaterThanOrEqualTo(0),
364);
365
366/** USD per million tokens. Omitted cache rates use the input rate. */
367export const UsageModelPriceOverride = Schema.Struct({
368 inputCostPerMillionTokens: UsageModelTokenPrice,
369 outputCostPerMillionTokens: UsageModelTokenPrice,
370 cacheReadCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice),
371 cacheWriteCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice),
372});
373export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type;
374
361const makeBinaryPathSetting = (fallback: string) =>375const makeBinaryPathSetting = (fallback: string) =>
362 TrimmedString.pipe(376 TrimmedString.pipe(
363 Schema.decodeTo(377 Schema.decodeTo(
...@@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({...@@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({
930 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(944 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
931 Schema.withDecodingDefault(Effect.succeed({})),945 Schema.withDecodingDefault(Effect.succeed({})),
932 ),946 ),
947 /** Exact model IDs, applied to past and future usage on this environment. */
948 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
949 Schema.withDecodingDefault(Effect.succeed({})),
950 ),
933});951});
934export type ServerSettings = typeof ServerSettings.Type;952export type ServerSettings = typeof ServerSettings.Type;
935953
...@@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({
1141 usageLimitSources: Schema.optionalKey(1159 usageLimitSources: Schema.optionalKey(
1142 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),1160 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),
1143 ),1161 ),
1162 /** Each entry replaces one model's rates; `null` restores automatic pricing. */
1163 usagePriceOverrides: Schema.optionalKey(
1164 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
1165 ),
1144});1166});
1145export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;1167export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
11461168
packages/contracts/src/usage.ts+1-1
...@@ -55,7 +55,7 @@ export type UsageResolution = typeof UsageResolution.Type;...@@ -55,7 +55,7 @@ export type UsageResolution = typeof UsageResolution.Type;
55 * Why a bucket's cost is what it is.55 * Why a bucket's cost is what it is.
56 *56 *
57 * - `providerReported` - the transcript carried an explicit cost figure.57 * - `providerReported` - the transcript carried an explicit cost figure.
58 * - `modelPriced` - we matched the model against the LiteLLM rate table.58 * - `modelPriced` - we used a custom price override or the LiteLLM rate table.
59 * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded59 * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded
60 * from cost.60 * from cost.
61 */61 */
packages/shared/src/serverSettings.test.ts+22
...@@ -322,6 +322,28 @@ describe("serverSettings helpers", () => {...@@ -322,6 +322,28 @@ describe("serverSettings helpers", () => {
322 expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]);322 expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]);
323 });323 });
324324
325 it("replaces and removes individual usage prices without clobbering other models", () => {
326 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
327 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
328 usagePriceOverrides: { "example-model": { ...prices, cacheReadCostPerMillionTokens: 0.5 } },
329 });
330 const added = applyServerSettingsPatch(current, {
331 usagePriceOverrides: { "other-model": prices },
332 });
333 const replaced = applyServerSettingsPatch(added, {
334 usagePriceOverrides: { "example-model": prices },
335 });
336 expect(replaced.usagePriceOverrides).toEqual({
337 "example-model": prices,
338 "other-model": prices,
339 });
340 const removed = applyServerSettingsPatch(replaced, {
341 usagePriceOverrides: { "example-model": null },
342 });
343 expect(removed.usagePriceOverrides).toEqual({ "other-model": prices });
344 expect(current.usagePriceOverrides["example-model"]?.cacheReadCostPerMillionTokens).toBe(0.5);
345 });
346
325 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {347 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
326 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {348 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
327 backgroundActivity: {349 backgroundActivity: {
packages/shared/src/serverSettings.ts+15-6
...@@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: {...@@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: {
123}123}
124124
125/** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */125/** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */
126function mergeUsageLimitSources(126function mergeSettingsEntries<Value>(
127 current: ServerSettings["usageLimitSources"],127 current: Readonly<Record<string, Value>>,
128 patch: NonNullable<ServerSettingsPatch["usageLimitSources"]>,128 patch: Readonly<Record<string, Value | null>>,
129): ServerSettings["usageLimitSources"] {129): Record<string, Value> {
130 const next = new Map(Object.entries(current));130 const next = new Map(Object.entries(current));
131 for (const [id, config] of Object.entries(patch)) {131 for (const [id, config] of Object.entries(patch)) {
132 if (config === null) {132 if (config === null) {
...@@ -135,7 +135,7 @@ function mergeUsageLimitSources(...@@ -135,7 +135,7 @@ function mergeUsageLimitSources(
135 next.set(id, config);135 next.set(id, config);
136 }136 }
137 }137 }
138 return Object.fromEntries(next) as ServerSettings["usageLimitSources"];138 return Object.fromEntries(next);
139}139}
140140
141export function applyServerSettingsPatch(141export function applyServerSettingsPatch(
...@@ -150,6 +150,7 @@ export function applyServerSettingsPatch(...@@ -150,6 +150,7 @@ export function applyServerSettingsPatch(
150 backgroundActivity,150 backgroundActivity,
151 // Merged per entry below; its `null` removals must not reach deepMerge.151 // Merged per entry below; its `null` removals must not reach deepMerge.
152 usageLimitSources: usageLimitSourcesPatch,152 usageLimitSources: usageLimitSourcesPatch,
153 usagePriceOverrides: usagePriceOverridesPatch,
153 ...patchForMerge154 ...patchForMerge
154 } = patch;155 } = patch;
155 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);156 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
...@@ -208,12 +209,20 @@ export function applyServerSettingsPatch(...@@ -208,12 +209,20 @@ export function applyServerSettingsPatch(
208 : {}),209 : {}),
209 ...(usageLimitSourcesPatch !== undefined210 ...(usageLimitSourcesPatch !== undefined
210 ? {211 ? {
211 usageLimitSources: mergeUsageLimitSources(212 usageLimitSources: mergeSettingsEntries(
212 current.usageLimitSources,213 current.usageLimitSources,
213 usageLimitSourcesPatch,214 usageLimitSourcesPatch,
214 ),215 ),
215 }216 }
216 : {}),217 : {}),
218 ...(usagePriceOverridesPatch !== undefined
219 ? {
220 usagePriceOverrides: mergeSettingsEntries(
221 current.usagePriceOverrides,
222 usagePriceOverridesPatch,
223 ),
224 }
225 : {}),
217 ...(patch.sourceControlWriterModelSelection !== undefined226 ...(patch.sourceControlWriterModelSelection !== undefined
218 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }227 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
219 : {}),228 : {}),