diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 9851b841c88c5..ed9fb01fc1f7f 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,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, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 32ef901326f77..8c09383d56c18 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, @@ -2077,6 +2082,111 @@ 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, 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), [ + { 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); + assert.deepStrictEqual(keepNothing.buckets, []); + assert.strictEqual(keepNothing.sources[0]?.distinctSessions, 0); + + 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; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f5f10fb156f3f..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, @@ -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,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( diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 17903cd062eae..8f94c84a3c470 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,38 @@ export function resolveModelAliases( return resolved; } +/** 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 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); +} + +const selectorKey = (selector: UsageModelSelector) => + JSON.stringify([selector.provider ?? null, selector.model]); + +const canonicalSelectors = (selectors: readonly UsageModelSelector[]) => + [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))] + // 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. */ +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; @@ -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)) { diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9a68a5fa724f7..f47a65cd08268 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,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, diff --git a/docs/user/usage.md b/docs/user/usage.md index 0289defe8bc71..71eb1e0fe8773 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -87,6 +87,30 @@ 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. 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 **Usage → Limits** pools every subscription account it can see per provider, so with several Codex 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 29aac2ae98ecf..0650b1f2093c7 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -5,7 +5,9 @@ import { 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 +41,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 +67,18 @@ 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; + }, + usageModelFilterAtom: (environmentId: EnvironmentId) => + Atom.make(modelFilters[environmentId]), refreshUsageRates: { label: "test:rates", requiredScopes: () => [], @@ -86,7 +93,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 +120,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..2dea725be1f14 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, + UsageModelFilter, UsageSummary, UsageSummaryInput, } from "@t3tools/contracts"; @@ -43,6 +44,14 @@ 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, + modelFilter: UsageModelFilter | undefined, +): UsageSummaryInput { + return modelFilter === undefined ? input : { ...input, modelFilter }; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -86,7 +95,7 @@ export async function refreshUsage({ registry: AtomRegistry.AtomRegistry; server: Pick< ReturnType, - "usageSummary" | "refreshUsageRates" + "usageSummary" | "refreshUsageRates" | "usageModelFilterAtom" >; presentations: Pick, "presentationAtom">; environmentIds: readonly EnvironmentId[]; @@ -94,7 +103,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.usageModelFilterAtom(environmentId))), + }); const presentation = presentations.presentationAtom(environmentId); const controller = new AbortController(); const abortWhenDisconnected = () => { 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 e90b977fae32d..b1848090d9d00 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. 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 8e34c10eb885e..7f5a609eee20a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -108,6 +108,23 @@ 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; a string provider lets older clients carry selectors for providers they lack. + */ +export const UsageModelSelector = Schema.Struct({ + provider: Schema.optional(TrimmedNonEmptyString), + 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 +134,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 +148,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; 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; }