From 307683d62e8fe16bca2148ca9c711ade0b381c8a Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:26:59 +0100 Subject: [PATCH 01/11] fix(server): proxied usage limits keep a safe reset time --- .../Adapters/CodexAdapterV2.test.ts | 16 ++ .../Adapters/CodexAdapterV2.ts | 28 ++- .../ProviderEventIngestor.test.ts | 62 ++++- .../orchestration-v2/ProviderEventIngestor.ts | 20 +- apps/server/src/provider/CodexProvider.ts | 6 +- .../src/provider/ProviderRegistry.test.ts | 2 + .../src/provider/codexUsageLimits.test.ts | 52 ++++- apps/server/src/provider/codexUsageLimits.ts | 42 ++++ apps/server/src/server.ts | 1 + .../src/usage/UsageLimitSources.test.ts | 61 ++++- apps/server/src/usage/UsageLimitSources.ts | 52 +++++ .../chat/ThreadDetailsUsage.logic.test.ts | 218 ++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 86 +++++++ .../components/chat/ThreadDetailsUsage.tsx | 114 +++++++-- docs/user/usage.md | 9 +- 15 files changed, 730 insertions(+), 39 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/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 84c78b91453c2..c9214dae6a7a3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5597,6 +5597,20 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "usage_limit", }, { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, + // regression: #26574 a proxy strips the rate-limit snapshot, so the reset is only in the text + { + name: "message-reset", + code: "usageLimitExceeded", + message: "You’ve hit your usage limit. Try again at May 19th, 2033 7:20 AM.", + notification: false, + expectedClass: "usage_limit", + expectedResetAt: DateTime.formatIso( + DateTime.makeZonedUnsafe("2033-05-19T07:21:00", { + timeZone: DateTime.zoneMakeLocal(), + adjustForTimeZone: true, + }), + ), + }, { name: "exhausted-429", code: "responseTooManyFailedAttempts", @@ -5859,6 +5873,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(terminal.failure.class, scenario.expectedClass); assert.equal(terminal.threadDisposition, "reusable"); if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt); + if ("expectedResetAt" in scenario) + assert.equal(terminal.failure.resetAt, scenario.expectedResetAt); if (scenario.name === "matching-details") assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); if ("resetAfter" in scenario) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 369608a289d2e..6db79261c6917 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -25,6 +25,7 @@ import { import { codexRateLimitsToUpdate, mergeCodexRateLimits, + codexMessageResetAt, codexUsageLimitResetAt, type CodexRateLimitSnapshot, } from "../../provider/codexUsageLimits.ts"; @@ -1169,11 +1170,14 @@ function withCodexManagedFailure( function failureResetAt( failure: OrchestrationV2ProviderFailure, snapshot: CodexRateLimitSnapshot | undefined, + now: DateTime.Utc, + codexTimeZone: string | undefined, ) { // Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot. return classifyCodexManagedError(failure.code)?.usageLimit ? null - : codexUsageLimitResetAt(snapshot); + : (codexUsageLimitResetAt(snapshot) ?? + codexMessageResetAt(failure.message, now, codexTimeZone)); } interface ActiveCodexTurnContext { @@ -1754,6 +1758,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( settings: resolvedRuntime?.config ?? adapterOptions.settings, environment: resolvedRuntime?.environment ?? adapterOptions.environment, }); + const codexTimeZone = (resolvedRuntime?.environment ?? adapterOptions.environment).TZ; const additionalContextByThread = yield* Ref.make( new Map< string, @@ -4498,7 +4503,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); const snapshot = yield* Ref.get(rateLimitSnapshot); for (const item of (yield* Ref.get(limitedTurnItems)).values()) { - const resetAt = failureResetAt(item.failure, snapshot); + const resetAt = failureResetAt( + item.failure, + snapshot, + yield* DateTime.now, + codexTimeZone, + ); if (resetAt === null || item.failure.resetAt != null) continue; const updated = { ...item, @@ -5843,7 +5853,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( failure.class === "usage_limit" ? { ...failure, - resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)), + resetAt: failureResetAt( + failure, + yield* Ref.get(rateLimitSnapshot), + yield* DateTime.now, + codexTimeZone, + ), } : failure, ...(input.providerRetry === undefined @@ -5883,7 +5898,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...event.failure, resetAt: event.failure.resetAt ?? - failureResetAt(event.failure, yield* Ref.get(rateLimitSnapshot)), + failureResetAt( + event.failure, + yield* Ref.get(rateLimitSnapshot), + yield* DateTime.now, + codexTimeZone, + ), }, } : event; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index b6adf6371fb60..476070de5db44 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -23,6 +23,7 @@ import { RunAttemptId, RunId, RuntimeRequestId, + type ServerProvider, TurnItemId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -45,6 +46,7 @@ import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -262,14 +264,17 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }, snapshot: { refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), - getSnapshot: Effect.die("unused"), + getSnapshot: Effect.sync(() => own as ServerProvider), refresh: Effect.die("unused"), resolveMaintenance: () => Effect.die("unused"), applyUsageLimits: () => Effect.die("unused"), streamChanges: Stream.empty, }, }; + let own: Partial | undefined; const lookups: ProviderInstanceId[] = []; + const hubLookups: string[] = []; + const hubResetAt = "2033-05-19T07:20:00.000Z"; const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ getInstance: (id) => Effect.sync(() => { @@ -292,7 +297,36 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit providerInstanceId: instance.instanceId, threadId: threadEvent.threadId, }; - for (const failureClass of ["validation_error", "usage_limit"] as const) { + const unread = { checkedAt: now.toString(), windows: [] }; + const cases = [ + { failureClass: "validation_error", provider: undefined, resetAt: undefined }, + { + failureClass: "usage_limit", + provider: { + auth: { status: "authenticated" }, + usageLimits: { ...unread, unavailable: { reason: "probeFailed" } }, + }, + resetAt: undefined, + }, + { + failureClass: "usage_limit", + provider: { + auth: { status: "authenticated", type: "customEndpoint" }, + usageLimits: { ...unread, unavailable: { reason: "probeFailed" } }, + }, + resetAt: hubResetAt, + }, + { + failureClass: "usage_limit", + provider: { + auth: { status: "unknown" }, + usageLimits: { ...unread, unavailable: { reason: "unsupported" } }, + }, + resetAt: hubResetAt, + }, + ] as const; + for (const [index, { failureClass, provider, resetAt }] of cases.entries()) { + own = provider; const stored = yield* ingestor.ingestNormalized({ ...input, event: { @@ -304,7 +338,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }), providerTurnId: idAllocator.derive.providerTurn({ driver: instance.driverKind, - nativeTurnId: failureClass, + nativeTurnId: String(index), }), runOrdinal: 1, failureItemOrdinal: 1, @@ -318,10 +352,28 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit }); assert.equal(stored.length, 1); if (failureClass === "validation_error") assert.deepEqual(lookups, []); + // regression: #26574 a proxied CLI drops the reset, so the hub's pool refill fills it + const item = stored[0]?.event; + assert.equal( + item?.type === "turn-item.updated" && item.payload.type === "error" + ? item.payload.failure.resetAt + : undefined, + resetAt, + ); } - assert.deepEqual(lookups, [instance.instanceId]); + assert.deepEqual(lookups, Array(6).fill(instance.instanceId)); + assert.deepEqual(hubLookups, [instance.driverKind, instance.driverKind]); yield* Deferred.await(refreshed); - }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }).pipe( + Effect.provide(makeLayerTest(layerTestEventSink, instances)), + Effect.provideService(UsageLimitResetFallback, { + resetAt: (driver) => + Effect.sync(() => { + hubLookups.push(driver); + return hubResetAt; + }), + }), + ); }), ), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index f27580d909aff..72805557f2d75 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -33,6 +33,7 @@ import { } from "../telemetry/ProviderUsageAnalytics.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -255,6 +256,7 @@ export const layer: Layer.Layer< const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const usageLimitResets = yield* UsageLimitResetFallback; const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); @@ -627,9 +629,25 @@ export const layer: Layer.Layer< return dismissed; } const occurredAt = yield* DateTime.now; - const failure = (yield* agentScope.oomKilled(input.threadId)) + const reported = (yield* agentScope.oomKilled(input.threadId)) ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } : input.event.failure; + // A proxy's CLI can drop the reset time; its hub still knows when the pool refills. + const provider = + reported.class === "usage_limit" && reported.resetAt == null + ? yield* Effect.flatMap( + instances.getInstance(input.providerInstanceId), + (instance) => instance?.snapshot.getSnapshot ?? Effect.undefined, + ) + : undefined; + // A direct login whose own probe failed is not served by the hub. + const proxied = + provider?.auth.type === "customEndpoint" || + provider?.usageLimits?.unavailable?.reason === "unsupported"; + const hubResetAt = proxied + ? yield* usageLimitResets.resetAt(input.event.driver) + : null; + const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt }; return [ ...dismissed, yield* makeDomainEvent(input, { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..b0eaba2972dd2 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -573,7 +573,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. - input.skipNativeUsage + input.skipNativeUsage || !accountResponse.account ? Effect.undefined : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), @@ -842,7 +842,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ? { status: "ready" as const, auth: managedAuth, message: undefined } : accountProbeStatus(snapshot); const usageLimits = - snapshot.account.account?.type === "apiKey" + // A custom model provider has no subscription for Codex to read. + snapshot.account.account?.type === "apiKey" || + (!managedAuth && !snapshot.account.account && !snapshot.account.requiresOpenaiAuth) ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 9b1187493967c..0e4d297f216c9 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -478,6 +478,8 @@ it.layer( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "unknown"); + // regression: #11873 a custom provider has no subscription to read, which is not a failure + assert.deepStrictEqual(status.usageLimits?.unavailable, { reason: "unsupported" }); }), ); diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 9c863f54c0867..a6cd0fa8d1a12 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -1,4 +1,5 @@ import * as CodexErrors from "effect-codex-app-server/errors"; +import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { @@ -261,7 +262,7 @@ describe("mergeCodexRateLimits", () => { }); }); -import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; +import { codexMessageResetAt, codexUsageLimitResetAt } from "./codexUsageLimits.ts"; describe("codexUsageLimitResetAt", () => { it("waits for every exhausted window and never invents an unknown reset", () => { @@ -277,3 +278,52 @@ describe("codexUsageLimitResetAt", () => { ).toBeNull(); }); }); + +describe("codexMessageResetAt", () => { + const local = (wallClock: string) => + DateTime.makeZonedUnsafe(wallClock, { + timeZone: DateTime.zoneMakeLocal(), + adjustForTimeZone: true, + }); + const now = DateTime.toUtc(local("2026-11-06T09:00:00")); + it.each([ + [ + "Plus", + "You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/settings/usage to purchase more credits or try again at Nov 11th, 2026 3:37 PM.", + "2026-11-11T15:38:00", + ], + ["today", "You’ve hit your usage limit. Try again at 12:05 PM.", "2026-11-06T12:06:00"], + [ + "midnight", + "You’ve hit your usage limit. Try again at Dec 1st, 2026 12:00 AM.", + "2026-12-01T00:01:00", + ], + ])("reads the local reset Codex words for %s, waiting out its minute", (_, message, expected) => { + expect(codexMessageResetAt(message, now, undefined)).toBe(DateTime.formatIso(local(expected))); + }); + + it("reads the wall time in the Codex process zone and never resolves a repeated hour early", () => { + const fallBack = DateTime.makeUnsafe("2026-11-01T05:10:00Z"); + const message = "You’ve hit your usage limit. Try again at 1:30 AM."; + expect(codexMessageResetAt(message, fallBack, "America/New_York")).toBe( + "2026-11-01T06:31:00.000Z", + ); + expect(codexMessageResetAt(message, fallBack, ":UTC")).toBeNull(); + }); + + it("ignores messages without a future reset", () => { + expect( + codexMessageResetAt("You’ve hit your usage limit. Try again later.", now, undefined), + ).toBeNull(); + expect( + codexMessageResetAt( + "exceeded retry limit, last status: 429 Too Many Requests", + now, + undefined, + ), + ).toBeNull(); + expect( + codexMessageResetAt("You’ve hit your usage limit. Try again at 8:15 AM.", now, undefined), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index d4f9c621119df..7f9399e4bbe02 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -201,3 +201,45 @@ export function codexUsageLimitResetAt( null, ); } + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** + * Codex words a usage limit's reset in its own local time, to the minute, and omits the date for + * today. A proxy that strips the rate-limit snapshot leaves this text as the only reset time. + */ +export function codexMessageResetAt( + message: string, + now: DateTime.Utc, + codexTimeZone: string | undefined, +): string | null { + const match = + /try again at (?:([a-z]{3}) (\d{1,2})(?:st|nd|rd|th), (\d{4}) )?(\d{1,2}):(\d{2}) ([ap]m)\b/i.exec( + message, + ); + if (!match) return null; + const [, month, day, year, hour, minute, meridiem] = match; + const monthIndex = month ? MONTHS.indexOf(month.toLowerCase()) : 0; + if (monthIndex < 0) return null; + const timeZone = Option.getOrElse( + DateTime.zoneFromString(codexTimeZone?.replace(/^:/, "") ?? ""), + DateTime.zoneMakeLocal, + ); + const { weekDay: _, ...today } = DateTime.toParts(DateTime.makeZonedUnsafe(now, { timeZone })); + const reset = DateTime.makeZonedUnsafe( + { + ...today, + ...(year ? { year: Number(year), month: monthIndex + 1, day: Number(day) } : {}), + hour: (Number(hour) % 12) + (meridiem!.toLowerCase() === "pm" ? 12 : 0), + minute: Number(minute), + second: 0, + millisecond: 0, + }, + // A wall time repeated by a DST change resolves to its later instant, so recovery never runs early. + { timeZone, adjustForTimeZone: true, disambiguation: "later" }, + ).pipe( + // The minute is truncated, so wait it out rather than resume early. + DateTime.add({ minutes: 1 }), + ); + return DateTime.isGreaterThan(reset, now) ? DateTime.formatIso(reset) : null; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5c9ab9c433ffc..aaaa0f333ef90 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -566,6 +566,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( ); const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( + Layer.provide(UsageLimitSources.layerResetFallback), Layer.provide(layerScheduledTaskWebhookOrigin), Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 274e63b46c906..cda6780016e2c 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,6 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { + ProviderDriverKind, + SecretProviderId, + ServerConfigStreamEvent, + UsageLimitSourceId, +} from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -157,3 +162,57 @@ it.effect("reads a management key from its secret provider", () => { Effect.scoped, ); }); + +it("waits for the first refill only when every pooled account of the driver is spent", () => { + const now = Date.parse("2026-11-06T12:00:00.000Z"); + const claude = ProviderDriverKind.make("claudeAgent"); + const account = (id: string, windows: ReadonlyArray) => ({ + id, + driver: claude, + usageLimits: { checkedAt: "2026-11-06T12:00:00.000Z", windows }, + }); + const session = (usedPercent: number, resetsAt: string) => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + resetsAt, + }); + const hub = (accounts: ReadonlyArray>) => + [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Hub", + checkedAt: "", + accounts, + }, + ] as never; + const spent = [ + account("a", [session(100, "2026-11-06T14:00:00.000Z")]), + account("b", [ + session(100, "2026-11-06T13:00:00.000Z"), + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 100, + resetsAt: "2026-11-10T00:00:00.000Z", + }, + ]), + ]; + expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, now)).toBe( + "2026-11-06T14:00:00.000Z", + ); + const withRoom = [...spent, account("c", [session(40, "2026-11-06T15:00:00.000Z")])]; + expect(UsageLimitSources.hubPoolResetAt(hub(withRoom), claude, now)).toBeNull(); + const unread = { + id: "d", + driver: claude, + usageLimits: { checkedAt: "", windows: [], unavailable: { reason: "probeFailed" } }, + }; + expect(UsageLimitSources.hubPoolResetAt(hub([...spent, unread]), claude, now)).toBeNull(); + expect( + UsageLimitSources.hubPoolResetAt(hub(spent), ProviderDriverKind.make("codex"), now), + ).toBeNull(); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..8f867dd29e51d 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -3,6 +3,7 @@ import { UsageLimitSourceError, type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, + type ProviderDriverKind, type ServerSettings, UsageLimitSourceConfig, settingsSecretLiteral, @@ -198,3 +199,54 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(UsageLimitSources, make).pipe( Layer.provide(SecretResolverLayer.layer), ); + +/** A hub returns a usage limit only when every account it pools for the driver is spent. */ +export function hubPoolResetAt( + sources: ReadonlyArray, + driver: ProviderDriverKind, + now: number, +): string | null { + let earliest: number | null = null; + for (const account of sources.flatMap((source) => source.accounts)) { + if (account.driver !== driver) continue; + // An unread account may be the one still blocked, so its refill is unknown. + if (account.usageLimits.unavailable) return null; + const spent = account.usageLimits.windows.filter((window) => window.usedPercent >= 100); + if (spent.length === 0) return null; + const resets = spent.map((window) => Date.parse(window.resetsAt ?? "")); + if (resets.some((reset) => !Number.isFinite(reset))) return null; + const recoversAt = Math.max(...resets); + if (recoversAt <= now) return null; + earliest = earliest === null ? recoversAt : Math.min(earliest, recoversAt); + } + return earliest === null ? null : DateTime.formatIso(DateTime.makeUnsafe(earliest)); +} + +export class UsageLimitResetFallback extends Context.Reference<{ + readonly resetAt: (driver: ProviderDriverKind) => Effect.Effect; +}>("t3/usage/UsageLimitResetFallback", { + defaultValue: () => ({ resetAt: () => Effect.succeed(null) }), +}) {} + +export const layerResetFallback = Layer.effect( + UsageLimitResetFallback, + Effect.gen(function* () { + const sources = yield* UsageLimitSources; + return { + resetAt: Effect.fn("UsageLimitResetFallback.resetAt")(function* (driver) { + const servesDriver = (snapshots: ReadonlyArray) => + snapshots.filter((source) => + source.accounts.some((account) => account.driver === driver), + ); + // Two hubs for one driver leave no way to tell which one served the failed turn. + if (servesDriver(yield* sources.current).length !== 1) return null; + // The terminal waits on this, so a slow hub falls back to its last reading. + yield* sources.refresh.pipe(Effect.timeoutOption("5 seconds")); + const hubs = servesDriver(yield* sources.current); + return hubs.length === 1 + ? hubPoolResetAt(hubs, driver, DateTime.toEpochMillis(yield* DateTime.now)) + : null; + }), + }; + }), +); 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..c4863aad5a729 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,218 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { nextPoolReset, poolSources, 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 codex = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("claudeAgent"); + +function limitsAt(usedPercent: number, at = checkedAt): ServerProviderUsageLimits { + return { + checkedAt: at, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + windowDurationMins: 300, + resetsAt: "2026-10-30T13:17:00.000Z", + }, + ], + }; +} + +function limitsResettingAt(usedPercent: number, resetsAt: string): ServerProviderUsageLimits { + const [window] = limitsAt(usedPercent).windows; + return { checkedAt, windows: [{ ...window!, resetsAt }] }; +} + +const unsupported: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" }, +}; + +function claudeInstance(usageLimits: ServerProviderUsageLimits): ServerProvider { + return { + instanceId, + driver: claude, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "customEndpoint", label: "Custom API endpoint" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits, + }; +} + +function signedIn(id: string, email: string, usageLimits: ServerProviderUsageLimits) { + const base = claudeInstance(usageLimits); + return { + ...base, + instanceId: ProviderInstanceId.make(id), + auth: { ...base.auth, email }, + } satisfies ServerProvider; +} + +interface HubAccountInput { + readonly id: string; + readonly email?: string; + readonly driver?: ProviderDriverKind; + readonly limits: ServerProviderUsageLimits; +} + +function hub( + label: string, + accounts: readonly HubAccountInput[], + error?: string, +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + ...(account.email ? { email: account.email } : {}), + usageLimits: account.limits, + })), + ...(error ? { error } : {}), + }; +} + +function shown( + own: ServerProviderUsageLimits, + sources: readonly UsageLimitSourceSnapshot[], + natives: readonly ServerProvider[] = [], +) { + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(own), ...natives], + sources, + now, + ); + if (resolved?.kind !== "pool") return resolved; + return { + kind: resolved.kind, + rows: resolved.windows.map((window) => ({ + label: window.label, + left: window.remainingPercent, + accounts: window.members.length, + from: poolSources(window), + })), + }; +} + +describe("resolveThreadDetailsLimits", () => { + it("shows nothing without a selected, known instance, or before its first reading", () => { + expect(resolveThreadDetailsLimits(null, [claudeInstance(unsupported)], [], now)).toBeNull(); + expect( + resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), [], [hub("Hub", [])], now), + ).toBeNull(); + const { usageLimits: _unread, ...unread } = claudeInstance(unsupported); + const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); + expect(resolveThreadDetailsLimits(instanceId, [unread], [readableHub], now)).toBeNull(); + }); + + it("keeps an instance's own readable limits over any hub", () => { + const own = limitsAt(40); + expect(shown(own, [hub("Hub", [{ id: "a.json", limits: limitsAt(90) }])])).toEqual({ + kind: "instance", + limits: own, + }); + }); + + it.each([ + { + name: "several accounts, pooled like Usage → Limits", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(20) }, + { id: "b.json", email: "b@example.com", limits: limitsAt(60) }, + ]), + ], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub"] }], + }, + { + name: "accounts across hubs, skipping a hub that failed to read", + sources: [ + hub("Home", [{ id: "a.json", email: "a@example.com", limits: limitsAt(20) }]), + hub("Work", [{ id: "b.json", email: "b@example.com", limits: limitsAt(40) }]), + hub("Down", [], "The hub answered with HTTP 404."), + ], + rows: [{ label: "Session", left: 70, accounts: 2, from: ["Home", "Work"] }], + }, + { + name: "only this driver's readable accounts", + sources: [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsAt(50) }, + { id: "c.json", email: "c@example.com", driver: codex, limits: limitsAt(0) }, + { id: "d.json", email: "d@example.com", limits: unsupported }, + ]), + ], + rows: [{ label: "Session", left: 50, accounts: 1, from: ["Hub"] }], + }, + { + name: "a login signed in natively and on a hub, counted once at its freshest reading", + sources: [hub("Hub", [{ id: "a.json", email: "a@example.com", limits: limitsAt(90) }])], + natives: [signedIn("claude-a", "a@example.com", limitsAt(30, "2026-10-30T11:59:30.000Z"))], + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Signed-in accounts"] }], + }, + ])("pools $name when the instance reads no limits", ({ sources, natives, rows }) => { + expect(shown(unsupported, sources, natives)).toEqual({ kind: "pool", rows }); + }); + + it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { + const soon = "2026-10-30T12:10:00.000Z"; + const later = "2026-10-30T14:00:00.000Z"; + const resolved = resolveThreadDetailsLimits( + instanceId, + [claudeInstance(unsupported)], + [ + hub("Hub", [ + { id: "a.json", email: "a@example.com", limits: limitsResettingAt(0, soon) }, + { id: "b.json", email: "b@example.com", limits: limitsResettingAt(80, later) }, + ]), + ], + now, + ); + if (resolved?.kind !== "pool") throw new Error("expected a pool"); + const [window] = resolved.windows; + expect(window!.resets.map((reset) => reset.restoresPercent)).toEqual([0, 40]); + expect(nextPoolReset(window!, now)).toMatchObject({ + at: Date.parse(later), + restoresPercent: 40, + }); + }); + + it("keeps the instance's notice when no hub has a readable account for its driver", () => { + const sources = [ + hub("Hub", [ + { id: "c.json", driver: codex, limits: limitsAt(10) }, + { id: "d.json", limits: unsupported }, + ]), + ]; + const natives = [signedIn("claude-b", "b@example.com", limitsAt(60))]; + expect(shown(unsupported, sources, natives)).toEqual({ + kind: "instance", + limits: unsupported, + }); + }); +}); 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..65c77743ee331 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,86 @@ +import { + EnvironmentId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + collectProviderUsageLimits, + displayLimitWindows, + type LimitPoolWindow, + limitsNotice, +} from "@t3tools/shared/usageLimits"; + +export type ThreadDetailsLimits = + | { readonly kind: "instance"; readonly limits: ServerProviderUsageLimits } + | { readonly kind: "pool"; readonly windows: readonly LimitPoolWindow[] }; + +// collectLimitAccounts keys its input by environment; the card reads one, so any id serves. +const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); + +export function resolveThreadDetailsLimits( + providerInstanceId: ProviderInstanceId | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const provider = providers.find((entry) => entry.instanceId === providerInstanceId); + if (!provider) return null; + const report = collectProviderUsageLimits(provider.instanceId, 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 === provider.instanceId)?.limits ?? + provider.usageLimits; + if (!own) return null; + if (limitsNotice(own) === null) return { kind: "instance", limits: own }; + // A proxied instance reads no limits of its own; its hub pools the accounts it spends. + return driverPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; +} + +export function nextPoolReset(window: LimitPoolWindow, now: number) { + const pooled = window.members.length > 1; + return ( + window.resets.find((reset) => reset.at > now && (!pooled || reset.restoresPercent > 0)) ?? null + ); +} + +function driverPool( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const hubReadsDriver = sources.some((source) => + source.accounts.some( + (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + ), + ); + if (!hubReadsDriver) return null; + const presentations = new Map([ + [ + CARD_ENVIRONMENT, + { + entry: { target: { label: "" } }, + serverConfig: { providers, usageLimitSources: sources }, + }, + ], + ]); + const accounts = collectLimitAccounts(presentations).filter( + (account) => account.driver === driver, + ); + const [pool] = collectLimitPools(accounts, now); + if (!pool) return null; + return { kind: "pool", windows: displayLimitWindows(pool) }; +} + +export function poolSources(window: LimitPoolWindow): string[] { + const hubs = window.members.flatMap(({ account }) => + account.environments.length === 0 && account.sourceLabel ? [account.sourceLabel] : [], + ); + const signedIn = window.members.some(({ account }) => account.environments.length > 0); + return [...new Set(hubs), ...(signedIn ? ["Signed-in accounts"] : [])]; +} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index f3ee87e1b7f7b..1a60f43f6c354 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -1,13 +1,14 @@ import type { ProviderInstanceId, ServerProvider, + ServerProviderUsageLimits, ServerProviderUsageWindow, UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { - collectProviderUsageLimits, formatDuration, type LimitPace, + type LimitPoolWindow, limitsNotice, paceOf, remainingPercent, @@ -23,6 +24,9 @@ import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { nextPoolReset, poolSources, resolveThreadDetailsLimits } from "./ThreadDetailsUsage.logic"; + +const MINUTE_MS = 60_000; const PACE_LABEL: Record = { ahead: "Ahead of pace", @@ -39,6 +43,12 @@ export interface ThreadDetailsUsageInput { type UsageTone = "error" | "warning" | null; +function limitTone(left: number): UsageTone { + if (left <= 10) return "error"; + if (left <= 20) return "warning"; + return null; +} + /** The selected provider's limit windows. Each row reads as room left. */ export function ThreadDetailsUsage({ providerInstanceId, @@ -47,17 +57,8 @@ 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 notice = limitsNotice(limits); + const resolved = resolveThreadDetailsLimits(providerInstanceId, providers, sources, now); + if (!resolved) return null; return (
- {notice ? ( - {notice}} - /> + {resolved.kind === "pool" ? ( + resolved.windows.map((window) => ) ) : ( - limits.windows.map((window) => ) + )}
); } +function InstanceRows({ + limits, + now, +}: { + readonly limits: ServerProviderUsageLimits; + readonly now: number; +}) { + const notice = limitsNotice(limits); + if (notice) { + return ( + {notice}} + /> + ); + } + return limits.windows.map((window) => ); +} + /** The thread's context window. The bar fills as context is used; the tick marks auto-compaction. */ export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowSnapshot }) { const maxTokens = usage.maxTokens != null && usage.maxTokens > 0 ? usage.maxTokens : null; @@ -153,7 +170,7 @@ function LimitRow({ + + {window.label}: {left}% left + {accounts > 1 ? ` across ${accounts} accounts` : ""} + + + {window.pace ? ( + {PACE_LABEL[window.pace]} + ) : null} + {from.length > 0 ? ( + From {from.join(", ")} + ) : null} + + } + /> + ); +} + +function PoolResetLine({ + window, + now, +}: { + readonly window: LimitPoolWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const next = nextPoolReset(window, now); + if (!next) return null; + const resetsAt = next.member.window.resetsAt; + if (!resetsAt) return null; + const remaining = next.at - now; + const when = remaining < MINUTE_MS ? "momentarily" : `in ${formatDuration(remaining)}`; + const restores = window.members.length > 1 ? next.restoresPercent : null; + const label = restores === null ? `Resets ${when}` : `+${restores}% ${when}`; + return ( + + {label} · {formatUpcomingTimestamp(resetsAt, timestampFormat, now)} + + ); +} + /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..5dadf3fde08c5 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -150,9 +150,12 @@ anything. The command is offered only for providers that appear under **Usage To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a -row to see when it resets and your pace. Turn on **Context window** to also show the thread's -context use against the model's limit. A tick on the bar marks where the provider compacts -automatically, when it reports that point. +row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub +reports no limits of its own, so the card shows that provider's accounts pooled, the hub's and +any signed-in ones, from this environment only. **Usage → Limits** pools every connected +environment. Turn on **Context window** to also show the thread's context use against the +model's limit. A tick on the bar marks where the provider compacts automatically, when it reports +that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials From 0c94651d60de0e09449d804772d53d91e1962422 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:50:38 +0100 Subject: [PATCH 02/11] fix(web): a direct login's failed reading no longer shows an unrelated hub's limits --- .../chat/ThreadDetailsUsage.logic.test.ts | 14 ++++++++++++++ .../components/chat/ThreadDetailsUsage.logic.ts | 4 +++- 2 files changed, 17 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 c4863aad5a729..a1767e361b2b6 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -130,6 +130,20 @@ describe("resolveThreadDetailsLimits", () => { expect(resolveThreadDetailsLimits(instanceId, [unread], [readableHub], now)).toBeNull(); }); + it("keeps a direct login's failed reading instead of an unrelated hub", () => { + const failed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }; + const direct = { ...claudeInstance(failed), auth: { status: "authenticated" as const } }; + const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); + expect(resolveThreadDetailsLimits(instanceId, [direct], [readableHub], now)).toEqual({ + kind: "instance", + limits: failed, + }); + }); + it("keeps an instance's own readable limits over any hub", () => { const own = limitsAt(40); expect(shown(own, [hub("Hub", [{ id: "a.json", limits: limitsAt(90) }])])).toEqual({ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 65c77743ee331..aae5a9ceafb81 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -36,7 +36,9 @@ export function resolveThreadDetailsLimits( report?.accounts.find((entry) => entry.instanceId === provider.instanceId)?.limits ?? provider.usageLimits; if (!own) return null; - if (limitsNotice(own) === null) return { kind: "instance", limits: own }; + const proxied = + provider.auth.type === "customEndpoint" || own.unavailable?.reason === "unsupported"; + if (limitsNotice(own) === null || !proxied) return { kind: "instance", limits: own }; // A proxied instance reads no limits of its own; its hub pools the accounts it spends. return driverPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; } From a4073d124d077851c4bf933110603026f79ebe5e Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:00:25 +0100 Subject: [PATCH 03/11] fix(server): an empty Codex TZ reads reset times as UTC --- apps/server/src/provider/codexUsageLimits.test.ts | 1 + apps/server/src/provider/codexUsageLimits.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index a6cd0fa8d1a12..990bd07cec909 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -309,6 +309,7 @@ describe("codexMessageResetAt", () => { "2026-11-01T06:31:00.000Z", ); expect(codexMessageResetAt(message, fallBack, ":UTC")).toBeNull(); + expect(codexMessageResetAt(message, fallBack, "")).toBeNull(); }); it("ignores messages without a future reset", () => { diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 7f9399e4bbe02..75101bde4d34d 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -221,10 +221,14 @@ export function codexMessageResetAt( const [, month, day, year, hour, minute, meridiem] = match; const monthIndex = month ? MONTHS.indexOf(month.toLowerCase()) : 0; if (monthIndex < 0) return null; - const timeZone = Option.getOrElse( - DateTime.zoneFromString(codexTimeZone?.replace(/^:/, "") ?? ""), - DateTime.zoneMakeLocal, - ); + const timeZone = + codexTimeZone === undefined + ? DateTime.zoneMakeLocal() + : // An empty TZ means UTC on Unix. + Option.getOrElse( + DateTime.zoneFromString(codexTimeZone.replace(/^:/, "") || "UTC"), + DateTime.zoneMakeLocal, + ); const { weekDay: _, ...today } = DateTime.toParts(DateTime.makeZonedUnsafe(now, { timeZone })); const reset = DateTime.makeZonedUnsafe( { From ef61c025348ac705ca7a1745c7d111c84560ed0f Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:35:13 +0100 Subject: [PATCH 04/11] fix(server): hub limits follow the accounts that serve the thread's model A CLIProxyAPI hub routes a model to the accounts that list it, which can belong to another driver: a Claude Code instance running GPT models spends the hub's Codex accounts. The reset fallback, the thread usage card, and the /usage-limits report now read each account's routed models and match the thread's model, falling back to the driver when the hub lists none. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 1 + .../features/threads/ThreadDetailScreen.tsx | 1 + .../ProviderEventIngestor.test.ts | 9 ++-- .../orchestration-v2/ProviderEventIngestor.ts | 13 ++++-- .../src/usage/UsageLimitSources.test.ts | 19 +++++--- apps/server/src/usage/UsageLimitSources.ts | 31 +++++++------ apps/server/src/usage/cliproxyApi.test.ts | 22 ++++++++++ apps/server/src/usage/cliproxyApi.ts | 21 +++++++++ apps/web/src/components/ChatView.tsx | 6 +++ .../chat/ThreadDetailsUsage.logic.test.ts | 30 ++++++++++--- .../chat/ThreadDetailsUsage.logic.ts | 43 +++++++++++-------- .../components/chat/ThreadDetailsUsage.tsx | 4 +- .../components/chat/useUsageLimitsPanel.ts | 6 ++- docs/user/usage.md | 5 ++- packages/contracts/src/providerUsageLimits.ts | 2 + packages/shared/src/usageLimits.test.ts | 26 +++++++++++ packages/shared/src/usageLimits.ts | 36 ++++++++++++++-- 17 files changed, 219 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 57bda9aa98c9b..872facb33dee0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -531,6 +531,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], Date.now(), + currentModelSelection.model, ); onShowUsageLimits(report); if (!report) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27cce9c5795ba..87e8bb469db17 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -981,6 +981,7 @@ function ThreadDetailBody( props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], usageLimitsPanel.now, + props.selectedThread.modelSelection.model, ) : null; const showUsageLimits = (report: UsageLimitsReport | null) => diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 476070de5db44..186d2e9ce341d 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -362,14 +362,17 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit ); } assert.deepEqual(lookups, Array(6).fill(instance.instanceId)); - assert.deepEqual(hubLookups, [instance.driverKind, instance.driverKind]); + assert.deepEqual( + hubLookups, + Array(2).fill(`${instance.driverKind}:${modelSelection.model}`), + ); yield* Deferred.await(refreshed); }).pipe( Effect.provide(makeLayerTest(layerTestEventSink, instances)), Effect.provideService(UsageLimitResetFallback, { - resetAt: (driver) => + resetAt: (driver, model) => Effect.sync(() => { - hubLookups.push(driver); + hubLookups.push(`${driver}:${model}`); return hubResetAt; }), }), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 72805557f2d75..986b6a186b736 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,6 +27,7 @@ import * as SqlError from "effect/sql/SqlError"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { isProxied } from "@t3tools/shared/usageLimits"; import { ProviderTurnAnalytics, type ProviderTurnAnalyticsContext, @@ -641,11 +642,15 @@ export const layer: Layer.Layer< ) : undefined; // A direct login whose own probe failed is not served by the hub. - const proxied = - provider?.auth.type === "customEndpoint" || - provider?.usageLimits?.unavailable?.reason === "unsupported"; + const proxied = provider !== undefined && isProxied(provider); + const model = proxied + ? yield* projections.getThread(input.threadId).pipe( + Effect.map((thread) => thread.modelSelection.model), + Effect.orElseSucceed(() => null), + ) + : null; const hubResetAt = proxied - ? yield* usageLimitResets.resetAt(input.event.driver) + ? yield* usageLimitResets.resetAt(input.event.driver, model) : null; const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt }; return [ diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index cda6780016e2c..63fd5bd8eb907 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -166,9 +166,10 @@ it.effect("reads a management key from its secret provider", () => { it("waits for the first refill only when every pooled account of the driver is spent", () => { const now = Date.parse("2026-11-06T12:00:00.000Z"); const claude = ProviderDriverKind.make("claudeAgent"); - const account = (id: string, windows: ReadonlyArray) => ({ + const account = (id: string, windows: ReadonlyArray, models?: ReadonlyArray) => ({ id, driver: claude, + ...(models ? { models } : {}), usageLimits: { checkedAt: "2026-11-06T12:00:00.000Z", windows }, }); const session = (usedPercent: number, resetsAt: string) => ({ @@ -201,18 +202,26 @@ it("waits for the first refill only when every pooled account of the driver is s }, ]), ]; - expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, now)).toBe( + expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, null, now)).toBe( "2026-11-06T14:00:00.000Z", ); const withRoom = [...spent, account("c", [session(40, "2026-11-06T15:00:00.000Z")])]; - expect(UsageLimitSources.hubPoolResetAt(hub(withRoom), claude, now)).toBeNull(); + expect(UsageLimitSources.hubPoolResetAt(hub(withRoom), claude, null, now)).toBeNull(); const unread = { id: "d", driver: claude, usageLimits: { checkedAt: "", windows: [], unavailable: { reason: "probeFailed" } }, }; - expect(UsageLimitSources.hubPoolResetAt(hub([...spent, unread]), claude, now)).toBeNull(); + expect(UsageLimitSources.hubPoolResetAt(hub([...spent, unread]), claude, null, now)).toBeNull(); expect( - UsageLimitSources.hubPoolResetAt(hub(spent), ProviderDriverKind.make("codex"), now), + UsageLimitSources.hubPoolResetAt(hub(spent), ProviderDriverKind.make("codex"), null, now), ).toBeNull(); + const routed = [ + account("opus", [session(100, "2026-11-06T13:00:00.000Z")], ["claude-opus-5-5"]), + account("sonnet", [session(40, "2026-11-06T15:00:00.000Z")], ["claude-sonnet-5-5"]), + ]; + expect(UsageLimitSources.hubPoolResetAt(hub(routed), claude, "claude-opus-5-5[1m]", now)).toBe( + "2026-11-06T13:00:00.000Z", + ); + expect(UsageLimitSources.hubPoolResetAt(hub(routed), claude, null, now)).toBeNull(); }); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 8f867dd29e51d..55bf69eb8c565 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -11,6 +11,7 @@ import { type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { hubRoutes } from "@t3tools/shared/usageLimits"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -200,15 +201,16 @@ export const layer = Layer.effect(UsageLimitSources, make).pipe( Layer.provide(SecretResolverLayer.layer), ); -/** A hub returns a usage limit only when every account it pools for the driver is spent. */ +/** A hub returns a usage limit only when every account it routes the model to is spent. */ export function hubPoolResetAt( sources: ReadonlyArray, driver: ProviderDriverKind, + model: string | null, now: number, ): string | null { let earliest: number | null = null; - for (const account of sources.flatMap((source) => source.accounts)) { - if (account.driver !== driver) continue; + const routes = hubRoutes(sources, driver, model); + for (const account of sources.flatMap((source) => source.accounts).filter(routes)) { // An unread account may be the one still blocked, so its refill is unknown. if (account.usageLimits.unavailable) return null; const spent = account.usageLimits.windows.filter((window) => window.usedPercent >= 100); @@ -223,7 +225,10 @@ export function hubPoolResetAt( } export class UsageLimitResetFallback extends Context.Reference<{ - readonly resetAt: (driver: ProviderDriverKind) => Effect.Effect; + readonly resetAt: ( + driver: ProviderDriverKind, + model: string | null, + ) => Effect.Effect; }>("t3/usage/UsageLimitResetFallback", { defaultValue: () => ({ resetAt: () => Effect.succeed(null) }), }) {} @@ -233,18 +238,18 @@ export const layerResetFallback = Layer.effect( Effect.gen(function* () { const sources = yield* UsageLimitSources; return { - resetAt: Effect.fn("UsageLimitResetFallback.resetAt")(function* (driver) { - const servesDriver = (snapshots: ReadonlyArray) => - snapshots.filter((source) => - source.accounts.some((account) => account.driver === driver), - ); - // Two hubs for one driver leave no way to tell which one served the failed turn. - if (servesDriver(yield* sources.current).length !== 1) return null; + resetAt: Effect.fn("UsageLimitResetFallback.resetAt")(function* (driver, model) { + const serving = (snapshots: ReadonlyArray) => { + const routes = hubRoutes(snapshots, driver, model); + return snapshots.filter((source) => source.accounts.some(routes)); + }; + // Two hubs for one model leave no way to tell which one served the failed turn. + if (serving(yield* sources.current).length !== 1) return null; // The terminal waits on this, so a slow hub falls back to its last reading. yield* sources.refresh.pipe(Effect.timeoutOption("5 seconds")); - const hubs = servesDriver(yield* sources.current); + const hubs = serving(yield* sources.current); return hubs.length === 1 - ? hubPoolResetAt(hubs, driver, DateTime.toEpochMillis(yield* DateTime.now)) + ? hubPoolResetAt(hubs, driver, model, DateTime.toEpochMillis(yield* DateTime.now)) : null; }), }; diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7b..efbdac652585e 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; + models?: (name: string) => Response; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -68,6 +69,12 @@ function fixture( ? decodeRequest(new TextDecoder().decode(request.body.body)) : undefined; requests.push({ path, ...(body ? { body } : {}) }); + if (path.endsWith("/auth-files/models")) + return HttpClientResponse.fromWeb( + request, + options.models?.(new URL(request.url).searchParams.get("name")!) ?? + Response.json({}, { status: 404 }), + ); if (path.endsWith("/auth-files")) return HttpClientResponse.fromWeb( request, @@ -224,6 +231,21 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("names the models the hub routes to each account, when the hub lists them", () => + Effect.gen(function* () { + const test = fixture({ + models: (name) => + name === "first.json" + ? Response.json({ models: [{ id: "gpt-5.6-sol" }, { id: " " }] }) + : Response.json({}, { status: 404 }), + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => account.models)).toEqual([["gpt-5.6-sol"], undefined]); + expect(result[1]?.usageLimits.windows).toHaveLength(1); + }), + ); + it.effect("resolves a missing usage plan from current, then legacy, token plans", () => Effect.gen(function* () { const test = fixture({ diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 87ec1e05b8f77..1a0d1f25c6ea2 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -10,6 +10,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"; @@ -39,6 +40,9 @@ const AuthFile = Schema.Struct({ ), }); const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); +const AuthFileModels = Schema.Struct({ + models: Schema.Array(Schema.Struct({ id: Schema.String })), +}); const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); const CodexWindow = Schema.Struct({ used_percent: Schema.Number, @@ -101,6 +105,7 @@ const CreditList = Schema.Struct({ }); const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles); +const decodeAuthFileModels = Schema.decodeUnknownEffect(AuthFileModels); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); @@ -213,6 +218,20 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* decodeAuthFiles(response)).files; }); + // Older hubs lack this endpoint; the account then counts for every model of its driver. + const routedModels = Effect.fn("CliproxyApi.routedModels")(function* ( + hub: CliproxyHub, + account: typeof AuthFile.Type, + ) { + const response = yield* management( + hub, + `auth-files/models?name=${encodeURIComponent(account.id)}`, + ); + return (yield* decodeAuthFileModels(response)).models.flatMap(({ id }) => + id.trim() ? [id.trim()] : [], + ); + }); + const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, @@ -274,6 +293,7 @@ export const makeCliproxyApi = Effect.gen(function* () { account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = yield* routedModels(hub, account).pipe(Effect.option); const base = { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), @@ -281,6 +301,7 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(account.id_token?.chatgpt_account_id?.trim() ? { workspaceId: account.id_token.chatgpt_account_id.trim() } : {}), + ...(Option.isSome(models) && models.value.length > 0 ? { models: models.value } : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76ad543630fb2..fc687e6381053 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1312,6 +1312,10 @@ export default function ChatView(props: ChatViewProps) { const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; + const activeUsageModel = + [composerSelectedModelSelection, activeThread?.modelSelection].find( + (selection) => selection?.instanceId === activeProviderInstanceId, + )?.model ?? null; const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ planModeEnabled: settings.planModeEnabled, provider: activeProviderStatus, @@ -1407,6 +1411,7 @@ export default function ChatView(props: ChatViewProps) { threadKey: routeThreadKey, panelKey: usageLimitsKey, providerInstanceId: activeProviderInstanceId, + model: activeUsageModel, providerDriver: activeProviderStatus?.driver ?? null, providers: providerStatuses, sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, @@ -3950,6 +3955,7 @@ export default function ChatView(props: ChatViewProps) { onDeleteProjectScript={deleteProjectScriptStable} usage={{ providerInstanceId: activeProviderInstanceId, + model: activeUsageModel, providers: providerStatuses, sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, 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 a1767e361b2b6..6536c8e5a312a 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -73,6 +73,7 @@ interface HubAccountInput { readonly id: string; readonly email?: string; readonly driver?: ProviderDriverKind; + readonly models?: readonly string[]; readonly limits: ServerProviderUsageLimits; } @@ -90,6 +91,7 @@ function hub( id: account.id, driver: account.driver ?? claude, ...(account.email ? { email: account.email } : {}), + ...(account.models ? { models: account.models } : {}), usageLimits: account.limits, })), ...(error ? { error } : {}), @@ -100,9 +102,11 @@ function shown( own: ServerProviderUsageLimits, sources: readonly UsageLimitSourceSnapshot[], natives: readonly ServerProvider[] = [], + model: string | null = null, ) { const resolved = resolveThreadDetailsLimits( instanceId, + model, [claudeInstance(own), ...natives], sources, now, @@ -121,13 +125,15 @@ function shown( describe("resolveThreadDetailsLimits", () => { it("shows nothing without a selected, known instance, or before its first reading", () => { - expect(resolveThreadDetailsLimits(null, [claudeInstance(unsupported)], [], now)).toBeNull(); expect( - resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), [], [hub("Hub", [])], now), + resolveThreadDetailsLimits(null, null, [claudeInstance(unsupported)], [], now), + ).toBeNull(); + expect( + resolveThreadDetailsLimits(ProviderInstanceId.make("gone"), null, [], [hub("Hub", [])], now), ).toBeNull(); const { usageLimits: _unread, ...unread } = claudeInstance(unsupported); const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); - expect(resolveThreadDetailsLimits(instanceId, [unread], [readableHub], now)).toBeNull(); + expect(resolveThreadDetailsLimits(instanceId, null, [unread], [readableHub], now)).toBeNull(); }); it("keeps a direct login's failed reading instead of an unrelated hub", () => { @@ -138,7 +144,7 @@ describe("resolveThreadDetailsLimits", () => { }; const direct = { ...claudeInstance(failed), auth: { status: "authenticated" as const } }; const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); - expect(resolveThreadDetailsLimits(instanceId, [direct], [readableHub], now)).toEqual({ + expect(resolveThreadDetailsLimits(instanceId, null, [direct], [readableHub], now)).toEqual({ kind: "instance", limits: failed, }); @@ -189,8 +195,19 @@ describe("resolveThreadDetailsLimits", () => { natives: [signedIn("claude-a", "a@example.com", limitsAt(30, "2026-10-30T11:59:30.000Z"))], rows: [{ label: "Session", left: 70, accounts: 1, from: ["Signed-in accounts"] }], }, - ])("pools $name when the instance reads no limits", ({ sources, natives, rows }) => { - expect(shown(unsupported, sources, natives)).toEqual({ kind: "pool", rows }); + { + name: "only the accounts the hub routes the model to, even of another driver", + sources: [ + hub("Hub", [ + { id: "a.json", models: ["claude-opus-5-5"], limits: limitsAt(10) }, + { id: "c.json", driver: codex, models: ["gpt-5.6-sol"], limits: limitsAt(30) }, + ]), + ], + model: "gpt-5.6-sol[1m]", + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Hub"] }], + }, + ])("pools $name when the instance reads no limits", ({ sources, natives, model, rows }) => { + expect(shown(unsupported, sources, natives, model)).toEqual({ kind: "pool", rows }); }); it("names the next reset that refills quota, skipping an unused account's 0% reset", () => { @@ -198,6 +215,7 @@ describe("resolveThreadDetailsLimits", () => { const later = "2026-10-30T14:00:00.000Z"; const resolved = resolveThreadDetailsLimits( instanceId, + null, [claudeInstance(unsupported)], [ hub("Hub", [ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index aae5a9ceafb81..c44494322ecb5 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -10,6 +10,8 @@ import { collectLimitPools, collectProviderUsageLimits, displayLimitWindows, + hubRoutes, + isProxied, type LimitPoolWindow, limitsNotice, } from "@t3tools/shared/usageLimits"; @@ -23,24 +25,25 @@ const CARD_ENVIRONMENT = EnvironmentId.make("thread-details"); export function resolveThreadDetailsLimits( providerInstanceId: ProviderInstanceId | null, + model: string | null, providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, now: number, ): ThreadDetailsLimits | null { const provider = providers.find((entry) => entry.instanceId === providerInstanceId); if (!provider) return null; - const report = collectProviderUsageLimits(provider.instanceId, providers, sources, now); + const report = collectProviderUsageLimits(provider.instanceId, providers, sources, now, model); // 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 === provider.instanceId)?.limits ?? provider.usageLimits; if (!own) return null; - const proxied = - provider.auth.type === "customEndpoint" || own.unavailable?.reason === "unsupported"; - if (limitsNotice(own) === null || !proxied) return { kind: "instance", limits: own }; + if (limitsNotice(own) === null || !isProxied(provider)) return { kind: "instance", limits: own }; // A proxied instance reads no limits of its own; its hub pools the accounts it spends. - return driverPool(provider.driver, providers, sources, now) ?? { kind: "instance", limits: own }; + return ( + hubPool(provider.driver, model, providers, sources, now) ?? { kind: "instance", limits: own } + ); } export function nextPoolReset(window: LimitPoolWindow, now: number) { @@ -50,33 +53,39 @@ export function nextPoolReset(window: LimitPoolWindow, now: number) { ); } -function driverPool( +function hubPool( driver: ServerProvider["driver"], + model: string | null, providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, now: number, ): ThreadDetailsLimits | null { - const hubReadsDriver = sources.some((source) => - source.accounts.some( - (account) => account.driver === driver && limitsNotice(account.usageLimits) === null, + const routes = hubRoutes(sources, driver, model); + const routed = sources.map((source) => ({ ...source, accounts: source.accounts.filter(routes) })); + const readable = routed + .flatMap((source) => source.accounts) + .filter((account) => limitsNotice(account.usageLimits) === null); + if (readable.length === 0) return null; + // A signed-in login joins the pool only as a fresher reading of an account the hub routes to. + const signedIn = providers.filter((provider) => + readable.some( + (account) => + account.driver === provider.driver && + account.email !== undefined && + account.email.toLowerCase() === provider.auth.email?.toLowerCase(), ), ); - if (!hubReadsDriver) return null; const presentations = new Map([ [ CARD_ENVIRONMENT, { entry: { target: { label: "" } }, - serverConfig: { providers, usageLimitSources: sources }, + serverConfig: { providers: signedIn, usageLimitSources: routed }, }, ], ]); - const accounts = collectLimitAccounts(presentations).filter( - (account) => account.driver === driver, - ); - const [pool] = collectLimitPools(accounts, now); - if (!pool) return null; - return { kind: "pool", windows: displayLimitWindows(pool) }; + const [pool] = collectLimitPools(collectLimitAccounts(presentations), now); + return pool ? { kind: "pool", windows: displayLimitWindows(pool) } : null; } export function poolSources(window: LimitPoolWindow): string[] { diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 1a60f43f6c354..3346ee02ed2d4 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -36,6 +36,7 @@ const PACE_LABEL: Record = { export interface ThreadDetailsUsageInput { readonly providerInstanceId: ProviderInstanceId | null; + readonly model: string | null; readonly providers: readonly ServerProvider[]; readonly sources: UsageLimitSourceSnapshots; readonly contextWindow: ContextWindowSnapshot | null; @@ -52,12 +53,13 @@ function limitTone(left: number): UsageTone { /** The selected provider's limit windows. Each row reads as room left. */ export function ThreadDetailsUsage({ providerInstanceId, + model, providers, sources, }: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); - const resolved = resolveThreadDetailsLimits(providerInstanceId, providers, sources, now); + const resolved = resolveThreadDetailsLimits(providerInstanceId, model, providers, sources, now); if (!resolved) return null; return ( diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index c0862a65bca28..f6911ab8676bf 100644 --- a/apps/web/src/components/chat/useUsageLimitsPanel.ts +++ b/apps/web/src/components/chat/useUsageLimitsPanel.ts @@ -16,6 +16,7 @@ export function useUsageLimitsPanel(input: { readonly threadKey: string; readonly panelKey: string | null; readonly providerInstanceId: ProviderInstanceId | null; + readonly model: string | null; readonly providerDriver: ServerProvider["driver"] | null; readonly providers: readonly ServerProvider[]; readonly sources: UsageLimitSourceSnapshots; @@ -25,6 +26,7 @@ export function useUsageLimitsPanel(input: { threadKey, panelKey, providerInstanceId, + model, providerDriver, providers, sources, @@ -39,7 +41,7 @@ export function useUsageLimitsPanel(input: { } const report = panel !== null && panelKey !== null && panel.key === panelKey && providerInstanceId !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now) + ? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now, model) : null; const banner = report !== null && panel !== null @@ -56,7 +58,7 @@ export function useUsageLimitsPanel(input: { const now = Date.now(); const nextReport = providerInstanceId !== null && panelKey !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) + ? collectProviderUsageLimits(providerInstanceId, providers, sources, now, model) : null; if (nextReport && panelKey !== null) { setPanel({ key: panelKey, threadKey, now }); diff --git a/docs/user/usage.md b/docs/user/usage.md index 5dadf3fde08c5..60ade0791fa7c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -151,8 +151,9 @@ To keep them in view on web and desktop, turn on **Usage limits** in **Settings The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub -reports no limits of its own, so the card shows that provider's accounts pooled, the hub's and -any signed-in ones, from this environment only. **Usage → Limits** pools every connected +reports no limits of its own, so the card pools the hub accounts that serve the thread's model, +from this environment only. A Claude instance that runs GPT models through the hub shows the +hub's Codex accounts. **Usage → Limits** pools every connected environment. Turn on **Context window** to also show the thread's context use against the model's limit. A tick on the bar marks where the provider compacts automatically, when it reports that point. diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..3cb32762849bd 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -61,6 +61,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), plan: Schema.optional(TrimmedNonEmptyString), workspaceId: Schema.optional(TrimmedNonEmptyString), + /** The models the hub routes to this account. */ + models: Schema.optional(Schema.Array(TrimmedNonEmptyString)), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 72a17323386c8..53f4f58a0769c 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1252,6 +1252,32 @@ describe("/usage-limits", () => { expect(report?.notices).toEqual([]); }); + it("shows a proxied instance the hub accounts its model routes to, of any driver", () => { + const routed = [ + { + ...sources[0]!, + accounts: sources[0]!.accounts.map((account) => + account.id === "oss" ? { ...account, models: ["gpt-5.6-sol"] } : account, + ), + }, + ]; + const proxy = provider({ + instanceId: ProviderInstanceId.make("claudex"), + driver: ProviderDriverKind.make("claudeAgent"), + auth: { status: "authenticated", type: "customEndpoint" }, + }); + const ids = (instance: ServerProvider, model: string | null) => + collectProviderUsageLimits( + instance.instanceId, + [selected, proxy], + routed, + now, + model, + )?.accounts.map((account) => account.id); + expect(ids(proxy, "gpt-5.6-sol[1m]")).toEqual(["codex", "hub:oss"]); + expect(ids(selected, "gpt-5.6-sol")).toEqual(["codex", "hub:oss"]); + }); + it("keeps the freshest usable native duplicate and the freshest native credits in either order", () => { const failed = provider({ auth: selected.auth, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 869f676acae69..77b8a33a18468 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -13,6 +13,7 @@ import { type ServerProvider, type ServerProviderUsageLimits, type ServerProviderUsageWindow, + type UsageLimitSourceAccount, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; @@ -750,14 +751,43 @@ export function withUsageLimitsCommands( }); } +/** An instance that sends requests through a hub reads no limits of its own. */ +export function isProxied(provider: ServerProvider): boolean { + return ( + provider.auth.type === "customEndpoint" || + provider.usageLimits?.unavailable?.reason === "unsupported" + ); +} + +/** The hub accounts that serve a model: those that list it, else every account of the driver. */ +export function hubRoutes( + sources: UsageLimitSourceSnapshots, + driver: ServerProvider["driver"], + model: string | null, +): (account: UsageLimitSourceAccount) => boolean { + // Claude Code's 1M-context suffix is not part of the model id a hub routes. + const slug = model?.replace(/\[1m\]$/i, ""); + const listed = (account: UsageLimitSourceAccount) => + slug !== undefined && account.models?.includes(slug) === true; + return sources.some((source) => source.accounts.some(listed)) + ? listed + : (account) => account.driver === driver; +} + export function collectProviderUsageLimits( instanceId: ProviderInstanceId, providers: readonly ServerProvider[], sources: UsageLimitSourceSnapshots, now: number, + model: string | null = null, ): UsageLimitsReport | null { const selected = providers.find((provider) => provider.instanceId === instanceId); - if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + if (!selected) return null; + // Only a proxied instance spends what its hub routes the model to, maybe another driver's accounts. + const routes = hubRoutes(sources, selected.driver, isProxied(selected) ? model : null); + const driver = + sources.flatMap((source) => source.accounts).find(routes)?.driver ?? selected.driver; + if (!hasProviderUsageLimits(driver, providers, sources)) return null; const resolveKey = accountKeyResolver([ ...providers.filter((provider) => provider.enabled).map(nativeReport), ...sources.flatMap((source) => source.accounts), @@ -768,7 +798,7 @@ export function collectProviderUsageLimits( { usage: NativeSnapshot; credits: NativeSnapshot | null } >(); for (const provider of providersWithLimits(providers)) { - if (provider.driver !== selected.driver || !provider.usageLimits) continue; + if (provider.driver !== driver || !provider.usageLimits) continue; const snapshot = { provider, limits: provider.usageLimits }; const key = resolveKey(nativeReport(provider)) ?? `instance:${provider.instanceId}`; const previous = nativeByAccount.get(key); @@ -848,7 +878,7 @@ export function collectProviderUsageLimits( }); } for (const source of sources) { - const matching = source.accounts.filter((account) => account.driver === selected.driver); + const matching = source.accounts.filter(routes); for (const account of matching) { const key = resolveKey(account); if (key && nativeAccountKeys.has(key)) continue; From 8b2304f28133b37acef889937982c6e6e7a51446 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:58:05 +0100 Subject: [PATCH 05/11] fix(web): the hub pool card keeps workspaces apart and shows every routed driver Co-Authored-By: Claude Opus 5.5 --- .../chat/ThreadDetailsUsage.logic.test.ts | 31 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index 6536c8e5a312a..b100bf3593125 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -74,6 +74,7 @@ interface HubAccountInput { readonly email?: string; readonly driver?: ProviderDriverKind; readonly models?: readonly string[]; + readonly workspaceId?: string; readonly limits: ServerProviderUsageLimits; } @@ -92,6 +93,7 @@ function hub( driver: account.driver ?? claude, ...(account.email ? { email: account.email } : {}), ...(account.models ? { models: account.models } : {}), + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), usageLimits: account.limits, })), ...(error ? { error } : {}), @@ -206,6 +208,35 @@ describe("resolveThreadDetailsLimits", () => { model: "gpt-5.6-sol[1m]", rows: [{ label: "Session", left: 70, accounts: 1, from: ["Hub"] }], }, + { + name: "every account the hub routes the model to, across drivers", + sources: [ + hub("Hub", [ + { id: "a.json", models: ["shared"], limits: limitsAt(10) }, + { id: "c.json", driver: codex, models: ["shared"], limits: limitsAt(30) }, + ]), + ], + model: "shared", + rows: [ + { label: "Session", left: 90, accounts: 1, from: ["Hub"] }, + { label: "Session", left: 70, accounts: 1, from: ["Hub"] }, + ], + }, + { + name: "a hub workspace without the same login's other workspace", + sources: [ + hub("Hub", [ + { id: "c.json", email: "c@example.com", workspaceId: "w1", limits: limitsAt(100) }, + ]), + ], + natives: [ + { + ...signedIn("claude-c", "c@example.com", limitsAt(0)), + auth: { status: "authenticated", email: "c@example.com", workspaceId: "w2" }, + } satisfies ServerProvider, + ], + rows: [{ label: "Session", left: 0, accounts: 1, from: ["Hub"] }], + }, ])("pools $name when the instance reads no limits", ({ sources, natives, model, rows }) => { expect(shown(unsupported, sources, natives, model)).toEqual({ kind: "pool", rows }); }); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index c44494322ecb5..08d64699c9afc 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -71,6 +71,7 @@ function hubPool( readable.some( (account) => account.driver === provider.driver && + account.workspaceId === provider.auth.workspaceId && account.email !== undefined && account.email.toLowerCase() === provider.auth.email?.toLowerCase(), ), @@ -84,8 +85,8 @@ function hubPool( }, ], ]); - const [pool] = collectLimitPools(collectLimitAccounts(presentations), now); - return pool ? { kind: "pool", windows: displayLimitWindows(pool) } : null; + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + return pools.length > 0 ? { kind: "pool", windows: pools.flatMap(displayLimitWindows) } : null; } export function poolSources(window: LimitPoolWindow): string[] { From 18f8c43227202c1492eec984a931962ebd9f4168 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:08:06 +0100 Subject: [PATCH 06/11] fix(shared): hub routing counts accounts with unread model lists and every routed driver Co-Authored-By: Claude Opus 5.5 --- .../src/usage/UsageLimitSources.test.ts | 4 ++++ packages/shared/src/usageLimits.test.ts | 22 +++++++++++++++++++ packages/shared/src/usageLimits.ts | 22 +++++++++++++------ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 63fd5bd8eb907..8707e640211d8 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -224,4 +224,8 @@ it("waits for the first refill only when every pooled account of the driver is s "2026-11-06T13:00:00.000Z", ); expect(UsageLimitSources.hubPoolResetAt(hub(routed), claude, null, now)).toBeNull(); + const unlisted = account("unlisted", [session(30, "2026-11-06T15:00:00.000Z")]); + expect( + UsageLimitSources.hubPoolResetAt(hub([...routed, unlisted]), claude, "claude-opus-5-5", now), + ).toBeNull(); }); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 53f4f58a0769c..91d54098f8580 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1275,6 +1275,28 @@ describe("/usage-limits", () => { model, )?.accounts.map((account) => account.id); expect(ids(proxy, "gpt-5.6-sol[1m]")).toEqual(["codex", "hub:oss"]); + const both = [ + { + ...routed[0]!, + accounts: [ + ...routed[0]!.accounts, + { + id: "claude-hub", + driver: ProviderDriverKind.make("claudeAgent"), + models: ["gpt-5.6-sol"], + usageLimits: limits, + }, + ], + }, + ]; + const reversed = [{ ...both[0]!, accounts: [...both[0]!.accounts].reverse() }]; + for (const hubs of [both, reversed]) { + expect( + collectProviderUsageLimits(proxy.instanceId, [selected, proxy], hubs, now, "gpt-5.6-sol") + ?.accounts.map((account) => account.id) + .toSorted(), + ).toEqual(["codex", "hub:claude-hub", "hub:oss"]); + } expect(ids(selected, "gpt-5.6-sol")).toEqual(["codex", "hub:oss"]); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 77b8a33a18468..7117a863c77d3 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -769,9 +769,13 @@ export function hubRoutes( const slug = model?.replace(/\[1m\]$/i, ""); const listed = (account: UsageLimitSourceAccount) => slug !== undefined && account.models?.includes(slug) === true; - return sources.some((source) => source.accounts.some(listed)) - ? listed - : (account) => account.driver === driver; + const serving = new Set( + sources.flatMap((source) => source.accounts.filter(listed).map((account) => account.driver)), + ); + // Model ids such as "opus" are aliases no hub lists, so no listing falls back to the driver. + if (serving.size === 0) return (account) => account.driver === driver; + // An account whose model list was unreadable may serve the model too. + return (account) => (account.models ? listed(account) : serving.has(account.driver)); } export function collectProviderUsageLimits( @@ -785,9 +789,13 @@ export function collectProviderUsageLimits( if (!selected) return null; // Only a proxied instance spends what its hub routes the model to, maybe another driver's accounts. const routes = hubRoutes(sources, selected.driver, isProxied(selected) ? model : null); - const driver = - sources.flatMap((source) => source.accounts).find(routes)?.driver ?? selected.driver; - if (!hasProviderUsageLimits(driver, providers, sources)) return null; + const drivers = new Set( + sources.flatMap((source) => source.accounts.filter(routes).map((account) => account.driver)), + ); + if (drivers.size === 0) drivers.add(selected.driver); + if (![...drivers].some((driver) => hasProviderUsageLimits(driver, providers, sources))) { + return null; + } const resolveKey = accountKeyResolver([ ...providers.filter((provider) => provider.enabled).map(nativeReport), ...sources.flatMap((source) => source.accounts), @@ -798,7 +806,7 @@ export function collectProviderUsageLimits( { usage: NativeSnapshot; credits: NativeSnapshot | null } >(); for (const provider of providersWithLimits(providers)) { - if (provider.driver !== driver || !provider.usageLimits) continue; + if (!drivers.has(provider.driver) || !provider.usageLimits) continue; const snapshot = { provider, limits: provider.usageLimits }; const key = resolveKey(nativeReport(provider)) ?? `instance:${provider.instanceId}`; const previous = nativeByAccount.get(key); From 8a76c9ac933e4fed1ccbb87c55c432eb43ab5ca6 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:21:53 +0100 Subject: [PATCH 07/11] fix(shared): only a custom-endpoint instance follows the hub's model routing An unsupported limits probe (a Cursor login with Keychain reads off, an external OpenCode server) does not mean the instance uses a hub. Co-Authored-By: Claude Opus 5.5 --- .../ProviderEventIngestor.test.ts | 8 ++++---- .../src/orchestration-v2/ProviderEventIngestor.ts | 15 ++++++++------- .../components/chat/ThreadDetailsUsage.logic.ts | 6 +++++- packages/shared/src/usageLimits.test.ts | 14 ++++++++++++++ packages/shared/src/usageLimits.ts | 8 ++++++-- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 186d2e9ce341d..09c2bdd2820f0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -362,10 +362,10 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit ); } assert.deepEqual(lookups, Array(6).fill(instance.instanceId)); - assert.deepEqual( - hubLookups, - Array(2).fill(`${instance.driverKind}:${modelSelection.model}`), - ); + assert.deepEqual(hubLookups, [ + `${instance.driverKind}:${modelSelection.model}`, + `${instance.driverKind}:null`, + ]); yield* Deferred.await(refreshed); }).pipe( Effect.provide(makeLayerTest(layerTestEventSink, instances)), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 986b6a186b736..a6f7bcf34e278 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,7 +27,7 @@ import * as SqlError from "effect/sql/SqlError"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { modelSelectionsEqual } from "@t3tools/shared/model"; -import { isProxied } from "@t3tools/shared/usageLimits"; +import { hubRoutedModel, isProxied } from "@t3tools/shared/usageLimits"; import { ProviderTurnAnalytics, type ProviderTurnAnalyticsContext, @@ -643,12 +643,13 @@ export const layer: Layer.Layer< : undefined; // A direct login whose own probe failed is not served by the hub. const proxied = provider !== undefined && isProxied(provider); - const model = proxied - ? yield* projections.getThread(input.threadId).pipe( - Effect.map((thread) => thread.modelSelection.model), - Effect.orElseSucceed(() => null), - ) - : null; + const model = + provider !== undefined && proxied + ? yield* projections.getThread(input.threadId).pipe( + Effect.map((thread) => hubRoutedModel(provider, thread.modelSelection.model)), + Effect.orElseSucceed(() => null), + ) + : null; const hubResetAt = proxied ? yield* usageLimitResets.resetAt(input.event.driver, model) : null; diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 08d64699c9afc..7b66a65253cea 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -10,6 +10,7 @@ import { collectLimitPools, collectProviderUsageLimits, displayLimitWindows, + hubRoutedModel, hubRoutes, isProxied, type LimitPoolWindow, @@ -42,7 +43,10 @@ export function resolveThreadDetailsLimits( if (limitsNotice(own) === null || !isProxied(provider)) return { kind: "instance", limits: own }; // A proxied instance reads no limits of its own; its hub pools the accounts it spends. return ( - hubPool(provider.driver, model, providers, sources, now) ?? { kind: "instance", limits: own } + hubPool(provider.driver, hubRoutedModel(provider, model), providers, sources, now) ?? { + kind: "instance", + limits: own, + } ); } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 91d54098f8580..7b3ab5045db88 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1275,6 +1275,20 @@ describe("/usage-limits", () => { model, )?.accounts.map((account) => account.id); expect(ids(proxy, "gpt-5.6-sol[1m]")).toEqual(["codex", "hub:oss"]); + const unread = provider({ + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + usageLimits: { ...limits, windows: [], unavailable: { reason: "unsupported" } }, + }); + expect( + collectProviderUsageLimits( + unread.instanceId, + [unread], + routed, + now, + "gpt-5.6-sol", + )?.accounts.map((account) => account.id) ?? [], + ).toEqual(["cursor"]); const both = [ { ...routed[0]!, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 7117a863c77d3..4cf399c6b6e8a 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -759,6 +759,11 @@ export function isProxied(provider: ServerProvider): boolean { ); } +/** Only an instance sent to a custom endpoint is known to reach the hub that routes its model. */ +export function hubRoutedModel(provider: ServerProvider, model: string | null): string | null { + return provider.auth.type === "customEndpoint" ? model : null; +} + /** The hub accounts that serve a model: those that list it, else every account of the driver. */ export function hubRoutes( sources: UsageLimitSourceSnapshots, @@ -787,8 +792,7 @@ export function collectProviderUsageLimits( ): UsageLimitsReport | null { const selected = providers.find((provider) => provider.instanceId === instanceId); if (!selected) return null; - // Only a proxied instance spends what its hub routes the model to, maybe another driver's accounts. - const routes = hubRoutes(sources, selected.driver, isProxied(selected) ? model : null); + const routes = hubRoutes(sources, selected.driver, hubRoutedModel(selected, model)); const drivers = new Set( sources.flatMap((source) => source.accounts.filter(routes).map((account) => account.driver)), ); From e2251592387962e34c1bdcfdd87d8b62a8f539da Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:36:04 +0100 Subject: [PATCH 08/11] fix(server): a hub reset follows the failing run's model, not a later selection Co-Authored-By: Claude Opus 5.5 --- .../ProviderEventIngestor.test.ts | 37 +++++++++++++++++-- .../orchestration-v2/ProviderEventIngestor.ts | 17 +++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 09c2bdd2820f0..8a16a1f8017f3 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -288,7 +288,36 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const threadEvent = yield* threadCreatedEvent(now); - yield* eventSink.write({ events: [threadEvent] }); + // The run started on an earlier model than the thread's current selection. + const runId = RunId.make("run:usage-limit"); + yield* eventSink.write({ + events: [ + threadEvent, + { + id: yield* idAllocator.allocate.event({ threadId: threadEvent.threadId }), + type: "run.created", + threadId: threadEvent.threadId, + occurredAt: now, + payload: { + id: runId, + threadId: threadEvent.threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection: { ...modelSelection, model: "gpt-run-model" }, + providerThreadId: null, + userMessageId: MessageId.make("message:usage-limit"), + rootNodeId: null, + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); const input = { providerSessionId: yield* idAllocator.allocate.providerSession({ providerInstanceId: instance.instanceId, @@ -315,6 +344,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit usageLimits: { ...unread, unavailable: { reason: "probeFailed" } }, }, resetAt: hubResetAt, + runId, }, { failureClass: "usage_limit", @@ -325,10 +355,11 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit resetAt: hubResetAt, }, ] as const; - for (const [index, { failureClass, provider, resetAt }] of cases.entries()) { + for (const [index, { failureClass, provider, resetAt, ...run }] of cases.entries()) { own = provider; const stored = yield* ingestor.ingestNormalized({ ...input, + ...run, event: { type: "turn.terminal", driver: instance.driverKind, @@ -363,7 +394,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit } assert.deepEqual(lookups, Array(6).fill(instance.instanceId)); assert.deepEqual(hubLookups, [ - `${instance.driverKind}:${modelSelection.model}`, + `${instance.driverKind}:gpt-run-model`, `${instance.driverKind}:null`, ]); yield* Deferred.await(refreshed); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index a6f7bcf34e278..478f39ebda3c0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -643,12 +643,21 @@ export const layer: Layer.Layer< : undefined; // A direct login whose own probe failed is not served by the hub. const proxied = provider !== undefined && isProxied(provider); + // The failing run's model, which may differ from a selection changed mid-turn. const model = provider !== undefined && proxied - ? yield* projections.getThread(input.threadId).pipe( - Effect.map((thread) => hubRoutedModel(provider, thread.modelSelection.model)), - Effect.orElseSucceed(() => null), - ) + ? yield* Effect.gen(function* () { + const { runs } = + input.runId === undefined + ? { runs: [] } + : yield* projections.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + const selection = + runs[0]?.modelSelection ?? + (yield* projections.getThread(input.threadId)).modelSelection; + return hubRoutedModel(provider, selection.model); + }).pipe(Effect.orElseSucceed(() => null)) : null; const hubResetAt = proxied ? yield* usageLimitResets.resetAt(input.event.driver, model) From f60fc3cf8adb70b3a97d446af86cdb53b6e1a646 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:27:14 +0100 Subject: [PATCH 09/11] fix(web): the thread usage card shows the hub account that served the model last CLIProxyAPI reports no account per turn. T3 now dates each hub account's latest served request from the hub's standard recent_requests buckets, and the thread usage card shows only the routed accounts the hub served last. With no recent request, the card pools every routed account as before. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/cliproxyApi.test.ts | 20 ++++++++++++++++ apps/server/src/usage/cliproxyApi.ts | 20 +++++++++++++++- .../chat/ThreadDetailsUsage.logic.test.ts | 23 +++++++++++++++++++ .../chat/ThreadDetailsUsage.logic.ts | 11 ++++++++- docs/user/usage.md | 7 +++--- packages/contracts/src/providerUsageLimits.ts | 2 ++ 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index efbdac652585e..3854734b7c64b 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -51,6 +51,7 @@ function fixture( accounts?: Array< (typeof accounts)[number] & { disabled?: boolean; + recent_requests?: ReadonlyArray<{ time: string; success: number; failed: number }>; id_token: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; } >; @@ -246,6 +247,25 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("dates each account's last served request from the hub's request buckets", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-10-30T12:05:00Z")); + const bucket = (success: number, failed = 0) => ({ time: "", success, failed }); + const test = fixture({ + accounts: [ + { ...accounts[0]!, recent_requests: [bucket(2), bucket(3), bucket(0, 4)] }, + { ...accounts[1]!, recent_requests: [bucket(0), bucket(0, 1)] }, + ], + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => account.lastServedAt)).toEqual([ + "2026-10-30T11:50:00.000Z", + undefined, + ]); + }), + ); + it.effect("resolves a missing usage plan from current, then legacy, token plans", () => Effect.gen(function* () { const test = fixture({ diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 1a0d1f25c6ea2..4b0aee15fe70f 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -31,6 +31,7 @@ const AuthFile = Schema.Struct({ provider: Schema.String, email: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), + recent_requests: Schema.optional(Schema.Array(Schema.Struct({ success: Schema.Number }))), id_token: Schema.optional( Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String), @@ -39,6 +40,18 @@ const AuthFile = Schema.Struct({ }), ), }); +// CLIProxyAPI counts each account's requests in 10-minute buckets aligned to the epoch, newest last. +const RECENT_BUCKET_MS = 10 * 60 * 1000; + +function lastServedAt(account: typeof AuthFile.Type, now: number): string | undefined { + const buckets = account.recent_requests ?? []; + const index = buckets.findLastIndex((bucket) => bucket.success > 0); + if (index === -1) return undefined; + const current = Math.floor(now / RECENT_BUCKET_MS) * RECENT_BUCKET_MS; + const start = current - (buckets.length - 1 - index) * RECENT_BUCKET_MS; + return DateTime.formatIso(DateTime.makeUnsafe(start)); +} + const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); const AuthFileModels = Schema.Struct({ models: Schema.Array(Schema.Struct({ id: Schema.String })), @@ -291,8 +304,10 @@ export const makeCliproxyApi = Effect.gen(function* () { const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, + listedAt: number, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); + const served = lastServedAt(account, listedAt); const models = yield* routedModels(hub, account).pipe(Effect.option); const base = { id: account.id, @@ -302,6 +317,7 @@ export const makeCliproxyApi = Effect.gen(function* () { ? { workspaceId: account.id_token.chatgpt_account_id.trim() } : {}), ...(Option.isSome(models) && models.value.length > 0 ? { models: models.value } : {}), + ...(served ? { lastServedAt: served } : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { @@ -403,12 +419,14 @@ export const makeCliproxyApi = Effect.gen(function* () { : new UsageLimitSourceError({ detail: "The hub could not list accounts." }), ), ); + // Buckets are relative to the listing, so every account shares its time. + const listedAt = DateTime.toEpochMillis(yield* DateTime.now); return yield* Effect.forEach( accounts.filter( (account) => !account.disabled && (account.provider === "codex" || account.provider === "claude"), ), - (account) => readAccount(hub, account), + (account) => readAccount(hub, account, listedAt), { concurrency: 4 }, ); }); diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts index b100bf3593125..45227db63b487 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -75,6 +75,7 @@ interface HubAccountInput { readonly driver?: ProviderDriverKind; readonly models?: readonly string[]; readonly workspaceId?: string; + readonly lastServedAt?: string; readonly limits: ServerProviderUsageLimits; } @@ -94,6 +95,7 @@ function hub( ...(account.email ? { email: account.email } : {}), ...(account.models ? { models: account.models } : {}), ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...(account.lastServedAt ? { lastServedAt: account.lastServedAt } : {}), usageLimits: account.limits, })), ...(error ? { error } : {}), @@ -222,6 +224,27 @@ describe("resolveThreadDetailsLimits", () => { { label: "Session", left: 70, accounts: 1, from: ["Hub"] }, ], }, + { + name: "only the account the hub served last", + sources: [ + hub("Hub", [ + { id: "a.json", lastServedAt: "2026-10-30T11:50:00.000Z", limits: limitsAt(20) }, + { id: "b.json", lastServedAt: "2026-10-30T10:00:00.000Z", limits: limitsAt(60) }, + { id: "c.json", limits: limitsAt(0) }, + ]), + ], + rows: [{ label: "Session", left: 80, accounts: 1, from: ["Hub"] }], + }, + { + name: "every account the hub served in the same latest window", + sources: [ + hub("Hub", [ + { id: "a.json", lastServedAt: "2026-10-30T11:50:00.000Z", limits: limitsAt(20) }, + { id: "b.json", lastServedAt: "2026-10-30T11:50:00.000Z", limits: limitsAt(60) }, + ]), + ], + rows: [{ label: "Session", left: 60, accounts: 2, from: ["Hub"] }], + }, { name: "a hub workspace without the same login's other workspace", sources: [ diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts index 7b66a65253cea..24b03c3ba03d2 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -3,6 +3,7 @@ import { type ProviderInstanceId, type ServerProvider, type ServerProviderUsageLimits, + type UsageLimitSourceAccount, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; import { @@ -65,7 +66,15 @@ function hubPool( now: number, ): ThreadDetailsLimits | null { const routes = hubRoutes(sources, driver, model); - const routed = sources.map((source) => ({ ...source, accounts: source.accounts.filter(routes) })); + const accounts = sources.flatMap((source) => source.accounts.filter(routes)); + // The hub names no account per turn; the latest one it served stands in for the thread's. + const lastServed = accounts + .flatMap((account) => (account.lastServedAt ? [account.lastServedAt] : [])) + .toSorted() + .at(-1); + const shown = (account: UsageLimitSourceAccount) => + routes(account) && (lastServed === undefined || account.lastServedAt === lastServed); + const routed = sources.map((source) => ({ ...source, accounts: source.accounts.filter(shown) })); const readable = routed .flatMap((source) => source.accounts) .filter((account) => limitsNotice(account.usageLimits) === null); diff --git a/docs/user/usage.md b/docs/user/usage.md index 60ade0791fa7c..833d580e32a1a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -151,9 +151,10 @@ To keep them in view on web and desktop, turn on **Usage limits** in **Settings The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub -reports no limits of its own, so the card pools the hub accounts that serve the thread's model, -from this environment only. A Claude instance that runs GPT models through the hub shows the -hub's Codex accounts. **Usage → Limits** pools every connected +reports no limits of its own, so the card shows the hub account that served the thread's model +most recently, from this environment only. If the hub has served none in the last few hours, the +card pools every hub account that serves the model. A Claude instance that runs GPT models +through the hub shows the hub's Codex accounts. **Usage → Limits** pools every connected environment. Turn on **Context window** to also show the thread's context use against the model's limit. A tick on the bar marks where the provider compacts automatically, when it reports that point. diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 3cb32762849bd..f4222a44a6744 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -63,6 +63,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ workspaceId: Schema.optional(TrimmedNonEmptyString), /** The models the hub routes to this account. */ models: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** Start of the latest window in which the hub served a request with this account. */ + lastServedAt: Schema.optional(IsoDateTime), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; From d18bde049d7369c30765461c712b947222406883 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:38:28 +0100 Subject: [PATCH 10/11] fix(server): a hub account with no routed models serves none An empty auth-files/models list means the hub routes no model to the account, so it no longer falls back to every model of its driver. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/cliproxyApi.test.ts | 7 +++++-- apps/server/src/usage/cliproxyApi.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 3854734b7c64b..640cabb5d9342 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -235,14 +235,17 @@ describe("CLIProxyAPI built-in management API", () => { it.effect("names the models the hub routes to each account, when the hub lists them", () => Effect.gen(function* () { const test = fixture({ + accounts: [...accounts, { ...accounts[1]!, id: "third.json", auth_index: "c" }], models: (name) => name === "first.json" ? Response.json({ models: [{ id: "gpt-5.6-sol" }, { id: " " }] }) - : Response.json({}, { status: 404 }), + : name === "third.json" + ? Response.json({ models: [] }) + : Response.json({}, { status: 404 }), }); const api = yield* test.api; const result = yield* api.readAccounts(config); - expect(result.map((account) => account.models)).toEqual([["gpt-5.6-sol"], undefined]); + expect(result.map((account) => account.models)).toEqual([["gpt-5.6-sol"], undefined, []]); expect(result[1]?.usageLimits.windows).toHaveLength(1); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 4b0aee15fe70f..e43c702596921 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -316,7 +316,7 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(account.id_token?.chatgpt_account_id?.trim() ? { workspaceId: account.id_token.chatgpt_account_id.trim() } : {}), - ...(Option.isSome(models) && models.value.length > 0 ? { models: models.value } : {}), + ...(Option.isSome(models) ? { models: models.value } : {}), ...(served ? { lastServedAt: served } : {}), }; const read = Effect.gen(function* () { From 8ad336bcba2732ec1889a21f333fa44442b21745 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:14:45 +0100 Subject: [PATCH 11/11] docs: drop the hub account note from usage limits --- docs/user/usage.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 833d580e32a1a..d25bdba81f00a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -150,14 +150,9 @@ anything. The command is offered only for providers that appear under **Usage To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a -row to see when it resets and your pace. A provider that sends requests through a CLIProxyAPI hub -reports no limits of its own, so the card shows the hub account that served the thread's model -most recently, from this environment only. If the hub has served none in the last few hours, the -card pools every hub account that serves the model. A Claude instance that runs GPT models -through the hub shows the hub's Codex accounts. **Usage → Limits** pools every connected -environment. Turn on **Context window** to also show the thread's context use against the -model's limit. A tick on the bar marks where the provider compacts automatically, when it reports -that point. +row to see when it resets and your pace. Turn on **Context window** to also show the thread's +context use against the model's limit. A tick on the bar marks where the provider compacts +automatically, when it reports that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials