Skip to content
7 changes: 6 additions & 1 deletion apps/server/src/provider/Drivers/AntigravityUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,6 +127,7 @@ export const layer = Layer.effect(AntigravityUsage, make);
export const antigravityUsageReader: ProviderUsageReader<AntigravitySettings, AntigravityUsage> = {
kind: "scan",
provider: "antigravity",
rateModel: antigravityRateModel,
scan: ({ instances, windowStartMs }) =>
AntigravityUsage.pipe(Effect.flatMap((usage) => usage.scan({ instances, windowStartMs }))),
};
15 changes: 8 additions & 7 deletions apps/server/src/provider/Drivers/antigravityUsageReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,19 @@ const MODEL_IDS: Record<number, string> = {
};

// 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 };
}

Expand Down
70 changes: 70 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -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 }),
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/usage/usageAggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/usage/usageAggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -70,6 +70,7 @@ export interface AggregateOptions {
readonly rates: RateTable;
readonly priceOverrides?: RateTable;
readonly modelAliases?: ReadonlyMap<string, string>;
readonly rateModel?: (provider: UsageRecord["provider"], model: string) => string | undefined;
readonly modelFilter?: UsageModelFilter;
readonly resolution?: UsageResolution;
readonly sinceTimeMs?: number;
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 12 additions & 4 deletions packages/provider-core/src/server/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,16 @@ export interface ProviderUsageScanInput<Config> {
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<Config, R> =
| {
| (ProviderUsageRateModel & {
readonly kind: "transcripts";
readonly provider: UsageProviderKind;
readonly format: TranscriptUsageFormat<unknown>;
Expand All @@ -126,12 +134,12 @@ export type ProviderUsageReader<Config, R> =
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<Config>,
) => Effect.Effect<ReadonlyArray<ProviderUsageScan>, UsageReadError, R>;
};
});
2 changes: 2 additions & 0 deletions packages/provider-cursor/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-pi/src/server/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export const piUsageReader: ProviderUsageReader<PiSettings, Path.Path> = {
kind: "transcripts",
provider: "pi",
format: piUsageFormat,
// Pi model IDs are `<provider>/<rate model>`.
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;
Expand Down
Loading