diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 3b1b927ef8a9b..0ec3c79867a80 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 b36e408c2a5d1..39dec4382de38 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -998,6 +998,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 09ec3034a9bc9..62e4bca75f75c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -6679,6 +6679,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", @@ -7003,6 +7017,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(terminal.failure.resetAt, resetAt); if (scenario.name === "newer-reset-during-read") assert.equal(terminal.failure.resetAt, "2033-05-20T11:06:40.000Z"); + 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 efa5d0a9c28f1..37276d5f40660 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"; @@ -1213,11 +1214,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 { @@ -1880,6 +1884,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>(), ); @@ -4810,7 +4815,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, @@ -6236,7 +6246,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 @@ -6276,7 +6291,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 f69444d235712..b0af0d408af30 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"; @@ -261,14 +263,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(() => { @@ -282,7 +287,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, @@ -291,9 +325,33 @@ 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", endpoint: "http://hub:8317" }, + usageLimits: { ...unread, unavailable: { reason: "unsupported" } }, + }, + resetAt: hubResetAt, + runId, + }, + // regression: #30778 an instance with no hub endpoint gets no hub reset + { + failureClass: "usage_limit", + provider: { + auth: { status: "authenticated" }, + usageLimits: { ...unread, unavailable: { reason: "unsupported" } }, + }, + resetAt: undefined, + }, + ] 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, @@ -303,7 +361,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, @@ -317,10 +375,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(4).fill(instance.instanceId)); + assert.deepEqual(hubLookups, [`${instance.driverKind}:http://hub:8317:gpt-run-model`]); yield* Deferred.await(refreshed); - }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }).pipe( + Effect.provide(makeLayerTest(layerTestEventSink, instances)), + Effect.provideService(UsageLimitResetFallback, { + resetAt: (driver, endpoint, model) => + Effect.sync(() => { + hubLookups.push(`${driver}:${endpoint}:${model}`); + return hubResetAt; + }), + }), + ); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6fd6e7bc3129c..b644e00f8141f 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -32,6 +32,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"; @@ -254,6 +255,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(); @@ -656,9 +658,40 @@ 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; + // Only an instance sent to a hub's address is served by that hub. + const endpoint = provider?.auth.endpoint; + // The failing run's model, which may differ from a selection changed mid-turn. + const model = + endpoint !== undefined + ? 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 selection.model; + }).pipe(Effect.orElseSucceed(() => null)) + : null; + const hubResetAt = + endpoint !== undefined + ? yield* usageLimitResets.resetAt(input.event.driver, endpoint, model) + : null; + const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt }; return [ ...dismissed, yield* makeDomainEvent(input, { diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index f969544fad064..18885f0417ebb 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -776,6 +776,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined; + const endpoint = customApiEndpoint ? URL.parse(baseUrl?.trim() ?? "")?.origin : undefined; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { const path = yield* Path.Path; const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment)) @@ -804,7 +805,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for. const authMetadata = apiProviderAuthMetadata(capabilities.apiProvider) ?? - (customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ?? + (customApiEndpoint + ? { type: "customEndpoint", label: "Custom API endpoint", ...(endpoint ? { endpoint } : {}) } + : undefined) ?? claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 7eadf550eab28..e0a96bdd8d489 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, + codexCustomEndpoint, codexRoutesSpendNativeQuota, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -192,4 +193,12 @@ it("reports native quota only for an OpenAI login on a native route", () => { name, ); } + assert.strictEqual(codexCustomEndpoint(hub), "https://hub.example.com"); + assert.strictEqual( + codexCustomEndpoint({ openai_base_url: "http://127.0.0.1:8317/v1" }), + "http://127.0.0.1:8317", + ); + for (const native of [{}, { openai_base_url: "https://chatgpt.com/x" }, null]) { + assert.strictEqual(codexCustomEndpoint(native), undefined); + } }); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ab2cda61e53ee..131a4f2e2aa11 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -95,17 +95,21 @@ const CodexCyberAccessConfig = Schema.Struct({ }); const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig); -function routesToNativeOpenai(config: unknown): boolean { +// Null is Codex's auth-dependent OpenAI default; undefined is a route that cannot be read. +function codexRouteBaseUrl(config: unknown): string | null | undefined { const decoded = decodeCodexRoutingConfig(config); - if (Option.isNone(decoded)) return false; + if (Option.isNone(decoded)) return undefined; const routing = decoded.value; const providerId = routing.model_provider ?? "openai"; + if (providerId === "openai") return routing.openai_base_url || null; const provider = routing.model_providers?.[providerId]; - if (providerId !== "openai" && !provider) return false; - const baseUrl = - providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; - // A provider without an override uses Codex's auth-dependent OpenAI default. - if (baseUrl == null) return true; + return provider ? (provider.base_url ?? null) : undefined; +} + +function routesToNativeOpenai(config: unknown): boolean { + const baseUrl = codexRouteBaseUrl(config); + if (baseUrl === undefined) return false; + if (baseUrl === null) return true; if (!URL.canParse(baseUrl)) return false; const url = new URL(baseUrl); return ( @@ -114,6 +118,15 @@ function routesToNativeOpenai(config: unknown): boolean { ); } +/** The origin of the custom endpoint Codex sends model requests to, if it uses one. */ +export function codexCustomEndpoint(config: unknown): string | undefined { + const baseUrl = codexRouteBaseUrl(config); + const url = baseUrl ? URL.parse(baseUrl) : null; + return url && /^https?:$/.test(url.protocol) && !routesToNativeOpenai(config) + ? url.origin + : undefined; +} + // A stored login does not mean this provider's traffic spends that login's quota. export const codexRoutesSpendNativeQuota = ( account: CodexSchema.V2GetAccountResponse, @@ -141,6 +154,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly endpoint?: string | undefined; readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; @@ -762,6 +776,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { account: accountResponse, + endpoint: config ? codexCustomEndpoint(config) : undefined, nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, @@ -1080,7 +1095,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu installed: true, version: snapshot.version ?? null, status: snapshot.models === undefined ? "warning" : accountStatus.status, - auth: accountStatus.auth, + auth: snapshot.endpoint + ? { ...accountStatus.auth, endpoint: snapshot.endpoint } + : accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : snapshot.models === undefined diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0601714dd618e..a4a2084bb1a18 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -594,12 +594,16 @@ it.layer( account: null, requiresOpenaiAuth: false, }, + endpoint: "http://127.0.0.1:8317", }), ), ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.auth.endpoint, "http://127.0.0.1:8317"); + // regression: #11873 a custom provider has no subscription to read, which is not a failure + assert.deepStrictEqual(status.usageLimits?.unavailable, { reason: "unsupported" }); }), ); @@ -4121,6 +4125,12 @@ it.layer( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.type, type); assert.strictEqual(status.auth.label, label); + assert.strictEqual( + status.auth.endpoint, + type === "customEndpoint" + ? new URL(settings.env?.ANTHROPIC_BASE_URL ?? baseUrl).origin + : undefined, + ); }).pipe( Effect.provide( layerMockSpawner((args) => { diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index ede0e5c47d20e..c46375e52ce1f 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -136,6 +136,14 @@ const sha256 = Effect.fnUntraced(function* (text: string) { export const antigravityAccountFingerprint = (userInfo: unknown) => decodeUserInfo(userInfo).pipe(Effect.flatMap((info) => sha256(`google-account\0${info.id}`))); +/** Gemini quota is metered apart from the Claude and GPT quota, so each blocks only its models. */ +export function antigravityWindowLimitsModel( + window: Pick, + model: string | null, +): boolean { + return model === null || window.id.startsWith("gemini") === model.startsWith("gemini"); +} + /** Windows from a `retrieveUserQuotaSummary` answer requested at `requestedAt`. */ export const antigravitySummaryWindows = (response: unknown, requestedAt: number) => decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))); diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index d0acf43bb4968..08da2812a6422 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -47,8 +47,20 @@ export const makeClaudeScopedLimitNames = Ref.make({ overageIncluded: undefined, }); +const SCOPED_WINDOW_PREFIX = "seven_day_"; +const scopeToken = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + function scopedWindowId(displayName: string): string { - return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; + return `${SCOPED_WINDOW_PREFIX}${scopeToken(displayName)}`; +} + +/** A model-scoped weekly allowance blocks only its own model; an unknown model is not blocked. */ +export function windowLimitsModel( + window: Pick, + model: string | null, +): boolean { + if (!window.id.startsWith(SCOPED_WINDOW_PREFIX)) return true; + return model !== null && scopeToken(model).includes(window.id.slice(SCOPED_WINDOW_PREFIX.length)); } function scopedWindow( diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index b560aba5d3c28..1ea8272763dcd 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -1,6 +1,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; import * as CodexErrors from "effect-codex-app-server/errors"; +import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { @@ -297,7 +298,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", () => { @@ -313,3 +314,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 dae95787afe82..9353fa2f508a8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -574,6 +574,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 ae3e468d099e4..9b832edb75828 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"; @@ -26,6 +31,13 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => "hub.test:8317", "Usage limit source", ]); + expect(snapshots.map((source) => source.origin)).toEqual([ + undefined, + undefined, + undefined, + "https://hub.test:8317", + undefined, + ]); expect(() => encodeConfigEvent({ version: 1, @@ -155,3 +167,144 @@ 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, 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", + }, + ]), + ]; + // regression: #30778 a spent Fable allowance does not hold back other models + expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, null, now)).toBe( + "2026-11-06T13:00:00.000Z", + ); + expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, "claude-sonnet-5-5", now)).toBe( + "2026-11-06T13:00:00.000Z", + ); + expect(UsageLimitSources.hubPoolResetAt(hub(spent), claude, "claude-fable-5-2", now)).toBe( + "2026-11-06T14:00:00.000Z", + ); + const idle = account("idle", [session(20, "2026-11-06T15:00:00.000Z")], []); + expect(UsageLimitSources.hubPoolResetAt(hub([...spent, idle]), claude, "opus", now)).toBe( + "2026-11-06T13: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(); + // regression: #30778 a spent Gemini quota does not hold back Claude on an Antigravity account + const antigravity = { + ...account("ag", [ + { ...session(100, "2026-11-06T13:00:00.000Z"), id: "3p-5h" }, + { ...session(100, "2026-11-07T12:00:00.000Z"), id: "gemini-5h" }, + ]), + driver: ProviderDriverKind.make("antigravity"), + models: ["claude-opus-4-6-thinking", "gemini-3-pro"], + }; + expect( + UsageLimitSources.hubPoolResetAt(hub([antigravity]), claude, "claude-opus-4-6-thinking", now), + ).toBe("2026-11-06T13:00:00.000Z"); +}); + +it.effect("takes a reset only from the hub at the instance's endpoint", () => { + const claude = ProviderDriverKind.make("claudeAgent"); + const spentHub = (id: string, origin: string) => ({ + id: UsageLimitSourceId.make(id), + kind: "cliproxy" as const, + label: id, + origin, + checkedAt: "2026-11-06T12:00:00.000Z", + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt: "2026-11-06T12:00:00.000Z", + windows: [ + { + id: "five_hour", + kind: "session" as const, + label: "Session", + usedPercent: 100, + resetsAt: `2026-11-06T1${id === "home" ? 3 : 4}:00:00.000Z`, + }, + ], + }, + }, + ], + }); + return Effect.gen(function* () { + const fallback = yield* UsageLimitSources.UsageLimitResetFallback; + expect(yield* fallback.resetAt(claude, "http://home:8317", null)).toBe( + "2026-11-06T13:00:00.000Z", + ); + // regression: #30778 a gateway that is not a configured hub gets no hub's reset + expect(yield* fallback.resetAt(claude, "http://gateway:8080", null)).toBeNull(); + }).pipe( + Effect.provide( + UsageLimitSources.layerResetFallback.pipe( + Layer.provide( + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([ + spentHub("home", "http://home:8317"), + spentHub("work", "http://work:8317"), + ]), + refresh: Effect.die("hub refresh"), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..6542ec0e29ead 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"; @@ -26,6 +28,8 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; +import { antigravityWindowLimitsModel } from "../provider/antigravityUsageLimits.ts"; +import { windowLimitsModel } from "../provider/claudeUsageLimits.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; export class UsageLimitSources extends Context.Service< @@ -79,7 +83,14 @@ export const make = Effect.gen(function* () { config: UsageLimitSourceConfig, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; + const url = URL.parse(config.url); + const base = { + id, + kind: config.kind, + label: sourceLabel(id, config), + ...(url && /^https?:$/.test(url.protocol) ? { origin: url.origin } : {}), + checkedAt, + } as const; const managementKey = yield* readManagementKey(config); if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; @@ -198,3 +209,59 @@ 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 && + windowLimitsModel(window, model) && + (account.driver !== "antigravity" || antigravityWindowLimitsModel(window, model)), + ); + 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, + endpoint: string, + 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, endpoint, model) { + // Only the hub at the instance's endpoint serves its turns. + return hubPoolResetAt( + (yield* sources.current).filter((source) => source.origin === endpoint), + driver, + model, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }), + }; + }), +); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971ff..0396e54f54268 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -60,6 +60,7 @@ function fixture( }>; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + models?: (name: string) => Response; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -72,6 +73,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, @@ -228,6 +235,24 @@ 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("resolves a missing usage plan from current, then legacy, token plans", () => Effect.gen(function* () { const test = fixture({ @@ -600,7 +625,9 @@ describe("CLIProxyAPI built-in management API", () => { }); const [account] = yield* (yield* test.api).readAccounts(config); expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed"); - expect(test.requests.map((request) => request.path)).toEqual(["/v0/management/auth-files"]); + expect(test.requests.map((request) => request.path)).not.toContain( + "/v0/management/api-call", + ); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33d..eb443280ad1ed 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -55,6 +55,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, @@ -117,6 +120,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)); @@ -438,6 +442,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: AuthFile, + ) { + 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: AuthFile, @@ -467,6 +485,7 @@ export const makeCliproxyApi = Effect.gen(function* () { provider: HubProvider, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = yield* routedModels(hub, account).pipe(Effect.option); const call: HubCall = (url, data) => apiCall(hub, account, provider.header(account, platform, architecture), url, data); const reading = yield* provider.read({ account, call, checkedAt }).pipe( @@ -480,7 +499,12 @@ export const makeCliproxyApi = Effect.gen(function* () { }), })), ); - return { id: account.id, driver: provider.driver, ...reading }; + return { + id: account.id, + driver: provider.driver, + ...reading, + ...(Option.isSome(models) ? { models: models.value } : {}), + }; }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4a964ad24dbd8..8c9549b0f86b2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1345,6 +1345,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 conversationProviderStatus = providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ?? activeProviderStatus; @@ -1421,6 +1425,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, @@ -3935,6 +3940,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..ddb3dd935ac49 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.test.ts @@ -0,0 +1,147 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { 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): ServerProviderUsageLimits { + return { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent, + windowDurationMins: 300, + resetsAt: "2026-10-30T13:17:00.000Z", + }, + ], + }; +} + +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", + endpoint: "http://127.0.0.1:8317", + }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits, + }; +} + +interface HubAccountInput { + readonly id: string; + readonly driver?: ProviderDriverKind; + readonly models?: readonly string[]; + readonly limits: ServerProviderUsageLimits; +} + +function hub( + label: string, + accounts: readonly HubAccountInput[], + origin = "http://127.0.0.1:8317", +): UsageLimitSourceSnapshot { + return { + id: UsageLimitSourceId.make(label), + kind: "cliproxy", + label, + origin, + checkedAt, + accounts: accounts.map((account) => ({ + id: account.id, + driver: account.driver ?? claude, + ...(account.models ? { models: account.models } : {}), + usageLimits: account.limits, + })), + }; +} + +function shown( + own: ServerProviderUsageLimits, + sources: readonly UsageLimitSourceSnapshot[], + model: string | null, +) { + const resolved = resolveThreadDetailsLimits( + instanceId, + model, + [claudeInstance(own)], + 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("keeps an instance's failed reading instead of a hub it is not sent to", () => { + const failed: ServerProviderUsageLimits = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }; + const direct = { ...claudeInstance(failed), auth: { status: "authenticated" as const } }; + const elsewhere = claudeInstance(unsupported); + const readableHub = hub("Hub", [{ id: "a.json", limits: limitsAt(20) }]); + const otherHub = hub("Other", [{ id: "a.json", limits: limitsAt(20) }], "http://hub"); + expect(resolveThreadDetailsLimits(instanceId, null, [direct], [readableHub], now)).toEqual({ + kind: "instance", + limits: failed, + }); + expect(resolveThreadDetailsLimits(instanceId, null, [elsewhere], [otherHub], now)).toEqual({ + kind: "instance", + limits: unsupported, + }); + }); + + it("pools only the accounts the hub routes the model to, even of another driver", () => { + const 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) }, + ]), + ]; + expect(shown(unsupported, sources, "gpt-5.6-sol[1m]")).toEqual({ + kind: "pool", + rows: [{ label: "Session", left: 70, accounts: 1, from: ["Hub"] }], + }); + }); +}); 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..42f6d027048b0 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.logic.ts @@ -0,0 +1,97 @@ +import { + EnvironmentId, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageLimits, + type UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitPools, + collectProviderUsageLimits, + displayLimitWindows, + hubRoutes, + instanceHubs, + 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; + const hubs = instanceHubs(provider, sources); + if (limitsNotice(own) === null || hubs.length === 0) return { kind: "instance", limits: own }; + // An instance sent to a hub reads no limits of its own; its hub pools the accounts it spends. + return hubPool(provider.driver, model, providers, hubs, 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 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.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 0d6c0129484ed..1672e96424bbe 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 "./compaction.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/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..ebdc60fec27c6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -181,8 +181,10 @@ accounts for other providers are not read. Codex accounts show banked reset cred account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure -the provider separately to send agent requests through the hub. Remove the hub from the same -settings section when you no longer need it. +the provider separately to send agent requests through the hub. A thread uses the hub's limits and +reset times only when its provider's endpoint has the same address as the hub, for example +`http://127.0.0.1:8317` for both. Remove the hub from the same settings section when you no longer +need it. ## Subscription usage widget diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 851de4c72ffe3..c8490d165184b 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -76,6 +76,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; @@ -84,6 +86,8 @@ export const UsageLimitSourceSnapshot = Schema.Struct({ id: UsageLimitSourceId, kind: Schema.Literal("cliproxy"), label: TrimmedNonEmptyString, + /** Origin of the hub, which also serves the agents sent through it. */ + origin: Schema.optional(TrimmedNonEmptyString), checkedAt: IsoDateTime, accounts: ForwardCompatibleArray(UsageLimitSourceAccount), error: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a983e7c0826ff..e8bcda3a76f5d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -70,6 +70,8 @@ export const ServerProviderAuth = Schema.Struct({ subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), workspaceId: Schema.optional(TrimmedNonEmptyString), + /** Origin of the custom endpoint the instance sends its requests to. */ + endpoint: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 9d2bf395a9d72..2d6646b7f0116 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1267,6 +1267,7 @@ describe("/usage-limits", () => { id: UsageLimitSourceId.make("hub"), kind: "cliproxy" as const, label: "Accounts", + origin: "http://hub:8317", checkedAt: limits.checkedAt, accounts: [ { @@ -1435,6 +1436,66 @@ 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", endpoint: "http://hub:8317" }, + }); + 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"]); + // regression: #30778 another hub's model list does not decide this hub's routing + const other = { + ...routed[0]!, + id: UsageLimitSourceId.make("other"), + origin: "http://other:8317", + accounts: [ + { + id: "x", + driver: ProviderDriverKind.make("grok"), + models: ["gpt-5.6-sol"], + usageLimits: limits, + }, + ], + }; + expect( + collectProviderUsageLimits( + proxy.instanceId, + [selected, proxy], + [other, ...routed], + now, + "gpt-5.6-sol", + )?.accounts.map((account) => account.id), + ).toEqual(["codex", "hub:oss"]); + // regression: #30778 a custom endpoint other than the hub does not follow the hub's routing + const elsewhere = { ...proxy, auth: { ...proxy.auth, endpoint: "http://other:8317" } }; + expect( + collectProviderUsageLimits( + proxy.instanceId, + [selected, elsewhere], + routed, + now, + "gpt-5.6-sol", + ), + ).toBeNull(); + 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 b7902f11d6733..dff8bdbc1a05c 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -14,6 +14,7 @@ import { type ServerProviderResetCredits, type ServerProviderUsageLimits, type ServerProviderUsageWindow, + type UsageLimitSourceAccount, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; @@ -878,14 +879,55 @@ export function withUsageLimitsCommands( }); } +/** The hubs an instance sends its requests through: those whose address is its endpoint. */ +export function instanceHubs( + provider: ServerProvider, + sources: UsageLimitSourceSnapshots, +): UsageLimitSourceSnapshots { + const endpoint = provider.auth.endpoint; + return endpoint === undefined ? [] : sources.filter((source) => source.origin === endpoint); +} + +/** 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 && account.models?.length !== 0; + // 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; + // Only an instance sent to a hub is known to reach the accounts that hub routes its model to. + const hubs = instanceHubs(selected, sources); + const routes = hubRoutes(hubs, selected.driver, hubs.length > 0 ? model : null); + const shownHubs = hubs.length > 0 ? hubs : sources; + const drivers = new Set( + shownHubs.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), @@ -896,7 +938,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); @@ -975,8 +1017,8 @@ export function collectProviderUsageLimits( limits: shownCredits ? { ...limits, resetCredits: shownCredits } : limits, }); } - for (const source of sources) { - const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const source of shownHubs) { + const matching = source.accounts.filter(routes); for (const account of matching) { const key = resolveKey(account); if (key && nativeAccountKeys.has(key)) continue;