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/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..8a16a1f8017f3 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(() => { @@ -283,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, @@ -292,9 +326,40 @@ 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, + runId, + }, + { + failureClass: "usage_limit", + provider: { + auth: { status: "unknown" }, + usageLimits: { ...unread, unavailable: { reason: "unsupported" } }, + }, + resetAt: hubResetAt, + }, + ] as const; + 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, @@ -304,7 +369,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 +383,31 @@ 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}:gpt-run-model`, + `${instance.driverKind}:null`, + ]); yield* Deferred.await(refreshed); - }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }).pipe( + Effect.provide(makeLayerTest(layerTestEventSink, instances)), + Effect.provideService(UsageLimitResetFallback, { + resetAt: (driver, model) => + Effect.sync(() => { + 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 f27580d909aff..478f39ebda3c0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,12 +27,14 @@ 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 { hubRoutedModel, isProxied } from "@t3tools/shared/usageLimits"; import { ProviderTurnAnalytics, type ProviderTurnAnalyticsContext, } 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 +257,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 +630,39 @@ 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 !== undefined && isProxied(provider); + // The failing run's model, which may differ from a selection changed mid-turn. + const model = + provider !== undefined && proxied + ? 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) + : 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..990bd07cec909 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,53 @@ 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(); + expect(codexMessageResetAt(message, fallBack, "")).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..75101bde4d34d 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -201,3 +201,49 @@ 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 = + 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( + { + ...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 fe2740a8c7a2d..d97c8ab34e2fd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -565,6 +565,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..8707e640211d8 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,70 @@ 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, models?: ReadonlyArray) => ({ + id, + driver: claude, + ...(models ? { models } : {}), + 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, 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, null, now)).toBeNull(); + const unread = { + id: "d", + driver: claude, + usageLimits: { checkedAt: "", windows: [], unavailable: { reason: "probeFailed" } }, + }; + expect(UsageLimitSources.hubPoolResetAt(hub([...spent, unread]), claude, null, now)).toBeNull(); + expect( + 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(); + 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/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..55bf69eb8c565 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, @@ -10,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"; @@ -198,3 +200,58 @@ 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 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; + 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); + 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, + model: string | null, + ) => 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, 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 = serving(yield* sources.current); + return hubs.length === 1 + ? 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..640cabb5d9342 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -51,11 +51,13 @@ 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 }; } >; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + models?: (name: string) => Response; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -68,6 +70,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 +232,43 @@ 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: " " }] }) + : 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[1]?.usageLimits.windows).toHaveLength(1); + }), + ); + + 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 87ec1e05b8f77..e43c702596921 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"; @@ -30,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), @@ -38,7 +40,22 @@ 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 })), +}); const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); const CodexWindow = Schema.Struct({ used_percent: Schema.Number, @@ -101,6 +118,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 +231,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, @@ -272,8 +304,11 @@ 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, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), @@ -281,6 +316,8 @@ 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: models.value } : {}), + ...(served ? { lastServedAt: served } : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { @@ -382,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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff4a642b5c31d..f82f442b9025d 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, @@ -3954,6 +3959,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 new file mode 100644 index 0000000000000..45227db63b487 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,304 @@ +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 models?: readonly string[]; + readonly workspaceId?: string; + readonly lastServedAt?: string; + 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 } : {}), + ...(account.models ? { models: account.models } : {}), + ...(account.workspaceId ? { workspaceId: account.workspaceId } : {}), + ...(account.lastServedAt ? { lastServedAt: account.lastServedAt } : {}), + usageLimits: account.limits, + })), + ...(error ? { error } : {}), + }; +} + +function shown( + own: ServerProviderUsageLimits, + sources: readonly UsageLimitSourceSnapshot[], + natives: readonly ServerProvider[] = [], + model: string | null = null, +) { + const resolved = resolveThreadDetailsLimits( + instanceId, + model, + [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, 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, null, [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, null, [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({ + 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"] }], + }, + { + 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"] }], + }, + { + 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: "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: [ + 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 }); + }); + + 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, + null, + [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..24b03c3ba03d2 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,111 @@ +import { + EnvironmentId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceAccount, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + collectProviderUsageLimits, + displayLimitWindows, + hubRoutedModel, + hubRoutes, + isProxied, + 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, + 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, 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; + 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, hubRoutedModel(provider, model), 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 hubPool( + driver: ServerProvider["driver"], + model: string | null, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): ThreadDetailsLimits | null { + const routes = hubRoutes(sources, driver, model); + 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); + 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.workspaceId === provider.auth.workspaceId && + account.email !== undefined && + account.email.toLowerCase() === provider.auth.email?.toLowerCase(), + ), + ); + const presentations = new Map([ + [ + CARD_ENVIRONMENT, + { + entry: { target: { label: "" } }, + serverConfig: { providers: signedIn, usageLimitSources: routed }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + return pools.length > 0 ? { kind: "pool", windows: pools.flatMap(displayLimitWindows) } : null; +} + +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..3346ee02ed2d4 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", @@ -32,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; @@ -39,25 +44,23 @@ 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, + model, providers, sources, }: 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, model, 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 +172,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/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/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..f4222a44a6744 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -61,6 +61,10 @@ 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)), + /** 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; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 236a8baa13a26..67cb5e0000e61 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1252,6 +1252,68 @@ 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"]); + 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]!, + 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"]); + }); + 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 1707deb735a49..daf57681548f9 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"; @@ -757,14 +758,55 @@ 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" + ); +} + +/** 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, + 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; + 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( 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; + const routes = hubRoutes(sources, selected.driver, hubRoutedModel(selected, model)); + 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), @@ -775,7 +817,7 @@ export function collectProviderUsageLimits( { usage: NativeSnapshot; credits: NativeSnapshot | null } >(); for (const provider of providersWithLimits(providers)) { - if (provider.driver !== selected.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); @@ -855,7 +897,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;