diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 274e63b46c906..054474c66b89c 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -2,6 +2,8 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Deferred from "effect/Deferred"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -157,3 +159,179 @@ it.effect("reads a management key from its secret provider", () => { Effect.scoped, ); }); + +it.effect("reads routed sessions from opted-in hubs, even with no client open", () => + Effect.gen(function* () { + const queueReads: string[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + expect(request.headers.authorization).toBe("Bearer key"); + const url = new URL(request.url); + if (url.pathname.endsWith("/usage-queue")) { + queueReads.push(url.host); + return HttpClientResponse.fromWeb( + request, + Response.json([{ session_id: "thread-session", auth_index: "a" }]), + ); + } + if (url.pathname.endsWith("/auth-files")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + files: [{ id: "claude-a.json", auth_index: "a", provider: "claude" }], + }), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ status_code: 200, body: JSON.stringify({}) }), + ); + }), + ); + const sources = yield* UsageLimitSources.make.pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + yield* sources.refresh; + yield* TestClock.adjust("30 seconds"); + + const snapshots = yield* sources.current; + expect(queueReads).toEqual(["routed.test"]); + expect( + snapshots.map((source) => [source.id, source.accounts.map((account) => account.sessionIds)]), + ).toEqual([ + ["routed", [["thread-session"]]], + ["observed", [undefined]], + ]); + + // Switching Thread accounts off drops the routes on that refresh, not on the next queue read. + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ + usageLimitSources: { + [UsageLimitSourceId.make("routed")]: { + kind: "cliproxy", + url: "https://routed.test", + managementKey: "key", + enabled: true, + threadAccounts: false, + }, + }, + }); + yield* sources.refresh; + const afterOff = yield* sources.current; + expect(afterOff.find((source) => source.id === "routed")?.accounts[0]?.sessionIds).toBe( + undefined, + ); + }).pipe( + Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => "key")), + invalidate: () => Effect.void, + label: (provider) => Effect.succeed(provider), + }), + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("routed")]: { + kind: "cliproxy", + url: "https://routed.test", + managementKey: { + provider: SecretProviderId.make("homelab"), + reference: "cliproxy#key", + }, + enabled: true, + threadAccounts: true, + }, + [UsageLimitSourceId.make("observed")]: { + kind: "cliproxy", + url: "https://observed.test", + managementKey: "key", + enabled: true, + }, + }, + }), + // No client lease: a turn that ends while nobody watches must still keep its route. + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + NodeCrypto.layer, + ]), + Effect.scoped, + ), +); + +it.effect("drops a queue read that lands after Thread accounts was switched off", () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + const http = HttpClient.make((request) => { + const url = new URL(request.url); + if (url.pathname.endsWith("/usage-queue")) { + return Deferred.await(release).pipe( + Effect.as( + HttpClientResponse.fromWeb( + request, + Response.json([{ session_id: "thread-session", auth_index: "a" }]), + ), + ), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + url.pathname.endsWith("/auth-files") + ? Response.json({ + files: [{ id: "claude-a.json", auth_index: "a", provider: "claude" }], + }) + : Response.json({ status_code: 200, body: JSON.stringify({}) }), + ), + ); + }); + const sources = yield* UsageLimitSources.make.pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + yield* sources.refresh; + // The 30-second queue read starts and waits on the hub. + yield* TestClock.adjust("30 seconds"); + + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ + usageLimitSources: { + [UsageLimitSourceId.make("routed")]: { + kind: "cliproxy", + url: "https://routed.test", + managementKey: "key", + enabled: true, + threadAccounts: false, + }, + }, + }); + yield* sources.refresh; + yield* Deferred.succeed(release, undefined); + yield* TestClock.adjust("1 second"); + + const snapshots = yield* sources.current; + expect(snapshots[0]?.accounts[0]?.sessionIds).toBe(undefined); + }).pipe( + Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + label: (provider) => Effect.succeed(provider), + }), + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("routed")]: { + kind: "cliproxy", + url: "https://routed.test", + managementKey: "key", + enabled: true, + threadAccounts: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + NodeCrypto.layer, + ]), + Effect.scoped, + ), +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..dc4c376663778 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -40,6 +40,31 @@ export class UsageLimitSources extends Context.Service< } >()("t3/usage/UsageLimitSources") {} +const ROUTE_READ_INTERVAL = Duration.seconds(30); +const MAX_ROUTES_PER_SOURCE = 200; + +function withSessionIds( + snapshots: ReadonlyArray, + routes: ReadonlyMap>, +): ReadonlyArray { + return snapshots.map((source) => { + const sessionIdsByAccount = new Map(); + for (const [sessionId, accountId] of routes.get(source.id) ?? []) { + const sessionIds = sessionIdsByAccount.get(accountId); + if (sessionIds) sessionIds.push(sessionId); + else sessionIdsByAccount.set(accountId, [sessionId]); + } + if (sessionIdsByAccount.size === 0) return source; + return { + ...source, + accounts: source.accounts.map((account) => { + const sessionIds = sessionIdsByAccount.get(account.id); + return sessionIds ? { ...account, sessionIds } : account; + }), + }; + }); +} + function sourceLabel(id: string, config: UsageLimitSourceConfig): string { if (config.label) return config.label; const fallback = id.trim() || "Usage limit source"; @@ -62,6 +87,11 @@ export const make = Effect.gen(function* () { const settingsService = yield* Settings.ServerSettingsService; const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const readsRef = yield* Ref.make>([]); + // Per source, provider session id to hub account id, oldest route first. + const routesRef = yield* Ref.make>>( + new Map(), + ); const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( config: UsageLimitSourceConfig, ) { @@ -94,14 +124,28 @@ export const make = Effect.gen(function* () { return { ...base, accounts: accounts.success }; }); + // Its own lock, so a route read never waits on a slow quota refresh yet never publishes stale reads. + const publishLock = yield* Semaphore.make(1); const publish = Effect.fn("UsageLimitSources.publish")(function* ( - next: ReadonlyArray, + reads: ReadonlyArray | undefined, ) { + if (reads) yield* Ref.set(readsRef, reads); + // Current settings, so no publish names routes for a hub switched out of Thread accounts, + // even when a queue read started before the switch lands after it. + const settings = yield* settingsService.getSettings.pipe( + Effect.orElseSucceed((): ServerSettings | null => null), + ); + const routes = new Map( + [...(yield* Ref.get(routesRef))].filter( + ([id]) => settings?.usageLimitSources[id]?.threadAccounts, + ), + ); + const next = withSessionIds(yield* Ref.get(readsRef), routes); const changed = yield* Ref.modify(stateRef, (previous) => Equal.equals(previous, next) ? [false, previous] : [true, next], ); if (changed) yield* PubSub.publish(changes, next); - }); + }, publishLock.withPermits(1)); // Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes. const refreshLock = yield* Semaphore.make(1); @@ -143,11 +187,48 @@ export const make = Effect.gen(function* () { input.creditId, ); const snapshot = yield* readSource(input.sourceId, config); - const previous = yield* Ref.get(stateRef); + const previous = yield* Ref.get(readsRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); return result; }, refreshLock.withPermits(1)); + // Opted-in hubs only; a failed read keeps the routes already known for that hub. + const readRoutes = Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.orElseSucceed((): ServerSettings | null => null), + ); + const entries = Object.entries(settings?.usageLimitSources ?? {}).filter( + ([, config]) => config.enabled && config.threadAccounts, + ); + const reads = yield* Effect.forEach( + entries, + ([id, config]) => + readManagementKey(config).pipe( + Effect.flatMap((managementKey) => + managementKey.length > 0 + ? api.readSessionAccounts({ url: config.url, managementKey }) + : Effect.succeed([]), + ), + Effect.orElseSucceed(() => []), + Effect.map((routes) => [id as UsageLimitSourceId, routes] as const), + ), + { concurrency: 4 }, + ); + yield* Ref.update(routesRef, (previous) => { + const next = new Map>(); + for (const [id, routes] of reads) { + const routed = new Map(previous.get(id)); + for (const { sessionId, accountId } of routes) { + routed.delete(sessionId); + routed.set(sessionId, accountId); + } + next.set(id, new Map([...routed].slice(-MAX_ROUTES_PER_SOURCE))); + } + return next; + }); + yield* publish(undefined); + }).pipe(Effect.ignoreCause({ log: true })); + yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), Stream.changes, @@ -175,6 +256,12 @@ export const make = Effect.gen(function* () { ), ).pipe(Effect.forkScoped); + // CLIProxyAPI keeps queued usage for 60 seconds by default. Not gated on client activity: + // a turn that ends while no client is open would otherwise lose its route for good. + yield* Effect.forever(Effect.sleep(ROUTE_READ_INTERVAL).pipe(Effect.andThen(readRoutes))).pipe( + Effect.forkScoped, + ); + yield* refresh.pipe(Effect.forkScoped); return { diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971ff..a65183cb6b6c3 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -60,6 +60,8 @@ function fixture( }>; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + usageQueue?: ReadonlyArray; + authFilesStatus?: number; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -75,8 +77,13 @@ function fixture( if (path.endsWith("/auth-files")) return HttpClientResponse.fromWeb( request, - Response.json({ files: options.accounts ?? accounts }), + Response.json( + { files: options.accounts ?? accounts }, + { status: options.authFilesStatus ?? 200 }, + ), ); + if (path.endsWith("/usage-queue")) + return HttpClientResponse.fromWeb(request, Response.json(options.usageQueue ?? [])); if (path.endsWith("/reset-quota")) return HttpClientResponse.fromWeb( request, @@ -437,6 +444,40 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("leaves the usage queue unread when the hub's accounts cannot be listed", () => + Effect.gen(function* () { + const test = fixture({ + authFilesStatus: 503, + usageQueue: [{ session_id: "session-1", auth_index: "a" }], + }); + const api = yield* test.api; + expect((yield* api.readSessionAccounts(config).pipe(Effect.result))._tag).toBe("Failure"); + // Reading the queue pops it, so the records must still be there for the next read. + expect(test.requests.map((request) => request.path)).not.toContainEqual( + expect.stringMatching(/usage-queue$/), + ); + }), + ); + + it.effect("names the account that served each queued session by its auth index", () => + Effect.gen(function* () { + const test = fixture({ + usageQueue: [ + { session_id: "session-1", auth_index: "b" }, + { session_id: "session-2", auth_index: "unknown" }, + { auth_index: "a" }, + "not a record", + { session_id: "session-3", auth_index: "a" }, + ], + }); + const api = yield* test.api; + expect(yield* api.readSessionAccounts(config)).toEqual([ + { sessionId: "session-1", accountId: "second.json" }, + { sessionId: "session-3", accountId: "first.json" }, + ]); + }), + ); + const antigravitySummary = { buckets: [ { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33d..74a0adc11bc72 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind, + TrimmedNonEmptyString, UsageLimitSourceError, type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, @@ -116,7 +117,14 @@ const CreditList = Schema.Struct({ ), }); +const UsageQueueRecord = Schema.Struct({ + session_id: TrimmedNonEmptyString, + auth_index: TrimmedNonEmptyString, +}); + const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles); +const decodeUsageQueue = Schema.decodeUnknownEffect(Schema.Array(Schema.Unknown)); +const decodeUsageQueueRecord = Schema.decodeUnknownOption(UsageQueueRecord); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); @@ -483,6 +491,24 @@ export const makeCliproxyApi = Effect.gen(function* () { return { id: account.id, driver: provider.driver, ...reading }; }); + // Pops the hub's usage queue: records read here are gone for every other reader. + const readSessionAccounts = Effect.fn("CliproxyApi.readSessionAccounts")(function* ( + config: CliproxyHub, + ) { + // Accounts first: a failed lookup after the pop would lose those records for good. + const accountIds = new Map( + (yield* authFiles(config)).map((account) => [account.auth_index, account.id]), + ); + // Records without a session or account, or not JSON objects, carry nothing to match. + const records = (yield* decodeUsageQueue( + yield* management(config, "usage-queue?count=1000"), + )).flatMap((record) => Option.toArray(decodeUsageQueueRecord(record))); + return records.flatMap(({ session_id, auth_index }) => { + const accountId = accountIds.get(auth_index); + return accountId ? [{ sessionId: session_id, accountId }] : []; + }); + }); + const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( hub: CliproxyHub, ): Effect.fn.Return, UsageLimitSourceError> { @@ -561,5 +587,5 @@ export const makeCliproxyApi = Effect.gen(function* () { ), ); }); - return { readAccounts, consume }; + return { readAccounts, readSessionAccounts, consume }; }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdbb91d9073ff..a2d2151c64308 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3919,6 +3919,11 @@ export default function ChatView(props: ChatViewProps) { providerInstanceId: activeProviderInstanceId, providers: providerStatuses, sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + // The session belongs to the thread's instance, not one the composer just picked. + providerSessionId: + activeProviderThread?.providerInstanceId === activeProviderInstanceId + ? (activeProviderThread?.nativeThreadRef?.nativeId ?? null) + : null, contextWindow: activeContextWindow, }} /> diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts new file mode 100644 index 0000000000000..04454f4e09b6a --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,487 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; + +const now = Date.parse("2026-10-30T12:00:00.000Z"); +const checkedAt = "2026-10-30T11:59:00.000Z"; +const claude = ProviderDriverKind.make("claudeAgent"); +const instanceId = ProviderInstanceId.make("claudeAgent"); +const codex = ProviderDriverKind.make("codex"); +const codexId = ProviderInstanceId.make("codex"); + +const session = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 19, + windowDurationMins: 300, + resetsAt: "2026-10-30T13:17:00.000Z", +} as const; + +const unsupported: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" }, +}; + +const probeFailed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, +}; + +function limitsAt(usedPercent: number): ServerProviderUsageLimits { + return { checkedAt, windows: [{ ...session, usedPercent }] }; +} + +function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider { + return { + instanceId, + driver: claude, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "apiKey", label: "Claude API Key" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits, + }; +} + +function codexInstance(usageLimits: ServerProviderUsageLimits, email?: string): ServerProvider { + const base = claudeInstance(usageLimits); + return { + ...base, + instanceId: codexId, + driver: codex, + auth: email ? { ...base.auth, email } : base.auth, + }; +} + +/** A Claude instance signed in to one workspace, by email unless `email` is null. */ +function signedIn( + id: string, + workspaceId: string, + limits: ServerProviderUsageLimits, + email: string | null = "me@example.com", +): ServerProvider { + const base = claudeInstance(limits); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, ...(email ? { email } : {}), workspaceId }, + }; +} + +function hub( + label: string, + accounts: ReadonlyArray<{ + id: string; + email?: string; + workspaceId?: string; + driver?: ProviderDriverKind; + limits: ServerProviderUsageLimits; + sessionIds?: string[]; + }>, +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + ...(account.email ? { email: account.email } : {}), + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...(account.sessionIds ? { sessionIds: account.sessionIds } : {}), + usageLimits: account.limits, + })), + }; +} + +describe("thread details limits", () => { + it("shows the hub's account for a proxied instance that reads no limits of its own", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [hub("CLIProxy (homebox)", [{ id: "claude-a.json", limits: limitsAt(19) }])], + now, + ); + + expect(resolved).toEqual({ limits: limitsAt(19), sourceLabel: "CLIProxy (homebox)" }); + }); + + it("keeps the instance's own notice when several hub accounts could serve the thread", () => { + const sources = [ + hub("hub", [ + { id: "claude-a.json", limits: limitsAt(19), sessionIds: ["other-session"] }, + { + id: "claude-b.json", + email: "b@example.com", + limits: limitsAt(60), + sessionIds: ["thread-session"], + }, + ]), + ]; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + sources, + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + // The hub's routing names the account that served this thread's session. + expect( + resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + sources, + now, + "thread-session", + ), + ).toEqual({ limits: limitsAt(60), sourceLabel: "hub" }); + }); + + it("prefers the routed hub account over a signed-in instance's own login", () => { + // Codex signed in to ChatGPT but sending through the hub, which served it from another account. + const providers = [codexInstance(limitsAt(5), "me@example.com")]; + const sources = [ + hub("hub", [ + { id: "codex-me.json", email: "me@example.com", driver: codex, limits: limitsAt(5) }, + { + id: "codex-other.json", + email: "other@example.com", + driver: codex, + limits: limitsAt(70), + sessionIds: ["codex-session"], + }, + ]), + ]; + + expect(resolveThreadDetailsLimits(codexId, providers, sources, now, "codex-session")).toEqual({ + limits: limitsAt(70), + sourceLabel: "hub", + }); + // Without a route for this session, the instance's own reading still wins. + expect(resolveThreadDetailsLimits(codexId, providers, sources, now, "unrouted")).toEqual({ + limits: limitsAt(5), + sourceLabel: null, + }); + }); + + it("keeps the notice when two hubs both routed the session", () => { + const routed = (label: string, usedPercent: number) => + hub(label, [ + { id: "claude-a.json", limits: limitsAt(usedPercent), sessionIds: ["thread-session"] }, + { id: "claude-b.json", email: "b@example.com", limits: limitsAt(5) }, + ]); + + expect( + resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [routed("hub-a", 19), routed("hub-b", 60)], + now, + "thread-session", + ), + ).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("does not take a routed session from another driver's account", () => { + const sources = [ + hub("hub", [ + { id: "codex.json", driver: codex, limits: limitsAt(70), sessionIds: ["shared-id"] }, + { id: "claude-a.json", limits: limitsAt(19) }, + { id: "claude-b.json", email: "b@example.com", limits: limitsAt(60) }, + ]), + ]; + + expect( + resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + sources, + now, + "shared-id", + ), + ).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("counts accounts across hubs, so one account on each of two hubs stays ambiguous", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub-a", [{ id: "claude-a.json", limits: limitsAt(19) }]), + hub("hub-b", [{ id: "claude-b.json", limits: limitsAt(60) }]), + ], + now, + ); + + expect(resolved?.sourceLabel).toBeNull(); + }); + + it("keeps the instance's own notice when another hub failed to read its accounts", () => { + const unreadable = { ...hub("hub-a", []), error: "Connection refused." }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [unreadable, hub("hub-b", [{ id: "claude-b.json", limits: limitsAt(60) }])], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("counts one login reported by two hubs once, and shows its freshest snapshot", () => { + const stale = { ...limitsAt(19), checkedAt: "2026-10-30T11:00:00.000Z" }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub-a", [{ id: "claude-a.json", email: "Me@Example.com", limits: stale }]), + hub("hub-b", [{ id: "claude-b.json", email: "me@example.com", limits: limitsAt(25) }]), + ], + now, + ); + + expect(resolved).toEqual({ limits: limitsAt(25), sourceLabel: "hub-b" }); + }); + + it("keeps the instance's notice when one login is reported in two workspaces", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub", [ + { + id: "claude-a.json", + email: "me@example.com", + workspaceId: "team-a", + limits: limitsAt(19), + }, + { + id: "claude-b.json", + email: "me@example.com", + workspaceId: "team-b", + limits: limitsAt(25), + }, + ]), + ], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("counts one login as one account when a hub omits the workspace another reports", () => { + const fresher = { ...limitsAt(25), checkedAt: "2026-10-30T11:59:30.000Z" }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub-a", [ + { + id: "claude-a.json", + email: "me@example.com", + workspaceId: "team-a", + limits: limitsAt(19), + }, + ]), + hub("hub-b", [{ id: "claude-b.json", email: "me@example.com", limits: fresher }]), + ], + now, + ); + + expect(resolved).toEqual({ limits: fresher, sourceLabel: "hub-b" }); + }); + + it("keeps one login's hub accounts apart when the instances report it in two workspaces", () => { + const fresher = { ...limitsAt(25), checkedAt: "2026-10-30T11:59:30.000Z" }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [ + claudeInstance(unsupported), + signedIn("claude-a", "team-a", limitsAt(10)), + signedIn("claude-b", "team-b", limitsAt(10)), + ], + [ + hub("hub", [ + { + id: "claude-a.json", + email: "me@example.com", + workspaceId: "team-a", + limits: limitsAt(19), + }, + { id: "claude-x.json", email: "me@example.com", limits: fresher }, + ]), + ], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("does not borrow a login's hub account when its workspace could be another instance's", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [signedIn(instanceId, "team-a", unsupported), signedIn("claude-b", "team-b", limitsAt(10))], + [hub("hub", [{ id: "claude-x.json", email: "me@example.com", limits: limitsAt(25) }])], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("does not borrow a hub account in a different workspace of the same login", () => { + const inTeamA = signedIn(instanceId, "team-a", unsupported); + const hubIn = (workspaceId: string) => [ + hub("hub", [ + { id: "claude-a.json", email: "me@example.com", workspaceId, limits: limitsAt(19) }, + ]), + ]; + + expect(resolveThreadDetailsLimits(instanceId, [inTeamA], hubIn("team-b"), now)).toEqual({ + limits: unsupported, + sourceLabel: null, + }); + expect(resolveThreadDetailsLimits(instanceId, [inTeamA], hubIn("team-a"), now)).toEqual({ + limits: limitsAt(19), + sourceLabel: "hub", + }); + }); + + it("counts one credential as one account when two hubs report it without an email", () => { + const credential = (usedPercent: number, at: string) => ({ + ...limitsAt(usedPercent), + checkedAt: at, + credentialFingerprint: "fp-1", + }); + const fresher = credential(25, "2026-10-30T11:59:30.000Z"); + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub-a", [{ id: "claude-a.json", limits: credential(19, checkedAt) }]), + hub("hub-b", [{ id: "claude-b.json", limits: fresher }]), + ], + now, + ); + + expect(resolved).toEqual({ limits: fresher, sourceLabel: "hub-b" }); + }); + + it("does not borrow a credential's hub account when its workspace could be another's", () => { + const credential = { ...limitsAt(10), credentialFingerprint: "fp-1" }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [ + claudeInstance(unsupported), + signedIn("claude-a", "team-a", credential, null), + signedIn("claude-b", "team-b", credential, null), + ], + [ + hub("hub", [ + { id: "claude-x.json", limits: { ...limitsAt(25), credentialFingerprint: "fp-1" } }, + ]), + ], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("falls back to the one hub account when a Codex instance's own probe failed", () => { + const resolved = resolveThreadDetailsLimits( + codexId, + [codexInstance(probeFailed)], + [hub("hub", [{ id: "codex-a.json", driver: codex, limits: limitsAt(30) }])], + now, + ); + + expect(resolved).toEqual({ limits: limitsAt(30), sourceLabel: "hub" }); + }); + + it("does not borrow a hub account that belongs to a different login", () => { + const hubFor = (email: string) => [ + hub("hub", [{ id: "codex-a.json", driver: codex, email, limits: limitsAt(30) }]), + ]; + + expect( + resolveThreadDetailsLimits( + codexId, + [codexInstance(probeFailed, "alice@example.com")], + hubFor("bob@example.com"), + now, + ), + ).toEqual({ limits: probeFailed, sourceLabel: null }); + expect( + resolveThreadDetailsLimits( + codexId, + [codexInstance(probeFailed, " Alice@Example.com ")], + hubFor("alice@example.com"), + now, + ), + ).toEqual({ limits: limitsAt(30), sourceLabel: "hub" }); + }); + + it("prefers the instance's own limits when it can read them", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(limitsAt(40))], + [hub("hub", [{ id: "claude-a.json", limits: limitsAt(19) }])], + now, + ); + + expect(resolved).toEqual({ limits: limitsAt(40), sourceLabel: null }); + }); + + it("keeps the instance's notice when a second hub account has no usable limits", () => { + // The hub may route the thread through either account, so the readable one is not enough. + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub", [ + { id: "claude-a.json", limits: limitsAt(19) }, + { + id: "claude-b.json", + limits: { ...unsupported, unavailable: { reason: "probeFailed" } }, + }, + ]), + ], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); + + it("ignores other drivers' hub accounts but not the only account's unusable limits", () => { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub", [ + { id: "codex-a.json", driver: ProviderDriverKind.make("codex"), limits: limitsAt(19) }, + { id: "claude-a.json", limits: unsupported }, + ]), + ], + now, + ); + + expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); + }); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts new file mode 100644 index 0000000000000..648a567220dd4 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,138 @@ +import type { + ProviderInstanceId, + ServerProvider, + ServerProviderUsageLimits, + UsageLimitSourceAccount, + UsageLimitSourceSnapshot, + UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + environmentAccountKeyResolver, + limitsNotice, +} from "@t3tools/shared/usageLimits"; + +export interface ThreadDetailsLimits { + readonly limits: ServerProviderUsageLimits; + /** The usage hub that reported these limits, when the instance could not read its own. */ + readonly sourceLabel: string | null; +} + +/** + * The limits the thread details card shows for one provider instance: the hub account that served + * the thread's session (Thread accounts), even over the instance's own signed-in login; else the + * instance's own limits; else the only hub account for the driver, since with several nothing says + * which one the thread spends. + */ +export function resolveThreadDetailsLimits( + providerInstanceId: ProviderInstanceId | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, + providerSessionId: string | null = null, +): ThreadDetailsLimits | null { + if (providerInstanceId === null) return null; + const provider = providers.find((entry) => entry.instanceId === providerInstanceId); + const report = collectProviderUsageLimits(providerInstanceId, providers, sources, now); + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const routed = + provider && providerSessionId + ? routedHubAccount(provider, sources, providerSessionId) + : undefined; + // The tooltip names only the hub: account emails stay redacted, as in Usage → Limits. + if (routed) return { limits: routed.account.usageLimits, sourceLabel: routed.source.label }; + + const own = + report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? + provider?.usageLimits; + if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; + const hub = provider ? onlyHubAccount(provider, providers, sources) : null; + if (hub) return { limits: hub.account.usageLimits, sourceLabel: hub.source.label }; + return own ? { limits: own, sourceLabel: null } : null; +} + +interface HubAccount { + readonly source: UsageLimitSourceSnapshot; + readonly account: UsageLimitSourceAccount; +} + +/** + * The hub account a hub last routed this provider session to, when it shares that routing. + * Only the driver must match: the hub served the session with this account, whatever login the + * instance itself is signed in to. + */ +function routedHubAccount( + provider: ServerProvider, + sources: UsageLimitSourceSnapshots, + providerSessionId: string, +): HubAccount | undefined { + const matches = sources + .flatMap((source) => source.accounts.map((account) => ({ source, account }))) + .filter( + ({ account }) => + account.driver === provider.driver && account.sessionIds?.includes(providerSessionId), + ); + // Two hubs both routed it (a moved base URL, chained hubs): nothing says which is current. + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * The one hub account serving a driver, if its limits are usable. Unreadable accounts still count, + * since the hub may route the thread through them. An account is a login (driver + email, else + * credential) plus workspace: one login from two hubs is one account, its freshest snapshot wins. + */ +function onlyHubAccount( + provider: ServerProvider, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): HubAccount | null { + // A hub that failed to read may hold accounts for this driver too. + if (sources.some((source) => source.error !== undefined)) return null; + const candidates = sources.flatMap((source) => + source.accounts + .filter((account) => canServeInstance(provider, account)) + .map((account) => ({ source, account })), + ); + // Learn workspaces from every report, so an account without one never merges into one of several. + const resolveKey = environmentAccountKeyResolver(providers, sources); + const [only, ...rest] = freshestPerAccount(candidates, resolveKey); + if (!only || rest.length > 0) return null; + // A login seen in several workspaces, reported without one, may be another workspace's account. + if (hasLogin(only.account) && resolveKey(only.account) === null) return null; + return limitsNotice(only.account.usageLimits) === null ? only : null; +} + +/** One entry per account key, keeping its most recently checked snapshot. */ +function freshestPerAccount( + candidates: readonly HubAccount[], + resolveKey: (account: UsageLimitSourceAccount) => string | null, +): HubAccount[] { + const freshestByAccount = new Map(); + for (const candidate of candidates) { + const key = resolveKey(candidate.account) ?? `${candidate.source.id}:${candidate.account.id}`; + const known = freshestByAccount.get(key); + if (!known || checkedAtMs(candidate) > checkedAtMs(known)) { + freshestByAccount.set(key, candidate); + } + } + return [...freshestByAccount.values()]; +} + +const hasLogin = (account: UsageLimitSourceAccount) => + normalizeEmail(account.email) !== undefined || + account.usageLimits.credentialFingerprint !== undefined; + +const checkedAtMs = ({ account }: HubAccount) => Date.parse(account.usageLimits.checkedAt); + +const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() || undefined; + +/** A hub account for the instance's driver, unless both sides name a different login or workspace. */ +function canServeInstance(provider: ServerProvider, account: UsageLimitSourceAccount): boolean { + if (account.driver !== provider.driver) return false; + const instanceEmail = normalizeEmail(provider.auth.email); + const hubEmail = normalizeEmail(account.email); + if (instanceEmail && hubEmail && instanceEmail !== hubEmail) return false; + const instanceWorkspace = provider.auth.workspaceId; + return !instanceWorkspace || !account.workspaceId || instanceWorkspace === account.workspaceId; +} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 0d6c0129484ed..929dae0825b13 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -5,7 +5,6 @@ import type { UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { - collectProviderUsageLimits, formatDuration, type LimitPace, limitsNotice, @@ -23,6 +22,7 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./compaction.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -34,29 +34,34 @@ export interface ThreadDetailsUsageInput { readonly providerInstanceId: ProviderInstanceId | null; readonly providers: readonly ServerProvider[]; readonly sources: UsageLimitSourceSnapshots; + /** The thread's native provider session, which a usage hub may report routing. */ + readonly providerSessionId: string | null; readonly contextWindow: ContextWindowSnapshot | null; } type UsageTone = "error" | "warning" | null; -/** The selected provider's limit windows. Each row reads as room left. */ +/** + * The selected provider's limit windows, or its usage hub's when it reads none of its own. + * Each row reads as room left. + */ export function ThreadDetailsUsage({ providerInstanceId, providers, sources, + providerSessionId, }: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); - const report = - providerInstanceId !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) - : null; - // Only this instance's data: the report keeps one instance per login, and any other entry may be - // a different login or workspace, so fall back to the instance's own snapshot. - const limits = - report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? - providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; - if (!limits) return null; + const resolved = resolveThreadDetailsLimits( + providerInstanceId, + providers, + sources, + now, + providerSessionId, + ); + if (!resolved) return null; + const { limits, sourceLabel } = resolved; const notice = limitsNotice(limits); return ( @@ -75,7 +80,9 @@ export function ThreadDetailsUsage({ tooltip={{notice}} /> ) : ( - limits.windows.map((window) => ) + limits.windows.map((window) => ( + + )) )} @@ -141,9 +148,11 @@ export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowS function LimitRow({ window, now, + sourceLabel, }: { readonly window: ServerProviderUsageWindow; readonly now: number; + readonly sourceLabel: string | null; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const left = remainingPercent(window); @@ -168,6 +177,7 @@ function LimitRow({ ) : null} {pace ? {PACE_LABEL[pace]} : null} + {sourceLabel ? From {sourceLabel} : null} } /> diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index 93f2fd6ddec51..0044bb0f4086d 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { @@ -13,6 +13,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -51,24 +52,41 @@ export function UsageProviderSettings({ entries.map(([id, source]) => { const label = source.label?.trim() || source.url; return ( - - CLI Proxy{source.enabled ? "" : " · Disabled"} - {label !== source.url ? ` · ${source.url}` : ""} - - } - control={ - !readOnly ? ( - updateSettings({ usageLimitSources: { [id]: null } })} + + + CLI Proxy{source.enabled ? "" : " · Disabled"} + {label !== source.url ? ` · ${source.url}` : ""} + + } + control={ + !readOnly ? ( + updateSettings({ usageLimitSources: { [id]: null } })} + /> + ) : null + } + /> + + updateSettings({ + usageLimitSources: { [id]: { ...source, threadAccounts } }, + }) + } /> - ) : null - } - /> + } + /> + ); }) )} diff --git a/apps/web/test/fixtures/usage-limits-hub/README.md b/apps/web/test/fixtures/usage-limits-hub/README.md index ec33b471b9ca7..16115ab100d12 100644 --- a/apps/web/test/fixtures/usage-limits-hub/README.md +++ b/apps/web/test/fixtures/usage-limits-hub/README.md @@ -62,6 +62,21 @@ its usage unreadable, so Limits leaves it out. 6. Edit `state.json` and press Refresh limits to switch scenarios. +## Thread accounts + +To check the card picks the account a hub routed a thread to, add `routes` to `state.json`, each entry +mapping a thread's provider session id to an account id from the scenario: + +```json +{ + "scenario": "A", + "n0": 1792515540000, + "routes": [{ "session_id": "", "auth_index": "a2" }] +} +``` + +Turn on **Thread accounts** for the hub. The server reads the routes every 30 seconds. + ## Not from the fixture - **"Codex: Could not read limits."** That banner comes from the local Codex install, which is unauthenticated with an empty `CODEX_HOME`. diff --git a/apps/web/test/fixtures/usage-limits-hub/hub.mjs b/apps/web/test/fixtures/usage-limits-hub/hub.mjs index 5d10671fd7f07..0d4fd087e8cbb 100644 --- a/apps/web/test/fixtures/usage-limits-hub/hub.mjs +++ b/apps/web/test/fixtures/usage-limits-hub/hub.mjs @@ -1,6 +1,7 @@ // Fake CLIProxyAPI management API serving the Limits scenarios in scenarios.json. // Usage: node hub.mjs [port]. state.json is {"scenario": "A", "n0": }; // it is re-read on every request, so edit it and press Refresh limits to switch scenarios. +// An optional "routes" array of {session_id, auth_index} is served as the usage queue. import * as NodeFS from "node:fs"; import * as NodeHttp from "node:http"; @@ -79,9 +80,11 @@ NodeHttp.createServer((request, response) => { let raw = ""; request.on("data", (chunk) => (raw += chunk)); request.on("end", () => { - const { scenario, n0 } = JSON.parse(NodeFS.readFileSync(statePath, "utf8")); + const { scenario, n0, routes = [] } = JSON.parse(NodeFS.readFileSync(statePath, "utf8")); const accounts = scenarios[scenario]; if (!accounts) return json(response, { files: [] }); + // shortcut: real hubs pop records once read; this serves them on every read, which T3 tolerates. + if (request.url.includes("/v0/management/usage-queue")) return json(response, routes); if (request.url.endsWith("/v0/management/auth-files")) { return json(response, { files: Object.entries(accounts).map(([id, account]) => ({ diff --git a/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..4a879e73aab7c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -180,6 +180,18 @@ The hub's Codex, Claude, xAI (shown as Grok) and Antigravity accounts appear und accounts for other providers are not read. Codex accounts show banked reset credits; select an account and choose **Use reset** to redeem one. No hub plugin is required. +When an instance cannot read its own limits and your connected hubs have exactly one account for +that provider between them, the thread details card shows that account's limits, and hovering a row +shows which hub they come from. A hub account signed in with a different email or workspace than the +instance is skipped. With several hub accounts, or while a hub cannot be read, the card keeps its notice, so +check **Usage → Limits**. + +To show the right account when a hub has several, turn on **Thread accounts** for that hub. T3 then +reads which account served each thread from the hub's usage queue and shows that account's limits, +even when the CLI itself is signed in to another account, as Codex can be while it sends through the hub. The +hub needs `usage-statistics-enabled` and session affinity turned on. Reading the queue removes its +records, so another tool or environment reading the same queue misses the ones T3 took. + This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same settings section when you no longer need it. diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 67acbe0d35b05..3515154347689 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -74,6 +74,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ plan: Schema.optional(TrimmedNonEmptyString), workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, + /** Provider session ids this hub recently routed to the account, newest last. */ + sessionIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2844c8181324a..e1c8a6c845e5d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -932,6 +932,8 @@ export const UsageLimitSourceConfig = Schema.Struct({ secretSetting, ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Opt-in: reading the hub's usage queue removes records other readers would otherwise see. + threadAccounts: Schema.optional(Schema.Boolean), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 716103ac2fc9b..0f3a1d496b796 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -246,6 +246,16 @@ function accountKeyResolver(reports: Iterable) { }; } +/** Keys one environment's accounts, learning workspaces from its enabled instances and hubs. */ +export function environmentAccountKeyResolver( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +) { + return accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); +} const hubAccountName = (account: { readonly id: string }) => account.id.replace(/\.json$/i, ""); export interface LimitAccount { @@ -848,10 +858,7 @@ export function collectProviderUsageLimits( ): UsageLimitsReport | null { const selected = providers.find((provider) => provider.instanceId === instanceId); if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; - const resolveKey = accountKeyResolver([ - ...providers.filter((provider) => provider.enabled).map(nativeReport), - ...sources.flatMap((source) => source.accounts), - ]); + const resolveKey = environmentAccountKeyResolver(providers, sources); type NativeSnapshot = { provider: ServerProvider; limits: ServerProviderUsageLimits }; const nativeByAccount = new Map< string,