From 61873664b7c4b7cf8b5b1588cb92f1dc7bffb442 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 13:55:17 -0400 Subject: [PATCH 01/19] fix(web): show a proxied instance's limits from its usage hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread details card read only the selected instance's own limits. An instance routed through CLIProxyAPI reports none ("unsupported"), so the card showed "Limits: Unavailable" even with the hub connected and Usage → Limits showing its accounts. When the instance has no usable limits and exactly one hub account serves its driver, the card shows that account's windows and names the hub in each row's tooltip. With several accounts, nothing says which one the thread spends, so the card keeps the instance's own notice. Refs pingdotgg/t3code#27235 --- .../chat/ThreadDetailsUsage.logic.test.ts | 154 ++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 52 ++++++ .../components/chat/ThreadDetailsUsage.tsx | 27 +-- 3 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts create mode 100644 apps/web/src/components/chat/ThreadDetailsUsage.logic.ts 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..75788dccc3366 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,154 @@ +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 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" }, +}; + +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 hub( + label: string, + accounts: ReadonlyArray<{ + id: string; + driver?: ProviderDriverKind; + limits: ServerProviderUsageLimits; + }>, +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + 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 resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("hub", [ + { id: "claude-a.json", limits: limitsAt(19) }, + { id: "claude-b.json", limits: limitsAt(60) }, + ]), + ], + now, + ); + + expect(resolved).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("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("ignores hub accounts for other drivers and hub accounts without usable 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 }); + }); + + it("shows nothing without a selected instance", () => { + expect( + resolveThreadDetailsLimits( + null, + [claudeInstance(unsupported)], + [hub("hub", [{ id: "claude-a.json", limits: limitsAt(19) }])], + now, + ), + ).toBeNull(); + }); +}); 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..f4194d051000c --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,52 @@ +import type { + ProviderInstanceId, + ServerProvider, + ServerProviderUsageLimits, + UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { collectProviderUsageLimits, 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. + * An instance routed through a usage hub (CLIProxyAPI) reads no subscription limits of its own, + * so the card falls back to the hub, but only when exactly one hub account serves the driver: + * with several, nothing says which one this thread spends. + */ +export function resolveThreadDetailsLimits( + providerInstanceId: ProviderInstanceId | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): 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 own = + report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? + provider?.usageLimits; + if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; + + const hubAccounts = provider + ? sources.flatMap((source) => + source.accounts + .filter( + (account) => + account.driver === provider.driver && limitsNotice(account.usageLimits) === null, + ) + .map((account) => ({ source, account })), + ) + : []; + const [onlyHubAccount] = hubAccounts; + if (hubAccounts.length === 1 && onlyHubAccount) { + return { limits: onlyHubAccount.account.usageLimits, sourceLabel: onlyHubAccount.source.label }; + } + return own ? { limits: own, sourceLabel: null } : null; +} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 05211d6ace44e..66150ca9602ff 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, @@ -22,6 +21,7 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -38,7 +38,10 @@ export interface ThreadDetailsUsageInput { 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, @@ -46,16 +49,9 @@ export function ThreadDetailsUsage({ }: 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); + if (!resolved) return null; + const { limits, sourceLabel } = resolved; const notice = limitsNotice(limits); return ( @@ -74,7 +70,9 @@ export function ThreadDetailsUsage({ tooltip={{notice}} /> ) : ( - limits.windows.map((window) => ) + limits.windows.map((window) => ( + + )) )} @@ -140,9 +138,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); @@ -167,6 +167,7 @@ function LimitRow({ ) : null} {pace ? {PACE_LABEL[pace]} : null} + {sourceLabel ? From {sourceLabel} : null} } /> From 21afe7f6d3e8563f213bdc47b1ec0d8b6207b38e Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 14:33:57 -0400 Subject: [PATCH 02/19] fix(web): count hub accounts by login for the thread limits fallback The fallback counted hub entries, so one login reported by two hubs read as ambiguous and the card kept "Unavailable" while Usage -> Limits showed one account. Count accounts by the same identity Usage -> Limits uses (exported `accountKey`) and show the freshest snapshot. A failed probe still falls back, which Codex behind a hub needs, but a hub account whose email differs from the instance's own login is skipped, so a native login never shows another login's windows. --- .../chat/ThreadDetailsUsage.logic.test.ts | 74 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 69 +++++++++++++---- packages/shared/src/usageLimits.ts | 2 +- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 75788dccc3366..2611810b1b9f3 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -55,6 +55,7 @@ function hub( label: string, accounts: ReadonlyArray<{ id: string; + email?: string; driver?: ProviderDriverKind; limits: ServerProviderUsageLimits; }>, @@ -67,6 +68,7 @@ function hub( accounts: accounts.map((account) => ({ id: account.id, driver: account.driver ?? claude, + ...(account.email ? { email: account.email } : {}), usageLimits: account.limits, })), }; @@ -114,6 +116,78 @@ describe("thread details limits", () => { expect(resolved?.sourceLabel).toBeNull(); }); + 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("falls back to the one hub account when a Codex instance's own probe failed", () => { + const codex = ProviderDriverKind.make("codex"); + const codexId = ProviderInstanceId.make("codex"); + const probeFailed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }; + const resolved = resolveThreadDetailsLimits( + codexId, + [{ ...claudeInstance(probeFailed), instanceId: codexId, driver: codex }], + [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 codex = ProviderDriverKind.make("codex"); + const codexId = ProviderInstanceId.make("codex"); + const probeFailed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }; + const instance = (email: string): ServerProvider => { + const base = claudeInstance(probeFailed); + return { + ...base, + instanceId: codexId, + driver: codex, + auth: { ...base.auth, email }, + }; + }; + const hubFor = (email: string) => [ + hub("hub", [{ id: "codex-a.json", driver: codex, email, limits: limitsAt(30) }]), + ]; + + expect( + resolveThreadDetailsLimits( + codexId, + [instance("alice@example.com")], + hubFor("bob@example.com"), + now, + ), + ).toEqual({ limits: probeFailed, sourceLabel: null }); + expect( + resolveThreadDetailsLimits( + codexId, + [instance(" 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, diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index f4194d051000c..e7b718fa3491e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -2,9 +2,10 @@ import type { ProviderInstanceId, ServerProvider, ServerProviderUsageLimits, + UsageLimitSourceSnapshot, UsageLimitSourceSnapshots, } from "@t3tools/contracts"; -import { collectProviderUsageLimits, limitsNotice } from "@t3tools/shared/usageLimits"; +import { accountKey, collectProviderUsageLimits, limitsNotice } from "@t3tools/shared/usageLimits"; export interface ThreadDetailsLimits { readonly limits: ServerProviderUsageLimits; @@ -34,19 +35,57 @@ export function resolveThreadDetailsLimits( provider?.usageLimits; if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; - const hubAccounts = provider - ? sources.flatMap((source) => - source.accounts - .filter( - (account) => - account.driver === provider.driver && limitsNotice(account.usageLimits) === null, - ) - .map((account) => ({ source, account })), - ) - : []; - const [onlyHubAccount] = hubAccounts; - if (hubAccounts.length === 1 && onlyHubAccount) { - return { limits: onlyHubAccount.account.usageLimits, sourceLabel: onlyHubAccount.source.label }; - } + const hub = provider ? onlyHubAccount(provider, sources) : null; + if (hub) return { limits: hub.account.usageLimits, sourceLabel: hub.source.label }; return own ? { limits: own, sourceLabel: null } : null; } + +type SourceAccount = UsageLimitSourceSnapshot["accounts"][number]; + +interface HubAccount { + readonly source: UsageLimitSourceSnapshot; + readonly account: SourceAccount; +} + +/** + * The one usable hub account serving a driver, or null when none or several do. + * Accounts are counted the way Usage → Limits counts them (driver + email, else credential), so one + * login reported by two hubs is one account; its freshest snapshot wins. + */ +function onlyHubAccount( + provider: ServerProvider, + sources: UsageLimitSourceSnapshots, +): HubAccount | null { + const candidates = sources.flatMap((source) => + source.accounts + .filter((account) => canServeInstance(provider, account)) + .map((account) => ({ source, account })), + ); + const freshestByAccount = new Map(); + for (const candidate of candidates) { + const key = hubAccountKey(candidate); + const known = freshestByAccount.get(key); + if (!known || checkedAtMs(candidate) > checkedAtMs(known)) { + freshestByAccount.set(key, candidate); + } + } + const [only, ...rest] = freshestByAccount.values(); + return only && rest.length === 0 ? only : null; +} + +const hubAccountKey = ({ source, account }: HubAccount) => + accountKey(account.driver, account.email, account.usageLimits) ?? `${source.id}:${account.id}`; + +const checkedAtMs = ({ account }: HubAccount) => Date.parse(account.usageLimits.checkedAt); + +const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() || undefined; + +/** A usable hub account for the instance's driver, unless both sides name different logins. */ +function canServeInstance(provider: ServerProvider, account: SourceAccount): boolean { + if (account.driver !== provider.driver || limitsNotice(account.usageLimits) !== null) { + return false; + } + const own = normalizeEmail(provider.auth.email); + const hub = normalizeEmail(account.email); + return !own || !hub || own === hub; +} diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index bf7dfea4e04bf..c890eb51a9b71 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -187,7 +187,7 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) { return [...links.values()]; } -function accountKey( +export function accountKey( driver: ServerProvider["driver"], email: string | undefined, limits?: ServerProviderUsageLimits, From ad142b9e4275eeb08aabf9fca2876892b6a6803b Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 14:34:08 -0400 Subject: [PATCH 03/19] docs: note the thread details card can show a hub's account --- docs/user/usage.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..ee4ade89575c1 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -153,6 +153,10 @@ environment that will connect to the hub and enter its URL and management key. The accounts appear under **Usage → Limits**. 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 a connected hub has exactly one account for that +provider, the thread details card shows that account's limits, and hovering a row shows which hub +they come from. With several hub accounts the card keeps its notice, so check **Usage → Limits**. + 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. From c7c2c5375cd95fd6ed549658008c7aa427ce0f1c Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 14:36:37 -0400 Subject: [PATCH 04/19] fix(web): keep workspaces apart when counting hub accounts Counting hub accounts by login alone merged one login's two workspaces, so the card showed whichever workspace was checked last instead of its notice. Key each account by login and workspace, as Usage -> Limits does. --- .../chat/ThreadDetailsUsage.logic.test.ts | 28 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 11 +++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 2611810b1b9f3..91505a3b950a9 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -56,6 +56,7 @@ function hub( accounts: ReadonlyArray<{ id: string; email?: string; + workspaceId?: string; driver?: ProviderDriverKind; limits: ServerProviderUsageLimits; }>, @@ -69,6 +70,7 @@ function hub( id: account.id, driver: account.driver ?? claude, ...(account.email ? { email: account.email } : {}), + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), usageLimits: account.limits, })), }; @@ -131,6 +133,32 @@ describe("thread details limits", () => { 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("falls back to the one hub account when a Codex instance's own probe failed", () => { const codex = ProviderDriverKind.make("codex"); const codexId = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index e7b718fa3491e..7a3253721e7a0 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -49,8 +49,8 @@ interface HubAccount { /** * The one usable hub account serving a driver, or null when none or several do. - * Accounts are counted the way Usage → Limits counts them (driver + email, else credential), so one - * login reported by two hubs is one account; its freshest snapshot wins. + * An account is its login (driver + email, else credential) and workspace, so one login reported by + * two hubs is one account and its freshest snapshot wins; two workspaces stay two accounts. */ function onlyHubAccount( provider: ServerProvider, @@ -73,8 +73,11 @@ function onlyHubAccount( return only && rest.length === 0 ? only : null; } -const hubAccountKey = ({ source, account }: HubAccount) => - accountKey(account.driver, account.email, account.usageLimits) ?? `${source.id}:${account.id}`; +function hubAccountKey({ source, account }: HubAccount): string { + const login = accountKey(account.driver, account.email, account.usageLimits); + if (!login) return `${source.id}:${account.id}`; + return account.workspaceId ? `${login}:${account.workspaceId}` : login; +} const checkedAtMs = ({ account }: HubAccount) => Date.parse(account.usageLimits.checkedAt); From c5aefffc35043a52c9e27e17421a96da68b34b54 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 14:36:47 -0400 Subject: [PATCH 05/19] docs: say the hub fallback counts accounts across hubs --- docs/user/usage.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index ee4ade89575c1..996a5aa1905c6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -153,9 +153,10 @@ environment that will connect to the hub and enter its URL and management key. The accounts appear under **Usage → Limits**. 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 a connected hub has exactly one account for that -provider, the thread details card shows that account's limits, and hovering a row shows which hub -they come from. With several hub accounts the card keeps its notice, so check **Usage → Limits**. +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 than the instance is +skipped. With several hub accounts the card keeps its notice, so check **Usage → Limits**. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same From 793c1b5a43f3fa1f85eec09d589ded0be27361e9 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 14:47:50 -0400 Subject: [PATCH 06/19] fix(web): count unreadable hub accounts before the limits fallback The fallback dropped hub accounts with unusable limits before counting, so a hub with one readable and one unreadable account for the driver showed the readable one as the thread's, though the hub may route the thread through the other. Count every account that can serve the instance, then fall back only when the single one has usable limits. Reported by CodeRabbit on #27740. --- .../chat/ThreadDetailsUsage.logic.test.ts | 22 ++++++++++++++++++- .../chat/ThreadDetailsUsage.logic.ts | 15 ++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 91505a3b950a9..b4616b4a60b06 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -227,7 +227,27 @@ describe("thread details limits", () => { expect(resolved).toEqual({ limits: limitsAt(40), sourceLabel: null }); }); - it("ignores hub accounts for other drivers and hub accounts without usable limits", () => { + 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)], diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 7a3253721e7a0..7b6ee68164087 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -48,9 +48,9 @@ interface HubAccount { } /** - * The one usable hub account serving a driver, or null when none or several do. - * An account is its login (driver + email, else credential) and workspace, so one login reported by - * two hubs is one account and its freshest snapshot wins; two workspaces stay two accounts. + * 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, @@ -70,7 +70,8 @@ function onlyHubAccount( } } const [only, ...rest] = freshestByAccount.values(); - return only && rest.length === 0 ? only : null; + if (!only || rest.length > 0) return null; + return limitsNotice(only.account.usageLimits) === null ? only : null; } function hubAccountKey({ source, account }: HubAccount): string { @@ -83,11 +84,9 @@ const checkedAtMs = ({ account }: HubAccount) => Date.parse(account.usageLimits. const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() || undefined; -/** A usable hub account for the instance's driver, unless both sides name different logins. */ +/** A hub account for the instance's driver, unless both sides name different logins. */ function canServeInstance(provider: ServerProvider, account: SourceAccount): boolean { - if (account.driver !== provider.driver || limitsNotice(account.usageLimits) !== null) { - return false; - } + if (account.driver !== provider.driver) return false; const own = normalizeEmail(provider.auth.email); const hub = normalizeEmail(account.email); return !own || !hub || own === hub; From fda987b371b82c50a80bb31cd5bdb55672f4d844 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 15:03:17 -0400 Subject: [PATCH 07/19] fix(web): key hub accounts with the shared account resolver The thread details fallback keyed hub accounts with its own rule, so a login one hub reports with a workspace and another reports without one counted as two accounts and the card kept its notice, while Usage -> Limits shows one account. Reuse accountKeyResolver instead, which folds a workspace-less report into the login's only known workspace. Also use the contracts' UsageLimitSourceAccount type, name the emails in canServeInstance by side, and share the Codex test fixtures. --- .../chat/ThreadDetailsUsage.logic.test.ts | 69 ++++++++++++------- .../chat/ThreadDetailsUsage.logic.ts | 28 ++++---- packages/shared/src/usageLimits.ts | 4 +- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index b4616b4a60b06..26cc57fe759a7 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -14,6 +14,8 @@ 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", @@ -30,6 +32,12 @@ const unsupported: ServerProviderUsageLimits = { unavailable: { reason: "unsupported" }, }; +const probeFailed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, +}; + function limitsAt(usedPercent: number): ServerProviderUsageLimits { return { checkedAt, windows: [{ ...session, usedPercent }] }; } @@ -51,6 +59,16 @@ function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider }; } +function codexInstance(usageLimits: ServerProviderUsageLimits, email?: string): ServerProvider { + const base = claudeInstance(usageLimits); + return { + ...base, + instanceId: codexId, + driver: codex, + auth: email ? { ...base.auth, email } : base.auth, + }; +} + function hub( label: string, accounts: ReadonlyArray<{ @@ -159,17 +177,32 @@ describe("thread details limits", () => { 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("falls back to the one hub account when a Codex instance's own probe failed", () => { - const codex = ProviderDriverKind.make("codex"); - const codexId = ProviderInstanceId.make("codex"); - const probeFailed: ServerProviderUsageLimits = { - checkedAt, - windows: [], - unavailable: { reason: "probeFailed" }, - }; const resolved = resolveThreadDetailsLimits( codexId, - [{ ...claudeInstance(probeFailed), instanceId: codexId, driver: codex }], + [codexInstance(probeFailed)], [hub("hub", [{ id: "codex-a.json", driver: codex, limits: limitsAt(30) }])], now, ); @@ -178,22 +211,6 @@ describe("thread details limits", () => { }); it("does not borrow a hub account that belongs to a different login", () => { - const codex = ProviderDriverKind.make("codex"); - const codexId = ProviderInstanceId.make("codex"); - const probeFailed: ServerProviderUsageLimits = { - checkedAt, - windows: [], - unavailable: { reason: "probeFailed" }, - }; - const instance = (email: string): ServerProvider => { - const base = claudeInstance(probeFailed); - return { - ...base, - instanceId: codexId, - driver: codex, - auth: { ...base.auth, email }, - }; - }; const hubFor = (email: string) => [ hub("hub", [{ id: "codex-a.json", driver: codex, email, limits: limitsAt(30) }]), ]; @@ -201,7 +218,7 @@ describe("thread details limits", () => { expect( resolveThreadDetailsLimits( codexId, - [instance("alice@example.com")], + [codexInstance(probeFailed, "alice@example.com")], hubFor("bob@example.com"), now, ), @@ -209,7 +226,7 @@ describe("thread details limits", () => { expect( resolveThreadDetailsLimits( codexId, - [instance(" Alice@Example.com ")], + [codexInstance(probeFailed, " Alice@Example.com ")], hubFor("alice@example.com"), now, ), diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 7b6ee68164087..2e0644bc9659e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -2,10 +2,15 @@ import type { ProviderInstanceId, ServerProvider, ServerProviderUsageLimits, + UsageLimitSourceAccount, UsageLimitSourceSnapshot, UsageLimitSourceSnapshots, } from "@t3tools/contracts"; -import { accountKey, collectProviderUsageLimits, limitsNotice } from "@t3tools/shared/usageLimits"; +import { + accountKeyResolver, + collectProviderUsageLimits, + limitsNotice, +} from "@t3tools/shared/usageLimits"; export interface ThreadDetailsLimits { readonly limits: ServerProviderUsageLimits; @@ -40,11 +45,9 @@ export function resolveThreadDetailsLimits( return own ? { limits: own, sourceLabel: null } : null; } -type SourceAccount = UsageLimitSourceSnapshot["accounts"][number]; - interface HubAccount { readonly source: UsageLimitSourceSnapshot; - readonly account: SourceAccount; + readonly account: UsageLimitSourceAccount; } /** @@ -61,9 +64,10 @@ function onlyHubAccount( .filter((account) => canServeInstance(provider, account)) .map((account) => ({ source, account })), ); + const resolveKey = accountKeyResolver(candidates.map(({ account }) => account)); const freshestByAccount = new Map(); for (const candidate of candidates) { - const key = hubAccountKey(candidate); + 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); @@ -74,20 +78,14 @@ function onlyHubAccount( return limitsNotice(only.account.usageLimits) === null ? only : null; } -function hubAccountKey({ source, account }: HubAccount): string { - const login = accountKey(account.driver, account.email, account.usageLimits); - if (!login) return `${source.id}:${account.id}`; - return account.workspaceId ? `${login}:${account.workspaceId}` : login; -} - 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 different logins. */ -function canServeInstance(provider: ServerProvider, account: SourceAccount): boolean { +function canServeInstance(provider: ServerProvider, account: UsageLimitSourceAccount): boolean { if (account.driver !== provider.driver) return false; - const own = normalizeEmail(provider.auth.email); - const hub = normalizeEmail(account.email); - return !own || !hub || own === hub; + const instanceEmail = normalizeEmail(provider.auth.email); + const hubEmail = normalizeEmail(account.email); + return !instanceEmail || !hubEmail || instanceEmail === hubEmail; } diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index c890eb51a9b71..60603de8d512e 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -187,7 +187,7 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) { return [...links.values()]; } -export function accountKey( +function accountKey( driver: ServerProvider["driver"], email: string | undefined, limits?: ServerProviderUsageLimits, @@ -214,7 +214,7 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ }); // Include every enabled login, even unreadable workspaces. -function accountKeyResolver(reports: Iterable) { +export function accountKeyResolver(reports: Iterable) { const workspaces = new Map>(); for (const report of reports) { const key = accountKey(report.driver, report.email, report.usageLimits); From f6d9b34163814ee9dc390ab9c9469a4d6a23dc27 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 15:04:26 -0400 Subject: [PATCH 08/19] fix(web): skip the hub limits fallback while a hub cannot be read A hub that failed to read arrives with no accounts, so the count skipped it and the card could show another hub's only account although the thread may spend the unreadable hub's accounts. Keep the instance's own notice while any hub reports an error, as unreadable accounts already count before their limits are judged. --- .../components/chat/ThreadDetailsUsage.logic.test.ts | 12 ++++++++++++ .../src/components/chat/ThreadDetailsUsage.logic.ts | 2 ++ docs/user/usage.md | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 26cc57fe759a7..01452d095ac8e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -136,6 +136,18 @@ describe("thread details limits", () => { 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( diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 2e0644bc9659e..385fd5794dd6e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -59,6 +59,8 @@ function onlyHubAccount( provider: 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)) diff --git a/docs/user/usage.md b/docs/user/usage.md index 996a5aa1905c6..a59a868e14baa 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -156,7 +156,8 @@ 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 than the instance is -skipped. With several hub accounts the card keeps its notice, so check **Usage → Limits**. +skipped. With several hub accounts, or while a hub cannot be read, the card keeps its notice, so +check **Usage → Limits**. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same From 0bdb4f147d81d3b9c5399df013e0e37edfa1302d Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 15:53:13 -0400 Subject: [PATCH 09/19] fix(web): match workspaces when borrowing hub limits Reject a hub account whose workspace differs from the instance's, and key hub accounts with the same resolver collectProviderUsageLimits uses, so the enabled instances' workspaces keep a workspace-less hub account apart. Addresses CodeRabbit's review on 6c9850faec. --- .../chat/ThreadDetailsUsage.logic.test.ts | 52 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 14 +++-- docs/user/usage.md | 4 +- packages/shared/src/usageLimits.ts | 18 +++++-- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 01452d095ac8e..e82723f6d7890 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -211,6 +211,58 @@ describe("thread details limits", () => { 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 signedIn = (id: string, workspaceId: string): ServerProvider => { + const base = claudeInstance(limitsAt(10)); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, email: "me@example.com", workspaceId }, + }; + }; + const fresher = { ...limitsAt(25), checkedAt: "2026-10-30T11:59:30.000Z" }; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported), signedIn("claude-a", "team-a"), signedIn("claude-b", "team-b")], + [ + 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 hub account in a different workspace of the same login", () => { + const base = claudeInstance(unsupported); + const inTeamA = { + ...base, + auth: { ...base.auth, email: "me@example.com", workspaceId: "team-a" }, + }; + 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("falls back to the one hub account when a Codex instance's own probe failed", () => { const resolved = resolveThreadDetailsLimits( codexId, diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 385fd5794dd6e..e39dd5c3b1eed 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -7,8 +7,8 @@ import type { UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { - accountKeyResolver, collectProviderUsageLimits, + environmentAccountKeyResolver, limitsNotice, } from "@t3tools/shared/usageLimits"; @@ -40,7 +40,7 @@ export function resolveThreadDetailsLimits( provider?.usageLimits; if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; - const hub = provider ? onlyHubAccount(provider, sources) : 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; } @@ -57,6 +57,7 @@ interface HubAccount { */ function onlyHubAccount( provider: ServerProvider, + providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, ): HubAccount | null { // A hub that failed to read may hold accounts for this driver too. @@ -66,7 +67,8 @@ function onlyHubAccount( .filter((account) => canServeInstance(provider, account)) .map((account) => ({ source, account })), ); - const resolveKey = accountKeyResolver(candidates.map(({ account }) => account)); + // Learn workspaces from every report, so an account without one never merges into one of several. + const resolveKey = environmentAccountKeyResolver(providers, sources); const freshestByAccount = new Map(); for (const candidate of candidates) { const key = resolveKey(candidate.account) ?? `${candidate.source.id}:${candidate.account.id}`; @@ -84,10 +86,12 @@ const checkedAtMs = ({ account }: HubAccount) => Date.parse(account.usageLimits. const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() || undefined; -/** A hub account for the instance's driver, unless both sides name different logins. */ +/** 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); - return !instanceEmail || !hubEmail || instanceEmail === hubEmail; + if (instanceEmail && hubEmail && instanceEmail !== hubEmail) return false; + const instanceWorkspace = provider.auth.workspaceId; + return !instanceWorkspace || !account.workspaceId || instanceWorkspace === account.workspaceId; } diff --git a/docs/user/usage.md b/docs/user/usage.md index a59a868e14baa..3a6b68506ed8f 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -155,8 +155,8 @@ 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 than the instance is -skipped. With several hub accounts, or while a hub cannot be read, the card keeps its notice, so +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**. This connection supplies usage information; configure diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 60603de8d512e..e487127707318 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -214,7 +214,7 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ }); // Include every enabled login, even unreadable workspaces. -export function accountKeyResolver(reports: Iterable) { +function accountKeyResolver(reports: Iterable) { const workspaces = new Map>(); for (const report of reports) { const key = accountKey(report.driver, report.email, report.usageLimits); @@ -232,6 +232,17 @@ export 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), + ]); +} + export interface LimitAccount { readonly key: string; readonly driver: ServerProvider["driver"]; @@ -746,10 +757,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, From 4613d0716c7ade4574d0fa1aade59aa938b9a542 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 16:00:01 -0400 Subject: [PATCH 10/19] test(web): drop the null-instance case from the thread limits tests It only covered resolveThreadDetailsLimits' one-line null guard, which predates the hub fallback; AGENTS.md asks to leave out tests of trivial logic. --- .../components/chat/ThreadDetailsUsage.logic.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index e82723f6d7890..d9a01f4bb1af4 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -343,15 +343,4 @@ describe("thread details limits", () => { expect(resolved).toEqual({ limits: unsupported, sourceLabel: null }); }); - - it("shows nothing without a selected instance", () => { - expect( - resolveThreadDetailsLimits( - null, - [claudeInstance(unsupported)], - [hub("hub", [{ id: "claude-a.json", limits: limitsAt(19) }])], - now, - ), - ).toBeNull(); - }); }); From e2ae0d687a3c62905416cb9b743e55c2e70d059c Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 16:33:43 -0400 Subject: [PATCH 11/19] fix(web): skip a hub account whose workspace is ambiguous When the instances report one login in several workspaces and the only hub account for it names no workspace, the resolver cannot key it, and its fallback key let it through as the sole account. Keep the instance's notice instead. Addresses CodeRabbit's review on c0df4f4ee4. --- .../chat/ThreadDetailsUsage.logic.test.ts | 19 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index d9a01f4bb1af4..d35af1324c908 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -241,6 +241,25 @@ describe("thread details limits", () => { 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 signedIn = (id: string, workspaceId: string, limits: ServerProviderUsageLimits) => { + const base = claudeInstance(limits); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, email: "me@example.com", workspaceId }, + }; + }; + 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 base = claudeInstance(unsupported); const inTeamA = { diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index e39dd5c3b1eed..14eebf6dba29a 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -79,9 +79,15 @@ function onlyHubAccount( } const [only, ...rest] = freshestByAccount.values(); 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; } +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; From 09fc955f4a6c794df3c68591568c468392359d65 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 20:57:29 -0400 Subject: [PATCH 12/19] refactor(web): pull hub account dedup into its own helper onlyHubAccount mixed candidate filtering, per-account dedup, and the ambiguity checks in one function. Keeping the freshest snapshot per account key is its own step, so it gets a helper; behavior is unchanged. --- .../chat/ThreadDetailsUsage.logic.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 14eebf6dba29a..d0985fa2ec0e8 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -69,6 +69,18 @@ function onlyHubAccount( ); // 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}`; @@ -77,11 +89,7 @@ function onlyHubAccount( freshestByAccount.set(key, candidate); } } - const [only, ...rest] = freshestByAccount.values(); - 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; + return [...freshestByAccount.values()]; } const hasLogin = (account: UsageLimitSourceAccount) => From 06489147bf5ac38e1dac0f5c4f4aaaf3a6673d18 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 30 Oct 2026 21:03:33 -0400 Subject: [PATCH 13/19] test(web): cover hub accounts keyed by credential fingerprint A hub login is an email or, without one, a credential fingerprint, but every test used emails. Two tests now cover the fingerprint side: one credential seen on two hubs counts once, and a sole credential account is refused when enabled instances report that credential in several workspaces. The three tests that built a signed-in Claude instance inline now share one signedIn fixture. --- .../chat/ThreadDetailsUsage.logic.test.ts | 83 ++++++++++++++----- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index d35af1324c908..7b5cd9af61d2e 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -69,6 +69,21 @@ function codexInstance(usageLimits: ServerProviderUsageLimits, email?: string): }; } +/** 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<{ @@ -212,18 +227,14 @@ describe("thread details limits", () => { }); it("keeps one login's hub accounts apart when the instances report it in two workspaces", () => { - const signedIn = (id: string, workspaceId: string): ServerProvider => { - const base = claudeInstance(limitsAt(10)); - return { - ...base, - instanceId: ProviderInstanceId.make(id), - auth: { ...base.auth, email: "me@example.com", workspaceId }, - }; - }; const fresher = { ...limitsAt(25), checkedAt: "2026-10-30T11:59:30.000Z" }; const resolved = resolveThreadDetailsLimits( instanceId, - [claudeInstance(unsupported), signedIn("claude-a", "team-a"), signedIn("claude-b", "team-b")], + [ + claudeInstance(unsupported), + signedIn("claude-a", "team-a", limitsAt(10)), + signedIn("claude-b", "team-b", limitsAt(10)), + ], [ hub("hub", [ { @@ -242,14 +253,6 @@ describe("thread details limits", () => { }); it("does not borrow a login's hub account when its workspace could be another instance's", () => { - const signedIn = (id: string, workspaceId: string, limits: ServerProviderUsageLimits) => { - const base = claudeInstance(limits); - return { - ...base, - instanceId: ProviderInstanceId.make(id), - auth: { ...base.auth, email: "me@example.com", workspaceId }, - }; - }; const resolved = resolveThreadDetailsLimits( instanceId, [signedIn(instanceId, "team-a", unsupported), signedIn("claude-b", "team-b", limitsAt(10))], @@ -261,11 +264,7 @@ describe("thread details limits", () => { }); it("does not borrow a hub account in a different workspace of the same login", () => { - const base = claudeInstance(unsupported); - const inTeamA = { - ...base, - auth: { ...base.auth, email: "me@example.com", workspaceId: "team-a" }, - }; + 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) }, @@ -282,6 +281,46 @@ describe("thread details limits", () => { }); }); + 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, From ef020bf77b3ca450328b474caf544b5bf9ba95bc Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Sat, 31 Oct 2026 12:52:36 +0600 Subject: [PATCH 14/19] feat(usage): thread details card reads the hub account a thread was routed to With several hub accounts, the thread details card can't tell which one a proxied thread spends, so it keeps its notice. CLIProxyAPI already records that: each usage-queue record carries the request's session_id and the auth_index that served it. An opt-in "Thread accounts" switch per hub makes the server read that queue every 30 seconds and attach recent session ids to each hub account. The card then shows the limits of the account whose sessions include the thread's native session id. The tooltip names only the hub, so account emails stay redacted. Reading pops the queue, so it stays off by default. --- .../src/usage/UsageLimitSources.test.ts | 72 +++++++++++++++++- apps/server/src/usage/UsageLimitSources.ts | 76 ++++++++++++++++++- apps/server/src/usage/cliproxyApi.test.ts | 22 ++++++ apps/server/src/usage/cliproxyApi.ts | 29 ++++++- apps/web/src/components/ChatView.tsx | 1 + .../chat/ThreadDetailsUsage.logic.test.ts | 30 ++++++-- .../chat/ThreadDetailsUsage.logic.ts | 25 +++++- .../components/chat/ThreadDetailsUsage.tsx | 11 ++- .../settings/UsageProviderSettings.tsx | 53 ++++++++----- docs/user/usage.md | 5 ++ packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/settings.ts | 2 + 12 files changed, 297 insertions(+), 31 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..80f5208653d58 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -4,7 +4,8 @@ import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -83,3 +84,72 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it.effect("reads routed sessions only from hubs that opted in", () => + Effect.gen(function* () { + const queueReads: string[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + 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]], + ]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("routed")]: { + kind: "cliproxy", + url: "https://routed.test", + managementKey: "key", + enabled: true, + threadAccounts: true, + }, + [UsageLimitSourceId.make("observed")]: { + kind: "cliproxy", + url: "https://observed.test", + managementKey: "key", + enabled: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + }), + NodeCrypto.layer, + ]), + Effect.scoped, + ), +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..2228c7ab663fc 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -36,6 +36,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"; @@ -51,6 +76,11 @@ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; 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 stateRef = yield* Ref.make>([]); const changes = yield* Effect.acquireRelease( PubSub.unbounded>(), @@ -75,8 +105,10 @@ export const make = Effect.gen(function* () { }); const publish = Effect.fn("UsageLimitSources.publish")(function* ( - next: ReadonlyArray, + reads?: ReadonlyArray, ) { + if (reads) yield* Ref.set(readsRef, reads); + const next = withSessionIds(yield* Ref.get(readsRef), yield* Ref.get(routesRef)); const changed = yield* Ref.modify(stateRef, (previous) => Equal.equals(previous, next) ? [false, previous] : [true, next], ); @@ -115,11 +147,43 @@ export const make = Effect.gen(function* () { } const result = yield* api.consume(config, input.accountId, 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 && config.managementKey.length > 0, + ); + const reads = yield* Effect.forEach( + entries, + ([id, config]) => + api.readSessionAccounts(config).pipe( + 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(); + }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); + yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), Stream.changes, @@ -147,6 +211,14 @@ export const make = Effect.gen(function* () { ), ).pipe(Effect.forkScoped); + // CLIProxyAPI keeps queued usage for 60 seconds by default. + yield* Effect.forever( + Effect.sleep(ROUTE_READ_INTERVAL).pipe( + Effect.andThen(backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })), + Effect.flatMap((shouldRun) => (shouldRun ? readRoutes : Effect.void)), + ), + ).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 4a76d32c15dea..625bc65bd396c 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -56,6 +56,7 @@ function fixture( >; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + usageQueue?: ReadonlyArray; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -73,6 +74,8 @@ function fixture( request, Response.json({ files: options.accounts ?? accounts }), ); + if (path.endsWith("/usage-queue")) + return HttpClientResponse.fromWeb(request, Response.json(options.usageQueue ?? [])); if (path.endsWith("/reset-quota")) return HttpClientResponse.fromWeb( request, @@ -409,6 +412,25 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + 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" }, + ]); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index daa1ea909f034..f86e47b96b9ce 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, @@ -10,6 +11,7 @@ import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -94,7 +96,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)); @@ -365,6 +374,24 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + // Pops the hub's usage queue: records read here are gone for every other reader. + const readSessionAccounts = Effect.fn("CliproxyApi.readSessionAccounts")(function* ( + config: UsageLimitSourceConfig, + ) { + // 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))); + if (records.length === 0) return []; + const accountIds = new Map( + (yield* authFiles(config)).map((account) => [account.auth_index, account.id]), + ); + 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* ( config: UsageLimitSourceConfig, ): Effect.fn.Return, UsageLimitSourceError> { @@ -437,5 +464,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 6ec89f8c36256..5d49610259883 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3908,6 +3908,7 @@ export default function ChatView(props: ChatViewProps) { providerInstanceId: activeProviderInstanceId, providers: providerStatuses, sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + providerSessionId: activeProviderThread?.nativeThreadRef?.nativeId ?? 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 index 7b5cd9af61d2e..d370992233535 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -92,6 +92,7 @@ function hub( workspaceId?: string; driver?: ProviderDriverKind; limits: ServerProviderUsageLimits; + sessionIds?: string[]; }>, ): UsageLimitSourceSnapshot { return { @@ -104,6 +105,7 @@ function hub( driver: account.driver ?? claude, ...(account.email ? { email: account.email } : {}), ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...(account.sessionIds ? { sessionIds: account.sessionIds } : {}), usageLimits: account.limits, })), }; @@ -122,19 +124,35 @@ describe("thread details limits", () => { }); 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)], - [ - hub("hub", [ - { id: "claude-a.json", limits: limitsAt(19) }, - { id: "claude-b.json", limits: limitsAt(60) }, - ]), - ], + 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("counts accounts across hubs, so one account on each of two hubs stays ambiguous", () => { diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index d0985fa2ec0e8..dd3bf64456d26 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -21,14 +21,15 @@ export interface ThreadDetailsLimits { /** * The limits the thread details card shows for one provider instance. * An instance routed through a usage hub (CLIProxyAPI) reads no subscription limits of its own, - * so the card falls back to the hub, but only when exactly one hub account serves the driver: - * with several, nothing says which one this thread spends. + * so the card falls back to the hub account that recently served the thread's provider session, + * else to the only hub account serving the driver: with several, nothing says which one it 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); @@ -40,6 +41,12 @@ export function resolveThreadDetailsLimits( provider?.usageLimits; if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; + 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 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; @@ -50,6 +57,20 @@ interface HubAccount { readonly account: UsageLimitSourceAccount; } +/** The hub account a hub last routed this provider session to, when it shares that routing. */ +function routedHubAccount( + provider: ServerProvider, + sources: UsageLimitSourceSnapshots, + providerSessionId: string, +): HubAccount | undefined { + return sources + .flatMap((source) => source.accounts.map((account) => ({ source, account }))) + .find( + ({ account }) => + canServeInstance(provider, account) && account.sessionIds?.includes(providerSessionId), + ); +} + /** * 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 diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 66150ca9602ff..7636f3df2d8ce 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -33,6 +33,8 @@ 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; } @@ -46,10 +48,17 @@ export function ThreadDetailsUsage({ providerInstanceId, providers, sources, + providerSessionId, }: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); - const resolved = resolveThreadDetailsLimits(providerInstanceId, providers, sources, now); + const resolved = resolveThreadDetailsLimits( + providerInstanceId, + providers, + sources, + now, + providerSessionId, + ); if (!resolved) return null; const { limits, sourceLabel } = resolved; const notice = limitsNotice(limits); diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index f238850ae04d0..29d25b4c326dd 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { serverEnvironment } from "../../state/server"; @@ -93,24 +93,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/docs/user/usage.md b/docs/user/usage.md index a2b0d13e87600..9802f5055831f 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -187,6 +187,11 @@ shows which hub they come from. A hub account signed in with a different email o 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. 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 5c4927d4a164e..1e3f20518782e 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -62,6 +62,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 4bb3119561b08..31de99263cff9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -860,6 +860,8 @@ export const UsageLimitSourceConfig = Schema.Struct({ url: TrimmedNonEmptyString, managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), 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; From 622e5c4f52bed9e5ab1cb7939124e95973cfcfe3 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 3 Nov 2026 12:33:29 +0600 Subject: [PATCH 15/19] fix(usage): the hub account a thread was routed to wins over a signed-in login Codex can be signed in to ChatGPT while it sends through a CLIProxyAPI hub. Its own limits then describe the signed-in account, not the one the hub served the thread from, and the card preferred them. A routed account also had to match the instance's email, so any other hub account was skipped. When a hub reports which account served the thread's session, that account now wins, matched by driver only. The usage hub fixture serves an optional routes list as its usage queue, so the card can be checked with several Claude and Codex accounts. --- .../chat/ThreadDetailsUsage.logic.test.ts | 47 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 26 +++++----- .../test/fixtures/usage-limits-hub/README.md | 15 ++++++ .../test/fixtures/usage-limits-hub/hub.mjs | 5 +- docs/user/usage.md | 3 +- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index d370992233535..30e9621e6d660 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -155,6 +155,53 @@ describe("thread details limits", () => { ).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("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, diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index dd3bf64456d26..9576beeb4a827 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -19,10 +19,10 @@ export interface ThreadDetailsLimits { } /** - * The limits the thread details card shows for one provider instance. - * An instance routed through a usage hub (CLIProxyAPI) reads no subscription limits of its own, - * so the card falls back to the hub account that recently served the thread's provider session, - * else to the only hub account serving the driver: with several, nothing says which one it spends. + * 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, @@ -36,17 +36,17 @@ export function resolveThreadDetailsLimits( 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 own = - report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? - provider?.usageLimits; - if (own && limitsNotice(own) === null) return { limits: own, sourceLabel: null }; - 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; @@ -57,7 +57,11 @@ interface HubAccount { readonly account: UsageLimitSourceAccount; } -/** The hub account a hub last routed this provider session to, when it shares that routing. */ +/** + * 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, @@ -67,7 +71,7 @@ function routedHubAccount( .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .find( ({ account }) => - canServeInstance(provider, account) && account.sessionIds?.includes(providerSessionId), + account.driver === provider.driver && account.sessionIds?.includes(providerSessionId), ); } diff --git a/apps/web/test/fixtures/usage-limits-hub/README.md b/apps/web/test/fixtures/usage-limits-hub/README.md index 102f8a31e573e..ffb31240ca2ea 100644 --- a/apps/web/test/fixtures/usage-limits-hub/README.md +++ b/apps/web/test/fixtures/usage-limits-hub/README.md @@ -59,6 +59,21 @@ Reset times are minute offsets from `n0`. 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 a2ba8d7c2ef09..35e60dbb33834 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"; @@ -58,9 +59,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 9802f5055831f..26734c4fbbbdd 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -188,7 +188,8 @@ instance is skipped. With several hub accounts, or while a hub cannot be read, t 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. The +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. From 360f7d04f216ce5f26331f37a90dd345009cbf86 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 3 Nov 2026 12:56:59 +0600 Subject: [PATCH 16/19] fix(usage): keep thread account routes when nobody is watching or a hub is slow Reading CLIProxyAPI's usage queue removes its records, and the hub keeps them for 60 seconds, so a route missed once is gone: - Queue reads no longer wait for an open client or for a slow quota refresh. Publishing has its own lock instead. - The hub's accounts are listed before the queue is popped, so a failed listing no longer throws records away. - Turning Thread accounts off drops that hub's routes on the same refresh. - The card ignores a routed session that belongs to another instance, and treats a session two hubs both routed as ambiguous. --- .../src/usage/UsageLimitSources.test.ts | 24 ++++++++++++++-- apps/server/src/usage/UsageLimitSources.ts | 28 +++++++++++-------- apps/server/src/usage/cliproxyApi.test.ts | 21 +++++++++++++- apps/server/src/usage/cliproxyApi.ts | 8 +++--- apps/web/src/components/ChatView.tsx | 6 +++- .../chat/ThreadDetailsUsage.logic.test.ts | 18 ++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 6 ++-- 7 files changed, 90 insertions(+), 21 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 80f5208653d58..ee3e614d118a3 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -85,7 +85,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => ), ); -it.effect("reads routed sessions only from hubs that opted in", () => +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) => @@ -126,6 +126,25 @@ it.effect("reads routed sessions only from hubs that opted in", () => ["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([ ServerSettings.layerTest({ @@ -145,8 +164,9 @@ it.effect("reads routed sessions only from hubs that opted in", () => }, }, }), + // No client lease: a turn that ends while nobody watches must still keep its route. Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(false), }), NodeCrypto.layer, ]), diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 2228c7ab663fc..447562196fc2d 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -104,8 +104,10 @@ 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* ( - reads?: ReadonlyArray, + reads: ReadonlyArray | undefined, ) { if (reads) yield* Ref.set(readsRef, reads); const next = withSessionIds(yield* Ref.get(readsRef), yield* Ref.get(routesRef)); @@ -113,7 +115,7 @@ export const make = Effect.gen(function* () { 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); @@ -129,6 +131,12 @@ export const make = Effect.gen(function* () { ([id, config]) => readSource(id as UsageLimitSourceId, config), { concurrency: 4 }, ); + // A hub switched out of Thread accounts stops naming routed accounts now, not on the next read. + yield* Ref.update( + routesRef, + (routes) => + new Map([...routes].filter(([id]) => settings?.usageLimitSources[id]?.threadAccounts)), + ); yield* publish(snapshots); }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); @@ -181,8 +189,8 @@ export const make = Effect.gen(function* () { } return next; }); - yield* publish(); - }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); + yield* publish(undefined); + }).pipe(Effect.ignoreCause({ log: true })); yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), @@ -211,13 +219,11 @@ export const make = Effect.gen(function* () { ), ).pipe(Effect.forkScoped); - // CLIProxyAPI keeps queued usage for 60 seconds by default. - yield* Effect.forever( - Effect.sleep(ROUTE_READ_INTERVAL).pipe( - Effect.andThen(backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })), - Effect.flatMap((shouldRun) => (shouldRun ? readRoutes : Effect.void)), - ), - ).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); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 625bc65bd396c..5ff5842de818f 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -57,6 +57,7 @@ function fixture( upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; usageQueue?: ReadonlyArray; + authFilesStatus?: number; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -72,7 +73,10 @@ 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 ?? [])); @@ -412,6 +416,21 @@ 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({ diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f86e47b96b9ce..f5a5c25369d9f 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -378,14 +378,14 @@ export const makeCliproxyApi = Effect.gen(function* () { const readSessionAccounts = Effect.fn("CliproxyApi.readSessionAccounts")(function* ( config: UsageLimitSourceConfig, ) { + // 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))); - if (records.length === 0) return []; - const accountIds = new Map( - (yield* authFiles(config)).map((account) => [account.auth_index, account.id]), - ); return records.flatMap(({ session_id, auth_index }) => { const accountId = accountIds.get(auth_index); return accountId ? [{ sessionId: session_id, accountId }] : []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d49610259883..532025385546b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3908,7 +3908,11 @@ export default function ChatView(props: ChatViewProps) { providerInstanceId: activeProviderInstanceId, providers: providerStatuses, sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, - providerSessionId: activeProviderThread?.nativeThreadRef?.nativeId ?? null, + // 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 index 30e9621e6d660..04454f4e09b6a 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -182,6 +182,24 @@ describe("thread details limits", () => { }); }); + 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", [ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 9576beeb4a827..648a567220dd4 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -67,12 +67,14 @@ function routedHubAccount( sources: UsageLimitSourceSnapshots, providerSessionId: string, ): HubAccount | undefined { - return sources + const matches = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) - .find( + .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; } /** From db1b5344f3eb2edffbb2d97b1a97a999da5d0674 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Tue, 3 Nov 2026 13:07:10 +0600 Subject: [PATCH 17/19] fix(usage): a queue read that lands after Thread accounts is switched off stays unpublished Publishing now filters routes by the current settings under its lock, so an in-flight read started before the switch cannot republish that hub's routes. This replaces pruning in refresh. --- .../src/usage/UsageLimitSources.test.ts | 74 +++++++++++++++++++ apps/server/src/usage/UsageLimitSources.ts | 18 +++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index ee3e614d118a3..98f966f919a0a 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -173,3 +174,76 @@ it.effect("reads routed sessions from opted-in hubs, even with no client open", 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([ + 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 447562196fc2d..72e341d32a7c7 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -110,7 +110,17 @@ export const make = Effect.gen(function* () { reads: ReadonlyArray | undefined, ) { if (reads) yield* Ref.set(readsRef, reads); - const next = withSessionIds(yield* Ref.get(readsRef), yield* Ref.get(routesRef)); + // 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], ); @@ -131,12 +141,6 @@ export const make = Effect.gen(function* () { ([id, config]) => readSource(id as UsageLimitSourceId, config), { concurrency: 4 }, ); - // A hub switched out of Thread accounts stops naming routed accounts now, not on the next read. - yield* Ref.update( - routesRef, - (routes) => - new Map([...routes].filter(([id]) => settings?.usageLimitSources[id]?.threadAccounts)), - ); yield* publish(snapshots); }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); From 1e3828cbce7420d7089d4599f208e98cc5920239 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Thu, 12 Nov 2026 21:23:30 +0600 Subject: [PATCH 18/19] test(usage): give the routed-session secret stubs the label main now requires --- apps/server/src/usage/UsageLimitSources.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 359dbf7d40e16..054474c66b89c 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -226,6 +226,7 @@ it.effect("reads routed sessions from opted-in hubs, even with no client open", Layer.succeed(SecretResolver.SecretResolver, { read: (references) => Effect.succeed(references.map(() => "key")), invalidate: () => Effect.void, + label: (provider) => Effect.succeed(provider), }), ServerSettings.layerTest({ usageLimitSources: { @@ -313,6 +314,7 @@ it.effect("drops a queue read that lands after Thread accounts was switched off" Layer.succeed(SecretResolver.SecretResolver, { read: (references) => Effect.succeed(references.map(() => undefined)), invalidate: () => Effect.void, + label: (provider) => Effect.succeed(provider), }), ServerSettings.layerTest({ usageLimitSources: { From cbc7d9c0f45f2566b9619702511108f6697add68 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Thu, 12 Nov 2026 21:24:08 +0600 Subject: [PATCH 19/19] fix(web): thread accounts setting builds and lints on current main --- apps/web/src/components/settings/UsageProviderSettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index 8706da7ce4bf6..0044bb0f4086d 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -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"; @@ -71,7 +72,7 @@ export function UsageProviderSettings({ />