From ce4c7f89b1ca1c8375260b24d49a1e2212b25704 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:21:31 -0800 Subject: [PATCH 1/9] feat(usage): filter usage summaries by provider and model Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/usage.ts | 13 ++- apps/server/src/usage/UsageService.test.ts | 84 +++++++++++++++++++ apps/server/src/usage/UsageService.ts | 3 + apps/server/src/usage/usageAggregation.ts | 15 ++++ apps/web/src/state/usage.ts | 13 ++- .../client-runtime/src/state/usage.test.ts | 37 +++++++- packages/client-runtime/src/state/usage.ts | 17 +++- packages/contracts/src/settings.ts | 4 + packages/contracts/src/usage.ts | 17 ++++ 9 files changed, 193 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 9851b841c88c5..358241635ff85 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -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"; @@ -52,7 +56,12 @@ 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.settingsValueAtom(environmentId))), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 32ef901326f77..b066cb43bbc45 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2077,6 +2077,90 @@ 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; + const line = (id: number, sessionId: string, model: string, outputTokens: number) => + claudeLine(id, outputTokens, model).replace('"session-1"', JSON.stringify(sessionId)); + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + line(1, "mixed", "orbit-1", 3) + + line(2, "mixed", "orbit-2", 5) + + line(3, "mixed", "drift-9", 7) + + line(4, "excluded-only", "drift-9", 11), + ), + ); + 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 = { + include: [{ provider: "claude" as const, model: "orbit-1" }, { model: "orbit-2" }], + }; + 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, included, excluded, hourly] = yield* Effect.all( + [ + service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: include }), + 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); + + 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); + } + assert.deepStrictEqual(included.modelFilter, include); + }).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; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f5f10fb156f3f..3bd6849b8918f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -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[] = []; @@ -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; }); @@ -897,6 +899,7 @@ 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) { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..1fdd1a189bb24 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -3,6 +3,8 @@ import type { UsageBucket, UsageCategoryCost, UsageDay, + UsageModelFilter, + UsageModelSelector, UsageResolution, UsageTokenTotals, } from "@t3tools/contracts"; @@ -67,6 +69,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -88,6 +91,17 @@ export function resolveModelAliases( return resolved; } +const matchesSelector = (record: UsageRecord) => (selector: UsageModelSelector) => + selector.model === record.model && + (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); +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; readonly duplicatesDropped: number; @@ -129,6 +143,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)) { diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9a68a5fa724f7..ed9a31dfc9b49 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -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"; @@ -55,7 +59,12 @@ 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.settingsValueAtom(environmentId))), + }), + ); const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29aac2ae98ecf..100b1f291b9c6 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,11 +1,14 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, type ServerProvider, + type UsageModelFilter, type UsageSummary, + type UsageSummaryInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -39,7 +42,7 @@ afterEach(() => { for (const registry of registries.splice(0)) registry.dispose(); }); -function harness(ids = ["a"]) { +function harness(ids = ["a"], modelFilters: Record = {}) { const registry = AtomRegistry.make(); registries.push(registry); const environments = ids.map((id) => { @@ -65,13 +68,22 @@ function harness(ids = ["a"]) { if (!environment) throw new Error(`Unknown environment: ${environmentId}`); return environment; } + const requested: { environmentId: EnvironmentId; input: UsageSummaryInput }[] = []; const options = { registry, environmentIds: environments.map((entry) => entry.environmentId), input, server: { - usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => - get(environmentId).query, + usageSummary: (target: { environmentId: EnvironmentId; input: UsageSummaryInput }) => { + requested.push(target); + return get(target.environmentId).query; + }, + settingsValueAtom: (environmentId: EnvironmentId) => { + const usageModelFilter = modelFilters[environmentId]; + return Atom.make( + usageModelFilter === undefined ? null : { ...DEFAULT_SERVER_SETTINGS, usageModelFilter }, + ); + }, refreshUsageRates: { label: "test:rates", requiredScopes: () => [], @@ -86,7 +98,7 @@ function harness(ids = ["a"]) { presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, }, } satisfies Parameters[0]; - return { registry, environments, refresh: () => refreshUsage(options) }; + return { registry, environments, requested, refresh: () => refreshUsage(options) }; } describe("manual usage refresh", () => { @@ -113,6 +125,23 @@ describe("manual usage refresh", () => { expect(finished).toBe(true); }); + it("rescans each environment with its own configured model filter", async () => { + const modelFilter = { include: [{ provider: "claude" as const, model: "orbit-1" }] }; + const { environments, requested, refresh } = harness(["filtered", "plain"], { + filtered: modelFilter, + }); + const refreshing = refresh(); + for (const entry of environments) { + entry.rates.resolve(AsyncResult.success(pricing)); + entry.scan.resolve(summary); + } + await refreshing; + expect(requested).toEqual([ + { environmentId: "filtered", input: { ...input, modelFilter } }, + { environmentId: "plain", input }, + ]); + }); + it("settles when an environment disconnects during the rescan", async () => { const { registry, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 7bc582eb7da39..244d3b11d2350 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ServerProvider, + ServerSettings, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -43,6 +44,15 @@ export function cursorKeychainAccessEnvironments< : environments.filter((environment) => environment.needsCursorKeychainAccess); } +/** The Usage tab asks each environment with that environment's configured model filter. */ +export function usageSummaryInput( + input: UsageSummaryInput, + settings: Pick | null, +): UsageSummaryInput { + const modelFilter = settings?.usageModelFilter; + return modelFilter === undefined ? input : { ...input, modelFilter }; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -86,7 +96,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" + "usageSummary" | "refreshUsageRates" | "settingsValueAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -94,7 +104,10 @@ export async function refreshUsage({ }): Promise { await Promise.all( environmentIds.map(async (environmentId) => { - const query = server.usageSummary({ environmentId, input }); + const query = server.usageSummary({ + environmentId, + input: usageSummaryInput(input, registry.get(server.settingsValueAtom(environmentId))), + }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); const abortWhenDisconnected = () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e90b977fae32d..9b225d1b0295c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -20,6 +20,7 @@ import { TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; +import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; @@ -1361,6 +1362,9 @@ export const ServerSettings = Schema.Struct({ usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + + /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. */ + usageModelFilter: Schema.optionalKey(UsageModelFilter), }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8e34c10eb885e..4224888df167b 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -108,6 +108,20 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** Matches a provider's original model ID, before usage model aliases apply. Omit provider to match any. */ +export const UsageModelSelector = Schema.Struct({ + provider: Schema.optional(UsageProviderKind), + model: TrimmedNonEmptyString, +}); +export type UsageModelSelector = typeof UsageModelSelector.Type; + +/** Keeps records matching `include` (all when omitted) and not matching `exclude`. */ +export const UsageModelFilter = Schema.Struct({ + include: Schema.optional(Schema.Array(UsageModelSelector)), + exclude: Schema.optional(Schema.Array(UsageModelSelector)), +}); +export type UsageModelFilter = typeof UsageModelFilter.Type; + export const UsageSummaryInput = Schema.Struct({ sinceDay: UsageDay, untilDay: UsageDay, @@ -117,6 +131,7 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), untilTime: Schema.optional(TrimmedNonEmptyString), awaitRefresh: Schema.optional(Schema.Boolean), + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; @@ -130,6 +145,8 @@ export const UsageSummary = Schema.Struct({ sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, scanDurationMs: NonNegativeInt, + /** The filter the server applied; absent means unfiltered, including servers that predate filters. */ + modelFilter: Schema.optional(UsageModelFilter), }); export type UsageSummary = typeof UsageSummary.Type; From c2ed114490ea98eb419c566c6fa5674556c32e02 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:32:22 -0800 Subject: [PATCH 2/9] fix(usage): keep model filters forward compatible and out of cross-filter merges Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/usage.ts | 5 +++- apps/server/src/usage/UsageService.test.ts | 26 ++++++++++++------- apps/server/src/usage/usageAggregation.ts | 2 +- apps/web/src/state/usage.ts | 5 +++- packages/client-runtime/src/state/server.ts | 8 ++++++ .../client-runtime/src/state/usage.test.ts | 9 ++----- packages/client-runtime/src/state/usage.ts | 9 +++---- packages/contracts/src/settings.test.ts | 11 ++++++++ packages/contracts/src/settings.ts | 4 +-- packages/contracts/src/usage.ts | 7 +++-- packages/shared/src/usageMerge.test.ts | 8 ++++++ packages/shared/src/usageMerge.ts | 5 +++- 12 files changed, 70 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 358241635ff85..ed9fb01fc1f7f 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -59,7 +59,10 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const result = get( serverEnvironment.usageSummary({ environmentId, - input: usageSummaryInput(input, get(serverEnvironment.settingsValueAtom(environmentId))), + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), }), ); const summary = AsyncResult.getOrElse(result, () => null); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b066cb43bbc45..dd8f4dec58c1c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -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, @@ -2080,15 +2085,13 @@ describe("UsageService", () => { it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; - const line = (id: number, sessionId: string, model: string, outputTokens: number) => - claudeLine(id, outputTokens, model).replace('"session-1"', JSON.stringify(sessionId)); yield* Effect.promise(() => NodeFSP.writeFile( transcript, - line(1, "mixed", "orbit-1", 3) + - line(2, "mixed", "orbit-2", 5) + - line(3, "mixed", "drift-9", 7) + - line(4, "excluded-only", "drift-9", 11), + 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 = { @@ -2096,7 +2099,12 @@ describe("UsageService", () => { "orbit-2": { input_cost_per_token: 0.000002, output_cost_per_token: 0.00002 }, }; const include = { - include: [{ provider: "claude" as const, model: "orbit-1" }, { model: "orbit-2" }], + // 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 = { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 1fdd1a189bb24..cd92ed06cf566 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -92,7 +92,7 @@ export function resolveModelAliases( } const matchesSelector = (record: UsageRecord) => (selector: UsageModelSelector) => - selector.model === record.model && + 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. */ diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ed9a31dfc9b49..f47a65cd08268 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -62,7 +62,10 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const result = get( serverEnvironment.usageSummary({ environmentId, - input: usageSummaryInput(input, get(serverEnvironment.settingsValueAtom(environmentId))), + input: usageSummaryInput( + input, + get(serverEnvironment.usageModelFilterAtom(environmentId)), + ), }), ); const summary = AsyncResult.getOrElse(result, () => null); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 890d1feaddf96..06eb14e2d8477 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1112,6 +1112,13 @@ export function createServerEnvironmentAtoms( ]); }).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}`), @@ -1194,6 +1201,7 @@ export function createServerEnvironmentAtoms( }), updateStateAtom, settingsValueAtom, + usageModelFilterAtom, providersValueAtom, providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:provider:auth-state", diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 100b1f291b9c6..0650b1f2093c7 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -78,12 +77,8 @@ function harness(ids = ["a"], modelFilters: Record = { requested.push(target); return get(target.environmentId).query; }, - settingsValueAtom: (environmentId: EnvironmentId) => { - const usageModelFilter = modelFilters[environmentId]; - return Atom.make( - usageModelFilter === undefined ? null : { ...DEFAULT_SERVER_SETTINGS, usageModelFilter }, - ); - }, + usageModelFilterAtom: (environmentId: EnvironmentId) => + Atom.make(modelFilters[environmentId]), refreshUsageRates: { label: "test:rates", requiredScopes: () => [], diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 244d3b11d2350..2dea725be1f14 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,7 +1,7 @@ import type { EnvironmentId, ServerProvider, - ServerSettings, + UsageModelFilter, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -47,9 +47,8 @@ export function cursorKeychainAccessEnvironments< /** The Usage tab asks each environment with that environment's configured model filter. */ export function usageSummaryInput( input: UsageSummaryInput, - settings: Pick | null, + modelFilter: UsageModelFilter | undefined, ): UsageSummaryInput { - const modelFilter = settings?.usageModelFilter; return modelFilter === undefined ? input : { ...input, modelFilter }; } @@ -96,7 +95,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" | "settingsValueAtom" + "usageSummary" | "refreshUsageRates" | "usageModelFilterAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -106,7 +105,7 @@ export async function refreshUsage({ environmentIds.map(async (environmentId) => { const query = server.usageSummary({ environmentId, - input: usageSummaryInput(input, registry.get(server.settingsValueAtom(environmentId))), + input: usageSummaryInput(input, registry.get(server.usageModelFilterAtom(environmentId))), }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 859644a54c72f..4b593d4d335b9 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -126,6 +126,17 @@ describe("ServerSettings usage price overrides", () => { expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow(); } }); + + it("ignores an invalid model filter without dropping the other settings", () => { + const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] }; + expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid); + const settings = decodeServerSettings({ + usageModelFilter: { exclude: [{ model: "" }] }, + usageModelAliases: { "drift-9": "orbit-1" }, + }); + expect(settings.usageModelFilter).toBeUndefined(); + expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" }); + }); }); describe("ServerSettings secret providers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9b225d1b0295c..b1848090d9d00 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1363,8 +1363,8 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), - /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. */ - usageModelFilter: Schema.optionalKey(UsageModelFilter), + /** Sent by the Usage tab with each summary request to this environment; edit in settings.json. Invalid values are ignored. */ + usageModelFilter: ForwardCompatibleOptional(UsageModelFilter), }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 4224888df167b..7f5a609eee20a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -108,9 +108,12 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; -/** Matches a provider's original model ID, before usage model aliases apply. Omit provider to match any. */ +/** + * Matches a provider's original model ID, before usage model aliases apply. Omit provider to + * match any; a string provider lets older clients carry selectors for providers they lack. + */ export const UsageModelSelector = Schema.Struct({ - provider: Schema.optional(UsageProviderKind), + provider: Schema.optional(TrimmedNonEmptyString), model: TrimmedNonEmptyString, }); export type UsageModelSelector = typeof UsageModelSelector.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index b58c2e6a8d878..828b652077f62 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -271,6 +271,14 @@ describe("mergeUsage", () => { ); expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); } + + const filtered = environment("new", { + ...partial.summary, + modelFilter: { exclude: [{ model: "claude-fable-5" }] }, + }); + const merged = mergeUsage([complete, filtered], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.sessions).toBe(1); }); it("retains a complete cell when a larger partial cell may have skipped old records", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 994ae01d6ffec..6c3e9e84ae21d 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -178,9 +178,12 @@ function claimSources(environments: readonly EnvironmentUsage[]): { if (source.status !== "partial") continue; const key = fingerprintKey(source.fingerprint); const owner = ownerScanByFingerprint.get(key); + // A scan under another model filter counts different records, so its cells are not missing ones. if ( owner?.source.status !== "ok" || - Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) + Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) || + JSON.stringify(environment.summary.modelFilter) !== + JSON.stringify(owner.environment.summary.modelFilter) ) { continue; } From cd692d040bc111924e5794ad79910770cf22b7ff Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:36:44 -0800 Subject: [PATCH 3/9] fix(usage): treat a filter that keeps everything as no filter Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 4 +++- apps/server/src/usage/UsageService.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index dd8f4dec58c1c..4f9ca65c45130 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2125,9 +2125,10 @@ describe("UsageService", () => { yield* Effect.gen(function* () { const service = yield* UsageService.make; // Same service, concurrent reads: the in-flight key must keep these scans apart. - const [unfiltered, included, excluded, hourly] = yield* Effect.all( + const [unfiltered, noOp, included, excluded, hourly] = yield* Effect.all( [ service.readSummary(WINDOW), + service.readSummary({ ...WINDOW, modelFilter: { exclude: [] } }), service.readSummary({ ...WINDOW, modelFilter: include }), service.readSummary({ ...WINDOW, modelFilter: exclude }), service.readSummary({ ...HOURLY, modelFilter: include }), @@ -2142,6 +2143,7 @@ describe("UsageService", () => { ]); assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); assert.strictEqual(unfiltered.modelFilter, undefined); + assert.deepStrictEqual(noOp, unfiltered); const kept = [ { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 3bd6849b8918f..4b977136cc404 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -902,7 +902,15 @@ export const make = Effect.gen(function* () { input.modelFilter ?? null, ]); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const readSummary = Effect.fn("UsageService.readSummary")(function* ( + requested: UsageSummaryInput, + ) { + // A filter that keeps every record is no filter, so it shares scans and merges with unfiltered reads. + const { modelFilter, ...unfiltered } = requested; + const input = + modelFilter?.include === undefined && (modelFilter?.exclude?.length ?? 0) === 0 + ? unfiltered + : requested; const settings = yield* readSettings; const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( From 31b6d86669142f0ec84ff5572644a411843ca3e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:38:15 -0800 Subject: [PATCH 4/9] fix(usage): give equal model filters one canonical form Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 18 +++++++++++++++--- apps/server/src/usage/UsageService.ts | 11 ++++------- apps/server/src/usage/usageAggregation.ts | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 4f9ca65c45130..c97188eb60dc8 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2125,11 +2125,15 @@ describe("UsageService", () => { 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, excluded, hourly] = yield* Effect.all( + 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 }), ], @@ -2143,7 +2147,7 @@ describe("UsageService", () => { ]); assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); assert.strictEqual(unfiltered.modelFilter, undefined); - assert.deepStrictEqual(noOp, unfiltered); + assert.strictEqual(noOp, unfiltered); const kept = [ { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, @@ -2156,7 +2160,15 @@ describe("UsageService", () => { for (const summary of [included, excluded, hourly]) { assert.strictEqual(summary.sources[0]?.distinctSessions, 1); } - assert.deepStrictEqual(included.modelFilter, include); + // 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( diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 4b977136cc404..7e2e8586ed95f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -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, @@ -905,12 +905,9 @@ export const make = Effect.gen(function* () { const readSummary = Effect.fn("UsageService.readSummary")(function* ( requested: UsageSummaryInput, ) { - // A filter that keeps every record is no filter, so it shares scans and merges with unfiltered reads. - const { modelFilter, ...unfiltered } = requested; - const input = - modelFilter?.include === undefined && (modelFilter?.exclude?.length ?? 0) === 0 - ? unfiltered - : requested; + 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( diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index cd92ed06cf566..93bb0ae844711 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -102,6 +102,27 @@ function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRec 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; From c806aaf38c54c6b7d398de2a86f1a8c73bc80f29 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:39:05 -0800 Subject: [PATCH 5/9] docs(usage): explain the usageModelFilter setting Co-Authored-By: Claude Opus 5.5 --- docs/user/usage.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/user/usage.md b/docs/user/usage.md index 0289defe8bc71..6f110ec865b41 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -87,6 +87,25 @@ 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. Omit +`provider` to match a model ID from any provider. 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 From d69ecf7fd0042e2d8bdf1f4be46a4d5e879c8865 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:40:33 -0800 Subject: [PATCH 6/9] docs(usage): list filter provider IDs Co-Authored-By: Claude Opus 5.5 --- docs/user/usage.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 6f110ec865b41..d9594e2b47f32 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -101,8 +101,10 @@ To count only some models, or to leave some out, add `usageModelFilter` to the s } ``` -`include` keeps only the listed models and `exclude` leaves the listed models out. Omit -`provider` to match a model ID from any provider. Filters match the model ID your provider +`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. From 6ed6c18bbb46dac2efad048c6d210282e2719fef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:42:53 -0800 Subject: [PATCH 7/9] fix(usage): sort filter selectors the same in every locale Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/usageAggregation.ts | 3 ++- docs/user/usage.md | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 93bb0ae844711..82b68987ac92e 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -107,7 +107,8 @@ const selectorKey = (selector: UsageModelSelector) => const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] - .sort(([a], [b]) => a.localeCompare(b)) + // Code-unit order, so servers in different locales echo the same form. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([, selector]) => selector); /** Equal filters get one form, so they share scans and clients can compare echoed filters. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index d9594e2b47f32..16428429c933e 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -104,9 +104,9 @@ To count only some models, or to leave some out, add `usageModelFilter` to the s `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. +model. Use the model ID that Usage shows without **Map to**, such as a provider-prefixed ID for +Pi. Filters apply before **Map to**, 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 From 844ce2d8b3807615d098356422842b45e2ce9f08 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 15:46:56 -0800 Subject: [PATCH 8/9] docs(usage): note that an invalid filter is dropped on the next save Co-Authored-By: Claude Opus 5.5 --- docs/user/usage.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 16428429c933e..71eb1e0fe8773 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -103,10 +103,13 @@ To count only some models, or to leave some out, add `usageModelFilter` to the s `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. Use the model ID that Usage shows without **Map to**, such as a provider-prefixed ID for -Pi. Filters apply before **Map to**, 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. +to match a model ID from any provider. Use the model ID that Usage shows without **Map to**, such +as a provider-prefixed ID for Pi. Filters apply before **Map to**, 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. + +If the setting is invalid, Usage ignores it and shows every model, and the next save of any other +setting removes it from `settings.json`. ## Track subscription limits From 1db7409b754785bb6dffc915117dede3a038d609 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 16:19:33 -0800 Subject: [PATCH 9/9] refactor(usage): trim each record's model once when filtering Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 32 ++++++++++++---------- apps/server/src/usage/usageAggregation.ts | 9 +++--- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index c97188eb60dc8..8c09383d56c18 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2125,20 +2125,22 @@ describe("UsageService", () => { 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" }, - ); + const [unfiltered, noOp, keepNothing, 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 }), + 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), [ @@ -2148,6 +2150,8 @@ describe("UsageService", () => { assert.strictEqual(unfiltered.sources[0]?.distinctSessions, 2); assert.strictEqual(unfiltered.modelFilter, undefined); assert.strictEqual(noOp, unfiltered); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); const kept = [ { model: "orbit-1", outputTokens: 3, costUsd: 40, records: 1, sessions: 1 }, diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 82b68987ac92e..8f94c84a3c470 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -91,14 +91,13 @@ 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); + const model = record.model.trim(); + const matches = (selector: UsageModelSelector) => + selector.model === model && + (selector.provider === undefined || selector.provider === record.provider); return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false); }