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 4d473f50c9d67..532233f7facce 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -129,18 +129,19 @@ const MODEL_IDS: Record = { }; // Effort and routing variants share the base model's price. -function withRateModel(record: UsageRecord): UsageRecord { - const base = record.model.replace( - /-(?:extra-low|low|medium|high|tiered|control|thinking|agent)$/, - "", - ); - const rateModel = /^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(record.model) +export const antigravityRateModel = (model: string) => { + const base = model.replace(/-(?:extra-low|low|medium|high|tiered|control|thinking|agent)$/, ""); + return /^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(model) ? "gemini-3.5-flash" - : record.model === "gemini-3.7-flash-safety-le" + : model === "gemini-3.7-flash-safety-le" ? "gemini-3.7-flash" : base === "gemini-3-flash" ? "gemini-3-flash-preview" : base; +}; + +function withRateModel(record: UsageRecord): UsageRecord { + 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 d40beb989ec21..3bf63ff7d9ae1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1226,6 +1226,76 @@ describe("UsageService", () => { }), ); + it.effect("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", + }, + ], + ); + }), + ); + it.effect( "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 d84de15a59ed9..3f79406fd9c95 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -90,6 +90,12 @@ const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], ); +const providerRateModels = 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), ); @@ -998,6 +1004,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides, modelAliases, + rateModel: (provider, model) => providerRateModels.get(provider)?.(model), ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 3819903606be2..58953fbcfafea 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -277,6 +277,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 ce7a78a99d9c1..f4c39753548fd 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 { @@ -70,6 +70,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; @@ -341,12 +342,16 @@ export class UsageAggregator { if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; } - // Aliases use the target's rate, replacing provider-specific rateModel. + // 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; - return { ...rest, model }; + const rateModel = + lookupRate(this.#options.rates, model) === null + ? this.#options.rateModel?.(record.provider, model) + : undefined; + 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 c2c75a641e53a..ff4c14dfefe00 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -105,8 +105,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; @@ -126,12 +134,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 85ab45b107d71..4b7477295fbd8 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"; @@ -72,6 +73,7 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", + rateModel: cursorRateModel, scan: ({ instances, 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 f3a2cb4ed03c9..2e274b464864a 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;