| author | |
| committer | |
| log | 394e8470c89927a06575e08c434006b2320f1781 |
| tree | 6f28eb29747c13099cd516707513b92b02380e53 |
| parent | 5e1c23d769b182018f5bd6d3623a7edec13d26bc |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 166 | expect(second.capabilities.attachmentUploads).toBe(true); |
| 167 | 167 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); |
| 168 | 168 | expect(second.capabilities.pullRequests).toBe(true); |
| 169 | expect(second.capabilities.usagePriceOverrides).toBe(true); | |
| 169 | 170 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 170 | 171 | expect(second.capabilities.threadPullRequestLinking).toBe(true); |
| 171 | 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 | 222 | threadSnooze: true, |
| 223 | 223 | environmentThemes: true, |
| 224 | 224 | usageLimitSources: true, |
| 225 | usagePriceOverrides: true, | |
| 225 | 226 | threadPinning: true, |
| 226 | 227 | threadPinReorder: true, |
| 227 | 228 | threadTitleRegeneration: true, |
apps/server/src/serverSettings.test.ts+26| ... | ... | @@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 272 | 272 | ).pipe(Effect.provide(makeServerSettingsLayer())), |
| 273 | 273 | ); |
| 274 | 274 | |
| 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 | 301 | it.effect("persists and broadcasts thread settlement settings", () => |
| 276 | 302 | Effect.scoped( |
| 277 | 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 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; |
| 11 | 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Deferred from "effect/Deferred"; | |
| 12 | 13 | import * as Effect from "effect/Effect"; |
| 13 | 14 | import * as Exit from "effect/Exit"; |
| 14 | 15 | import * as Fiber from "effect/Fiber"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 15 | 17 | import * as Layer from "effect/Layer"; |
| 16 | 18 | import * as Scheduler from "effect/Scheduler"; |
| 17 | 19 | import * as TestClock from "effect/testing/TestClock"; |
| ... | ... | @@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts"; |
| 21 | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 22 | 24 | import * as UsageService from "./UsageService.ts"; |
| 23 | 25 | |
| 24 | function claudeLine(id: number, outputTokens: number): string { | |
| 26 | function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { | |
| 25 | 27 | return `${JSON.stringify({ |
| 26 | 28 | type: "assistant", |
| 27 | 29 | timestamp: "2026-08-01T10:00:00Z", |
| ... | ... | @@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string { |
| 29 | 31 | sessionId: "session-1", |
| 30 | 32 | message: { |
| 31 | 33 | id: `msg_${id}`, |
| 32 | model: "claude-fable-5", | |
| 34 | model, | |
| 33 | 35 | usage: { input_tokens: 10, output_tokens: outputTokens }, |
| 34 | 36 | }, |
| 35 | 37 | })}\n`; |
| ... | ... | @@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens |
| 96 | 98 | } |
| 97 | 99 | |
| 98 | 100 | describe("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 | 144 | it.live("counts appended usage on a rescan of a grown transcript", () => |
| 100 | 145 | Effect.gen(function* () { |
| 101 | 146 | const { transcript, settings, home } = yield* setup; |
| ... | ... | @@ -114,6 +159,65 @@ describe("UsageService", () => { |
| 114 | 159 | }).pipe(Effect.scoped), |
| 115 | 160 | ); |
| 116 | 161 | |
| 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 | 221 | it.live("shares one scan between concurrent identical requests", () => |
| 118 | 222 | Effect.gen(function* () { |
| 119 | 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 | 16 | |
| 17 | 17 | import { |
| 18 | 18 | USAGE_CONTRACT_VERSION, |
| 19 | type ServerSettings as ServerSettingsValue, | |
| 19 | 20 | type UsageProviderKind, |
| 20 | 21 | type UsageSource, |
| 21 | 22 | type UsagePricing, |
| ... | ... | @@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 44 | 45 | import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; |
| 45 | 46 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; |
| 46 | 47 | import { UsageAggregator } from "./usageAggregation.ts"; |
| 47 | import { parseRateTable, type RateTable } from "./usagePricing.ts"; | |
| 48 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; | |
| 48 | 49 | import { |
| 49 | 50 | listTranscriptFiles, |
| 50 | 51 | readDirectoryVolumeId, |
| ... | ... | @@ -232,24 +233,22 @@ export const make = Effect.gen(function* () { |
| 232 | 233 | return nestedExists ? nested : path.join(homePath, "projects"); |
| 233 | 234 | }); |
| 234 | 235 | |
| 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 | ); | |
| 252 | 247 | |
| 248 | /** Resolves the transcript directory for each provider. */ | |
| 249 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( | |
| 250 | settings: ServerSettingsValue, | |
| 251 | ) { | |
| 253 | 252 | const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); |
| 254 | 253 | const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); |
| 255 | 254 | const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); |
| ... | ... | @@ -379,10 +378,15 @@ export const make = Effect.gen(function* () { |
| 379 | 378 | | null; |
| 380 | 379 | } |
| 381 | 380 | |
| 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 | 385 | // The home resolvers ask for `Path` themselves; satisfy them from the |
| 384 | 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 | 390 | const scanned: ScannedDir[] = []; |
| 387 | 391 | for (const { provider, dir, fileName } of dirs) { |
| 388 | 392 | const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); |
| ... | ... | @@ -406,7 +410,10 @@ export const make = Effect.gen(function* () { |
| 406 | 410 | return scanned; |
| 407 | 411 | }); |
| 408 | 412 | |
| 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 | 417 | if (input.sinceDay > input.untilDay) { |
| 411 | 418 | return yield* new UsageReadError({ |
| 412 | 419 | reason: "invalidWindow", |
| ... | ... | @@ -455,9 +462,10 @@ export const make = Effect.gen(function* () { |
| 455 | 462 | // Pricing only matters once records are aggregated, so the rate table |
| 456 | 463 | // loads while transcripts stream instead of gating them: a cold rates |
| 457 | 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)], { | |
| 459 | concurrency: 2, | |
| 460 | }); | |
| 465 | const [, scannedDirs] = yield* Effect.all( | |
| 466 | [ensureRates(false), collectDirs(windowStartMs, settings)], | |
| 467 | { concurrency: 2 }, | |
| 468 | ); | |
| 461 | 469 | |
| 462 | 470 | const aggregator = new UsageAggregator({ |
| 463 | 471 | timeZone: input.timeZone, |
| ... | ... | @@ -466,6 +474,7 @@ export const make = Effect.gen(function* () { |
| 466 | 474 | resolution: input.resolution ?? "day", |
| 467 | 475 | ...hourlyWindow, |
| 468 | 476 | rates, |
| 477 | priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), | |
| 469 | 478 | }); |
| 470 | 479 | |
| 471 | 480 | const sources: UsageSource[] = []; |
| ... | ... | @@ -547,13 +556,16 @@ export const make = Effect.gen(function* () { |
| 547 | 556 | }); |
| 548 | 557 | |
| 549 | 558 | /** |
| 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 | |
| 551 | 560 | * page open on two clients at once) share one scan instead of racing over |
| 552 | 561 | * the same corpus twice. |
| 553 | 562 | */ |
| 554 | 563 | const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>(); |
| 555 | 564 | |
| 556 | const scanKey = (input: UsageSummaryInput): string => | |
| 565 | const scanKey = ( | |
| 566 | input: UsageSummaryInput, | |
| 567 | priceOverrides: ServerSettingsValue["usagePriceOverrides"], | |
| 568 | ): string => | |
| 557 | 569 | JSON.stringify([ |
| 558 | 570 | input.timeZone, |
| 559 | 571 | input.sinceDay, |
| ... | ... | @@ -561,10 +573,12 @@ export const make = Effect.gen(function* () { |
| 561 | 573 | input.resolution ?? "day", |
| 562 | 574 | input.sinceTime ?? null, |
| 563 | 575 | input.untilTime ?? null, |
| 576 | priceOverrides, | |
| 564 | 577 | ]); |
| 565 | 578 | |
| 566 | 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 | 582 | const deferred = yield* Effect.uninterruptible( |
| 569 | 583 | Effect.gen(function* () { |
| 570 | 584 | const existing = inflightScans.get(key); |
| ... | ... | @@ -576,7 +590,7 @@ export const make = Effect.gen(function* () { |
| 576 | 590 | inflightScans.set(key, created); |
| 577 | 591 | // Detached so one departing client cannot tear the scan out from under |
| 578 | 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 | 594 | Effect.onExit((exit) => |
| 581 | 595 | Effect.sync(() => inflightScans.delete(key)).pipe( |
| 582 | 596 | Effect.andThen(Deferred.done(created, exit)), |
apps/server/src/usage/usageAggregation.ts+8-1| ... | ... | @@ -61,6 +61,7 @@ export interface AggregateOptions { |
| 61 | 61 | readonly sinceDay: string; |
| 62 | 62 | readonly untilDay: string; |
| 63 | 63 | readonly rates: RateTable; |
| 64 | readonly priceOverrides?: RateTable; | |
| 64 | 65 | readonly resolution?: UsageResolution; |
| 65 | 66 | readonly sinceTimeMs?: number; |
| 66 | 67 | readonly untilTimeMs?: number; |
| ... | ... | @@ -165,11 +166,17 @@ export class UsageAggregator { |
| 165 | 166 | record.model, |
| 166 | 167 | record.totals, |
| 167 | 168 | record.reportedCostUsd, |
| 169 | this.#options.priceOverrides, | |
| 168 | 170 | ); |
| 169 | 171 | |
| 170 | 172 | bucket.totals = addTotals(bucket.totals, record.totals); |
| 171 | 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 | 180 | bucket.records += 1; |
| 174 | 181 | if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; |
| 175 | 182 | if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; |
apps/server/src/usage/usagePricing.test.ts+75-1| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; | |
| 3 | import { | |
| 4 | cacheSavingsUsd, | |
| 5 | createOverrideRateTable, | |
| 6 | lookupRate, | |
| 7 | normalizeModelName, | |
| 8 | parseRateTable, | |
| 9 | priceUsage, | |
| 10 | } from "./usagePricing.ts"; | |
| 4 | 11 | |
| 5 | 12 | const rate = (input: number, cacheRead?: number) => ({ |
| 6 | 13 | input_cost_per_token: input, |
| ... | ... | @@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({ |
| 9 | 16 | }); |
| 10 | 17 | |
| 11 | 18 | describe("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 | 86 | it("keeps the existing model-name normalization contract", () => { |
| 13 | 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 | * |
| 8 | 8 | * @module usagePricing |
| 9 | 9 | */ |
| 10 | import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; | |
| 10 | import type { | |
| 11 | UsageCostSource, | |
| 12 | UsageModelPriceOverride, | |
| 13 | UsageTokenTotals, | |
| 14 | } from "@t3tools/contracts"; | |
| 11 | 15 | |
| 12 | 16 | /** |
| 13 | 17 | * The subset of a LiteLLM entry we price against. All values are USD per token. |
| ... | ... | @@ -26,6 +30,25 @@ export interface ModelRate { |
| 26 | 30 | |
| 27 | 31 | export type RateTable = ReadonlyMap<string, ModelRate>; |
| 28 | 32 | |
| 33 | /** Custom IDs keep their case, provider prefix, and variant suffix. */ | |
| 34 | export 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 | 52 | /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ |
| 30 | 53 | interface LiteLlmEntry { |
| 31 | 54 | readonly input_cost_per_token?: unknown; |
| ... | ... | @@ -168,12 +191,14 @@ export function priceUsage( |
| 168 | 191 | model: string, |
| 169 | 192 | totals: UsageTokenTotals, |
| 170 | 193 | reportedCostUsd: number | null, |
| 194 | overrides?: RateTable, | |
| 171 | 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 | 198 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; |
| 174 | 199 | } |
| 175 | 200 | |
| 176 | const rate = lookupRate(table, model); | |
| 201 | const rate = override ?? lookupRate(table, model); | |
| 177 | 202 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; |
| 178 | 203 | |
| 179 | 204 | const costUsd = |
| ... | ... | @@ -189,8 +214,13 @@ export function priceUsage( |
| 189 | 214 | * What the cached input would have cost at full input rates, minus what it |
| 190 | 215 | * actually cost. Drives the "cache savings" figure. |
| 191 | 216 | */ |
| 192 | export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { | |
| 193 | const rate = lookupRate(table, model); | |
| 217 | export 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); | |
| 194 | 224 | if (rate === null) return 0; |
| 195 | 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 | 57 | vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); |
| 58 | 58 | vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); |
| 59 | 59 | vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); |
| 60 | vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); | |
| 60 | 61 | vi.mock("./usageProviders", async (importOriginal) => { |
| 61 | 62 | const actual = await importOriginal<typeof import("./usageProviders")>(); |
| 62 | 63 | return { |
apps/web/src/components/usage/UsagePage.tsx+6| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 37 | 37 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 38 | 38 | import { UsageLimitsSection } from "./UsageLimits"; |
| 39 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; | |
| 39 | 40 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 40 | 41 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 41 | 42 | |
| ... | ... | @@ -264,6 +265,11 @@ export function UsagePage() { |
| 264 | 265 | |
| 265 | 266 | <ScrollArea className="min-h-0 flex-1"> |
| 266 | 267 | <WorkspacePageContainer width="wide"> |
| 268 | {!showingLimits ? ( | |
| 269 | <div className="flex justify-end"> | |
| 270 | <UsagePriceOverrides usage={environments} /> | |
| 271 | </div> | |
| 272 | ) : null} | |
| 267 | 273 | {showingLimits ? ( |
| 268 | 274 | <UsageLimitsSection /> |
| 269 | 275 | ) : settling ? ( |
apps/web/src/components/usage/UsagePriceOverrides.tsx created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { EnvironmentId, UsageModelPriceOverride } from "@t3tools/contracts"; | |
| 3 | import { useState } from "react"; | |
| 4 | ||
| 5 | import { isElectron } from "../../env"; | |
| 6 | import { | |
| 7 | type EnvironmentPresentation, | |
| 8 | useEnvironments, | |
| 9 | usePrimaryEnvironmentId, | |
| 10 | } from "../../state/environments"; | |
| 11 | import { serverEnvironment } from "../../state/server"; | |
| 12 | import { useEnvironmentSessionState } from "../../state/session"; | |
| 13 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 14 | import type { EnvironmentUsageStatus } from "../../state/usage"; | |
| 15 | import { | |
| 16 | resolvePrimaryOperateAccess, | |
| 17 | resolveRemoteOperateAccess, | |
| 18 | } from "../settings/ProviderSettingsPanel.logic"; | |
| 19 | import { Button } from "../ui/button"; | |
| 20 | import { | |
| 21 | Dialog, | |
| 22 | DialogDescription, | |
| 23 | DialogHeader, | |
| 24 | DialogPanel, | |
| 25 | DialogPopup, | |
| 26 | DialogTitle, | |
| 27 | } from "../ui/dialog"; | |
| 28 | import { Input } from "../ui/input"; | |
| 29 | import { Label } from "../ui/label"; | |
| 30 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 31 | import { parseUsagePriceForm, USAGE_PRICE_FIELDS, usagePriceForm } from "./usagePriceForm"; | |
| 32 | ||
| 33 | export 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 | ||
| 49 | function 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 | ||
| 117 | function 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { parseUsagePriceForm, usagePriceForm } from "./usagePriceForm.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import type { UsageModelPriceOverride } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export 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 | ||
| 10 | export type UsagePriceForm = { model: string } & Record< | |
| 11 | (typeof USAGE_PRICE_FIELDS)[number]["key"], | |
| 12 | string | |
| 13 | >; | |
| 14 | ||
| 15 | export 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. */ | |
| 26 | export 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 | 12 | If recent work is missing or a new model shows no cost, refresh to rescan session history and |
| 13 | 13 | update model pricing. |
| 14 | 14 | |
| 15 | ## Set custom model prices | |
| 16 | ||
| 17 | On web or desktop, open **Usage → Model prices** to add, edit, or remove a model's estimated | |
| 18 | price. Choose the environment whose history you want to price, then enter the exact model ID and | |
| 19 | USD rates per million input and output tokens. You can enter any model ID, including models | |
| 20 | without public pricing. | |
| 21 | ||
| 22 | Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for | |
| 23 | tokens that are free. Saved prices replace automatic pricing for all of that environment's | |
| 24 | history and are shared with clients connected to it. Set prices on each environment that needs | |
| 25 | them. Removing a price restores automatic pricing. | |
| 26 | ||
| 15 | 27 | ## Track subscription limits |
| 16 | 28 | |
| 17 | 29 | **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 | 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 | 831 | const providersValueAtom = Atom.family((environmentId: EnvironmentId) => |
| 811 | 832 | Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( |
| 812 | 833 | Atom.withLabel(`environment-data:server:providers:${environmentId}`), |
| ... | ... | @@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 892 | 913 | label: "environment-data:server:usage-summary", |
| 893 | 914 | tag: WS_METHODS.serverGetUsageSummary, |
| 894 | 915 | staleTimeMs: 60_000, |
| 916 | refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), | |
| 895 | 917 | }), |
| 896 | 918 | configProjection, |
| 897 | 919 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
packages/client-runtime/src/state/serverUsage.test.ts created+263| ... | ... | @@ -0,0 +1,263 @@ |
| 1 | import { | |
| 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"; | |
| 13 | import { expect, it } from "@effect/vitest"; | |
| 14 | import * as Deferred from "effect/Deferred"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Queue from "effect/Queue"; | |
| 19 | import * as Stream from "effect/Stream"; | |
| 20 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 21 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 22 | ||
| 23 | import { | |
| 24 | AVAILABLE_CONNECTION_STATE, | |
| 25 | PrimaryConnectionTarget, | |
| 26 | type PreparedConnection, | |
| 27 | type SupervisorConnectionState, | |
| 28 | } from "../connection/model.ts"; | |
| 29 | import { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 30 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | |
| 31 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | |
| 32 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | |
| 33 | import type { RpcSession } from "../rpc/session.ts"; | |
| 34 | import { createServerEnvironmentAtoms } from "./server.ts"; | |
| 35 | ||
| 36 | const 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 | ||
| 43 | const INPUT: UsageSummaryInput = { | |
| 44 | sinceDay: UsageDay.make("2026-09-01"), | |
| 45 | untilDay: UsageDay.make("2026-09-04"), | |
| 46 | timeZone: "UTC", | |
| 47 | }; | |
| 48 | ||
| 49 | const CONFIG = { | |
| 50 | settings: DEFAULT_SERVER_SETTINGS, | |
| 51 | environment: { serverVersion: "0.0.1", capabilities: {} }, | |
| 52 | } as ServerConfig; | |
| 53 | ||
| 54 | const 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 | ||
| 176 | function 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 | ||
| 192 | it.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 | ||
| 235 | it.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 | 105 | /** Server streams quota from configured usage-limit sources. Same |
| 106 | 106 | version-skew contract as environmentThemes. */ |
| 107 | 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 | 110 | /** Server understands thread.pin / thread.unpin commands. Same |
| 109 | 111 | version-skew contract as threadSettlement. */ |
| 110 | 112 | threadPinning: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/settings.test.ts+52| ... | ... | @@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 21 | 21 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 22 | 22 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); |
| 23 | 23 | |
| 24 | describe("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 | ||
| 24 | 76 | describe("ClaudeSettings auto-compaction", () => { |
| 25 | 77 | it("uses Claude's default threshold when no override is configured", () => { |
| 26 | 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 | 358 | |
| 359 | 359 | // ── Server Settings (server-authoritative) ──────────────────── |
| 360 | 360 | |
| 361 | const 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. */ | |
| 367 | export const UsageModelPriceOverride = Schema.Struct({ | |
| 368 | inputCostPerMillionTokens: UsageModelTokenPrice, | |
| 369 | outputCostPerMillionTokens: UsageModelTokenPrice, | |
| 370 | cacheReadCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), | |
| 371 | cacheWriteCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), | |
| 372 | }); | |
| 373 | export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; | |
| 374 | ||
| 361 | 375 | const makeBinaryPathSetting = (fallback: string) => |
| 362 | 376 | TrimmedString.pipe( |
| 363 | 377 | Schema.decodeTo( |
| ... | ... | @@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({ |
| 930 | 944 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( |
| 931 | 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 | }); |
| 934 | 952 | export type ServerSettings = typeof ServerSettings.Type; |
| 935 | 953 | |
| ... | ... | @@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1141 | 1159 | usageLimitSources: Schema.optionalKey( |
| 1142 | 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 | }); |
| 1145 | 1167 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1146 | 1168 |
packages/contracts/src/usage.ts+1-1| ... | ... | @@ -55,7 +55,7 @@ export type UsageResolution = typeof UsageResolution.Type; |
| 55 | 55 | * Why a bucket's cost is what it is. |
| 56 | 56 | * |
| 57 | 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 | 59 | * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded |
| 60 | 60 | * from cost. |
| 61 | 61 | */ |
packages/shared/src/serverSettings.test.ts+22| ... | ... | @@ -322,6 +322,28 @@ describe("serverSettings helpers", () => { |
| 322 | 322 | expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); |
| 323 | 323 | }); |
| 324 | 324 | |
| 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 | 347 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { |
| 326 | 348 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 327 | 349 | backgroundActivity: { |
packages/shared/src/serverSettings.ts+15-6| ... | ... | @@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: { |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | 125 | /** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ |
| 126 | function mergeUsageLimitSources( | |
| 127 | current: ServerSettings["usageLimitSources"], | |
| 128 | patch: NonNullable<ServerSettingsPatch["usageLimitSources"]>, | |
| 129 | ): ServerSettings["usageLimitSources"] { | |
| 126 | function mergeSettingsEntries<Value>( | |
| 127 | current: Readonly<Record<string, Value>>, | |
| 128 | patch: Readonly<Record<string, Value | null>>, | |
| 129 | ): Record<string, Value> { | |
| 130 | 130 | const next = new Map(Object.entries(current)); |
| 131 | 131 | for (const [id, config] of Object.entries(patch)) { |
| 132 | 132 | if (config === null) { |
| ... | ... | @@ -135,7 +135,7 @@ function mergeUsageLimitSources( |
| 135 | 135 | next.set(id, config); |
| 136 | 136 | } |
| 137 | 137 | } |
| 138 | return Object.fromEntries(next) as ServerSettings["usageLimitSources"]; | |
| 138 | return Object.fromEntries(next); | |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | 141 | export function applyServerSettingsPatch( |
| ... | ... | @@ -150,6 +150,7 @@ export function applyServerSettingsPatch( |
| 150 | 150 | backgroundActivity, |
| 151 | 151 | // Merged per entry below; its `null` removals must not reach deepMerge. |
| 152 | 152 | usageLimitSources: usageLimitSourcesPatch, |
| 153 | usagePriceOverrides: usagePriceOverridesPatch, | |
| 153 | 154 | ...patchForMerge |
| 154 | 155 | } = patch; |
| 155 | 156 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); |
| ... | ... | @@ -208,12 +209,20 @@ export function applyServerSettingsPatch( |
| 208 | 209 | : {}), |
| 209 | 210 | ...(usageLimitSourcesPatch !== undefined |
| 210 | 211 | ? { |
| 211 | usageLimitSources: mergeUsageLimitSources( | |
| 212 | usageLimitSources: mergeSettingsEntries( | |
| 212 | 213 | current.usageLimitSources, |
| 213 | 214 | usageLimitSourcesPatch, |
| 214 | 215 | ), |
| 215 | 216 | } |
| 216 | 217 | : {}), |
| 218 | ...(usagePriceOverridesPatch !== undefined | |
| 219 | ? { | |
| 220 | usagePriceOverrides: mergeSettingsEntries( | |
| 221 | current.usagePriceOverrides, | |
| 222 | usagePriceOverridesPatch, | |
| 223 | ), | |
| 224 | } | |
| 225 | : {}), | |
| 217 | 226 | ...(patch.sourceControlWriterModelSelection !== undefined |
| 218 | 227 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 219 | 228 | : {}), |