| 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,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 166 | expect(second.capabilities.attachmentUploads).toBe(true); | 166 | expect(second.capabilities.attachmentUploads).toBe(true); |
| 167 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); | 167 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); |
| 168 | expect(second.capabilities.pullRequests).toBe(true); | 168 | expect(second.capabilities.pullRequests).toBe(true); |
| 169 | expect(second.capabilities.usagePriceOverrides).toBe(true); | ||
| 169 | expect(second.capabilities.threadTitleRegeneration).toBe(true); | 170 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 170 | expect(second.capabilities.threadPullRequestLinking).toBe(true); | 171 | expect(second.capabilities.threadPullRequestLinking).toBe(true); |
| 171 | expect(second.capabilities.agentActivityPublishing).toBe(false); | 172 | expect(second.capabilities.agentActivityPublishing).toBe(false); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { | ... | @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { |
| 222 | threadSnooze: true, | 222 | threadSnooze: true, |
| 223 | environmentThemes: true, | 223 | environmentThemes: true, |
| 224 | usageLimitSources: true, | 224 | usageLimitSources: true, |
| 225 | usagePriceOverrides: true, | ||
| 225 | threadPinning: true, | 226 | threadPinning: true, |
| 226 | threadPinReorder: true, | 227 | threadPinReorder: true, |
| 227 | threadTitleRegeneration: true, | 228 | threadTitleRegeneration: true, |
apps/server/src/serverSettings.test.ts+26| ... | @@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 272 | ).pipe(Effect.provide(makeServerSettingsLayer())), | 272 | ).pipe(Effect.provide(makeServerSettingsLayer())), |
| 273 | ); | 273 | ); |
| 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 | it.effect("persists and broadcasts thread settlement settings", () => | 301 | it.effect("persists and broadcasts thread settlement settings", () => |
| 276 | Effect.scoped( | 302 | Effect.scoped( |
| 277 | Effect.gen(function* () { | 303 | Effect.gen(function* () { |
apps/server/src/usage/UsageService.test.ts+106-2| ... | @@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; |
| 11 | import * as Duration from "effect/Duration"; | 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | 14 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; | 15 | import * as Fiber from "effect/Fiber"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 15 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 16 | import * as Scheduler from "effect/Scheduler"; | 18 | import * as Scheduler from "effect/Scheduler"; |
| 17 | import * as TestClock from "effect/testing/TestClock"; | 19 | import * as TestClock from "effect/testing/TestClock"; |
| ... | @@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts"; |
| 21 | import * as ServerSettings from "../serverSettings.ts"; | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 22 | import * as UsageService from "./UsageService.ts"; | 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 | return `${JSON.stringify({ | 27 | return `${JSON.stringify({ |
| 26 | type: "assistant", | 28 | type: "assistant", |
| 27 | timestamp: "2026-08-01T10:00:00Z", | 29 | timestamp: "2026-08-01T10:00:00Z", |
| ... | @@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string { | ... | @@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string { |
| 29 | sessionId: "session-1", | 31 | sessionId: "session-1", |
| 30 | message: { | 32 | message: { |
| 31 | id: `msg_${id}`, | 33 | id: `msg_${id}`, |
| 32 | model: "claude-fable-5", | 34 | model, |
| 33 | usage: { input_tokens: 10, output_tokens: outputTokens }, | 35 | usage: { input_tokens: 10, output_tokens: outputTokens }, |
| 34 | }, | 36 | }, |
| 35 | })}\n`; | 37 | })}\n`; |
| ... | @@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens | ... | @@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens |
| 96 | } | 98 | } |
| 97 | 99 | ||
| 98 | describe("UsageService", () => { | 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 | it.live("counts appended usage on a rescan of a grown transcript", () => | 144 | it.live("counts appended usage on a rescan of a grown transcript", () => |
| 100 | Effect.gen(function* () { | 145 | Effect.gen(function* () { |
| 101 | const { transcript, settings, home } = yield* setup; | 146 | const { transcript, settings, home } = yield* setup; |
| ... | @@ -114,6 +159,65 @@ describe("UsageService", () => { | ... | @@ -114,6 +159,65 @@ describe("UsageService", () => { |
| 114 | }).pipe(Effect.scoped), | 159 | }).pipe(Effect.scoped), |
| 115 | ); | 160 | ); |
| 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 | it.live("shares one scan between concurrent identical requests", () => | 221 | it.live("shares one scan between concurrent identical requests", () => |
| 118 | Effect.gen(function* () { | 222 | Effect.gen(function* () { |
| 119 | const { transcript, settings, home } = yield* setup; | 223 | const { transcript, settings, home } = yield* setup; |
apps/server/src/usage/UsageService.ts+42-28| ... | @@ -16,6 +16,7 @@ import * as NodeOS from "node:os"; | ... | @@ -16,6 +16,7 @@ import * as NodeOS from "node:os"; |
| 16 | 16 | ||
| 17 | import { | 17 | import { |
| 18 | USAGE_CONTRACT_VERSION, | 18 | USAGE_CONTRACT_VERSION, |
| 19 | type ServerSettings as ServerSettingsValue, | ||
| 19 | type UsageProviderKind, | 20 | type UsageProviderKind, |
| 20 | type UsageSource, | 21 | type UsageSource, |
| 21 | type UsagePricing, | 22 | type UsagePricing, |
| ... | @@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts"; | ... | @@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 44 | import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; | 45 | import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; |
| 45 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | 46 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; |
| 46 | import { UsageAggregator } from "./usageAggregation.ts"; | 47 | import { UsageAggregator } from "./usageAggregation.ts"; |
| 47 | import { parseRateTable, type RateTable } from "./usagePricing.ts"; | 48 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; |
| 48 | import { | 49 | import { |
| 49 | listTranscriptFiles, | 50 | listTranscriptFiles, |
| 50 | readDirectoryVolumeId, | 51 | readDirectoryVolumeId, |
| ... | @@ -232,24 +233,22 @@ export const make = Effect.gen(function* () { | ... | @@ -232,24 +233,22 @@ export const make = Effect.gen(function* () { |
| 232 | return nestedExists ? nested : path.join(homePath, "projects"); | 233 | return nestedExists ? nested : path.join(homePath, "projects"); |
| 233 | }); | 234 | }); |
| 234 | 235 | ||
| 235 | /** Resolves the transcript directory for each provider. */ | 236 | // A settings failure must not silently discard custom rates or transcript homes. |
| 236 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { | 237 | const readSettings = settingsService.getSettings.pipe( |
| 237 | // A settings failure must surface as an error: swallowing it here would | 238 | Effect.catchCause( |
| 238 | // present "zero usage from every provider" as a valid answer. | 239 | (cause) => |
| 239 | const settings = yield* settingsService.getSettings.pipe( | 240 | new UsageReadError({ |
| 240 | Effect.catchCause( | 241 | reason: "scanFailed", |
| 241 | (cause) => | 242 | detail: "Server settings could not be read.", |
| 242 | new UsageReadError({ | 243 | cause: Cause.squash(cause), |
| 243 | reason: "scanFailed", | 244 | }), |
| 244 | // Bounded description; the squashed failure travels as the cause. | 245 | ), |
| 245 | // Squashed, not the Cause tree: a full tree in a Defect field is | 246 | ); |
| 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 | ); | ||
| 252 | 247 | ||
| 248 | /** Resolves the transcript directory for each provider. */ | ||
| 249 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( | ||
| 250 | settings: ServerSettingsValue, | ||
| 251 | ) { | ||
| 253 | const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); | 252 | const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); |
| 254 | const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); | 253 | const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); |
| 255 | const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); | 254 | const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); |
| ... | @@ -379,10 +378,15 @@ export const make = Effect.gen(function* () { | ... | @@ -379,10 +378,15 @@ export const make = Effect.gen(function* () { |
| 379 | | null; | 378 | | null; |
| 380 | } | 379 | } |
| 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 | // The home resolvers ask for `Path` themselves; satisfy them from the | 385 | // The home resolvers ask for `Path` themselves; satisfy them from the |
| 384 | // instance we already hold so the scan stays context-free. | 386 | // instance we already hold so the scan stays context-free. |
| 385 | const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); | 387 | const dirs = yield* resolveTranscriptDirs(settings).pipe( |
| 388 | Effect.provideService(Path.Path, path), | ||
| 389 | ); | ||
| 386 | const scanned: ScannedDir[] = []; | 390 | const scanned: ScannedDir[] = []; |
| 387 | for (const { provider, dir, fileName } of dirs) { | 391 | for (const { provider, dir, fileName } of dirs) { |
| 388 | const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); | 392 | const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); |
| ... | @@ -406,7 +410,10 @@ export const make = Effect.gen(function* () { | ... | @@ -406,7 +410,10 @@ export const make = Effect.gen(function* () { |
| 406 | return scanned; | 410 | return scanned; |
| 407 | }); | 411 | }); |
| 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 | if (input.sinceDay > input.untilDay) { | 417 | if (input.sinceDay > input.untilDay) { |
| 411 | return yield* new UsageReadError({ | 418 | return yield* new UsageReadError({ |
| 412 | reason: "invalidWindow", | 419 | reason: "invalidWindow", |
| ... | @@ -455,9 +462,10 @@ export const make = Effect.gen(function* () { | ... | @@ -455,9 +462,10 @@ export const make = Effect.gen(function* () { |
| 455 | // Pricing only matters once records are aggregated, so the rate table | 462 | // Pricing only matters once records are aggregated, so the rate table |
| 456 | // loads while transcripts stream instead of gating them: a cold rates | 463 | // loads while transcripts stream instead of gating them: a cold rates |
| 457 | // fetch on a slow network no longer delays the scan by its own timeout. | 464 | // fetch on a slow network no longer delays the scan by its own timeout. |
| 458 | const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], { | 465 | const [, scannedDirs] = yield* Effect.all( |
| 459 | concurrency: 2, | 466 | [ensureRates(false), collectDirs(windowStartMs, settings)], |
| 460 | }); | 467 | { concurrency: 2 }, |
| 468 | ); | ||
| 461 | 469 | ||
| 462 | const aggregator = new UsageAggregator({ | 470 | const aggregator = new UsageAggregator({ |
| 463 | timeZone: input.timeZone, | 471 | timeZone: input.timeZone, |
| ... | @@ -466,6 +474,7 @@ export const make = Effect.gen(function* () { | ... | @@ -466,6 +474,7 @@ export const make = Effect.gen(function* () { |
| 466 | resolution: input.resolution ?? "day", | 474 | resolution: input.resolution ?? "day", |
| 467 | ...hourlyWindow, | 475 | ...hourlyWindow, |
| 468 | rates, | 476 | rates, |
| 477 | priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), | ||
| 469 | }); | 478 | }); |
| 470 | 479 | ||
| 471 | const sources: UsageSource[] = []; | 480 | const sources: UsageSource[] = []; |
| ... | @@ -547,13 +556,16 @@ export const make = Effect.gen(function* () { | ... | @@ -547,13 +556,16 @@ export const make = Effect.gen(function* () { |
| 547 | }); | 556 | }); |
| 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 | * page open on two clients at once) share one scan instead of racing over | 560 | * page open on two clients at once) share one scan instead of racing over |
| 552 | * the same corpus twice. | 561 | * the same corpus twice. |
| 553 | */ | 562 | */ |
| 554 | const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>(); | 563 | const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>(); |
| 555 | 564 | ||
| 556 | const scanKey = (input: UsageSummaryInput): string => | 565 | const scanKey = ( |
| 566 | input: UsageSummaryInput, | ||
| 567 | priceOverrides: ServerSettingsValue["usagePriceOverrides"], | ||
| 568 | ): string => | ||
| 557 | JSON.stringify([ | 569 | JSON.stringify([ |
| 558 | input.timeZone, | 570 | input.timeZone, |
| 559 | input.sinceDay, | 571 | input.sinceDay, |
| ... | @@ -561,10 +573,12 @@ export const make = Effect.gen(function* () { | ... | @@ -561,10 +573,12 @@ export const make = Effect.gen(function* () { |
| 561 | input.resolution ?? "day", | 573 | input.resolution ?? "day", |
| 562 | input.sinceTime ?? null, | 574 | input.sinceTime ?? null, |
| 563 | input.untilTime ?? null, | 575 | input.untilTime ?? null, |
| 576 | priceOverrides, | ||
| 564 | ]); | 577 | ]); |
| 565 | 578 | ||
| 566 | const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { | 579 | const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { |
| 567 | const key = scanKey(input); | 580 | const settings = yield* readSettings; |
| 581 | const key = scanKey(input, settings.usagePriceOverrides); | ||
| 568 | const deferred = yield* Effect.uninterruptible( | 582 | const deferred = yield* Effect.uninterruptible( |
| 569 | Effect.gen(function* () { | 583 | Effect.gen(function* () { |
| 570 | const existing = inflightScans.get(key); | 584 | const existing = inflightScans.get(key); |
| ... | @@ -576,7 +590,7 @@ export const make = Effect.gen(function* () { | ... | @@ -576,7 +590,7 @@ export const make = Effect.gen(function* () { |
| 576 | inflightScans.set(key, created); | 590 | inflightScans.set(key, created); |
| 577 | // Detached so one departing client cannot tear the scan out from under | 591 | // Detached so one departing client cannot tear the scan out from under |
| 578 | // the fibers awaiting it; a finished scan warms the cache either way. | 592 | // the fibers awaiting it; a finished scan warms the cache either way. |
| 579 | yield* scanSummary(input).pipe( | 593 | yield* scanSummary(input, settings).pipe( |
| 580 | Effect.onExit((exit) => | 594 | Effect.onExit((exit) => |
| 581 | Effect.sync(() => inflightScans.delete(key)).pipe( | 595 | Effect.sync(() => inflightScans.delete(key)).pipe( |
| 582 | Effect.andThen(Deferred.done(created, exit)), | 596 | Effect.andThen(Deferred.done(created, exit)), |
apps/server/src/usage/usageAggregation.ts+8-1| ... | @@ -61,6 +61,7 @@ export interface AggregateOptions { | ... | @@ -61,6 +61,7 @@ export interface AggregateOptions { |
| 61 | readonly sinceDay: string; | 61 | readonly sinceDay: string; |
| 62 | readonly untilDay: string; | 62 | readonly untilDay: string; |
| 63 | readonly rates: RateTable; | 63 | readonly rates: RateTable; |
| 64 | readonly priceOverrides?: RateTable; | ||
| 64 | readonly resolution?: UsageResolution; | 65 | readonly resolution?: UsageResolution; |
| 65 | readonly sinceTimeMs?: number; | 66 | readonly sinceTimeMs?: number; |
| 66 | readonly untilTimeMs?: number; | 67 | readonly untilTimeMs?: number; |
| ... | @@ -165,11 +166,17 @@ export class UsageAggregator { | ... | @@ -165,11 +166,17 @@ export class UsageAggregator { |
| 165 | record.model, | 166 | record.model, |
| 166 | record.totals, | 167 | record.totals, |
| 167 | record.reportedCostUsd, | 168 | record.reportedCostUsd, |
| 169 | this.#options.priceOverrides, | ||
| 168 | ); | 170 | ); |
| 169 | 171 | ||
| 170 | bucket.totals = addTotals(bucket.totals, record.totals); | 172 | bucket.totals = addTotals(bucket.totals, record.totals); |
| 171 | bucket.costUsd += priced.costUsd; | 173 | bucket.costUsd += priced.costUsd; |
| 172 | bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); | 174 | bucket.cacheSavingsUsd += cacheSavingsUsd( |
| 175 | this.#options.rates, | ||
| 176 | record.model, | ||
| 177 | record.totals, | ||
| 178 | this.#options.priceOverrides, | ||
| 179 | ); | ||
| 173 | bucket.records += 1; | 180 | bucket.records += 1; |
| 174 | if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; | 181 | if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; |
| 175 | if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; | 182 | if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; |
apps/server/src/usage/usagePricing.test.ts+75-1| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 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 | const rate = (input: number, cacheRead?: number) => ({ | 12 | const rate = (input: number, cacheRead?: number) => ({ |
| 6 | input_cost_per_token: input, | 13 | input_cost_per_token: input, |
| ... | @@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({ | ... | @@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({ |
| 9 | }); | 16 | }); |
| 10 | 17 | ||
| 11 | describe("usage pricing", () => { | 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 | it("keeps the existing model-name normalization contract", () => { | 86 | it("keeps the existing model-name normalization contract", () => { |
| 13 | expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); | 87 | expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); |
| 14 | }); | 88 | }); |
apps/server/src/usage/usagePricing.ts+35-5| ... | @@ -7,7 +7,11 @@ | ... | @@ -7,7 +7,11 @@ |
| 7 | * | 7 | * |
| 8 | * @module usagePricing | 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 | * The subset of a LiteLLM entry we price against. All values are USD per token. | 17 | * The subset of a LiteLLM entry we price against. All values are USD per token. |
| ... | @@ -26,6 +30,25 @@ export interface ModelRate { | ... | @@ -26,6 +30,25 @@ export interface ModelRate { |
| 26 | 30 | ||
| 27 | export type RateTable = ReadonlyMap<string, ModelRate>; | 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 | /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ | 52 | /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ |
| 30 | interface LiteLlmEntry { | 53 | interface LiteLlmEntry { |
| 31 | readonly input_cost_per_token?: unknown; | 54 | readonly input_cost_per_token?: unknown; |
| ... | @@ -168,12 +191,14 @@ export function priceUsage( | ... | @@ -168,12 +191,14 @@ export function priceUsage( |
| 168 | model: string, | 191 | model: string, |
| 169 | totals: UsageTokenTotals, | 192 | totals: UsageTokenTotals, |
| 170 | reportedCostUsd: number | null, | 193 | reportedCostUsd: number | null, |
| 194 | overrides?: RateTable, | ||
| 171 | ): PricedUsage { | 195 | ): PricedUsage { |
| 172 | if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { | 196 | const override = overrides?.get(model.trim()); |
| 197 | if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { | ||
| 173 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; | 198 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; |
| 174 | } | 199 | } |
| 175 | 200 | ||
| 176 | const rate = lookupRate(table, model); | 201 | const rate = override ?? lookupRate(table, model); |
| 177 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; | 202 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; |
| 178 | 203 | ||
| 179 | const costUsd = | 204 | const costUsd = |
| ... | @@ -189,8 +214,13 @@ export function priceUsage( | ... | @@ -189,8 +214,13 @@ export function priceUsage( |
| 189 | * What the cached input would have cost at full input rates, minus what it | 214 | * What the cached input would have cost at full input rates, minus what it |
| 190 | * actually cost. Drives the "cache savings" figure. | 215 | * actually cost. Drives the "cache savings" figure. |
| 191 | */ | 216 | */ |
| 192 | export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { | 217 | export function cacheSavingsUsd( |
| 193 | const rate = lookupRate(table, model); | 218 | table: RateTable, |
| 219 | model: string, | ||
| 220 | totals: UsageTokenTotals, | ||
| 221 | overrides?: RateTable, | ||
| 222 | ): number { | ||
| 223 | const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); | ||
| 194 | if (rate === null) return 0; | 224 | if (rate === null) return 0; |
| 195 | return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); | 225 | return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); |
| 196 | } | 226 | } |
apps/web/src/components/usage/UsagePage.test.tsx+1| ... | @@ -57,6 +57,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ | ... | @@ -57,6 +57,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ |
| 57 | vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); | 57 | vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); |
| 58 | vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); | 58 | vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); |
| 59 | vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); | 59 | vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); |
| 60 | vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); | ||
| 60 | vi.mock("./usageProviders", async (importOriginal) => { | 61 | vi.mock("./usageProviders", async (importOriginal) => { |
| 61 | const actual = await importOriginal<typeof import("./usageProviders")>(); | 62 | const actual = await importOriginal<typeof import("./usageProviders")>(); |
| 62 | return { | 63 | return { |
apps/web/src/components/usage/UsagePage.tsx+6| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 36 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 37 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; | 37 | import { WorkspacePageHeader } from "../WorkspacePageHeader"; |
| 38 | import { UsageLimitsSection } from "./UsageLimits"; | 38 | import { UsageLimitsSection } from "./UsageLimits"; |
| 39 | import { UsagePriceOverrides } from "./UsagePriceOverrides"; | ||
| 39 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; | 40 | import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; |
| 40 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; | 41 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; |
| 41 | 42 | ||
| ... | @@ -264,6 +265,11 @@ export function UsagePage() { | ... | @@ -264,6 +265,11 @@ export function UsagePage() { |
| 264 | 265 | ||
| 265 | <ScrollArea className="min-h-0 flex-1"> | 266 | <ScrollArea className="min-h-0 flex-1"> |
| 266 | <WorkspacePageContainer width="wide"> | 267 | <WorkspacePageContainer width="wide"> |
| 268 | {!showingLimits ? ( | ||
| 269 | <div className="flex justify-end"> | ||
| 270 | <UsagePriceOverrides usage={environments} /> | ||
| 271 | </div> | ||
| 272 | ) : null} | ||
| 267 | {showingLimits ? ( | 273 | {showingLimits ? ( |
| 268 | <UsageLimitsSection /> | 274 | <UsageLimitsSection /> |
| 269 | ) : settling ? ( | 275 | ) : settling ? ( |
apps/web/src/components/usage/UsagePriceOverrides.tsx created+318| ... | @@ -0,0 +1,318 @@ | ||
| 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,6 +12,18 @@ record are missing from the totals. |
| 12 | If recent work is missing or a new model shows no cost, refresh to rescan session history and | 12 | If recent work is missing or a new model shows no cost, refresh to rescan session history and |
| 13 | update model pricing. | 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 | ## Track subscription limits | 27 | ## Track subscription limits |
| 16 | 28 | ||
| 17 | **Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also | 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,6 +807,27 @@ export function createServerEnvironmentAtoms<R, E>( |
| 807 | Atom.withLabel(`environment-data:server:settings:${environmentId}`), | 807 | Atom.withLabel(`environment-data:server:settings:${environmentId}`), |
| 808 | ), | 808 | ), |
| 809 | ); | 809 | ); |
| 810 | const usagePricesAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 811 | Atom.make((get) => { | ||
| 812 | const overrides = get(settingsValueAtom(environmentId))?.usagePriceOverrides ?? {}; | ||
| 813 | // Only changed prices should trigger another transcript scan. Settings | ||
| 814 | // snapshots can recreate the same mapping in a different property order. | ||
| 815 | return JSON.stringify( | ||
| 816 | Object.keys(overrides) | ||
| 817 | .sort() | ||
| 818 | .map((model) => { | ||
| 819 | const price = overrides[model]!; | ||
| 820 | return [ | ||
| 821 | model, | ||
| 822 | price.inputCostPerMillionTokens, | ||
| 823 | price.outputCostPerMillionTokens, | ||
| 824 | price.cacheReadCostPerMillionTokens, | ||
| 825 | price.cacheWriteCostPerMillionTokens, | ||
| 826 | ]; | ||
| 827 | }), | ||
| 828 | ); | ||
| 829 | }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)), | ||
| 830 | ); | ||
| 810 | const providersValueAtom = Atom.family((environmentId: EnvironmentId) => | 831 | const providersValueAtom = Atom.family((environmentId: EnvironmentId) => |
| 811 | Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( | 832 | Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( |
| 812 | Atom.withLabel(`environment-data:server:providers:${environmentId}`), | 833 | Atom.withLabel(`environment-data:server:providers:${environmentId}`), |
| ... | @@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -892,6 +913,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 892 | label: "environment-data:server:usage-summary", | 913 | label: "environment-data:server:usage-summary", |
| 893 | tag: WS_METHODS.serverGetUsageSummary, | 914 | tag: WS_METHODS.serverGetUsageSummary, |
| 894 | staleTimeMs: 60_000, | 915 | staleTimeMs: 60_000, |
| 916 | refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), | ||
| 895 | }), | 917 | }), |
| 896 | configProjection, | 918 | configProjection, |
| 897 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | 919 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
packages/client-runtime/src/state/serverUsage.test.ts created+263| ... | @@ -0,0 +1,263 @@ | ||
| 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,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 105 | /** Server streams quota from configured usage-limit sources. Same | 105 | /** Server streams quota from configured usage-limit sources. Same |
| 106 | version-skew contract as environmentThemes. */ | 106 | version-skew contract as environmentThemes. */ |
| 107 | usageLimitSources: Schema.optionalKey(Schema.Boolean), | 107 | usageLimitSources: Schema.optionalKey(Schema.Boolean), |
| 108 | /** Server persists custom model rates and applies them to usage summaries. */ | ||
| 109 | usagePriceOverrides: Schema.optionalKey(Schema.Boolean), | ||
| 108 | /** Server understands thread.pin / thread.unpin commands. Same | 110 | /** Server understands thread.pin / thread.unpin commands. Same |
| 109 | version-skew contract as threadSettlement. */ | 111 | version-skew contract as threadSettlement. */ |
| 110 | threadPinning: Schema.optionalKey(Schema.Boolean), | 112 | threadPinning: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/settings.test.ts+52| ... | @@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | ... | @@ -21,6 +21,58 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 21 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | 21 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 22 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); | 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 | describe("ClaudeSettings auto-compaction", () => { | 76 | describe("ClaudeSettings auto-compaction", () => { |
| 25 | it("uses Claude's default threshold when no override is configured", () => { | 77 | it("uses Claude's default threshold when no override is configured", () => { |
| 26 | expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); | 78 | expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); |
packages/contracts/src/settings.ts+22| ... | @@ -358,6 +358,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS | ... | @@ -358,6 +358,20 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS |
| 358 | 358 | ||
| 359 | // ── Server Settings (server-authoritative) ──────────────────── | 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 | const makeBinaryPathSetting = (fallback: string) => | 375 | const makeBinaryPathSetting = (fallback: string) => |
| 362 | TrimmedString.pipe( | 376 | TrimmedString.pipe( |
| 363 | Schema.decodeTo( | 377 | Schema.decodeTo( |
| ... | @@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -930,6 +944,10 @@ export const ServerSettings = Schema.Struct({ |
| 930 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( | 944 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( |
| 931 | Schema.withDecodingDefault(Effect.succeed({})), | 945 | Schema.withDecodingDefault(Effect.succeed({})), |
| 932 | ), | 946 | ), |
| 947 | /** Exact model IDs, applied to past and future usage on this environment. */ | ||
| 948 | usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( | ||
| 949 | Schema.withDecodingDefault(Effect.succeed({})), | ||
| 950 | ), | ||
| 933 | }); | 951 | }); |
| 934 | export type ServerSettings = typeof ServerSettings.Type; | 952 | export type ServerSettings = typeof ServerSettings.Type; |
| 935 | 953 | ||
| ... | @@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1141,6 +1159,10 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1141 | usageLimitSources: Schema.optionalKey( | 1159 | usageLimitSources: Schema.optionalKey( |
| 1142 | Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), | 1160 | Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), |
| 1143 | ), | 1161 | ), |
| 1162 | /** Each entry replaces one model's rates; `null` restores automatic pricing. */ | ||
| 1163 | usagePriceOverrides: Schema.optionalKey( | ||
| 1164 | Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), | ||
| 1165 | ), | ||
| 1144 | }); | 1166 | }); |
| 1145 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | 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,7 +55,7 @@ export type UsageResolution = typeof UsageResolution.Type; |
| 55 | * Why a bucket's cost is what it is. | 55 | * Why a bucket's cost is what it is. |
| 56 | * | 56 | * |
| 57 | * - `providerReported` - the transcript carried an explicit cost figure. | 57 | * - `providerReported` - the transcript carried an explicit cost figure. |
| 58 | * - `modelPriced` - we matched the model against the LiteLLM rate table. | 58 | * - `modelPriced` - we used a custom price override or the LiteLLM rate table. |
| 59 | * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded | 59 | * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded |
| 60 | * from cost. | 60 | * from cost. |
| 61 | */ | 61 | */ |
packages/shared/src/serverSettings.test.ts+22| ... | @@ -322,6 +322,28 @@ describe("serverSettings helpers", () => { | ... | @@ -322,6 +322,28 @@ describe("serverSettings helpers", () => { |
| 322 | expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); | 322 | expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); |
| 323 | }); | 323 | }); |
| 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 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { | 347 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { |
| 326 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 348 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 327 | backgroundActivity: { | 349 | backgroundActivity: { |
packages/shared/src/serverSettings.ts+15-6| ... | @@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: { | ... | @@ -123,10 +123,10 @@ function mergeModelSelectionOptionsById(input: { |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | /** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ | 125 | /** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ |
| 126 | function mergeUsageLimitSources( | 126 | function mergeSettingsEntries<Value>( |
| 127 | current: ServerSettings["usageLimitSources"], | 127 | current: Readonly<Record<string, Value>>, |
| 128 | patch: NonNullable<ServerSettingsPatch["usageLimitSources"]>, | 128 | patch: Readonly<Record<string, Value | null>>, |
| 129 | ): ServerSettings["usageLimitSources"] { | 129 | ): Record<string, Value> { |
| 130 | const next = new Map(Object.entries(current)); | 130 | const next = new Map(Object.entries(current)); |
| 131 | for (const [id, config] of Object.entries(patch)) { | 131 | for (const [id, config] of Object.entries(patch)) { |
| 132 | if (config === null) { | 132 | if (config === null) { |
| ... | @@ -135,7 +135,7 @@ function mergeUsageLimitSources( | ... | @@ -135,7 +135,7 @@ function mergeUsageLimitSources( |
| 135 | next.set(id, config); | 135 | next.set(id, config); |
| 136 | } | 136 | } |
| 137 | } | 137 | } |
| 138 | return Object.fromEntries(next) as ServerSettings["usageLimitSources"]; | 138 | return Object.fromEntries(next); |
| 139 | } | 139 | } |
| 140 | 140 | ||
| 141 | export function applyServerSettingsPatch( | 141 | export function applyServerSettingsPatch( |
| ... | @@ -150,6 +150,7 @@ export function applyServerSettingsPatch( | ... | @@ -150,6 +150,7 @@ export function applyServerSettingsPatch( |
| 150 | backgroundActivity, | 150 | backgroundActivity, |
| 151 | // Merged per entry below; its `null` removals must not reach deepMerge. | 151 | // Merged per entry below; its `null` removals must not reach deepMerge. |
| 152 | usageLimitSources: usageLimitSourcesPatch, | 152 | usageLimitSources: usageLimitSourcesPatch, |
| 153 | usagePriceOverrides: usagePriceOverridesPatch, | ||
| 153 | ...patchForMerge | 154 | ...patchForMerge |
| 154 | } = patch; | 155 | } = patch; |
| 155 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); | 156 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); |
| ... | @@ -208,12 +209,20 @@ export function applyServerSettingsPatch( | ... | @@ -208,12 +209,20 @@ export function applyServerSettingsPatch( |
| 208 | : {}), | 209 | : {}), |
| 209 | ...(usageLimitSourcesPatch !== undefined | 210 | ...(usageLimitSourcesPatch !== undefined |
| 210 | ? { | 211 | ? { |
| 211 | usageLimitSources: mergeUsageLimitSources( | 212 | usageLimitSources: mergeSettingsEntries( |
| 212 | current.usageLimitSources, | 213 | current.usageLimitSources, |
| 213 | usageLimitSourcesPatch, | 214 | usageLimitSourcesPatch, |
| 214 | ), | 215 | ), |
| 215 | } | 216 | } |
| 216 | : {}), | 217 | : {}), |
| 218 | ...(usagePriceOverridesPatch !== undefined | ||
| 219 | ? { | ||
| 220 | usagePriceOverrides: mergeSettingsEntries( | ||
| 221 | current.usagePriceOverrides, | ||
| 222 | usagePriceOverridesPatch, | ||
| 223 | ), | ||
| 224 | } | ||
| 225 | : {}), | ||
| 217 | ...(patch.sourceControlWriterModelSelection !== undefined | 226 | ...(patch.sourceControlWriterModelSelection !== undefined |
| 218 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } | 227 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 219 | : {}), | 228 | : {}), |