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) => {
166166 expect(second.capabilities.attachmentUploads).toBe(true);
167167 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
168168 expect(second.capabilities.pullRequests).toBe(true);
169 expect(second.capabilities.usagePriceOverrides).toBe(true);
169170 expect(second.capabilities.threadTitleRegeneration).toBe(true);
170171 expect(second.capabilities.threadPullRequestLinking).toBe(true);
171172 expect(second.capabilities.agentActivityPublishing).toBe(false);
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222222 threadSnooze: true,
223223 environmentThemes: true,
224224 usageLimitSources: true,
225 usagePriceOverrides: true,
225226 threadPinning: true,
226227 threadPinReorder: true,
227228 threadTitleRegeneration: true,
apps/server/src/serverSettings.test.ts+26
......@@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => {
272272 ).pipe(Effect.provide(makeServerSettingsLayer())),
273273 );
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
275301 it.effect("persists and broadcasts thread settlement settings", () =>
276302 Effect.scoped(
277303 Effect.gen(function* () {
apps/server/src/usage/UsageService.test.ts+106-2
......@@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
99import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1010import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
1111import * as Duration from "effect/Duration";
12import * as Deferred from "effect/Deferred";
1213import * as Effect from "effect/Effect";
1314import * as Exit from "effect/Exit";
1415import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
1517import * as Layer from "effect/Layer";
1618import * as Scheduler from "effect/Scheduler";
1719import * as TestClock from "effect/testing/TestClock";
......@@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts";
2123import * as ServerSettings from "../serverSettings.ts";
2224import * as UsageService from "./UsageService.ts";
2325
24function claudeLine(id: number, outputTokens: number): string {
26function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
2527 return `${JSON.stringify({
2628 type: "assistant",
2729 timestamp: "2026-08-01T10:00:00Z",
......@@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string {
2931 sessionId: "session-1",
3032 message: {
3133 id: `msg_${id}`,
32 model: "claude-fable-5",
34 model,
3335 usage: { input_tokens: 10, output_tokens: outputTokens },
3436 },
3537 })}\n`;
......@@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
9698}
9799
98100describe("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
99144 it.live("counts appended usage on a rescan of a grown transcript", () =>
100145 Effect.gen(function* () {
101146 const { transcript, settings, home } = yield* setup;
......@@ -114,6 +159,65 @@ describe("UsageService", () => {
114159 }).pipe(Effect.scoped),
115160 );
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
117221 it.live("shares one scan between concurrent identical requests", () =>
118222 Effect.gen(function* () {
119223 const { transcript, settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+42-28
......@@ -16,6 +16,7 @@ import * as NodeOS from "node:os";
1616
1717import {
1818 USAGE_CONTRACT_VERSION,
19 type ServerSettings as ServerSettingsValue,
1920 type UsageProviderKind,
2021 type UsageSource,
2122 type UsagePricing,
......@@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts";
4445import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
4546import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
4647import { UsageAggregator } from "./usageAggregation.ts";
47import { parseRateTable, type RateTable } from "./usagePricing.ts";
48import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
4849import {
4950 listTranscriptFiles,
5051 readDirectoryVolumeId,
......@@ -232,24 +233,22 @@ export const make = Effect.gen(function* () {
232233 return nestedExists ? nested : path.join(homePath, "projects");
233234 });
234235
235 /** Resolves the transcript directory for each provider. */
236 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {
237 // A settings failure must surface as an error: swallowing it here would
238 // present "zero usage from every provider" as a valid answer.
239 const settings = yield* settingsService.getSettings.pipe(
240 Effect.catchCause(
241 (cause) =>
242 new UsageReadError({
243 reason: "scanFailed",
244 // Bounded description; the squashed failure travels as the cause.
245 // Squashed, not the Cause tree: a full tree in a Defect field is
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 );
236 // A settings failure must not silently discard custom rates or transcript homes.
237 const readSettings = settingsService.getSettings.pipe(
238 Effect.catchCause(
239 (cause) =>
240 new UsageReadError({
241 reason: "scanFailed",
242 detail: "Server settings could not be read.",
243 cause: Cause.squash(cause),
244 }),
245 ),
246 );
252247
248 /** Resolves the transcript directory for each provider. */
249 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
250 settings: ServerSettingsValue,
251 ) {
253252 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
254253 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
255254 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
......@@ -379,10 +378,15 @@ export const make = Effect.gen(function* () {
379378 | null;
380379 }
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 ) {
383385 // The home resolvers ask for `Path` themselves; satisfy them from the
384386 // 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 );
386390 const scanned: ScannedDir[] = [];
387391 for (const { provider, dir, fileName } of dirs) {
388392 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
......@@ -406,7 +410,10 @@ export const make = Effect.gen(function* () {
406410 return scanned;
407411 });
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 ) {
410417 if (input.sinceDay > input.untilDay) {
411418 return yield* new UsageReadError({
412419 reason: "invalidWindow",
......@@ -455,9 +462,10 @@ export const make = Effect.gen(function* () {
455462 // Pricing only matters once records are aggregated, so the rate table
456463 // loads while transcripts stream instead of gating them: a cold rates
457464 // fetch on a slow network no longer delays the scan by its own timeout.
458 const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], {
459 concurrency: 2,
460 });
465 const [, scannedDirs] = yield* Effect.all(
466 [ensureRates(false), collectDirs(windowStartMs, settings)],
467 { concurrency: 2 },
468 );
461469
462470 const aggregator = new UsageAggregator({
463471 timeZone: input.timeZone,
......@@ -466,6 +474,7 @@ export const make = Effect.gen(function* () {
466474 resolution: input.resolution ?? "day",
467475 ...hourlyWindow,
468476 rates,
477 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
469478 });
470479
471480 const sources: UsageSource[] = [];
......@@ -547,13 +556,16 @@ export const make = Effect.gen(function* () {
547556 });
548557
549558 /**
550 * In-flight scans by window, so concurrent identical requests (the usage
559 * In-flight scans by window and custom prices, so concurrent identical requests (the usage
551560 * page open on two clients at once) share one scan instead of racing over
552561 * the same corpus twice.
553562 */
554563 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 =>
557569 JSON.stringify([
558570 input.timeZone,
559571 input.sinceDay,
......@@ -561,10 +573,12 @@ export const make = Effect.gen(function* () {
561573 input.resolution ?? "day",
562574 input.sinceTime ?? null,
563575 input.untilTime ?? null,
576 priceOverrides,
564577 ]);
565578
566579 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);
568582 const deferred = yield* Effect.uninterruptible(
569583 Effect.gen(function* () {
570584 const existing = inflightScans.get(key);
......@@ -576,7 +590,7 @@ export const make = Effect.gen(function* () {
576590 inflightScans.set(key, created);
577591 // Detached so one departing client cannot tear the scan out from under
578592 // the fibers awaiting it; a finished scan warms the cache either way.
579 yield* scanSummary(input).pipe(
593 yield* scanSummary(input, settings).pipe(
580594 Effect.onExit((exit) =>
581595 Effect.sync(() => inflightScans.delete(key)).pipe(
582596 Effect.andThen(Deferred.done(created, exit)),
apps/server/src/usage/usageAggregation.ts+8-1
......@@ -61,6 +61,7 @@ export interface AggregateOptions {
6161 readonly sinceDay: string;
6262 readonly untilDay: string;
6363 readonly rates: RateTable;
64 readonly priceOverrides?: RateTable;
6465 readonly resolution?: UsageResolution;
6566 readonly sinceTimeMs?: number;
6667 readonly untilTimeMs?: number;
......@@ -165,11 +166,17 @@ export class UsageAggregator {
165166 record.model,
166167 record.totals,
167168 record.reportedCostUsd,
169 this.#options.priceOverrides,
168170 );
169171
170172 bucket.totals = addTotals(bucket.totals, record.totals);
171173 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 );
173180 bucket.records += 1;
174181 if (priced.costSource === "unpriced") bucket.unpricedRecords += 1;
175182 if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;
apps/server/src/usage/usagePricing.test.ts+75-1
......@@ -1,6 +1,13 @@
11import { 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
512const rate = (input: number, cacheRead?: number) => ({
613 input_cost_per_token: input,
......@@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({
916});
1017
1118describe("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
1286 it("keeps the existing model-name normalization contract", () => {
1387 expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5");
1488 });
apps/server/src/usage/usagePricing.ts+35-5
......@@ -7,7 +7,11 @@
77 *
88 * @module usagePricing
99 */
10import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts";
10import type {
11 UsageCostSource,
12 UsageModelPriceOverride,
13 UsageTokenTotals,
14} from "@t3tools/contracts";
1115
1216/**
1317 * The subset of a LiteLLM entry we price against. All values are USD per token.
......@@ -26,6 +30,25 @@ export interface ModelRate {
2630
2731export 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
2952/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
3053interface LiteLlmEntry {
3154 readonly input_cost_per_token?: unknown;
......@@ -168,12 +191,14 @@ export function priceUsage(
168191 model: string,
169192 totals: UsageTokenTotals,
170193 reportedCostUsd: number | null,
194 overrides?: RateTable,
171195): PricedUsage {
172 if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
196 const override = overrides?.get(model.trim());
197 if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
173198 return { costUsd: reportedCostUsd, costSource: "providerReported" };
174199 }
175200
176 const rate = lookupRate(table, model);
201 const rate = override ?? lookupRate(table, model);
177202 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
178203
179204 const costUsd =
......@@ -189,8 +214,13 @@ export function priceUsage(
189214 * What the cached input would have cost at full input rates, minus what it
190215 * actually cost. Drives the "cache savings" figure.
191216 */
192export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {
193 const rate = lookupRate(table, model);
217export function cacheSavingsUsd(
218 table: RateTable,
219 model: string,
220 totals: UsageTokenTotals,
221 overrides?: RateTable,
222): number {
223 const rate = overrides?.get(model.trim()) ?? lookupRate(table, model);
194224 if (rate === null) return 0;
195225 return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
196226}
apps/web/src/components/usage/UsagePage.test.tsx+1
......@@ -57,6 +57,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({
5757vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" }));
5858vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" }));
5959vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" }));
60vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null }));
6061vi.mock("./usageProviders", async (importOriginal) => {
6162 const actual = await importOriginal<typeof import("./usageProviders")>();
6263 return {
apps/web/src/components/usage/UsagePage.tsx+6
......@@ -36,6 +36,7 @@ import {
3636import { WorkspacePageContainer } from "../WorkspacePageContainer";
3737import { WorkspacePageHeader } from "../WorkspacePageHeader";
3838import { UsageLimitsSection } from "./UsageLimits";
39import { UsagePriceOverrides } from "./UsagePriceOverrides";
3940import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
4041import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
4142
......@@ -264,6 +265,11 @@ export function UsagePage() {
264265
265266 <ScrollArea className="min-h-0 flex-1">
266267 <WorkspacePageContainer width="wide">
268 {!showingLimits ? (
269 <div className="flex justify-end">
270 <UsagePriceOverrides usage={environments} />
271 </div>
272 ) : null}
267273 {showingLimits ? (
268274 <UsageLimitsSection />
269275 ) : 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.
1212If recent work is missing or a new model shows no cost, refresh to rescan session history and
1313update 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
1527## Track subscription limits
1628
1729**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>(
807807 Atom.withLabel(`environment-data:server:settings:${environmentId}`),
808808 ),
809809 );
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 );
810831 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
811832 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
812833 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
......@@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>(
892913 label: "environment-data:server:usage-summary",
893914 tag: WS_METHODS.serverGetUsageSummary,
894915 staleTimeMs: 60_000,
916 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),
895917 }),
896918 configProjection,
897919 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({
105105 /** Server streams quota from configured usage-limit sources. Same
106106 version-skew contract as environmentThemes. */
107107 usageLimitSources: Schema.optionalKey(Schema.Boolean),
108 /** Server persists custom model rates and applies them to usage summaries. */
109 usagePriceOverrides: Schema.optionalKey(Schema.Boolean),
108110 /** Server understands thread.pin / thread.unpin commands. Same
109111 version-skew contract as threadSettlement. */
110112 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.test.ts+52
......@@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
2121const encodeServerSettings = Schema.encodeSync(ServerSettings);
2222const 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
2476describe("ClaudeSettings auto-compaction", () => {
2577 it("uses Claude's default threshold when no override is configured", () => {
2678 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
packages/contracts/src/settings.ts+22
......@@ -358,6 +358,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS
358358
359359// ── 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
361375const makeBinaryPathSetting = (fallback: string) =>
362376 TrimmedString.pipe(
363377 Schema.decodeTo(
......@@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({
930944 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
931945 Schema.withDecodingDefault(Effect.succeed({})),
932946 ),
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 ),
933951});
934952export type ServerSettings = typeof ServerSettings.Type;
935953
......@@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({
11411159 usageLimitSources: Schema.optionalKey(
11421160 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),
11431161 ),
1162 /** Each entry replaces one model's rates; `null` restores automatic pricing. */
1163 usagePriceOverrides: Schema.optionalKey(
1164 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
1165 ),
11441166});
11451167export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
11461168
packages/contracts/src/usage.ts+1-1
......@@ -55,7 +55,7 @@ export type UsageResolution = typeof UsageResolution.Type;
5555 * Why a bucket's cost is what it is.
5656 *
5757 * - `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.
5959 * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded
6060 * from cost.
6161 */
packages/shared/src/serverSettings.test.ts+22
......@@ -322,6 +322,28 @@ describe("serverSettings helpers", () => {
322322 expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]);
323323 });
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
325347 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
326348 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
327349 backgroundActivity: {
packages/shared/src/serverSettings.ts+15-6
......@@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: {
123123}
124124
125125/** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */
126function mergeUsageLimitSources(
127 current: ServerSettings["usageLimitSources"],
128 patch: NonNullable<ServerSettingsPatch["usageLimitSources"]>,
129): ServerSettings["usageLimitSources"] {
126function mergeSettingsEntries<Value>(
127 current: Readonly<Record<string, Value>>,
128 patch: Readonly<Record<string, Value | null>>,
129): Record<string, Value> {
130130 const next = new Map(Object.entries(current));
131131 for (const [id, config] of Object.entries(patch)) {
132132 if (config === null) {
......@@ -135,7 +135,7 @@ function mergeUsageLimitSources(
135135 next.set(id, config);
136136 }
137137 }
138 return Object.fromEntries(next) as ServerSettings["usageLimitSources"];
138 return Object.fromEntries(next);
139139}
140140
141141export function applyServerSettingsPatch(
......@@ -150,6 +150,7 @@ export function applyServerSettingsPatch(
150150 backgroundActivity,
151151 // Merged per entry below; its `null` removals must not reach deepMerge.
152152 usageLimitSources: usageLimitSourcesPatch,
153 usagePriceOverrides: usagePriceOverridesPatch,
153154 ...patchForMerge
154155 } = patch;
155156 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
......@@ -208,12 +209,20 @@ export function applyServerSettingsPatch(
208209 : {}),
209210 ...(usageLimitSourcesPatch !== undefined
210211 ? {
211 usageLimitSources: mergeUsageLimitSources(
212 usageLimitSources: mergeSettingsEntries(
212213 current.usageLimitSources,
213214 usageLimitSourcesPatch,
214215 ),
215216 }
216217 : {}),
218 ...(usagePriceOverridesPatch !== undefined
219 ? {
220 usagePriceOverrides: mergeSettingsEntries(
221 current.usagePriceOverrides,
222 usagePriceOverridesPatch,
223 ),
224 }
225 : {}),
217226 ...(patch.sourceControlWriterModelSelection !== undefined
218227 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
219228 : {}),