Skip to content
16 changes: 14 additions & 2 deletions apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
type UsageSummary,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
import {
needsCursorKeychainAccess,
refreshUsage,
usageSummaryInput,
} from "@t3tools/client-runtime/state/usage";
import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import { AsyncResult, Atom } from "effect/reactivity";
Expand Down Expand Up @@ -52,7 +56,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
});
continue;
}
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
const result = get(
serverEnvironment.usageSummary({
environmentId,
input: usageSummaryInput(
input,
get(serverEnvironment.usageModelFilterAtom(environmentId)),
),
}),
);
const summary = AsyncResult.getOrElse(result, () => null);
statuses.push({
environmentId,
Expand Down
110 changes: 108 additions & 2 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@ const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unkno
const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));

function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
function claudeLine(
id: number,
outputTokens: number,
model = "claude-fable-5",
sessionId = "session-1",
): string {
return `${JSON.stringify({
type: "assistant",
timestamp: "2026-08-01T10:00:00Z",
requestId: `req_${id}`,
sessionId: "session-1",
sessionId,
message: {
id: `msg_${id}`,
model,
Expand Down Expand Up @@ -2077,6 +2082,107 @@ describe("UsageService", () => {
}).pipe(Effect.scoped),
);

it.live("filters original model IDs before aliases, pricing, buckets and session counts", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() =>
NodeFSP.writeFile(
transcript,
claudeLine(1, 3, "orbit-1", "mixed") +
claudeLine(2, 5, "orbit-2", "mixed") +
claudeLine(3, 7, "drift-9", "mixed") +
claudeLine(4, 11, "drift-9", "excluded-only"),
),
);
const rates = {
"orbit-1": { input_cost_per_token: 0.000001, output_cost_per_token: 0.00001 },
"orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 },
};
const include = {
// The codex selector must not match the Claude drift-9 records.
include: [
{ provider: "claude", model: "orbit-1" },
{ model: "orbit-2" },
{ provider: "codex", model: "drift-9" },
],
};
const exclude = { exclude: [{ model: "drift-9" }] };
const HOURLY: UsageSummaryInput = {
...WINDOW,
resolution: "hour",
sinceTime: "2026-08-01T09:00:00Z",
untilTime: "2026-08-01T11:00:00Z",
};
const cells = (summary: UsageSummary) =>
summary.buckets.map(({ model, totals, costUsd, records, sessions }) => ({
model,
outputTokens: totals.outputTokens,
costUsd: Math.round(costUsd * 1e6),
records,
sessions,
}));

yield* Effect.gen(function* () {
const service = yield* UsageService.make;
// Same service, concurrent reads: the in-flight key must keep these scans apart.
const [unfiltered, noOp, included, reordered, excluded, hourly] = yield* Effect.all(
[
service.readSummary(WINDOW),
service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }),
service.readSummary({ ...WINDOW, modelFilter: include }),
service.readSummary({
...WINDOW,
modelFilter: { include: [...include.include].reverse() },
}),
service.readSummary({ ...WINDOW, modelFilter: exclude }),
service.readSummary({ ...HOURLY, modelFilter: include }),
],
{ concurrency: "unbounded" },
);

// Unfiltered keeps today's behavior: the alias folds drift-9 into orbit-1.
assert.deepStrictEqual(cells(unfiltered), [
{ model: "orbit-1", outputTokens: 21, costUsd: 240, records: 3, sessions: 2 },
{ model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 },
]);
assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2);
assert.strictEqual(unfiltered.modelFilter, undefined);
assert.strictEqual(noOp, unfiltered);

const kept = [
{ model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 },
{ model: "orbit-2", outputTokens: 5, costUsd: 120, records: 1, sessions: 1 },
];
assert.deepStrictEqual(cells(included), kept);
assert.deepStrictEqual(cells(excluded), kept);
assert.deepStrictEqual(cells(hourly), kept);
assert.strictEqual(hourly.buckets[0]?.hourStart, "2026-08-01T10:00:00.000Z");
for (const summary of [included, excluded, hourly]) {
assert.strictEqual(summary.sources[0]?.distinctSessions, 1);
}
// Equal filters share one scan and echo one canonical form.
assert.strictEqual(reordered, included);
assert.deepStrictEqual(included.modelFilter, {
include: [
{ provider: "claude", model: "orbit-1" },
{ provider: "codex", model: "drift-9" },
{ model: "orbit-2" },
],
});
}).pipe(
Effect.scoped,
Effect.provide(
layerService({
prefix: "usage-service-model-filter-test",
home,
settings: { ...settings, usageModelAliases: { "drift-9": "orbit-1" } },
ratesDocument: rates,
}),
),
);
}).pipe(Effect.scoped),
);

it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
Expand Down
12 changes: 10 additions & 2 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type {
TranscriptUsageFormat,
UsageRecord,
} from "@t3tools/provider-core/server/usage";
import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
import {
listTranscriptFiles,
Expand Down Expand Up @@ -724,6 +724,7 @@ export const make = Effect.gen(function* () {
rates,
priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
modelAliases: resolveModelAliases(settings.usageModelAliases),
...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }),
});

const sources: UsageSource[] = [];
Expand Down Expand Up @@ -882,6 +883,7 @@ export const make = Effect.gen(function* () {
sources,
pricing: pricing(),
scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }),
} satisfies UsageSummary;
});

Expand All @@ -897,9 +899,15 @@ export const make = Effect.gen(function* () {
input.untilTime ?? null,
settings,
input.awaitRefresh === true,
input.modelFilter ?? null,
]);

const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
const readSummary = Effect.fn("UsageService.readSummary")(function* (
requested: UsageSummaryInput,
) {
const { modelFilter: requestedFilter, ...unfiltered } = requested;
const modelFilter = canonicalModelFilter(requestedFilter);
const input = modelFilter === undefined ? unfiltered : { ...unfiltered, modelFilter };
const settings = yield* readSettings;
const key = scanKey(input, settings);
const deferred = yield* Effect.uninterruptible(
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/usage/usageAggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
UsageBucket,
UsageCategoryCost,
UsageDay,
UsageModelFilter,
UsageModelSelector,
UsageResolution,
UsageTokenTotals,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -67,6 +69,7 @@ export interface AggregateOptions {
readonly rates: RateTable;
readonly priceOverrides?: RateTable;
readonly modelAliases?: ReadonlyMap<string, string>;
readonly modelFilter?: UsageModelFilter;
readonly resolution?: UsageResolution;
readonly sinceTimeMs?: number;
readonly untilTimeMs?: number;
Expand All @@ -88,6 +91,38 @@ export function resolveModelAliases(
return resolved;
}

const matchesSelector = (record: UsageRecord) => (selector: UsageModelSelector) =>
selector.model === record.model.trim() &&
(selector.provider === undefined || selector.provider === record.provider);

/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */
function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean {
if (filter === undefined) return true;
const matches = matchesSelector(record);
return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false);
}

const selectorKey = (selector: UsageModelSelector) =>
JSON.stringify([selector.provider ?? null, selector.model]);

const canonicalSelectors = (selectors: readonly UsageModelSelector[]) =>
[...new Map(selectors.map((selector) => [selectorKey(selector), selector]))]
.sort(([a], [b]) => a.localeCompare(b))
.map(([, selector]) => selector);

/** Equal filters get one form, so they share scans and clients can compare echoed filters. */
export function canonicalModelFilter(
filter: UsageModelFilter | undefined,
): UsageModelFilter | undefined {
if (filter === undefined) return undefined;
const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined;
if (filter.include === undefined && exclude === undefined) return undefined;
return {
...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }),
...(exclude === undefined ? {} : { exclude }),
};
}

export interface AggregateResult {
readonly buckets: readonly UsageBucket[];
readonly duplicatesDropped: number;
Expand Down Expand Up @@ -129,6 +164,7 @@ export class UsageAggregator {
}

add(input: UsageRecord, sourcePath?: string): boolean {
if (!keepsUsageRecord(this.#options.modelFilter, input)) return false;
const record = this.#mapModel(input);
if (record.dedupeKey !== null) {
if (this.#seen.has(record.dedupeKey)) {
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
type UsageProviderKind,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
import {
needsCursorKeychainAccess,
refreshUsage,
usageSummaryInput,
} from "@t3tools/client-runtime/state/usage";
import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
import { AsyncResult, Atom } from "effect/reactivity";
import { useState } from "react";
Expand Down Expand Up @@ -55,7 +59,15 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
});
continue;
}
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
const result = get(
serverEnvironment.usageSummary({
environmentId,
input: usageSummaryInput(
input,
get(serverEnvironment.usageModelFilterAtom(environmentId)),
),
}),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const summary = AsyncResult.getOrElse(result, () => null);
statuses.push({
environmentId,
Expand Down
21 changes: 21 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish
without writing again to environments that already saved. Changes are not queued after you close
the dialog.

## Show only some models

To count only some models, or to leave some out, add `usageModelFilter` to the server's
`settings.json` in its `userdata` directory, such as `~/.t3/userdata/settings.json`:

```json
{
"usageModelFilter": {
"include": [{ "provider": "claude", "model": "orbit-1" }, { "model": "orbit-2" }],
"exclude": [{ "model": "drift-9" }]
}
}
```

`include` keeps only the listed models and `exclude` leaves the listed models out. `provider` is
one of `claude`, `codex`, `cursor`, `grok`, `opencode`, `antigravity`, `pi`, or `muse`; omit it
to match a model ID from any provider. If the setting is invalid, Usage ignores it and shows every
model. Filters match the model ID your provider
recorded, before **Map to** applies, so a model you leave out is not counted under its mapped
name. Usage applies each environment's own filter. Remove the setting to show every model again.

## Track subscription limits

**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
Expand Down
8 changes: 8 additions & 0 deletions packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,13 @@ export function createServerEnvironmentAtoms<R, E>(
]);
}).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),
);
// Settings snapshots rebuild the filter object; only a changed filter should touch the Usage tab.
const usageModelFilterAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get) => get(settingsValueAtom(environmentId))?.usageModelFilter).pipe(
Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)),
Atom.withLabel(`environment-data:server:usage-model-filter:${environmentId}`),
),
);
const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
Atom.withLabel(`environment-data:server:providers:${environmentId}`),
Expand Down Expand Up @@ -1194,6 +1201,7 @@ export function createServerEnvironmentAtoms<R, E>(
}),
updateStateAtom,
settingsValueAtom,
usageModelFilterAtom,
providersValueAtom,
providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:provider:auth-state",
Expand Down
Loading
Loading