Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () {
environmentThemes: true,
usageLimitSources: true,
usagePriceOverrides: true,
usageModelAliases: true,
threadPinning: true,
threadPinReorder: true,
threadActiveReorder: true,
Expand Down
18 changes: 8 additions & 10 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn
import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
import { readAntigravityUsage } from "./antigravityUsageReader.ts";
import { readCursorAccountUsage } from "./cursorUsageReader.ts";
import { UsageAggregator } from "./usageAggregation.ts";
import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
import {
listTranscriptFiles,
Expand Down Expand Up @@ -821,6 +821,7 @@ export const make = Effect.gen(function* () {
...hourlyWindow,
rates,
priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
modelAliases: resolveModelAliases(settings.usageModelAliases),
});

const sources: UsageSource[] = [];
Expand Down Expand Up @@ -925,31 +926,28 @@ export const make = Effect.gen(function* () {
});

/**
* In-flight scans by window and custom prices, so concurrent identical requests (the usage
* In-flight scans by window and usage settings, so concurrent identical requests (the usage
* page open on two clients at once) share one scan instead of racing over
* the same corpus twice.
*/
const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();

const scanKey = (
input: UsageSummaryInput,
priceOverrides: ServerSettingsValue["usagePriceOverrides"],
cursorKeychainUsageEnabled: boolean,
): string =>
const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string =>
JSON.stringify([
input.timeZone,
input.sinceDay,
input.untilDay,
input.resolution ?? "day",
input.sinceTime ?? null,
input.untilTime ?? null,
priceOverrides,
cursorKeychainUsageEnabled,
settings.usagePriceOverrides,
settings.usageModelAliases,
settings.cursorKeychainUsageEnabled,
]);

const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
const settings = yield* readSettings;
const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled);
const key = scanKey(input, settings);
const deferred = yield* Effect.uninterruptible(
Effect.gen(function* () {
const existing = inflightScans.get(key);
Expand Down
41 changes: 40 additions & 1 deletion apps/server/src/usage/usageAggregation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";

import { UsageAggregator } from "./usageAggregation.ts";
import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
import type { RateTable } from "./usagePricing.ts";
import type { UsageRecord } from "./usageTranscripts.ts";

Expand Down Expand Up @@ -253,6 +253,25 @@ describe("UsageAggregator", () => {
expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);
});

it("folds a mapped model into its target and prices it there", () => {
const aggregator = new UsageAggregator({
timeZone: "UTC",
sinceDay: "2026-08-01",
untilDay: "2026-08-31",
rates,
modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }),
});
aggregator.add(record());
aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" }));
const result = aggregator.finish();

expect(result.buckets).toHaveLength(1);
expect(result.buckets[0]?.model).toBe("claude-fable-5");
expect(result.buckets[0]?.records).toBe(2);
expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9);
expect(result.buckets[0]?.unpricedRecords).toBe(0);
});

it("separates providers and models into their own buckets", () => {
const result = aggregate([
record(),
Expand All @@ -263,3 +282,23 @@ describe("UsageAggregator", () => {
expect(result.buckets).toHaveLength(3);
});
});

describe("resolveModelAliases", () => {
it("follows chains to the final model and drops chains that enter a loop", () => {
expect(
resolveModelAliases({
"preview[1m]": "preview",
preview: "example-model",
loop: "back",
back: "loop",
intoLoop: "loop",
self: "self",
}),
).toEqual(
new Map([
["preview[1m]", "example-model"],
["preview", "example-model"],
]),
);
});
});
35 changes: 34 additions & 1 deletion apps/server/src/usage/usageAggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,35 @@ export interface AggregateOptions {
readonly untilDay: string;
readonly rates: RateTable;
readonly priceOverrides?: RateTable;
/** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */
readonly modelAliases?: ReadonlyMap<string, string>;
readonly resolution?: UsageResolution;
readonly sinceTimeMs?: number;
readonly untilTimeMs?: number;
}

/**
* Resolves user model mappings to their final target, so `a -> b` and
* `b -> c` both land on `c`. A model whose chain enters a loop is left
* unmapped.
*/
export function resolveModelAliases(
aliases: Readonly<Record<string, string>>,
): ReadonlyMap<string, string> {
const resolved = new Map<string, string>();
for (const model of Object.keys(aliases)) {
const seen = new Set([model]);
let target = aliases[model]!;
while (Object.hasOwn(aliases, target) && !seen.has(target)) {
seen.add(target);
target = aliases[target]!;
}
// Stopping on a mapped model means the chain entered a loop.
if (!Object.hasOwn(aliases, target)) resolved.set(model, target);
}
return resolved;
}

export interface AggregateResult {
readonly buckets: readonly UsageBucket[];
/** Records dropped because an earlier record carried the same dedupe key. */
Expand Down Expand Up @@ -147,7 +171,8 @@ export class UsageAggregator {
* can derive per-window facts (distinct sessions, for one) from the records
* that landed rather than everything the mtime prefilter happened to admit.
*/
add(record: UsageRecord, sourcePath?: string): boolean {
add(input: UsageRecord, sourcePath?: string): boolean {
const record = this.#mapModel(input);
if (record.dedupeKey !== null) {
if (this.#seen.has(record.dedupeKey)) {
this.#duplicatesDropped += 1;
Expand Down Expand Up @@ -217,6 +242,14 @@ export class UsageAggregator {
return true;
}

/** The target's own rate applies, so a provider-specific `rateModel` is dropped. */
#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 };
}

/**
* Records arrive file by file in time order, so most land in the bucket the
* previous record used. Checking that first skips building and hashing a
Expand Down
Loading
Loading