From d90bd7004926645ce048593d90cf78abd4b35a84 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 6 Nov 2026 17:15:08 -0500 Subject: [PATCH 1/2] fix(server): usage mapped onto a Pi, Cursor or Antigravity model prices at that model's rate Map to dropped the mapped record's rateModel and looked the target up by its displayed ID. Pi, Cursor and Antigravity display IDs that the rate table does not list (`anthropic/claude-opus-4-6`, `gpt-5.1-codex-high`, `gemini-3-flash-preview-high`) and price their own records through a separate rate model, so mapped usage came out unpriced or unsplit while the target's own usage was priced. Each of those readers now names the rate model for one of its IDs, and the aggregator gives a mapped record the target's rate model. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/AntigravityUsage.ts | 7 +- .../Drivers/antigravityUsageReader.ts | 4 +- apps/server/src/usage/UsageService.test.ts | 70 +++++++++++++++++++ apps/server/src/usage/UsageService.ts | 7 ++ apps/server/src/usage/usageAggregation.ts | 6 +- packages/provider-core/src/server/usage.ts | 16 +++-- packages/provider-cursor/src/server/driver.ts | 2 + packages/provider-pi/src/server/usage.ts | 2 + 8 files changed, 106 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts index 5cb55ed77ac09..2eb9744a3e42a 100644 --- a/apps/server/src/provider/Drivers/AntigravityUsage.ts +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -15,7 +15,11 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { resolveAntigravityInstanceDirectories } from "../antigravityAuthSupport.ts"; -import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { + antigravityRateModel, + makeAntigravityUsageCache, + readAntigravityUsage, +} from "./antigravityUsageReader.ts"; export class AntigravityUsage extends Context.Service< AntigravityUsage, @@ -123,6 +127,7 @@ export const layer = Layer.effect(AntigravityUsage, make); export const antigravityUsageReader: ProviderUsageReader = { kind: "scan", provider: "antigravity", + rateModel: antigravityRateModel, scan: ({ instances, windowStartMs }) => AntigravityUsage.pipe(Effect.flatMap((usage) => usage.scan({ instances, windowStartMs }))), }; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index d6cb74aa19e42..81740103fe802 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -103,8 +103,10 @@ const MODEL_IDS: Record = { }; // ACP slugs append the thinking level, which shares the base model's price. +export const antigravityRateModel = (model: string) => model.replace(/-(?:low|medium|high)$/, ""); + function withRateModel(record: UsageRecord): UsageRecord { - const rateModel = record.model.replace(/-(?:low|medium|high)$/, ""); + const rateModel = antigravityRateModel(record.model); return rateModel === record.model ? record : { ...record, rateModel }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 31d7fbbda0a13..d826c8d7e43fe 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -988,6 +988,76 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("prices Pi usage mapped onto a Pi model at that model's rate", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const dir = NodePath.join(home, "pi", "sessions", "project"); + const message = (id: string, model: string) => ({ + type: "message", + id, + timestamp: "2026-08-01T10:00:00Z", + message: { + role: "assistant", + provider: "anthropic", + model, + usage: { input: 10, output: 5 }, + }, + }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + { type: "session", id: "session" }, + message("released", "actual-model"), + message("preview", "actual-model-preview"), + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-pi-model-alias", + home, + ratesDocument: { + "actual-model": { input_cost_per_token: 1, output_cost_per_token: 4 }, + }, + settings: { + ...settings, + usageModelAliases: { "anthropic/actual-model-preview": "anthropic/actual-model" }, + }, + }), + ), + ); + assert.deepStrictEqual( + summary.buckets + .filter((bucket) => bucket.provider === "pi") + .map(({ model, records, unpricedRecords, costUsd, costSource }) => ({ + model, + records, + unpricedRecords, + costUsd, + costSource, + })), + [ + { + model: "anthropic/actual-model", + records: 2, + unpricedRecords: 0, + costUsd: 60, + costSource: "modelPriced", + }, + ], + ); + }).pipe(Effect.scoped), + ); + it.live( "reads Muse inference history once, persists appended usage, and reprices unknown models", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 8876f0af131f1..3f942f0f7b9ca 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -82,6 +82,12 @@ const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], ); +const rateModels = new Map( + BUILT_IN_USAGE_DRIVERS.flatMap(({ usage }) => + usage?.rateModel === undefined ? [] : [[usage.provider, usage.rateModel] as const], + ), +); + const transcriptFormats = new Map( transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), ); @@ -729,6 +735,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), modelAliases: resolveModelAliases(settings.usageModelAliases), + rateModel: (provider, model) => rateModels.get(provider)?.(model), ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 8f94c84a3c470..841abd42e8fcd 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -69,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly rateModel?: (provider: UsageRecord["provider"], model: string) => string | undefined; readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; @@ -235,12 +236,13 @@ export class UsageAggregator { return true; } - // Aliases use the target's rate, replacing provider-specific rateModel. + // Aliases use the target's rate, which a provider may name apart from the target's ID. #mapModel(record: UsageRecord): UsageRecord { const model = this.#options.modelAliases?.get(record.model); if (model === undefined) return record; const { rateModel: _rateModel, ...rest } = record; - return { ...rest, model }; + const rateModel = this.#options.rateModel?.(record.provider, model); + return rateModel === undefined ? { ...rest, model } : { ...rest, model, rateModel }; } // Time-ordered records usually reuse the previous bucket; avoid rebuilding its key. diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 509d890808987..c9f9c62b88c74 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -104,8 +104,16 @@ export interface ProviderUsageScanInput { readonly awaitRefresh: boolean; } +interface ProviderUsageRateModel { + /** + * The rate-table name for a model ID this provider records with a separate `rateModel`, so + * usage mapped onto that ID prices like the provider's own records of it. + */ + readonly rateModel?: (model: string) => string; +} + export type ProviderUsageReader = - | { + | (ProviderUsageRateModel & { readonly kind: "transcripts"; readonly provider: UsageProviderKind; readonly format: TranscriptUsageFormat; @@ -119,12 +127,12 @@ export type ProviderUsageReader = never, R >; - } - | { + }) + | (ProviderUsageRateModel & { readonly kind: "scan"; readonly provider: UsageProviderKind; /** Fails only when the whole usage read cannot go on; an unreadable source is a status. */ readonly scan: ( input: ProviderUsageScanInput, ) => Effect.Effect, UsageReadError, R>; - }; + }); diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 088c97c685c58..cf86cc6e9b08f 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; +import { cursorRateModel } from "./accountUsage.ts"; import { readCursorUsageLimits } from "./usageLimits.ts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -74,6 +75,7 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", + rateModel: cursorRateModel, scan: ({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index 408042a7e481b..2a4ecfc560008 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -129,6 +129,8 @@ export const piUsageReader: ProviderUsageReader = { kind: "transcripts", provider: "pi", format: piUsageFormat, + // Pi model IDs are `/`. + rateModel: (model) => model.slice(model.indexOf("/") + 1), directories: Effect.fn("piUsageReader.directories")(function* ({ config, environment }) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; From a40e8588b5d68ffe5eae9979828d47e8559bcbfc Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Mon, 9 Nov 2026 21:14:58 -0500 Subject: [PATCH 2/2] fix(server): mapped usage keeps the target's own rate Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/usageAggregation.test.ts | 1 + apps/server/src/usage/usageAggregation.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 08b3f3ae1b8c8..e48c271828ab8 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -197,6 +197,7 @@ describe("UsageAggregator", () => { untilDay: "2026-08-31", rates, modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + rateModel: () => "claude-fable", }); aggregator.add(record()); aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 841abd42e8fcd..6cbba5870b32f 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; -import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; +import { cacheSavingsUsd, lookupRate, priceUsage, type RateTable } from "./usagePricing.ts"; // en-CA gives ISO-ordered parts in the requested zone; Date getters are host-local. function makeDayFormatter(timeZone: string): (timestampMs: number) => string { @@ -236,12 +236,15 @@ export class UsageAggregator { return true; } - // Aliases use the target's rate, which a provider may name apart from the target's ID. + // Aliases use the target's rate. Only an unpriced target goes through the provider's own rate naming. #mapModel(record: UsageRecord): UsageRecord { const model = this.#options.modelAliases?.get(record.model); if (model === undefined) return record; const { rateModel: _rateModel, ...rest } = record; - const rateModel = this.#options.rateModel?.(record.provider, model); + const rateModel = + lookupRate(this.#options.rates, model) === null + ? this.#options.rateModel?.(record.provider, model) + : undefined; return rateModel === undefined ? { ...rest, model } : { ...rest, model, rateModel }; }