From 51a9a88ac89646a9a5cd23715ead5d9fdd5eb1ed Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 11:59:45 -0500 Subject: [PATCH 01/95] fix(server): poll provider usage only while visible Separate provider health probes from visible usage refreshes. Cache successful and failed reads, share HTTP cooldowns across accounts and windows, and retain stale readings after failures. --- .../src/features/usage/UsageLimitsSection.tsx | 4 + .../features/usage/usageLimitsRefresh.test.ts | 4 + .../src/features/usage/usageLimitsRefresh.ts | 15 +- .../src/provider/AntigravityProvider.ts | 3 +- .../provider/ClaudeCapabilitiesProbe.test.ts | 20 ++- apps/server/src/provider/ClaudeProvider.ts | 11 +- apps/server/src/provider/CodexProvider.ts | 3 +- .../src/provider/Drivers/AntigravityDriver.ts | 3 +- .../src/provider/Drivers/ClaudeDriver.ts | 62 +++++--- .../src/provider/Drivers/CodexDriver.ts | 6 +- apps/server/src/provider/ProviderHostLive.ts | 8 + .../provider/ProviderInstanceRegistry.test.ts | 34 ++-- .../server/src/provider/claudeResetCredits.ts | 2 +- .../src/usage/UsageLimitSources.test.ts | 1 + apps/server/src/usage/UsageLimitSources.ts | 42 ++--- apps/server/src/usage/cliproxyApi.test.ts | 48 +++++- apps/server/src/usage/cliproxyApi.ts | 89 ++++++++++- apps/server/src/ws.ts | 33 ++-- .../components/chat/ThreadDetailsPanel.tsx | 4 +- .../components/chat/ThreadDetailsUsage.tsx | 21 ++- .../components/chat/useUsageLimitsPanel.ts | 2 + apps/web/src/components/usage/UsagePage.tsx | 42 ++++- .../src/hooks/useLiveRefresh.browser.test.tsx | 18 +++ apps/web/src/hooks/useLiveRefresh.ts | 22 ++- apps/web/src/hooks/useUsageLimitsRefresh.ts | 30 ++++ docs/user/usage.md | 4 + packages/contracts/src/providerUsageLimits.ts | 1 + packages/contracts/src/rpc.ts | 9 +- .../provider-core/src/server/ProviderHost.ts | 2 + .../src/server/managedProvider.test.ts | 44 +++++- .../src/server/managedProvider.ts | 31 +++- .../src/server/usageHttpClient.test.ts | 83 ++++++++++ .../src/server/usageHttpClient.ts | 147 ++++++++++++++++++ .../src/server/usageLimits.test.ts | 5 +- .../provider-core/src/server/usageLimits.ts | 20 ++- packages/provider-cursor/src/server/driver.ts | 89 ++++++----- packages/provider-grok/src/server/driver.ts | 50 +++--- .../provider-grok/src/server/usageLimits.ts | 2 + .../provider-opencode/src/server/driver.ts | 13 +- packages/shared/src/usageLimits.ts | 13 ++ 40 files changed, 852 insertions(+), 188 deletions(-) create mode 100644 apps/web/src/hooks/useUsageLimitsRefresh.ts create mode 100644 packages/provider-core/src/server/usageHttpClient.test.ts create mode 100644 packages/provider-core/src/server/usageHttpClient.ts diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 7f9c6bf28ff3c..0c3a8e5308768 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -14,6 +14,7 @@ import { formatDuration, formatResetsIn, limitsNotice, + STALE_USAGE_NOTICE, paceOf, paceRemainingPercent, remainingPercent, @@ -197,6 +198,9 @@ export function AccountLimits(props: { {props.trailing} + {limits.stale ? ( + {STALE_USAGE_NOTICE} + ) : null} {notice ? ( {notice} ) : ( diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 80310b98d8aed..15c2ee1467af9 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -11,6 +11,10 @@ const state = vi.hoisted(() => ({ throw new Error("refresh not set up"); }, })); +vi.mock("../../lib/appState", () => ({ + isAppActive: () => true, + subscribeAppState: () => () => {}, +})); vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: { diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 84cc4d3566bf2..92274747bc7cf 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -1,3 +1,4 @@ +import { isAppActive, subscribeAppState } from "../../lib/appState"; import { EnvironmentId } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; @@ -18,6 +19,7 @@ export async function refreshEnvironmentLimits( environmentId: EnvironmentId, options: { readonly automatic?: boolean; readonly afterPending?: boolean } = {}, ): Promise { + if (!isAppActive()) return; // One refresh runs per environment at a time, so results land in the order // they were started and the newest outcome always wins. const result = await refreshUsageLimits( @@ -26,7 +28,7 @@ export async function refreshEnvironmentLimits( runAtomCommand( registry, serverEnvironment.refreshProviders, - { environmentId, input: {} }, + { environmentId, input: { usage: true } }, { reportFailure: false }, ), options.automatic, @@ -40,7 +42,16 @@ export async function refreshEnvironmentLimits( // one every few minutes per environment. const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { - void refreshEnvironmentLimits(get.registry, environmentId, { automatic: true }); + const refresh = () => { + void refreshEnvironmentLimits(get.registry, environmentId, { automatic: true }); + }; + refresh(); + const unsubscribe = subscribeAppState(refresh); + const timer = setInterval(refresh, 5 * 60_000); + get.addFinalizer(() => { + unsubscribe(); + clearInterval(timer); + }); }).pipe(Atom.withLabel(`mobile:limits-auto-refresh:${environmentId}`)), ); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ed047331e73ee..c547418858a72 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, @@ -233,7 +234,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const republish = yield* Deferred.make>(); const scope = yield* Effect.scope; const refreshUsage = Effect.fn("AntigravityProvider.refreshUsage")(function* () { - if (!options.probeUsage || (yield* PublishingUsage)) return; + if (!options.probeUsage || (yield* PublishingUsage) || !(yield* UsagePolling)) return; yield* FiberHandle.run( usageRead, options.probeUsage.pipe( diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index d71421debc727..298ecde43431a 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -49,6 +49,9 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () => it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () => Effect.gen(function* () { let abortSignal: AbortSignal | undefined; + const usage = vi.fn( + () => new Promise((_, reject) => reject(new Error("usage unavailable"))), + ); const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { abortSignal = options?.abortController?.signal; return { @@ -57,9 +60,8 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }], }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => - new Promise((_, reject) => reject(new Error("usage unavailable"))), - } as ReturnType; + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: usage, + } as unknown as ReturnType; }); yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); const fileSystem = yield* FileSystem.FileSystem; @@ -85,5 +87,17 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi assert.equal(abortSignal?.aborted, false); yield* TestClock.adjust("3 seconds"); assert.equal(abortSignal?.aborted, true); + yield* probeClaudeCapabilities( + yield* decodeClaudeSettings({ binaryPath: "claude" }), + { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: configDir }, + undefined, + false, + false, + ); + assert.equal(usage.mock.calls.length, 1); + assert.equal( + query.mock.calls.at(-1)?.[0]?.options?.env?.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, + "1", + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 97512d3d621f0..d8e560e429735 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -341,6 +341,7 @@ const probeClaudeCapabilities = ( environment?: NodeJS.ProcessEnv, cwd?: string, includeUsage = true, + discoverModels = true, ) => { const abort = new AbortController(); const abortProbe = Effect.sync(() => { @@ -354,7 +355,7 @@ const probeClaudeCapabilities = ( ); const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); // Workspace probes (no usage) discard models; the machine probe refreshes the shared cache. - const graceMs = includeUsage + const graceMs = discoverModels ? claudeGatewayDiscoveryGraceMs({ ...claudeEnvironment, ...(yield* readClaudeSettingsEnv(claudeSettings, cwd, claudeEnvironment)), @@ -369,7 +370,9 @@ const probeClaudeCapabilities = ( options: buildClaudeCapabilitiesProbeQueryOptions({ executablePath, abortController: abort, - environment: claudeEnvironment, + environment: includeUsage + ? claudeEnvironment + : { ...claudeEnvironment, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" }, cwd, managedMcpConfig, }), @@ -463,7 +466,9 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); const probeResult = cwdExists - ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false, false).pipe( + Effect.result, + ) : undefined; const capabilities = probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ed9e3d0cde684..88a643e4c54d7 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -781,7 +782,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), environment: resolvedEnvironment, - ...(managedAuth ? { skipNativeUsage: true } : {}), + skipNativeUsage: Boolean(managedAuth) || !(yield* UsagePolling), }).pipe( // Process teardown can outlast the work budget without discarding a completed snapshot. Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index a8f97b0f14743..d444a6e202737 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -107,7 +107,8 @@ export const AntigravityDriver: ProviderDriver< const digest = yield* Digest.Digest; const random = yield* SecureRandom.SecureRandom; const fileSystem = yield* FileSystem.FileSystem; - const httpClient = yield* HttpClient.HttpClient; + const httpClient = + (yield* ProviderHost.ProviderHost).usageHttpClient ?? (yield* HttpClient.HttpClient); const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const host = yield* ProviderHost.ProviderHost; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c0a9c5698d92c..4231127855208 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,9 +1,9 @@ +import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; @@ -49,7 +49,6 @@ import { type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; import { - makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, mergeClaudeProcessEnvironment, resolveClaudeHomePath, @@ -104,7 +103,8 @@ export const ClaudeDriver: ProviderDriver(undefined); const capabilitiesProbeCache = yield* Cache.makeWith( - () => - probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( + (includeUsage: boolean) => + probeClaudeCapabilities(effectiveConfig, processEnv, stateDir, includeUsage).pipe( Effect.flatMap((capabilities) => ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( Effect.map((workspaceId) => ({ @@ -212,23 +212,26 @@ export const ClaudeDriver: ProviderDriver - // Only fresh usage earns the TTL; failures, fallbacks and usage-less probes retry. - Exit.isSuccess(exit) && - exit.value._tag === "fresh" && - exit.value.capabilities.usage !== undefined - ? CAPABILITIES_PROBE_TTL - : Duration.zero, + capacity: 2, + timeToLive: () => CAPABILITIES_PROBE_TTL, }, ); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( - effectiveConfig, - stateDir, - processEnv, - ); - const resolveCapabilities = Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe( - Effect.map((result) => result.capabilities), + const resolveCapabilities = Effect.gen(function* () { + let includeUsage = yield* UsagePolling; + if (includeUsage && usageHttpClient) { + const token = yield* ClaudeResetCredits.readAccessToken(configDir).pipe( + Effect.orElseSucceed(() => undefined), + ); + if ( + token && + (yield* usageHttpClient.isCoolingDown("https://api.anthropic.com", `Bearer ${token}`)) + ) + includeUsage = false; + } + return (yield* Cache.get(capabilitiesProbeCache, includeUsage)).capabilities; + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); const checkProvider = catalogService.refreshInBackground.pipe( @@ -275,7 +278,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => @@ -342,7 +345,22 @@ export const ClaudeDriver: ProviderDriver undefined), + ); + if (token) + yield* usageHttpClient.invalidateSuccessfulReads( + "https://api.anthropic.com", + `Bearer ${token}`, + ); + } + const refreshed = yield* snapshot.refresh.pipe( + Effect.provideService(UsagePolling, true), + Effect.provideService(UsageAfterReset, true), + ); const after = refreshed.usageLimits?.checkedAt; if ( outcome === "reset" && diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index c34aa00a19646..01bf1b0766399 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,3 +1,4 @@ +import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -349,7 +350,10 @@ export const CodexDriver: ProviderDriver< Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - const refreshed = yield* snapshot.refresh; + const refreshed = yield* snapshot.refresh.pipe( + Effect.provideService(UsagePolling, true), + Effect.provideService(UsageAfterReset, true), + ); const after = refreshed.usageLimits?.checkedAt; if ( outcome === "reset" && diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 6398210599b61..b3d72809a7bd3 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -1,8 +1,11 @@ +import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import { HttpClient } from "effect/http"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -22,7 +25,12 @@ export const layer = Layer.effect( const secrets = yield* ServerSecretStore.ServerSecretStore; const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient); + const usageHttpClient = Option.isSome(httpClient) + ? yield* makeUsageHttpClient(httpClient.value) + : undefined; return ProviderHost.ProviderHost.of({ + ...(usageHttpClient ? { usageHttpClient } : {}), paths: { cwd: config.cwd, baseDir: config.baseDir, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 4bae35385d62b..6f24deab365ac 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; @@ -538,7 +539,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { auth: { status: "authenticated", email: "account-a@example.com", workspaceId: "org-a" }, slashCommands: expect.arrayContaining([expect.objectContaining({ name: "last-good" })]), }); - expect(first.usageLimits?.unavailable).toBeUndefined(); + expect(first.usageLimits?.unavailable?.reason).toBe("probeFailed"); expect(probeCount).toBe(1); yield* TestClock.adjust("1 second"); @@ -564,13 +565,14 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { expect(failedProbeCount).toBeGreaterThan(1); failProbe = false; + yield* claude!.invalidateCaches ?? Effect.void; const recovered = yield* claude!.snapshot.refresh; expect(recovered).toMatchObject({ status: "ready", auth: { status: "authenticated", email: "account-b@example.com", workspaceId: "org-b" }, slashCommands: expect.arrayContaining([expect.objectContaining({ name: "recovered" })]), }); - expect(recovered.usageLimits?.unavailable).toBeUndefined(); + expect(recovered.usageLimits?.unavailable?.reason).toBe("probeFailed"); const recoveredProbeCount = probeCount; expect(recoveredProbeCount).toBeGreaterThan(failedProbeCount); @@ -589,15 +591,9 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { expect(sameAccountFailureProbeCount).toBeGreaterThan(recoveredProbeCount); const repeatedFailure = yield* claude!.snapshot.refresh; - expect(repeatedFailure).toMatchObject({ - status: "warning", - auth: { status: "unknown" }, - message: "Claude Agent CLI failed while checking account status.", - }); - expect(repeatedFailure.slashCommands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: "recovered" })]), - ); - expect(probeCount).toBeGreaterThan(sameAccountFailureProbeCount); + expect(repeatedFailure.auth).toEqual(sameAccountFailure.auth); + expect(repeatedFailure.slashCommands).toEqual(sameAccountFailure.slashCommands); + expect(probeCount).toBe(sameAccountFailureProbeCount); }).pipe(Effect.provide(layerTest)), ); @@ -659,7 +655,9 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provideService(HttpClient.HttpClient, client)); const instance = yield* registry.getInstance(instanceId); expect(instance).toBeDefined(); - const before = yield* instance!.snapshot.refresh; + const before = yield* instance!.snapshot.refresh.pipe( + Effect.provideService(UsagePolling, true), + ); expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); @@ -734,7 +732,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const instance = yield* registry.getInstance(instanceId); expect(instance).toBeDefined(); return { - refresh: instance!.snapshot.refresh, + refresh: instance!.snapshot.refresh.pipe(Effect.provideService(UsagePolling, true)), recover: fs.writeFileString(recovered, ""), probeLog: fs.readFileString(probeLog), }; @@ -743,8 +741,8 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { for (const state of ["signed-out", "usage-failed"] as const) { it.effect.skipIf(windowsHost)( state === "signed-out" - ? "shows Claude usage on the first refresh after signing back in" - : "retries Claude usage on the first refresh after a failed usage read", + ? "refreshes Claude usage after signing back in and the cache expires" + : "caches a failed Claude usage probe until its normal refresh interval", () => Effect.gen(function* () { const fixture = yield* makeClaudeCacheFixture(state); @@ -757,7 +755,13 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { expect(before.usageLimits?.unavailable?.reason).toBe("probeFailed"); } + if (state === "usage-failed") { + const log = yield* fixture.probeLog; + yield* fixture.refresh; + expect(yield* fixture.probeLog).toBe(log); + } yield* fixture.recover; + yield* TestClock.adjust("5 minutes"); const after = yield* fixture.refresh; expect(after.auth.status).toBe("authenticated"); expect(after.usageLimits?.unavailable).toBeUndefined(); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edada..a4ac2c917f083 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -134,7 +134,7 @@ const readJson = Effect.fn("claudeResetCredits.readJson")(function* { }); return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; + yield* sources.refresh; const snapshots = yield* sources.streamChanges.pipe( Stream.filter((next) => next.length > 0), Stream.runHead, diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7d40b0fb9ecea..a55618acad038 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -1,5 +1,5 @@ +import { UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; import { - DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, UsageLimitSourceError, type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, @@ -9,10 +9,8 @@ import { type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; -import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; @@ -21,7 +19,6 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; @@ -61,7 +58,6 @@ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; const secrets = yield* SecretResolver.SecretResolver; - const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( config: UsageLimitSourceConfig, ) { @@ -74,6 +70,10 @@ export const make = Effect.gen(function* () { PubSub.shutdown, ); + const lastGoodSources = new Map< + string, + readonly UsageLimitSourceSnapshot["accounts"][number][] + >(); const readSource = Effect.fn("UsageLimitSources.readSource")(function* ( id: UsageLimitSourceId, config: UsageLimitSourceConfig, @@ -84,13 +84,19 @@ export const make = Effect.gen(function* () { if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; } + const accountKey = `${id}\0${config.url}\0${managementKey}`; const accounts = yield* api - .readAccounts({ url: config.url, managementKey }) + .readAccounts({ url: config.url, managementKey }, yield* UsageProviderFilter) .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); - return { ...base, accounts: [], error: accounts.failure.detail }; + return { + ...base, + accounts: lastGoodSources.get(accountKey) ?? [], + error: accounts.failure.detail, + }; } + lastGoodSources.set(accountKey, accounts.success); return { ...base, accounts: accounts.success }; }); @@ -155,28 +161,6 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); - const interval = settingsService.getSettings.pipe( - Effect.map( - (settings) => resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, - ), - Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), - ); - yield* Effect.forever( - interval.pipe( - Effect.flatMap((wait) => Effect.sleep(Duration.isPositive(wait) ? wait : "60 seconds")), - Effect.andThen(interval), - Effect.flatMap((current) => - Duration.isPositive(current) - ? backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }) - : Effect.succeed(false), - ), - Effect.flatMap((shouldRun) => (shouldRun ? refresh : Effect.void)), - Effect.ignoreCause({ log: true }), - ), - ).pipe(Effect.forkScoped); - - yield* refresh.pipe(Effect.forkScoped); - return { current: Ref.get(stateRef), consumeResetCredit, diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 4a76d32c15dea..85ffca68298a7 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -54,7 +54,11 @@ function fixture( id_token: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; } >; - upstream?: (request: RequestBody) => { status: number; body: unknown }; + upstream?: (request: RequestBody) => { + status: number; + body: unknown; + headers?: Record; + }; cooldownStatus?: number; } = {}, ) { @@ -106,7 +110,11 @@ function fixture( }; return HttpClientResponse.fromWeb( request, - Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }), + Response.json({ + status_code: upstream.status, + body: encodeJson(upstream.body), + header: "headers" in upstream ? upstream.headers : undefined, + }), ); }), ); @@ -194,6 +202,42 @@ describe("CLIProxyAPI hub failures", () => { }); describe("CLIProxyAPI built-in management API", () => { + it.effect( + "paces filtered Claude accounts, preserves readings, and shares account cooldowns", + () => + Effect.gen(function* () { + let limited = false; + const test = fixture({ + accounts: accounts.map((account) => ({ ...account, provider: "claude" })), + upstream: () => + limited + ? { status: 429, body: {}, headers: { "Retry-After": ["600"] } } + : { status: 200, body: { five_hour: { utilization: 25, resets_at: null } } }, + }); + const api = yield* test.api; + expect(yield* api.readAccounts(config, ["codex"])).toEqual([]); + const first = yield* api.readAccounts(config, ["claudeAgent"]); + expect(first.map((account) => account.usageLimits.windows[0]?.usedPercent)).toEqual([ + 25, 25, + ]); + yield* api.readAccounts(config, ["claudeAgent"]); + expect( + test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), + ).toHaveLength(2); + limited = true; + yield* TestClock.adjust("5 minutes"); + const stale = yield* api.readAccounts(config, ["claudeAgent"]); + expect(stale.map((account) => account.usageLimits)).toEqual( + first.map((account) => ({ ...account.usageLimits, stale: true })), + ); + yield* TestClock.adjust("5 minutes"); + yield* api.readAccounts(config, ["claudeAgent"]); + expect( + test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), + ).toHaveLength(4); + }), + ); + it.effect( "reads both accounts and their earliest unexpired credits without plugin endpoints", () => diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 4fb59d041c3d3..f4e8275840b79 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -1,9 +1,14 @@ +import { usageRetryDelay } from "@t3tools/provider-core/server/usageLimits"; +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import { ProviderDriverKind, UsageLimitSourceError, type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, type UsageLimitSourceConfig, + type ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; @@ -16,7 +21,10 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + makeUnavailableUsageLimits, + resolveUsageLimitsAfterProbe, +} from "@t3tools/provider-core/server/usageLimits"; /** A hub with its management key already read from wherever the setting keeps it. */ export interface CliproxyHub { @@ -39,7 +47,19 @@ const AuthFile = Schema.Struct({ ), }); const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); -const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); +const ApiResponse = Schema.Struct({ + status_code: Schema.Number, + body: Schema.String, + header: Schema.optional( + Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), +}); +const ApiCallInput = Schema.Struct({ + hub: Schema.Struct({ url: Schema.String, managementKey: Schema.String }), + account: AuthFile, + url: Schema.String, +}); +const decodeApiCallInput = Schema.decodeEffect(Schema.fromJsonString(ApiCallInput)); const CodexWindow = Schema.Struct({ used_percent: Schema.Number, reset_at: Schema.optional(Schema.NullOr(Schema.Number)), @@ -212,12 +232,24 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* decodeAuthFiles(response)).files; }); - const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( + const cooldowns = new Map(); + const apiCallUncached = Effect.fn("CliproxyApi.apiCall")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, url: string, data?: unknown, ) { + const accountKey = yield* encodeJson([ + hub.url, + hub.managementKey, + account.auth_index, + account.id, + ]); + const now = yield* Clock.currentTimeMillis; + if (data === undefined && now < (cooldowns.get(accountKey) ?? 0)) + return yield* new UsageLimitSourceError({ + detail: "The provider is cooling down after a rate limit.", + }); const header = account.provider === "codex" ? { @@ -243,6 +275,19 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); const response = yield* decodeApiResponse(raw); + if (response.status_code === 429) { + const value = Object.entries(response.header ?? {}).find( + ([name]) => name.toLowerCase() === "retry-after", + )?.[1]; + cooldowns.set( + accountKey, + (yield* Clock.currentTimeMillis) + + usageRetryDelay( + typeof value === "string" ? value : value?.[0], + yield* Clock.currentTimeMillis, + ), + ); + } if (response.status_code < 200 || response.status_code >= 300) { return yield* new UsageLimitSourceError({ detail: `The provider refused the hub request (HTTP ${response.status_code}).`, @@ -268,11 +313,33 @@ export const makeCliproxyApi = Effect.gen(function* () { .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); }); + const usageCache = yield* Cache.makeWith( + (key: string) => + decodeApiCallInput(key).pipe( + Effect.mapError( + () => new UsageLimitSourceError({ detail: "Could not read the usage request." }), + ), + Effect.flatMap(({ hub, account, url }) => apiCallUncached(hub, account, url)), + ), + { capacity: 128, timeToLive: () => Duration.minutes(5) }, + ); + const apiCall = Effect.fn("CliproxyApi.cachedApiCall")(function* ( + hub: CliproxyHub, + account: typeof AuthFile.Type, + url: string, + data?: unknown, + ) { + if (data !== undefined) return yield* apiCallUncached(hub, account, url, data); + return yield* Cache.get(usageCache, yield* encodeJson({ hub, account, url })); + }); + + const lastGoodLimits = new Map(); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); + const accountKey = `${hub.url}\0${hub.managementKey}\0${account.auth_index}\0${account.id}\0${account.email ?? ""}\0${account.id_token?.chatgpt_account_id ?? ""}`; const base = { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), @@ -368,11 +435,21 @@ export const makeCliproxyApi = Effect.gen(function* () { message: "The hub could not read this account's usage.", }), })), + Effect.map((result) => { + const usageLimits = + resolveUsageLimitsAfterProbe({ + published: lastGoodLimits.get(accountKey), + probed: result.usageLimits, + }) ?? result.usageLimits; + if (!usageLimits.unavailable) lastGoodLimits.set(accountKey, usageLimits); + return { ...result, usageLimits }; + }), ); }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( hub: CliproxyHub, + providers?: readonly string[], ): Effect.fn.Return, UsageLimitSourceError> { const accounts = yield* authFiles(hub).pipe( Effect.mapError((error) => @@ -384,7 +461,10 @@ export const makeCliproxyApi = Effect.gen(function* () { return yield* Effect.forEach( accounts.filter( (account) => - !account.disabled && (account.provider === "codex" || account.provider === "claude"), + !account.disabled && + (account.provider === "codex" || account.provider === "claude") && + (providers === undefined || + providers.includes(account.provider === "claude" ? "claudeAgent" : account.provider)), ), (account) => readAccount(hub, account), { concurrency: 4 }, @@ -420,6 +500,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const )[response.code]; if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; + yield* Cache.invalidateAll(usageCache); const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2bd0acc22b8c3..1cdf633dd2cd4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { UsagePolling, UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -2427,21 +2428,29 @@ export const layerWsRpc = ( if (input.reloadSecrets === true && input.instanceId === undefined) { yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); } + if (input.usageSources === true) { + yield* usageLimitSources.refresh.pipe( + Effect.provideService(UsageProviderFilter, input.usageProviders), + ); + return { providers: yield* providerRegistry.getProviders }; + } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. - if (input.instanceId === undefined) { + if (input.usage === true && input.instanceId === undefined) { yield* usageLimitSources.refresh; } - let providers = yield* input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh === true, - }) - : input.reloadSecrets === true - ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh(); + let providers = yield* ( + input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh === true, + }) + : input.reloadSecrets === true + ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() + ).pipe(Effect.provideService(UsagePolling, input.usage === true)); if (input.refreshModels) { const instances = yield* providerInstances.listInstances; for (const instance of instances) { diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 5c648016ff502..18f16b00ec7cb 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -258,7 +258,9 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { /> ) : null} - {visible.usage ? : null} + {visible.usage ? ( + + ) : null} {visible.context && props.usage.contextWindow ? ( diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index 05211d6ace44e..7dccba464e114 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -1,3 +1,5 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useUsageLimitsRefresh } from "../../hooks/useUsageLimitsRefresh"; import type { ProviderInstanceId, ServerProvider, @@ -6,6 +8,7 @@ import type { } from "@t3tools/contracts"; import { collectProviderUsageLimits, + STALE_USAGE_NOTICE, formatDuration, type LimitPace, limitsNotice, @@ -40,10 +43,17 @@ type UsageTone = "error" | "warning" | null; /** The selected provider's limit windows. Each row reads as room left. */ export function ThreadDetailsUsage({ + environmentId, providerInstanceId, providers, sources, -}: Omit) { +}: Omit & { readonly environmentId: EnvironmentId }) { + useUsageLimitsRefresh( + environmentId, + providerInstanceId, + true, + providers.find((provider) => provider.instanceId === providerInstanceId)?.driver, + ); const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); const report = @@ -65,6 +75,15 @@ export function ThreadDetailsUsage({ showHeading={false} >
+ {limits.stale ? ( + {STALE_USAGE_NOTICE}} + /> + ) : null} {notice ? ( Promise.all( - Array.from(presentations, ([environmentId, presentation]) => { + Array.from(presentationsWithProviders, ([environmentId, presentation]) => { if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { return refreshUsageLimits( environmentId, - () => refreshProviders({ environmentId, input: {} }), + () => { + const providers = (presentation.serverConfig?.providers ?? []).filter( + (provider) => + ![...hiddenProviders].some( + (hidden) => + hidden === (provider.driver === "claudeAgent" ? "claude" : provider.driver), + ), + ); + return Promise.all([ + refreshProviders({ + environmentId, + input: { + usageSources: true, + usageProviders: PROVIDER_ORDER.filter( + (provider) => !hiddenProviders.has(provider), + ).map((provider) => + ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), + ), + }, + }), + ...providers.map((provider) => + refreshProviders({ + environmentId, + input: { instanceId: provider.instanceId, usage: true }, + }), + ), + ]); + }, automatic, afterPending, ); @@ -338,10 +366,12 @@ export function UsagePage() { const autoRefreshLimits = useEffectEvent(() => { void refreshLimits(true); }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes provider limits from the server when the connected environment set changes - useEffect(() => { - if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); - }, [showingLimits, connectedLimitsEnvironments]); + useLiveRefresh(() => autoRefreshLimits(), { + enabled: showingLimits && connectedLimitsEnvironments !== "", + key: `usage-page:${connectedLimitsEnvironments}:${[...hiddenProviders].sort().join(",")}`, + refreshOnShow: true, + refreshWhileIdle: true, + }); const days = enumerateDays(shownWindow.sinceDay, shownWindow.untilDay); const hours = diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 393ba06a87265..35734af8eecd5 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -20,6 +20,24 @@ function Probe(props: { } describe("useLiveRefresh", () => { + it("defers a hidden mounted usage surface until the window becomes visible", async () => { + const refresh = vi.fn(); + let visibility: DocumentVisibilityState = "hidden"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); + using _timers = fakeTimers({ now: 2_000_000 }); + // A bare hook probe; nothing here navigates. + await using _screen = await render( + , + { router: false }, + ); + expect(refresh).not.toHaveBeenCalled(); + visibility = "visible"; + await act(() => document.dispatchEvent(new Event("visibilitychange"))); + expect(refresh).toHaveBeenCalledTimes(1); + }); + it("with refreshOnShow, reads on every show unless it read within the floor", async () => { const refresh = vi.fn(); // The first enable reads the clock. diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index d261a13ad654a..8d1b7b1f76c0d 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -83,6 +83,7 @@ export function useLiveRefresh( * long stale when shown. */ readonly refreshOnShow?: boolean; + readonly refreshWhileIdle?: boolean; } = {}, ): void { const { @@ -90,6 +91,7 @@ export function useLiveRefresh( key, intervalMs = LIVE_REFRESH_INTERVAL_MS, refreshOnShow = false, + refreshWhileIdle = false, } = options; const runRefresh = useEffectEvent((viewId: string, now: number) => { if (refresh === null) return; @@ -106,10 +108,13 @@ export function useLiveRefresh( const visible = () => document.visibilityState === "visible"; const onArrival = () => { const now = Date.now(); - if (now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return; + if (!refreshWhileIdle && now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return; const lastRefreshedAt = lastRefreshedAtByView.get(viewId); if (lastRefreshedAt === undefined) { - lastRefreshedAtByView.set(viewId, now); + if (visible()) { + if (refreshOnShow) read(now); + else lastRefreshedAtByView.set(viewId, now); + } return; } if (shouldRefreshOnArrival({ visible: visible(), now, lastRefreshedAt })) read(now); @@ -117,7 +122,14 @@ export function useLiveRefresh( const onInterval = () => { const now = Date.now(); const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now; - if (shouldRefreshOnInterval({ visible: visible(), now, lastRefreshedAt, lastInteractedAt })) { + if ( + shouldRefreshOnInterval({ + visible: visible(), + now, + lastRefreshedAt, + lastInteractedAt: refreshWhileIdle ? now : lastInteractedAt, + }) + ) { read(now); } }; @@ -136,7 +148,7 @@ export function useLiveRefresh( onArrival(); syncTimer(); }); - if (refreshOnShow) { + if (refreshOnShow && visible()) { const now = Date.now(); const lastRefreshedAt = lastRefreshedAtByView.get(viewId); if (lastRefreshedAt === undefined || now - lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS) { @@ -152,5 +164,5 @@ export function useLiveRefresh( document.removeEventListener("visibilitychange", onVisibilityChange); stopWatchingInteraction(); }; - }, [enabled, viewId, intervalMs, refreshOnShow]); + }, [enabled, viewId, intervalMs, refreshOnShow, refreshWhileIdle]); } diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts new file mode 100644 index 0000000000000..437a2cf32d156 --- /dev/null +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { environmentPresentations } from "../state/presentation"; +import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; +import { useAtomCommand } from "../state/use-atom-command"; +import { serverEnvironment } from "../state/server"; +import { useLiveRefresh } from "./useLiveRefresh"; + +export function useUsageLimitsRefresh( + environmentId: EnvironmentId, + instanceId: ProviderInstanceId | null, + enabled = true, + driver?: ProviderDriverKind | null, +) { + const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); + const connected = presentation?.connection.phase === "connected"; + const refresh = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false }); + useLiveRefresh( + () => { + if (instanceId !== null) void refresh({ environmentId, input: { instanceId, usage: true } }); + if (driver) + void refresh({ environmentId, input: { usageSources: true, usageProviders: [driver] } }); + }, + { + enabled: enabled && connected && instanceId !== null, + key: `usage:${environmentId}:${instanceId}`, + refreshOnShow: true, + refreshWhileIdle: true, + }, + ); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..8c9da7867386e 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -114,6 +114,10 @@ setting removes it from `settings.json`. ## Track subscription limits +Limits refresh every five minutes while a limits view is visible. Closing the view or hiding the +window stops automatic usage requests; provider health checks continue. Refreshing can reuse a +recent reading, and rate-limited accounts wait before retrying, including manual refreshes. + **Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your enabled environments and hubs you read one number per window rather than a list. Each window card shows how much of the pool is left and a bar with one segment per account, diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..85742820c1416 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -31,6 +31,7 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; /** Clear bars for unavailable accounts; retain last good bars after a failed probe. */ export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, + stale: Schema.optional(Schema.Boolean), windows: ForwardCompatibleArray(ServerProviderUsageWindow), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 737059e855768..e168f92a77726 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -164,7 +164,11 @@ import { ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, } from "./provider.ts"; -import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; +import { + ProviderDriverKind, + ProviderInstanceId, + ProviderInstanceMutation, +} from "./providerInstance.ts"; import { GuestShareCreateError, GuestShareCreateInput, @@ -643,6 +647,9 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), fresh: Schema.optional(Schema.Boolean), + usage: Schema.optional(Schema.Boolean), + usageSources: Schema.optional(Schema.Boolean), + usageProviders: Schema.optional(Schema.Array(ProviderDriverKind)), /** Bypass model caches; explicit rediscovery may open sessions, background refreshes must not. */ refreshModels: Schema.optional(Schema.Boolean), /** Read secret references again; unlocking a vault can prompt on the host, so only explicit user refreshes ask. */ diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 298613bf13c91..4ca8ed00d10f8 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -8,6 +8,7 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Option from "effect/Option"; import type * as Scope from "effect/Scope"; +import type { makeUsageHttpClient } from "./usageHttpClient.ts"; import type * as Stream from "effect/Stream"; import type { ProviderCredentialError } from "./errors.ts"; @@ -32,6 +33,7 @@ export interface ProviderCredentials { export class ProviderHost extends Context.Service< ProviderHost, { + readonly usageHttpClient?: Effect.Success>; readonly paths: ProviderHostPaths; readonly settings: { readonly get: Effect.Effect; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 9df85130d6017..54d086f16725f 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -7,6 +7,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { UsagePolling } from "./usageLimits.ts"; import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -140,6 +141,38 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect( + "keeps health probes independent and shares usage refreshes across visible surfaces", + () => + Effect.scoped( + Effect.gen(function* () { + const reads = yield* Ref.make(0); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + if (yield* UsagePolling) yield* Ref.update(reads, (n) => n + 1); + return refreshedSnapshot; + }), + }); + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(reads), 0); + yield* Effect.all([provider.refresh, provider.refresh], { + concurrency: "unbounded", + }).pipe(Effect.provideService(UsagePolling, true)); + assert.strictEqual(yield* Ref.get(reads), 1); + yield* TestClock.adjust("5 minutes"); + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(reads), 1); + yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true)); + assert.strictEqual(yield* Ref.get(reads), 2); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => Effect.scoped( Effect.gen(function* () { @@ -756,7 +789,10 @@ describe("makeManagedServerProvider", () => { windows: [{ ...weekly }], }); assert.strictEqual(yield* provider.getSnapshot, partial); - assert.deepStrictEqual((yield* provider.refresh).usageLimits, failedLimits); + assert.deepStrictEqual( + (yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true))).usageLimits, + failedLimits, + ); const completeLimits = { checkedAt: "2026-04-10T00:00:04.000Z", @@ -766,7 +802,11 @@ describe("makeManagedServerProvider", () => { ], } as const; yield* Ref.set(probeSnapshot, { ...refreshedSnapshot, usageLimits: completeLimits }); - assert.deepStrictEqual((yield* provider.refresh).usageLimits, completeLimits); + yield* TestClock.adjust("5 minutes"); + assert.deepStrictEqual( + (yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true))).usageLimits, + completeLimits, + ); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 9471be743f79d..4c975b4e7fb93 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -4,6 +4,7 @@ import { ServerSettingsError, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -16,7 +17,12 @@ import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import * as ProviderHost from "./ProviderHost.ts"; -import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; +import { + applyUsageLimitsUpdate, + resolveUsageLimitsAfterProbe, + UsagePolling, + UsageAfterReset, +} from "./usageLimits.ts"; import type { ManagedServerProvider } from "./snapshot.ts"; interface ProviderSnapshotState { @@ -61,6 +67,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Scope.Scope | ProviderHost.ProviderHost > { const host = yield* ProviderHost.ProviderHost; + let usageRefreshAfter = 0; const refreshSemaphore = yield* Semaphore.make(1); const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( @@ -150,7 +157,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } - const probedSnapshot = yield* input.checkProvider; + const now = yield* Clock.currentTimeMillis; + const pollingUsage = + (yield* UsagePolling) && ((yield* UsageAfterReset) || now >= usageRefreshAfter); + const probedSnapshot = yield* input.checkProvider.pipe( + Effect.provideService(UsagePolling, pollingUsage), + ); + if (pollingUsage) usageRefreshAfter = (yield* Clock.currentTimeMillis) + 5 * 60_000; const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( snapshotStateRef, (state) => { @@ -165,11 +178,17 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); + if (switchedAccount && !pollingUsage) usageRefreshAfter = 0; const usageLimits = switchedAccount ? probedSnapshot.usageLimits : resolveUsageLimitsAfterProbe({ published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, + probed: + !pollingUsage && + probedSnapshot.auth.status === "authenticated" && + state.snapshot.usageLimits + ? state.snapshot.usageLimits + : probedSnapshot.usageLimits, }); const workspaceId = usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; @@ -241,7 +260,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Effect.andThen(input.invalidateUsageCache ?? Effect.void), Effect.andThen(input.getSettings), Effect.flatMap((settings) => - applySnapshotBase(settings, { forceRefresh: true }), + applySnapshotBase(settings, { forceRefresh: true }).pipe( + Effect.provideService(UsagePolling, true), + ), ), ), ), @@ -265,7 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const refreshOnInterval = Effect.fn("ManagedServerProvider.refreshOnInterval")(function* () { const { snapshot } = yield* Ref.get(snapshotStateRef); if (!snapshot.enabled) return; - if (!needsUsageRecovery(snapshot) || !input.checkUsageLimits) { + if (!(yield* UsagePolling) || !needsUsageRecovery(snapshot) || !input.checkUsageLimits) { if (needsProviderRefresh(snapshot)) { yield* applySnapshotBase(yield* input.getSettings, { forceRefresh: true }); } diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts new file mode 100644 index 0000000000000..92c7de6312803 --- /dev/null +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -0,0 +1,83 @@ +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { makeUsageHttpClient } from "./usageHttpClient.ts"; + +const url = "https://api.anthropic.com/api/oauth/usage"; +const request = (account: string, suffix = "") => + HttpClientRequest.get(url + suffix).pipe(HttpClientRequest.bearerToken(account)); + +it.effect( + "deduplicates concurrent surfaces and windows, isolates accounts, and caches failed reads", + () => + Effect.gen(function* () { + let calls = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((input) => + Effect.sync(() => { + calls++; + return HttpClientResponse.fromWeb(input, Response.json({ calls }, { status: 503 })); + }), + ), + ); + const responses = yield* Effect.all( + [client.execute(request("a")), client.execute(request("a"))], + { concurrency: "unbounded" }, + ); + assert.strictEqual(calls, 1); + assert.deepStrictEqual(yield* responses[0]!.json, yield* responses[1]!.json); + yield* client.execute(request("b")); + assert.strictEqual(calls, 2); + yield* TestClock.adjust("4 minutes"); + yield* client.execute(request("a")); + assert.strictEqual(calls, 2); + yield* TestClock.adjust("1 minute"); + yield* client.execute(request("a")); + assert.strictEqual(calls, 3); + }), +); + +it.effect.each(["seconds", "date", "missing"] as const)( + "enforces %s Retry-After across usage requests and manual refreshes", + (kind) => + Effect.gen(function* () { + let calls = 0; + const now = yield* Clock.currentTimeMillis; + const header = + kind === "seconds" + ? "600" + : kind === "date" + ? DateTime.toDateUtc(DateTime.makeUnsafe(now + 600_000)).toUTCString() + : undefined; + const client = yield* makeUsageHttpClient( + HttpClient.make((input) => + Effect.sync(() => { + calls++; + return HttpClientResponse.fromWeb( + input, + Response.json({}, { status: 429, headers: header ? { "retry-after": header } : {} }), + ); + }), + ), + ); + yield* client.execute(request("a")); + assert.strictEqual( + yield* client.isCoolingDown("https://api.anthropic.com", "Bearer a"), + true, + ); + yield* client.invalidateSuccessfulReads("https://api.anthropic.com", "Bearer a"); + yield* client.execute(request("a", "?cedar_ember=1")); + assert.strictEqual(calls, 1); + yield* client.execute(request("b")); + assert.strictEqual(calls, 2); + yield* TestClock.adjust(kind === "missing" ? "4 minutes" : "9 minutes"); + yield* client.execute(request("a", "?manual=1")); + assert.strictEqual(calls, 2); + yield* TestClock.adjust("1 minute"); + yield* client.execute(request("a", "?manual=2")); + assert.strictEqual(calls, 3); + }), +); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts new file mode 100644 index 0000000000000..5b38cd156ac3f --- /dev/null +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -0,0 +1,147 @@ +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Exit from "effect/Exit"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import type * as HttpClientError from "effect/http/HttpClientError"; + +const encodeKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const TTL_MS = 5 * 60_000; + +function isUsageUrl(url: string) { + const parsed = URL.parse(url); + if (!parsed) return false; + return ( + (parsed.hostname === "api.anthropic.com" && parsed.pathname === "/api/oauth/usage") || + (parsed.hostname === "opencode.ai" && parsed.pathname === "/zen/go/v1/usage") || + (parsed.hostname === "cli-chat-proxy.grok.com" && parsed.pathname === "/v1/billing") || + parsed.pathname === "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" || + (["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"].includes( + parsed.hostname, + ) && + ["/v1internal:fetchAvailableModels", "/v1internal:retrieveUserQuotaSummary"].includes( + parsed.pathname, + )) + ); +} + +export function usageRetryDelay(header: string | undefined, now: number) { + if (header === undefined) return TTL_MS; + const seconds = /^\d+(?:\.\d+)?$/.test(header.trim()) ? Number(header) : NaN; + const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - now; + return Number.isFinite(delay) && delay >= 0 ? Math.max(1000, delay) : TTL_MS; +} + +export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( + client: HttpClient.HttpClient, +) { + return Effect.sync(() => { + type Snapshot = { + readonly status: number; + readonly headers: Readonly>; + readonly body: ArrayBuffer; + }; + const cached = new Map< + string, + { + readonly accountKey: string; + readonly result: Exit.Exit; + readonly expiresAt: number; + } + >(); + const accounts = new Map(); + const cachedClient = HttpClient.transform(client, (read, request) => { + if (!isUsageUrl(request.url)) return read; + return Effect.gen(function* () { + const accountKey = encodeKey([ + new URL(request.url).origin, + request.headers.authorization ?? "", + ]); + let account = accounts.get(accountKey); + if (!account) { + account = { lock: Semaphore.makeUnsafe(1), retryAt: 0 }; + accounts.set(accountKey, account); + } + const state = account; + const key = encodeKey([ + accountKey, + request.method, + request.url, + request.urlParams, + request.body, + ]); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const previous = cached.get(key); + if (previous && now < previous.expiresAt) { + const saved = yield* previous.result; + return HttpClientResponse.fromWeb(request, new Response(saved.body.slice(0), saved)); + } + if (now < state.retryAt) { + return HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 429, + headers: { "retry-after": String(Math.ceil((state.retryAt - now) / 1000)) }, + }), + ); + } + const result = yield* read.pipe( + Effect.flatMap((response) => + Effect.gen(function* () { + if ( + response.status === 429 || + (response.status >= 500 && response.headers["retry-after"] !== undefined) + ) { + state.retryAt = + (yield* Clock.currentTimeMillis) + + usageRetryDelay( + response.headers["retry-after"], + yield* Clock.currentTimeMillis, + ); + } + return { + status: response.status, + headers: response.headers, + body: yield* response.arrayBuffer, + }; + }), + ), + Effect.exit, + ); + const finishedAt = yield* Clock.currentTimeMillis; + cached.set(key, { + accountKey, + result, + expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), + }); + const saved = yield* result; + return HttpClientResponse.fromWeb(request, new Response(saved.body.slice(0), saved)); + }), + ); + }); + }); + return Object.assign(cachedClient, { + invalidateSuccessfulReads: (origin: string, authorization: string) => + Effect.sync(() => { + const accountKey = encodeKey([origin, authorization]); + for (const [key, saved] of cached) { + if ( + saved.accountKey === accountKey && + Exit.isSuccess(saved.result) && + saved.result.value.status < 400 + ) + cached.delete(key); + } + }), + isCoolingDown: (origin: string, authorization: string) => + Effect.map( + Clock.currentTimeMillis, + (now) => now < (accounts.get(encodeKey([origin, authorization]))?.retryAt ?? 0), + ), + }); + }); +}); diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 4b9c1b39c862b..8df819ef1d36b 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -115,7 +115,10 @@ describe("resolveUsageLimitsAfterProbe", () => { it("keeps the last good windows through a failed probe but not an unsupported one", () => { const failed = { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } }; const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; - expect(resolveUsageLimitsAfterProbe({ published, probed: failed })).toBe(published); + expect(resolveUsageLimitsAfterProbe({ published, probed: failed })).toEqual({ + ...published, + stale: true, + }); expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 1e4bcd0090854..57d9e8bda828e 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -1,8 +1,25 @@ +export { makeUsageHttpClient, usageRetryDelay } from "./usageHttpClient.ts"; import type { ProviderUsageLimitsUpdate, ServerProviderUsageLimits, ServerProviderUsageWindow, } from "@t3tools/contracts"; +import * as Context from "effect/Context"; + +export const UsagePolling = Context.Reference("t3/provider/UsagePolling", { + defaultValue: () => false, +}); + +export const UsageAfterReset = Context.Reference("t3/provider/UsageAfterReset", { + defaultValue: () => false, +}); + +export const UsageProviderFilter = Context.Reference( + "t3/provider/UsageProviderFilter", + { + defaultValue: () => undefined, + }, +); const WINDOW_KIND_ORDER: Record = { session: 0, @@ -83,6 +100,7 @@ export function applyUsageLimitsUpdate(input: { previous === undefined ? { reason: "probeFailed" as const } : previous.unavailable; return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), + ...(previous?.stale ? { stale: true } : {}), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), ...(unavailable !== undefined ? { unavailable } : {}), }; @@ -105,7 +123,7 @@ export function resolveUsageLimitsAfterProbe(input: { }): ServerProviderUsageLimits | undefined { const { published, probed } = input; if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { - return published; + return { ...published, stale: true }; } if ( published && diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 088c97c685c58..bc2efa27ec2bb 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind, ProviderSetupError, @@ -98,7 +99,8 @@ export const CursorDriver: ProviderDriver< const host = yield* ProviderHost.ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; + const httpClient = + (yield* ProviderHost.ProviderHost).usageHttpClient ?? (yield* HttpClient.HttpClient); const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; const processEnv = yield* mergeProviderInstanceEnvironment(environment); @@ -214,52 +216,55 @@ export const CursorDriver: ProviderDriver< auth.withAccess, ); - const checkProvider = auth.readApiKey.pipe( - Effect.orElseSucceed(() => undefined), - Effect.flatMap((apiKey) => - checkCursorProviderStatus( - effectiveConfig, - { - ...processEnv, - CURSOR_API_KEY: apiKey, - }, - auth.usesApiKey ? "api-key" : "browser", - ).pipe( - Effect.filterOrElse( - (snapshot) => - !( - effectiveConfig.enabled && - snapshot.installed && - snapshot.auth.status === "authenticated" - ), - (snapshot) => - host.settings.get.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, + const checkProvider = Effect.flatMap(UsagePolling, (includeUsage) => + auth.readApiKey.pipe( + Effect.orElseSucceed(() => undefined), + Effect.flatMap((apiKey) => + checkCursorProviderStatus( + effectiveConfig, + { + ...processEnv, + CURSOR_API_KEY: apiKey, + }, + auth.usesApiKey ? "api-key" : "browser", + ).pipe( + Effect.filterOrElse( + (snapshot) => + !( + includeUsage && + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + host.settings.get.pipe( + Effect.flatMap((settings) => + readCursorUsageLimits( + effectiveConfig, + { ...processEnv, CURSOR_API_KEY: apiKey }, + settings.cursorKeychainUsageEnabled, + ), ), + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + ), ), ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(CursorKeychain.CursorKeychain, keychain), + Effect.map(stampSnapshot), + Effect.tap((provider) => + Effect.sync(() => { + // A failed check lists only custom models, so keep the last loaded catalog. + if (provider.models.some((model) => !model.isCustom)) { + catalogModels = provider.models; + } + }), + ), + Effect.provide(CursorSdkCatalog.layer), + Effect.provideService(HttpClient.HttpClient, httpClient), ), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(CursorKeychain.CursorKeychain, keychain), - Effect.map(stampSnapshot), - Effect.tap((provider) => - Effect.sync(() => { - // A failed check lists only custom models, so keep the last loaded catalog. - if (provider.models.some((model) => !model.isCustom)) { - catalogModels = provider.models; - } - }), - ), - Effect.provide(CursorSdkCatalog.layer), - Effect.provideService(HttpClient.HttpClient, httpClient), ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index c9f683c3417ee..57101753c55fa 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind } from "@t3tools/contracts"; import { GrokSettings } from "../settings.ts"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -119,7 +120,8 @@ export const GrokDriver: ProviderDriver }) { const random = yield* SecureRandom.SecureRandom; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; + const httpClient = + (yield* ProviderHost.ProviderHost).usageHttpClient ?? (yield* HttpClient.HttpClient); const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -168,29 +170,31 @@ export const GrokDriver: ProviderDriver ); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.filterOrElse( - (snapshot) => - !( - effectiveConfig.enabled && - snapshot.installed && - snapshot.auth.status === "authenticated" - ), - (snapshot) => - readGrokAccount(processEnv).pipe( - Effect.map(({ email, usageLimits }) => ({ - ...snapshot, - auth: email ? { ...snapshot.auth, email } : snapshot.auth, - usageLimits, - })), - ), + const checkProvider = Effect.flatMap(UsagePolling, (includeUsage) => + checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.filterOrElse( + (snapshot) => + !( + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + readGrokAccount(processEnv, includeUsage).pipe( + Effect.map(({ email, usageLimits }) => ({ + ...snapshot, + auth: email ? { ...snapshot.auth, email } : snapshot.auth, + usageLimits, + })), + ), + ), + Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), - Effect.map(stampIdentity), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(SecureRandom.SecureRandom, random), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index c6ba583c139ce..fb6c08e1f6d25 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -160,6 +160,7 @@ const readGrokCredential = Effect.fn("readGrokCredential")(function* ( export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( environment: NodeJS.ProcessEnv = process.env, + includeUsage = true, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const probeFailed = makeUnavailableUsageLimits({ @@ -172,6 +173,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( ); if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; const email = credential.value?.email?.trim() || undefined; + if (!includeUsage) return { email, usageLimits: probeFailed }; const token = credential.value?.key?.trim(); if (!token) { return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index eb62d44b9aa94..c499b4e278151 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Digest from "effect/crypto/Digest"; @@ -195,7 +196,8 @@ export const OpenCodeDriver: ProviderDriver< const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const httpClient = yield* HttpClient.HttpClient; + const httpClient = + (yield* ProviderHost.ProviderHost).usageHttpClient ?? (yield* HttpClient.HttpClient); const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const digest = yield* Digest.Digest; const host = yield* ProviderHost.ProviderHost; @@ -365,11 +367,16 @@ export const OpenCodeDriver: ProviderDriver< Effect.annotateLogs({ providerInstanceId: instanceId }), Effect.annotateSpans({ providerInstanceId: instanceId }), ), - usageLimits: checkUsageLimits, + usageLimits: Effect.flatMap(UsagePolling, (enabled) => + enabled ? checkUsageLimits : Effect.succeed(null), + ), }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(({ provider, usageLimits }) => ({ + ...provider, + ...(usageLimits ? { usageLimits } : {}), + })), Effect.map(stampIdentity), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 869f676acae69..c00f92a0fcea4 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -18,6 +18,8 @@ import { import * as DateTime from "effect/DateTime"; +export const STALE_USAGE_NOTICE = "Showing the last reading. Usage refresh failed."; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -400,8 +402,16 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; const name = provider.displayName?.trim() || String(provider.driver); if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + if (provider.usageLimits?.stale) + notices.push(`${label(environmentLabel, name)}: ${STALE_USAGE_NOTICE}`); } for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + for (const account of source.accounts) { + if (account.usageLimits.stale) + notices.push( + `${label(environmentLabel, account.email ?? source.label)}: ${STALE_USAGE_NOTICE}`, + ); + } if (source.error) { notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); } else if (source.accounts.length === 0) { @@ -875,5 +885,8 @@ export function collectProviderUsageLimits( notices.push(`${source.label}: ${source.error}`); } } + for (const account of accounts) { + if (account.limits.stale) notices.push(`${account.label}: ${STALE_USAGE_NOTICE}`); + } return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; } From 22c9753404f106ea9f38c5ff2337b25ab6dd7d2c Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 12:44:20 -0500 Subject: [PATCH 02/95] fix(server): preserve usage refresh state across surfaces --- .../src/provider/AntigravityProvider.test.ts | 22 +- apps/server/src/provider/CodexProvider.ts | 4 +- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../src/provider/Drivers/CodexDriver.test.ts | 2 +- .../src/provider/Drivers/CodexDriver.ts | 10 +- apps/server/src/provider/ProviderRegistry.ts | 33 +- .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 1 + .../src/usage/UsageLimitSources.test.ts | 71 ++++ apps/server/src/usage/UsageLimitSources.ts | 14 +- apps/server/src/ws.ts | 8 +- apps/web/src/components/usage/UsagePage.tsx | 70 ++-- docs/user/usage.md | 2 +- .../src/server/managedProvider.test.ts | 53 ++- .../src/server/managedProvider.ts | 31 +- .../src/server/driver.test.ts | 305 +++++++++--------- 16 files changed, 405 insertions(+), 226 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 791ed748de2ef..0713a83862087 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -1,3 +1,4 @@ +import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; @@ -167,6 +168,13 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( ); const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( Effect.andThen(initialUpdate), + Effect.andThen( + Effect.flatMap(UsagePolling, (visible) => + visible && options.probeUsage + ? (provider.snapshot.refreshUsage ?? Effect.void) + : Effect.void, + ), + ), Effect.asVoid, ); // Subscribe before the trigger; yield the returned pull to await the publish. @@ -732,7 +740,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }); yield* harness.provider.snapshot.refresh; yield* Deferred.await(failedReadApplied); - expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual(limits); + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual({ + ...limits, + stale: true, + }); yield* harness.provider.onSignedOut; expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); yield* Ref.set(usage, { @@ -746,6 +757,9 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* harness.provider.snapshot.refresh; expect((yield* unsupported)?.usageLimits?.windows).toEqual([]); }), + ).pipe( + Effect.provideService(UsagePolling, true), + Effect.provideService(UsageAfterReset, true), ), ); @@ -776,7 +790,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* Fiber.join(refresh); expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); }), - ), + ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), ); it.effect("reports ready and starts sessions while one shared quota read is pending", () => @@ -809,7 +823,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* Deferred.succeed(readResult, limits); expect((yield* published)?.usageLimits).toEqual(limits); }), - ), + ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), ); it.effect("a quota read for the previous account is dropped after a new sign-in", () => @@ -846,6 +860,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* harness.provider.onSessionStarted(started); expect((yield* republished)?.usageLimits).toEqual(failed); }), - ), + ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), ); }); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 88a643e4c54d7..a0101c66cf195 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,4 +1,3 @@ -import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -750,6 +749,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, managedAuth?: ServerProvider["auth"], + skipUsage = false, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -782,7 +782,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), environment: resolvedEnvironment, - skipNativeUsage: Boolean(managedAuth) || !(yield* UsagePolling), + ...(managedAuth || skipUsage ? { skipNativeUsage: true } : {}), }).pipe( // Process teardown can outlast the work budget without discarding a completed snapshot. Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4231127855208..7bd60fc939d3a 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -173,8 +173,7 @@ export const ClaudeDriver: ProviderDriver(undefined); const capabilitiesProbeCache = yield* Cache.makeWith( @@ -278,7 +277,6 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 985c14df21103..868dc9c0af03b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -253,7 +253,7 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.auth.email).toBe("account@example.test"); expect(restored.runtimePaths?.homePath).toBe(sharedHome); expect(restored.runtimePaths?.shadowHomePath).toContain( - `providers/codex/${instanceId}/shadow`, + NodePath.join("providers", "codex", instanceId, "shadow"), ); const scan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe(Effect.exit); expect(Exit.isFailure(scan)).toBe(true); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 01bf1b0766399..fab7598ea4a62 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -232,7 +232,15 @@ export const CodexDriver: ProviderDriver< const checkProvider = modelCatalog.refreshInBackground.pipe( Effect.andThen( Effect.zipWith( - checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + Effect.flatMap(UsagePolling, (includeUsage) => + checkCodexProviderStatus( + effectiveConfig, + undefined, + processEnv, + undefined, + !includeUsage, + ), + ), currentCatalog, (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), { concurrent: true }, diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index fb6aeaac26b63..1edd8f94464f7 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -59,6 +59,10 @@ export class ProviderRegistry extends Context.Service< provider?: ProviderDriverKind, ) => Effect.Effect>; + readonly refreshUsage: ( + instanceId?: ProviderInstanceId, + ) => Effect.Effect>; + /** Unknown instance ids return the cached list without error. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, @@ -602,13 +606,17 @@ export const layer = Layer.effect( .getInstance(instance.instanceId) .pipe(Effect.map((current) => current === instance)); - const refreshOneSource = Effect.fn("refreshOneSource")(function* (instance: ProviderInstance) { + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( + instance: ProviderInstance, + usage = false, + ) { const providerSource = buildSnapshotSource(instance); // Enrichment can publish a newer snapshot before refresh returns its pre-enrichment probe. - const probe = yield* providerSource.refresh.pipe( - Effect.andThen(providerSource.getSnapshot), - Effect.exit, - ); + const probe = yield* ( + usage + ? (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.orDie) + : providerSource.refresh + ).pipe(Effect.andThen(providerSource.getSnapshot), Effect.exit); // Settings can replace or remove the instance mid-probe; its late result or failure is stale. if (!(yield* isCurrentInstance(instance))) { return yield* Ref.get(providersRef); @@ -619,6 +627,20 @@ export const layer = Layer.effect( ); }); + const refreshUsage = Effect.fn("ProviderRegistry.refreshUsage")(function* ( + instanceId?: ProviderInstanceId, + ) { + const instances = yield* instanceRegistry.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => instanceId === undefined || instance.instanceId === instanceId, + ), + (instance) => refreshOneSource(instance, true), + { concurrency: "unbounded" }, + ); + return yield* Ref.get(providersRef); + }); + type RefreshAllCompletion = Deferred.Deferred>; const refreshAllInFlightRef = yield* Ref.make>( Option.none(), @@ -1071,6 +1093,7 @@ export const layer = Layer.effect( return { getProviders: Ref.get(providersRef), + refreshUsage, refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 77a44c7fa29a5..e701bd0ce02f0 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -196,6 +196,7 @@ function makeRegistry( const registry: ProviderRegistry.ProviderRegistry["Service"] = { getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), + refreshUsage: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), reloadSecretsAndRefresh: () => Ref.get(providersRef), refreshAfterUpdate: () => { diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index f75ecd141e71a..e955e399897a6 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -10,6 +10,7 @@ const makeProviderRegistryMock = ( ): ProviderRegistry.ProviderRegistry["Service"] => ({ getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), + refreshUsage: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 3128d3bfa9350..562ce90821dc2 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,3 +1,4 @@ +import { UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; @@ -12,6 +13,11 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageLimitSources from "./UsageLimitSources.ts"; +const encodeSourceBody = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeSourceRequest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ auth_index: Schema.String, url: Schema.String })), +); + const encodeConfigEvent = Schema.encodeSync(ServerConfigStreamEvent); it.effect("keeps malformed source URLs encodable for config subscribers", () => @@ -156,3 +162,68 @@ it.effect("reads a management key from its secret provider", () => { Effect.scoped, ); }); + +it.effect("retains another window's account when provider-filtered usage refreshes finish", () => + Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["claudeAgent"])); + expect((yield* sources.current)[0]?.accounts.map((account) => account.driver)).toEqual([ + "claudeAgent", + ]); + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); + expect((yield* sources.current)[0]?.accounts.map((account) => account.driver).sort()).toEqual([ + "claudeAgent", + "codex", + ]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("hub")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "key", + enabled: true, + }, + }, + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: () => Effect.succeed([]), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/auth-files")) + return HttpClientResponse.fromWeb( + request, + Response.json({ + files: [ + { id: "claude", auth_index: "claude", provider: "claude" }, + { id: "codex", auth_index: "codex", provider: "codex" }, + ], + }), + ); + const input = decodeSourceRequest( + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : "{}", + ); + const body = input.url.endsWith("/rate-limit-reset-credits") + ? { credits: [] } + : input.auth_index === "claude" + ? { five_hour: { utilization: 20, resets_at: null } } + : { rate_limit: { primary_window: { used_percent: 20 } } }; + return HttpClientResponse.fromWeb( + request, + Response.json({ status_code: 200, body: encodeSourceBody(body) }), + ); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ), +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index a55618acad038..c96e60592ef8c 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -85,8 +85,9 @@ export const make = Effect.gen(function* () { return { ...base, accounts: [], error: missingManagementKey(config) }; } const accountKey = `${id}\0${config.url}\0${managementKey}`; + const providers = yield* UsageProviderFilter; const accounts = yield* api - .readAccounts({ url: config.url, managementKey }, yield* UsageProviderFilter) + .readAccounts({ url: config.url, managementKey }, providers) .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); @@ -96,8 +97,15 @@ export const make = Effect.gen(function* () { error: accounts.failure.detail, }; } - lastGoodSources.set(accountKey, accounts.success); - return { ...base, accounts: accounts.success }; + const retained = + providers === undefined + ? [] + : (lastGoodSources.get(accountKey) ?? []).filter( + (account) => !providers.includes(account.driver), + ); + const merged = [...retained, ...accounts.success]; + lastGoodSources.set(accountKey, merged); + return { ...base, accounts: merged }; }); const publish = Effect.fn("UsageLimitSources.publish")(function* ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1cdf633dd2cd4..1600473b98257 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2447,9 +2447,11 @@ export const layerWsRpc = ( }) : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() + : input.usage === true + ? providerRegistry.refreshUsage(input.instanceId) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() ).pipe(Effect.provideService(UsagePolling, input.usage === true)); if (input.refreshModels) { const instances = yield* providerInstances.listInstances; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 061bbc9d47c54..e0d4d70eec492 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -16,10 +16,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; -import { - cursorKeychainAccessEnvironments, - refreshUsageLimits, -} from "@t3tools/client-runtime/state/usage"; +import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { updatingProvidersLabel, usageEnvironmentProgress, @@ -251,46 +248,39 @@ export function UsagePage() { if (nextMetric === "limits") setLimitsNow(Date.now()); updatePreferences({ metric: nextMetric }); }; - const refreshLimits = (automatic = false, afterPending = false) => + const refreshLimits = () => // Settled so the timestamp still advances when a refresh throws. settlePromise(() => Promise.all( Array.from(presentationsWithProviders, ([environmentId, presentation]) => { if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshUsageLimits( - environmentId, - () => { - const providers = (presentation.serverConfig?.providers ?? []).filter( - (provider) => - ![...hiddenProviders].some( - (hidden) => - hidden === (provider.driver === "claudeAgent" ? "claude" : provider.driver), - ), - ); - return Promise.all([ - refreshProviders({ - environmentId, - input: { - usageSources: true, - usageProviders: PROVIDER_ORDER.filter( - (provider) => !hiddenProviders.has(provider), - ).map((provider) => - ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), - ), - }, - }), - ...providers.map((provider) => - refreshProviders({ - environmentId, - input: { instanceId: provider.instanceId, usage: true }, - }), - ), - ]); - }, - automatic, - afterPending, + const providers = (presentation.serverConfig?.providers ?? []).filter( + (provider) => + ![...hiddenProviders].some( + (hidden) => + hidden === (provider.driver === "claudeAgent" ? "claude" : provider.driver), + ), ); + return Promise.all([ + refreshProviders({ + environmentId, + input: { + usageSources: true, + usageProviders: PROVIDER_ORDER.filter( + (provider) => !hiddenProviders.has(provider), + ).map((provider) => + ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), + ), + }, + }), + ...providers.map((provider) => + refreshProviders({ + environmentId, + input: { instanceId: provider.instanceId, usage: true }, + }), + ), + ]); } }), ), @@ -364,7 +354,7 @@ export function UsagePage() { .sort() .join(","); const autoRefreshLimits = useEffectEvent(() => { - void refreshLimits(true); + void refreshLimits(); }); useLiveRefresh(() => autoRefreshLimits(), { enabled: showingLimits && connectedLimitsEnvironments !== "", @@ -561,7 +551,7 @@ export function UsagePage() { environments={cursorAccessEnvironments} onEnabled={() => { void refresh(); - void refreshLimits(false, true); + void refreshLimits(); }} /> ) : null @@ -594,7 +584,7 @@ export function UsagePage() { showChatGptSharing={showChatGptSharing} onCursorEnabled={() => { void refresh(); - void refreshLimits(false, true); + void refreshLimits(); }} onSelectModel={selectUsageModel} /> diff --git a/docs/user/usage.md b/docs/user/usage.md index 8c9da7867386e..583129c721f66 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -115,7 +115,7 @@ setting removes it from `settings.json`. ## Track subscription limits Limits refresh every five minutes while a limits view is visible. Closing the view or hiding the -window stops automatic usage requests; provider health checks continue. Refreshing can reuse a +window stops periodic usage requests; provider health checks continue. Refreshing can reuse a recent reading, and rate-limited accounts wait before retrying, including manual refreshes. **Usage → Limits** pools every subscription account it can see per provider, so with several Codex diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 54d086f16725f..d79f665c95452 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -157,7 +157,7 @@ describe("makeManagedServerProvider", () => { if (yield* UsagePolling) yield* Ref.update(reads, (n) => n + 1); return refreshedSnapshot; }), - }); + }).pipe(Effect.provideService(UsagePolling, true)); yield* provider.refresh; assert.strictEqual(yield* Ref.get(reads), 0); yield* Effect.all([provider.refresh, provider.refresh], { @@ -173,6 +173,57 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect( + "can enable unsupported usage while failed probes still keep their cache lifetime", + () => + Effect.scoped( + Effect.gen(function* () { + const reads = yield* Ref.make(0); + const limits = yield* Ref.make({ + checkedAt: refreshedSnapshot.checkedAt, + windows: [], + unavailable: { reason: "unsupported" }, + }); + const snapshot = Ref.get(limits).pipe( + Effect.map((usageLimits) => ({ ...refreshedSnapshot, usageLimits })), + ); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => snapshot, + checkProvider: snapshot, + checkUsageLimits: Ref.update(reads, (n) => n + 1).pipe(Effect.andThen(Ref.get(limits))), + }); + yield* provider.refresh; + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 1); + const good = { + checkedAt: refreshedSnapshot.checkedAt, + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 25 }], + } as const; + yield* Ref.set(limits, good); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 2); + yield* Ref.set(limits, { + checkedAt: refreshedSnapshot.checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 2); + yield* TestClock.adjust("5 minutes"); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 3); + assert.deepStrictEqual((yield* provider.getSnapshot).usageLimits, { + ...good, + stale: true, + }); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 4c975b4e7fb93..74750ac8809da 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -158,11 +158,20 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( } const now = yield* Clock.currentTimeMillis; + const current = (yield* Ref.get(snapshotStateRef)).snapshot; const pollingUsage = - (yield* UsagePolling) && ((yield* UsageAfterReset) || now >= usageRefreshAfter); - const probedSnapshot = yield* input.checkProvider.pipe( - Effect.provideService(UsagePolling, pollingUsage), - ); + (yield* UsagePolling) && + ((yield* UsageAfterReset) || + now >= usageRefreshAfter || + current.usageLimits?.unavailable?.reason === "unsupported"); + const probedSnapshot = + pollingUsage && + input.checkUsageLimits && + current.installed && + current.status === "ready" && + current.auth.status === "authenticated" + ? withUsageLimits(current, yield* input.checkUsageLimits) + : yield* input.checkProvider.pipe(Effect.provideService(UsagePolling, pollingUsage)); if (pollingUsage) usageRefreshAfter = (yield* Clock.currentTimeMillis) + 5 * 60_000; const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( snapshotStateRef, @@ -186,6 +195,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( probed: !pollingUsage && probedSnapshot.auth.status === "authenticated" && + (probedSnapshot.usageLimits === undefined || + probedSnapshot.usageLimits.unavailable?.reason === "probeFailed") && state.snapshot.usageLimits ? state.snapshot.usageLimits : probedSnapshot.usageLimits, @@ -240,12 +251,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { const { snapshot } = yield* Ref.get(snapshotStateRef); - if ( - !snapshot.enabled || - !snapshot.usageLimits || - snapshot.usageLimits.unavailable?.reason === "unsupported" - ) - return; + if (!snapshot.enabled || snapshot.usageLimits?.externalUsage) return; // An in-flight refresh may have read cached usage, so one refresh waits behind it. const claim = Symbol(); const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); @@ -352,7 +358,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( // retries, like the periodic and initial checks below. yield* Stream.runForEach(input.streamSettings, (nextSettings) => Effect.asVoid(applySnapshot(nextSettings)).pipe(Effect.ignoreCause({ log: true })), - ).pipe(Effect.forkScoped); + ).pipe(Effect.provideService(UsagePolling, false), Effect.forkScoped); yield* Effect.forever( getRefreshInterval.pipe( @@ -381,9 +387,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), Effect.ignoreCause({ log: true }), ), - ).pipe(Effect.forkScoped); + ).pipe(Effect.provideService(UsagePolling, false), Effect.forkScoped); yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + Effect.provideService(UsagePolling, false), Effect.ignoreCause({ log: true }), Effect.forkScoped, ); diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 665f4785459d9..232007f110fb9 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -365,161 +365,166 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { unsupported: false, ...scenario, })), - )("Go usage recovery $name without restarting a ready catalog", (scenario) => - Effect.gen(function* () { - const usageStarted = yield* Deferred.make(); - let usageReads = 0; - let serverStarts = 0; - let modelReads = 0; - const server = HttpClient.make((request) => { - if (request.url === "https://opencode.ai/zen/go/v1/usage") { - usageReads += 1; - assert.strictEqual(request.headers.authorization, "Bearer test-go-key"); - if (scenario.unsupported) + )( + "Go usage recovery $name only on visible refresh without restarting a ready catalog", + (scenario) => + Effect.gen(function* () { + const usageStarted = yield* Deferred.make(); + const healthGate = yield* Deferred.make(); + let usageReads = 0; + let serverStarts = 0; + let modelReads = 0; + const server = HttpClient.make((request) => { + if (request.url === "https://opencode.ai/zen/go/v1/usage") { + usageReads += 1; + assert.strictEqual(request.headers.authorization, "Bearer test-go-key"); + if (scenario.unsupported) + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 403 })), + ); + if (usageReads === 1) + return Deferred.succeed(usageStarted, undefined).pipe(Effect.andThen(Effect.never)); + const window = { percent: 10, resetsAt: "2026-11-01T00:00:00.000Z" }; return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 403 })), + HttpClientResponse.fromWeb( + request, + Response.json({ usage: { rolling: window, weekly: window, monthly: window } }), + ), ); - if (usageReads === 1) - return Deferred.succeed(usageStarted, undefined).pipe(Effect.andThen(Effect.never)); - const window = { percent: 10, resetsAt: "2026-11-01T00:00:00.000Z" }; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ usage: { rolling: window, weekly: window, monthly: window } }), - ), + } + const path = new URL(request.url).pathname; + if (path === "/api/model") modelReads += 1; + const body = + path === "/api/info" + ? JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body) + : { + location: { directory: "/work" }, + data: + path === "/api/model" + ? [ + { + id: "big-pickle", + modelID: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + package: "@opencode/ai/providers/openai-compatible", + settings: {}, + compatibility: {}, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1789516800000 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1000000, output: 65536 }, + }, + ] + : [], + }; + return Deferred.await(healthGate).pipe( + Effect.as(HttpClientResponse.fromWeb(request, Response.json(body))), ); - } - const path = new URL(request.url).pathname; - if (path === "/api/model") modelReads += 1; - const body = - path === "/api/info" - ? JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body) - : { - location: { directory: "/work" }, - data: - path === "/api/model" - ? [ - { - id: "big-pickle", - modelID: "big-pickle", - providerID: "opencode", - name: "Big Pickle", - package: "@opencode/ai/providers/openai-compatible", - settings: {}, - compatibility: {}, - capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 1789516800000 }, - cost: [], - status: "active", - enabled: true, - limit: { context: 1000000, output: 65536 }, - }, - ] - : [], - }; - return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body))); - }); - const runtime = { - ...openCode2Runtime, - startOpenCodeServerProcess: ({ - verify, - }: Parameters< - OpenCodeRuntime.OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] - >[0]) => - Effect.gen(function* () { - serverStarts += 1; - const url = "http://127.0.0.1:4096"; - const version = verify ? yield* verify(url) : "2.0.18"; - return { url, version, isRunning: Effect.succeed(true), exitCode: Effect.never }; - }), - }; - const instance = yield* OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-usage-recovery"), - displayName: undefined, - environment: [ - { - name: "OPENCODE_AUTH_CONTENT", - sensitive: true, - value: JSON.stringify({ - "opencode-go": { type: "api", key: "test-go-key" }, + }); + const runtime = { + ...openCode2Runtime, + startOpenCodeServerProcess: ({ + verify, + }: Parameters< + OpenCodeRuntime.OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] + >[0]) => + Effect.gen(function* () { + serverStarts += 1; + const url = "http://127.0.0.1:4096"; + const version = verify ? yield* verify(url) : "2.0.18"; + return { url, version, isRunning: Effect.succeed(true), exitCode: Effect.never }; }), - }, - ], - enabled: scenario.enabled, - config: OpenCodeDriver.defaultConfig(), - }).pipe( - Effect.provide( - TestProviderHost.layer({ - runBackgroundWork: scenario.demand, - settings: scenario.intervalOff - ? { - ...DEFAULT_SERVER_SETTINGS, - backgroundActivity: { - ...DEFAULT_SERVER_SETTINGS.backgroundActivity, - profile: "custom", - overrides: { providerHealthRefreshInterval: Duration.zero }, - }, - } - : DEFAULT_SERVER_SETTINGS, - }), - ), - Effect.provideService(HttpClient.HttpClient, server), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), - ); - const initial = scenario.enabled - ? yield* instance.snapshot.streamChanges.pipe( - Stream.filter((snapshot) => snapshot.usageLimits !== undefined), - Stream.take(1), - Stream.runCollect, - Effect.forkChild, - ) - : undefined; - if (scenario.enabled && !scenario.unsupported) yield* Deferred.await(usageStarted); - yield* TestClock.adjust("5 seconds"); - const failed = initial - ? Array.from(yield* Fiber.join(initial))[0] - : yield* instance.snapshot.getSnapshot; - if (scenario.enabled) { - assert.strictEqual(failed?.status, "ready"); - assert.strictEqual(failed?.auth.status, "authenticated"); - } - const initialReason = - scenario.enabled && !scenario.unsupported ? "probeFailed" : "unsupported"; - assert.strictEqual(failed?.usageLimits?.unavailable?.reason, initialReason); - const catalogReads = modelReads; - const starts = serverStarts; + }; + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-usage-recovery"), + displayName: undefined, + environment: [ + { + name: "OPENCODE_AUTH_CONTENT", + sensitive: true, + value: JSON.stringify({ + "opencode-go": { type: "api", key: "test-go-key" }, + }), + }, + ], + enabled: scenario.enabled, + config: OpenCodeDriver.defaultConfig(), + }).pipe( + Effect.provide( + TestProviderHost.layer({ + runBackgroundWork: scenario.demand, + settings: scenario.intervalOff + ? { + ...DEFAULT_SERVER_SETTINGS, + backgroundActivity: { + ...DEFAULT_SERVER_SETTINGS.backgroundActivity, + profile: "custom", + overrides: { providerHealthRefreshInterval: Duration.zero }, + }, + } + : DEFAULT_SERVER_SETTINGS, + }), + ), + Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); + const ready = scenario.enabled + ? yield* instance.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.status === "ready"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }), + ) + : undefined; + yield* Deferred.succeed(healthGate, undefined); + yield* TestClock.adjust("5 seconds"); + if (ready) yield* Fiber.join(ready); + assert.strictEqual(usageReads, 0); + const initial = yield* instance.snapshot.refreshUsage!.pipe(Effect.forkChild); + if (scenario.enabled && !scenario.unsupported) yield* Deferred.await(usageStarted); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(initial); + const failed = yield* instance.snapshot.getSnapshot; + if (scenario.enabled) { + assert.strictEqual(failed?.status, "ready"); + assert.strictEqual(failed?.auth.status, "authenticated"); + } + const initialReason = scenario.enabled + ? scenario.unsupported + ? "unsupported" + : "probeFailed" + : undefined; + assert.strictEqual(failed?.usageLimits?.unavailable?.reason, initialReason); + const catalogReads = modelReads; + const starts = serverStarts; - const shouldRecover = - scenario.enabled && scenario.demand && !scenario.intervalOff && !scenario.unsupported; - const recovery = shouldRecover - ? yield* instance.snapshot.streamChanges.pipe( - Stream.filter((snapshot) => snapshot.usageLimits?.windows.length === 3), - Stream.take(1), - Stream.runCollect, - Effect.forkChild, - ) - : undefined; - yield* TestClock.adjust("5 minutes"); - const recovered = yield* instance.snapshot.getSnapshot; - const expectedReads = !scenario.enabled ? 0 : shouldRecover ? 2 : 1; - assert.strictEqual(usageReads, expectedReads); - if (recovery) assert.deepStrictEqual(Array.from(yield* Fiber.join(recovery)), [recovered]); - assert.strictEqual( - recovered.usageLimits?.unavailable?.reason, - shouldRecover ? undefined : initialReason, - ); - assert.deepStrictEqual( - recovered.usageLimits?.windows.map((window) => window.kind), - shouldRecover ? ["session", "weekly", "monthly"] : [], - ); - assert.strictEqual(modelReads, catalogReads); - assert.strictEqual(serverStarts, starts); - yield* TestClock.adjust("15 minutes"); - assert.strictEqual(usageReads, expectedReads); - assert.strictEqual(modelReads, catalogReads); - assert.strictEqual(serverStarts, starts); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + const shouldRecover = + scenario.enabled && scenario.demand && !scenario.intervalOff && !scenario.unsupported; + yield* TestClock.adjust("5 minutes"); + assert.strictEqual(usageReads, scenario.enabled ? 1 : 0); + if (shouldRecover) yield* instance.snapshot.refreshUsage!; + const recovered = yield* instance.snapshot.getSnapshot; + const expectedReads = !scenario.enabled ? 0 : shouldRecover ? 2 : 1; + assert.strictEqual(usageReads, expectedReads); + assert.strictEqual( + recovered.usageLimits?.unavailable?.reason, + shouldRecover ? undefined : initialReason, + ); + assert.deepStrictEqual( + recovered.usageLimits?.windows.map((window) => window.kind), + shouldRecover ? ["session", "weekly", "monthly"] : scenario.enabled ? [] : undefined, + ); + assert.strictEqual(modelReads, catalogReads); + assert.strictEqual(serverStarts, starts); + yield* TestClock.adjust("15 minutes"); + assert.strictEqual(usageReads, expectedReads); + assert.strictEqual(modelReads, catalogReads); + assert.strictEqual(serverStarts, starts); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); }); From 77455614ee69f51ea8233139c3513c8407daf53a Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 13:50:03 -0500 Subject: [PATCH 03/95] test(server): inject the shared usage client in reset fixtures --- .../src/provider/ProviderInstanceRegistry.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 6f24deab365ac..a5c6a9394aa01 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,4 +1,5 @@ -import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; +import { UsagePolling, makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; @@ -613,7 +614,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ); const client = HttpClient.make((request) => Effect.gen(function* () { - if (request.url.endsWith("/api/oauth/usage")) { + if (new URL(request.url).pathname === "/api/oauth/usage") { return HttpClientResponse.fromWeb( request, Response.json({ @@ -632,6 +633,8 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); }), ); + const host = yield* ProviderHost.ProviderHost; + const usageHttpClient = yield* makeUsageHttpClient(client); const instanceId = ProviderInstanceId.make("claude_reset"); const { registry } = yield* makeProviderInstanceRegistry({ drivers: [ClaudeDriver], @@ -652,7 +655,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }), }, }, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ProviderHost.ProviderHost, { ...host, usageHttpClient }), + ); const instance = yield* registry.getInstance(instanceId); expect(instance).toBeDefined(); const before = yield* instance!.snapshot.refresh.pipe( From f7c157110e33bbf2838ddfa40ea3cabef6f898f8 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 14:09:34 -0500 Subject: [PATCH 04/95] fix: address usage polling review regressions --- .../features/threads/ThreadDetailScreen.tsx | 12 +++++ .../usage/useInstanceLimitsRefresh.ts | 44 +++++++++++++++++++ .../src/widgets/useSubscriptionUsage.ts | 18 +++++--- .../src/provider/Drivers/ClaudeDriver.ts | 14 +++++- .../provider/ProviderInstanceRegistry.test.ts | 20 ++++++++- apps/server/src/provider/ProviderRegistry.ts | 6 ++- .../src/hooks/useLiveRefresh.browser.test.tsx | 42 +++++++++++------- apps/web/src/hooks/useLiveRefresh.ts | 6 +-- packages/client-runtime/src/state/server.ts | 4 ++ .../src/server/managedProvider.ts | 29 ++---------- packages/provider-cursor/src/server/driver.ts | 18 +++++++- .../provider-opencode/src/server/driver.ts | 11 ++++- 12 files changed, 164 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index be338a76a0be4..b2b4f9787e02d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { useInstanceLimitsRefresh } from "../usage/useInstanceLimitsRefresh"; import { ChildThreadApprovals } from "./ChildThreadApprovals"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; @@ -942,6 +943,17 @@ function ThreadDetailBody( usageLimitsPanel.now, ) : null; + useInstanceLimitsRefresh( + appAtomRegistry, + props.environmentId, + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers.find( + (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, + )?.driver, + usageLimitsPanel !== null && + activeUserInputRequestId === null && + props.connectionStateLabel === "connected", + ); const showUsageLimits = (report: UsageLimitsReport | null) => setUsageLimitsPanel( report === null diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts new file mode 100644 index 0000000000000..d0b01be918253 --- /dev/null +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -0,0 +1,44 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect } from "react"; +import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { AtomRegistry } from "effect/reactivity"; +import { isAppActive, subscribeAppState } from "../../lib/appState"; +import { serverEnvironment } from "../../state/server"; + +export function useInstanceLimitsRefresh( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + instanceId: ProviderInstanceId, + driver: ProviderDriverKind | undefined, + visible: boolean, +) { + const focused = useIsFocused(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle + useEffect(() => { + if (!visible || !focused) return; + const refresh = () => { + if (!isAppActive()) return; + void runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { environmentId, input: { instanceId, usage: true } }, + { reportFailure: false }, + ); + if (driver) + void runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { environmentId, input: { usageSources: true, usageProviders: [driver] } }, + { reportFailure: false }, + ); + }; + refresh(); + const unsubscribe = subscribeAppState(refresh); + const timer = setInterval(refresh, 5 * 60_000); + return () => { + unsubscribe(); + clearInterval(timer); + }; + }, [registry, environmentId, instanceId, driver, visible, focused]); +} diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.ts b/apps/mobile/src/widgets/useSubscriptionUsage.ts index 0ba5ad1f5107d..6a8b316e237f4 100644 --- a/apps/mobile/src/widgets/useSubscriptionUsage.ts +++ b/apps/mobile/src/widgets/useSubscriptionUsage.ts @@ -6,11 +6,11 @@ import { isAppActive, subscribeAppState } from "../lib/appState"; import { appAtomRegistry } from "../state/atom-registry"; import { environmentPresentations } from "../state/presentation"; import { serverEnvironment } from "../state/server"; -import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot"; +import { createWidgetRefresher } from "./subscriptionUsageSnapshot"; /** * App-lifetime worker that keeps provider quotas fresh for the usage widget: - * on resume, on every connection change, and on a fixed interval while active. + * on resume and connection changes; periodic reads belong to visible usage UI. * Each environment is refreshed at most once per interval. */ export function installSubscriptionUsageRefresh(): () => void { @@ -18,15 +18,23 @@ export function installSubscriptionUsageRefresh(): () => void { runAtomCommand( appAtomRegistry, serverEnvironment.refreshProviders, - { environmentId, input: {} }, + { environmentId, input: { usage: true } }, { label: serverEnvironment.refreshProviders.label, reportFailure: false }, ), ); + let lastConnections = ""; const update = () => { - if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return; + if (!isAppActive()) { + lastConnections = ""; + return; + } + if (!appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return; const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)] .filter(([, presentation]) => presentation.connection.phase === "connected") .map(([id]) => id); + const key = [...connected].sort().join(","); + if (key === lastConnections) return; + lastConnections = key; void refresh(connected, Date.now()); }; @@ -37,11 +45,9 @@ export function installSubscriptionUsageRefresh(): () => void { { immediate: true }, ); const unsubscribeAppState = subscribeAppState(update); - const timer = setInterval(update, WIDGET_REFRESH_INTERVAL); return () => { unsubscribeCatalog(); unsubscribePresentations(); unsubscribeAppState(); - clearInterval(timer); }; } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 7bd60fc939d3a..36b19d85a6aa9 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -215,6 +215,7 @@ export const ClaudeDriver: ProviderDriver CAPABILITIES_PROBE_TTL, }, ); + let lastAccount: ClaudeCapabilitiesProbe | undefined; const resolveCapabilities = Effect.gen(function* () { let includeUsage = yield* UsagePolling; if (includeUsage && usageHttpClient) { @@ -227,7 +228,18 @@ export const ClaudeDriver: ProviderDriver { const claudeId = ProviderInstanceId.make("claude_probe_recovery"); let probeCount = 0; let successfulProbeCount = 0; + let overrideAccount: string | undefined; let failProbe = false; const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( () => @@ -501,7 +502,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ], account: { email: - successfulProbeCount === 1 ? "account-a@example.com" : "account-b@example.com", + overrideAccount ?? + (successfulProbeCount === 1 + ? "account-a@example.com" + : "account-b@example.com"), subscriptionType: "pro", tokenSource: "oauth", }, @@ -595,6 +599,20 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { expect(repeatedFailure.auth).toEqual(sameAccountFailure.auth); expect(repeatedFailure.slashCommands).toEqual(sameAccountFailure.slashCommands); expect(probeCount).toBe(sameAccountFailureProbeCount); + + failProbe = false; + overrideAccount = "account-a@example.com"; + yield* claude!.invalidateCaches ?? Effect.void; + yield* claude!.snapshot.refresh; + yield* TestClock.adjust("1 minute"); + yield* claude!.snapshot.refreshUsage!; + expect((yield* claude!.snapshot.getSnapshot).auth.email).toBe(overrideAccount); + yield* TestClock.adjust("4 minutes"); + overrideAccount = "account-b@example.com"; + const changedHealth = yield* claude!.snapshot.refresh; + expect(changedHealth.auth.email).toBe(overrideAccount); + yield* claude!.snapshot.refreshUsage!; + expect((yield* claude!.snapshot.getSnapshot).auth.email).toBe(overrideAccount); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 1edd8f94464f7..929799eaff0a3 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -635,7 +635,8 @@ export const layer = Layer.effect( instances.filter( (instance) => instanceId === undefined || instance.instanceId === instanceId, ), - (instance) => refreshOneSource(instance, true), + (instance) => + refreshOneSource(instance, true).pipe(Effect.catchCause(recoverRefreshFailure)), { concurrency: "unbounded" }, ); return yield* Ref.get(providersRef); @@ -1093,7 +1094,8 @@ export const layer = Layer.effect( return { getProviders: Ref.get(providersRef), - refreshUsage, + refreshUsage: (instanceId) => + refreshUsage(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 35734af8eecd5..4bc6ff1679c80 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -20,23 +20,31 @@ function Probe(props: { } describe("useLiveRefresh", () => { - it("defers a hidden mounted usage surface until the window becomes visible", async () => { - const refresh = vi.fn(); - let visibility: DocumentVisibilityState = "hidden"; - using _visibility = vi - .spyOn(document, "visibilityState", "get") - .mockImplementation(() => visibility); - using _timers = fakeTimers({ now: 2_000_000 }); - // A bare hook probe; nothing here navigates. - await using _screen = await render( - , - { router: false }, - ); - expect(refresh).not.toHaveBeenCalled(); - visibility = "visible"; - await act(() => document.dispatchEvent(new Event("visibilitychange"))); - expect(refresh).toHaveBeenCalledTimes(1); - }); + it.each([true, false])( + "refreshes a hidden mount on arrival with refreshOnShow=%s", + async (refreshOnShow) => { + const refresh = vi.fn(); + let visibility: DocumentVisibilityState = "hidden"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); + using _timers = fakeTimers({ now: 2_000_000 }); + // A bare hook probe; nothing here navigates. + await using _screen = await render( + , + { router: false }, + ); + expect(refresh).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTime(30_000)); + visibility = "visible"; + await act(() => document.dispatchEvent(new Event("visibilitychange"))); + expect(refresh).toHaveBeenCalledTimes(1); + }, + ); it("with refreshOnShow, reads on every show unless it read within the floor", async () => { const refresh = vi.fn(); diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 8d1b7b1f76c0d..1b46529cefe15 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -111,10 +111,8 @@ export function useLiveRefresh( if (!refreshWhileIdle && now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return; const lastRefreshedAt = lastRefreshedAtByView.get(viewId); if (lastRefreshedAt === undefined) { - if (visible()) { - if (refreshOnShow) read(now); - else lastRefreshedAtByView.set(viewId, now); - } + if (!refreshOnShow) lastRefreshedAtByView.set(viewId, now); + else if (visible()) read(now); return; } if (shouldRefreshOnArrival({ visible: visible(), now, lastRefreshedAt })) read(now); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 04a2d66321517..81742973ce262 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1426,6 +1426,10 @@ export function createServerEnvironmentAtoms( input.cwd ?? null, input.fresh ?? false, input.refreshModels ?? false, + input.reloadSecrets ?? false, + input.usage ?? false, + input.usageSources ?? false, + input.usageProviders === undefined ? null : [...new Set(input.usageProviders)].sort(), ]), }, }), diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 74750ac8809da..ba4a4c7309445 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -278,13 +278,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); }); - const needsUsageRecovery = (snapshot: ServerProvider) => - input.checkUsageLimits !== undefined && - snapshot.installed && - snapshot.status === "ready" && - snapshot.auth.status === "authenticated" && - snapshot.usageLimits?.unavailable?.reason === "probeFailed"; - const needsProviderRefresh = (snapshot: ServerProvider) => input.refreshOnInterval !== false && (typeof input.refreshOnInterval !== "function" || input.refreshOnInterval(snapshot)); @@ -292,32 +285,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const refreshOnInterval = Effect.fn("ManagedServerProvider.refreshOnInterval")(function* () { const { snapshot } = yield* Ref.get(snapshotStateRef); if (!snapshot.enabled) return; - if (!(yield* UsagePolling) || !needsUsageRecovery(snapshot) || !input.checkUsageLimits) { - if (needsProviderRefresh(snapshot)) { - yield* applySnapshotBase(yield* input.getSettings, { forceRefresh: true }); - } - return; + if (needsProviderRefresh(snapshot)) { + yield* applySnapshotBase(yield* input.getSettings, { forceRefresh: true }); } - const probed = yield* input.checkUsageLimits; - const next = yield* Ref.modify(snapshotStateRef, (state) => { - const usageLimits = resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed, - }); - const snapshot = withUsageLimits(state.snapshot, usageLimits); - return [snapshot, { ...state, snapshot }] as const; - }); - yield* PubSub.publish(changesPubSub, next); }); const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only // republish a snapshot that differs in checkedAt. - if ( - !state.snapshot.enabled || - (!needsUsageRecovery(state.snapshot) && !needsProviderRefresh(state.snapshot)) - ) { + if (!state.snapshot.enabled || !needsProviderRefresh(state.snapshot)) { return false; } const instanceId = state.snapshot.instanceId; diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index bc2efa27ec2bb..b2dd128738e04 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,4 +1,7 @@ -import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; +import { + UsagePolling, + makeUnavailableUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind, ProviderSetupError, @@ -253,6 +256,19 @@ export const CursorDriver: ProviderDriver< Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(CursorKeychain.CursorKeychain, keychain), + Effect.map((provider) => + !includeUsage && + provider.usageLimits === undefined && + provider.auth.status === "authenticated" + ? { + ...provider, + usageLimits: makeUnavailableUsageLimits({ + checkedAt: provider.checkedAt, + reason: "probeFailed", + }), + } + : provider, + ), Effect.map(stampSnapshot), Effect.tap((provider) => Effect.sync(() => { diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index c499b4e278151..d17b2937ef638 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,4 +1,7 @@ -import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; +import { + UsagePolling, + makeUnavailableUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Digest from "effect/crypto/Digest"; @@ -375,7 +378,11 @@ export const OpenCodeDriver: ProviderDriver< ).pipe( Effect.map(({ provider, usageLimits }) => ({ ...provider, - ...(usageLimits ? { usageLimits } : {}), + usageLimits: + usageLimits ?? + (provider.auth.status === "authenticated" + ? makeUnavailableUsageLimits({ checkedAt: provider.checkedAt, reason: "probeFailed" }) + : undefined), })), Effect.map(stampIdentity), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), From 934fba4cb298d0569ac95cb0165a8841b0876850 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 14:44:11 -0500 Subject: [PATCH 05/95] fix: preserve usage freshness across cached and delayed reads --- .../src/features/usage/usageLimitsRefresh.ts | 28 +++++++++--- .../usage/useInstanceLimitsRefresh.ts | 44 ++++++++++++------- .../src/provider/AntigravityProvider.test.ts | 29 +++++++++--- .../src/provider/AntigravityProvider.ts | 11 ++++- apps/server/src/usage/cliproxyApi.test.ts | 8 +++- apps/server/src/usage/cliproxyApi.ts | 15 +++++-- apps/web/src/components/usage/UsagePage.tsx | 2 +- .../src/hooks/useLiveRefresh.browser.test.tsx | 25 ++++++++++- apps/web/src/hooks/useLiveRefresh.ts | 33 +++++++++++--- apps/web/src/hooks/useUsageLimitsRefresh.ts | 11 +++-- 10 files changed, 162 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 92274747bc7cf..82aa15d171793 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -42,15 +42,31 @@ export async function refreshEnvironmentLimits( // one every few minutes per environment. const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { - const refresh = () => { - void refreshEnvironmentLimits(get.registry, environmentId, { automatic: true }); + let disposed = false; + let pending = false; + let timer: ReturnType | undefined; + const refresh = async () => { + if (!isAppActive() || pending || disposed) return; + pending = true; + clearTimeout(timer); + try { + await refreshEnvironmentLimits(get.registry, environmentId, { automatic: true }); + } finally { + pending = false; + if (!disposed) + timer = setTimeout(() => { + void refresh(); + }, 5 * 60_000); + } }; - refresh(); - const unsubscribe = subscribeAppState(refresh); - const timer = setInterval(refresh, 5 * 60_000); + void refresh(); + const unsubscribe = subscribeAppState(() => { + void refresh(); + }); get.addFinalizer(() => { + disposed = true; unsubscribe(); - clearInterval(timer); + clearTimeout(timer); }); }).pipe(Atom.withLabel(`mobile:limits-auto-refresh:${environmentId}`)), ); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index d0b01be918253..8ba3d2d793d3c 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -17,28 +17,42 @@ export function useInstanceLimitsRefresh( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle useEffect(() => { if (!visible || !focused) return; + let disposed = false; + let pending = false; + let timer: ReturnType | undefined; const refresh = () => { - if (!isAppActive()) return; - void runAtomCommand( - registry, - serverEnvironment.refreshProviders, - { environmentId, input: { instanceId, usage: true } }, - { reportFailure: false }, - ); - if (driver) - void runAtomCommand( + if (!isAppActive() || pending || disposed) return; + pending = true; + clearTimeout(timer); + const completed = () => { + pending = false; + if (!disposed) timer = setTimeout(refresh, 5 * 60_000); + }; + void Promise.all([ + runAtomCommand( registry, serverEnvironment.refreshProviders, - { environmentId, input: { usageSources: true, usageProviders: [driver] } }, + { environmentId, input: { instanceId, usage: true } }, { reportFailure: false }, - ); + ), + driver + ? runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { environmentId, input: { usageSources: true, usageProviders: [driver] } }, + { reportFailure: false }, + ) + : undefined, + ]).then(completed, completed); }; - refresh(); - const unsubscribe = subscribeAppState(refresh); - const timer = setInterval(refresh, 5 * 60_000); + void refresh(); + const unsubscribe = subscribeAppState(() => { + void refresh(); + }); return () => { + disposed = true; unsubscribe(); - clearInterval(timer); + clearTimeout(timer); }; }, [registry, environmentId, instanceId, driver, visible, focused]); } diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 0713a83862087..de157ea682024 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -374,7 +374,13 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { it.effect("publishes session metadata and native commands without another health probe", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeHarness(); + let usageReads = 0; + const harness = yield* makeHarness({ + probeUsage: Effect.sync(() => { + usageReads++; + return { checkedAt: "2026-01-01T00:00:00.000Z", windows: [] }; + }), + }); yield* harness.initialize; const nextReady = yield* Stream.toPull( harness.provider.snapshot.streamChanges.pipe( @@ -392,6 +398,8 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { auth: { status: "authenticated", type: "oauth-personal" }, supportsTextGeneration: true, }); + expect(snapshot.usageLimits?.unavailable?.reason).toBe("probeFailed"); + expect(usageReads).toBe(0); expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); expect(snapshot.slashCommands).toEqual(expectedCommands); expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( @@ -718,7 +726,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { }; const usage = yield* Ref.make(limits); const harness = yield* makeHarness({ probeUsage: Ref.get(usage) }); - const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + const published = yield* harness.nextPublish( + (snapshot) => + snapshot.usageLimits !== undefined && snapshot.usageLimits.unavailable === undefined, + ); yield* harness.initialize; expect((yield* published)?.usageLimits).toEqual(limits); // The second health check is the one the failed background read triggers. @@ -805,14 +816,17 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { Effect.andThen(Deferred.await(readResult)), ), }); - const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + const published = yield* harness.nextPublish( + (snapshot) => + snapshot.usageLimits !== undefined && snapshot.usageLimits.unavailable === undefined, + ); yield* harness.initialize; yield* Deferred.await(readStarted); yield* harness.provider.onSessionStarted(started, "/workspace"); yield* harness.provider.onSessionStarted(started, "/other"); const ready = yield* harness.provider.snapshot.getSnapshot; expect(ready).toMatchObject({ status: "ready", auth: { status: "authenticated" } }); - expect(ready.usageLimits).toBeUndefined(); + expect(ready.usageLimits?.unavailable?.reason).toBe("probeFailed"); expect(yield* Ref.get(reads)).toBe(1); const limits = { checkedAt: "2026-10-09T12:00:00.000Z", @@ -838,7 +852,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { Deferred.succeed(oldReadStarted, undefined).pipe(Effect.andThen(Effect.never)), ); const harness = yield* makeHarness({ probeUsage: Ref.get(usage).pipe(Effect.flatten) }); - const published = yield* harness.nextPublish((snapshot) => !!snapshot.usageLimits); + const published = yield* harness.nextPublish( + (snapshot) => + snapshot.usageLimits !== undefined && snapshot.usageLimits.unavailable === undefined, + ); yield* harness.initialize; yield* Deferred.await(oldReadStarted); yield* Ref.set(usage, Effect.succeed(newLimits)); @@ -853,7 +870,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { } satisfies ServerProviderUsageLimits; yield* Ref.set(usage, Effect.succeed(failed)); const republished = yield* harness.nextPublish( - (snapshot) => snapshot.usageLimits?.unavailable?.reason === "probeFailed", + (snapshot) => snapshot.usageLimits?.checkedAt === failed.checkedAt, ); yield* harness.provider.onSignedIn; expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index c547418858a72..a304d62d2ed8c 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -1,4 +1,7 @@ -import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; +import { + UsagePolling, + makeUnavailableUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; import { ANTIGRAVITY_DEFAULT_MODEL, ProviderDriverKind, @@ -180,6 +183,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const authUncheckedMessage = usesCredentials ? `Antigravity is installed. Connect with ${options.auth.label} to load models.` : AUTH_UNCHECKED_MESSAGE; + const pendingUsage = (checkedAt: string) => + options.probeUsage + ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) + : undefined; const checkedAt = DateTime.formatIso(yield* DateTime.now); const initialDraft = { ...buildServerProvider({ @@ -197,6 +204,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func version: null, status: "warning", auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, + ...(options.probeUsage ? { usageLimits: pendingUsage(checkedAt)! } : {}), message: settings.enabled ? "Checking Antigravity availability." : "Antigravity is disabled in T3 Code settings.", @@ -386,6 +394,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func installed: true, status: "ready", version: started.initializeResult.agentInfo?.version || draft.version, + usageLimits: draft.usageLimits ?? pendingUsage(updatedAt), auth: { status: "authenticated", type: options.auth?.type ?? "oauth-personal", diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 85ffca68298a7..53509ab27903b 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -220,12 +220,16 @@ describe("CLIProxyAPI built-in management API", () => { expect(first.map((account) => account.usageLimits.windows[0]?.usedPercent)).toEqual([ 25, 25, ]); - yield* api.readAccounts(config, ["claudeAgent"]); + yield* TestClock.adjust("1 minute"); + const cached = yield* api.readAccounts(config, ["claudeAgent"]); + expect(cached.map((account) => account.usageLimits.checkedAt)).toEqual( + first.map((account) => account.usageLimits.checkedAt), + ); expect( test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), ).toHaveLength(2); limited = true; - yield* TestClock.adjust("5 minutes"); + yield* TestClock.adjust("4 minutes"); const stale = yield* api.readAccounts(config, ["claudeAgent"]); expect(stale.map((account) => account.usageLimits)).toEqual( first.map((account) => ({ ...account.usageLimits, stale: true })), diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f4e8275840b79..fb352e4846964 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -320,6 +320,9 @@ export const makeCliproxyApi = Effect.gen(function* () { () => new UsageLimitSourceError({ detail: "Could not read the usage request." }), ), Effect.flatMap(({ hub, account, url }) => apiCallUncached(hub, account, url)), + Effect.flatMap((body) => + Effect.map(DateTime.now, (now) => ({ body, checkedAt: DateTime.formatIso(now) })), + ), ), { capacity: 128, timeToLive: () => Duration.minutes(5) }, ); @@ -330,7 +333,7 @@ export const makeCliproxyApi = Effect.gen(function* () { data?: unknown, ) { if (data !== undefined) return yield* apiCallUncached(hub, account, url, data); - return yield* Cache.get(usageCache, yield* encodeJson({ hub, account, url })); + return (yield* Cache.get(usageCache, yield* encodeJson({ hub, account, url }))).body; }); const lastGoodLimits = new Map(); @@ -350,7 +353,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); + const { body, checkedAt } = yield* Cache.get( + usageCache, + yield* encodeJson({ hub, account, url: "https://api.anthropic.com/api/oauth/usage" }), + ); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -382,7 +388,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); + const { body, checkedAt } = yield* Cache.get( + usageCache, + yield* encodeJson({ hub, account, url: `${CODEX_BASE}/usage` }), + ); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e0d4d70eec492..7a4da0914b61f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -354,7 +354,7 @@ export function UsagePage() { .sort() .join(","); const autoRefreshLimits = useEffectEvent(() => { - void refreshLimits(); + return refreshLimits(); }); useLiveRefresh(() => autoRefreshLimits(), { enabled: showingLimits && connectedLimitsEnvironments !== "", diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 4bc6ff1679c80..3e567e341a0ea 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -4,22 +4,45 @@ import { act, fakeTimers, render } from "../../test/render"; import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "./useLiveRefresh"; function Probe(props: { - readonly refresh: () => void; + readonly refresh: () => void | Promise; readonly viewKey: string; readonly enabled?: boolean; readonly intervalMs?: number | null; readonly refreshOnShow?: boolean; + readonly refreshWhileIdle?: boolean; }) { useLiveRefresh(props.refresh, { key: props.viewKey, enabled: props.enabled ?? true, ...(props.intervalMs !== undefined ? { intervalMs: props.intervalMs } : {}), + refreshWhileIdle: props.refreshWhileIdle ?? false, ...(props.refreshOnShow !== undefined ? { refreshOnShow: props.refreshOnShow } : {}), }); return null; } describe("useLiveRefresh", () => { + it("schedules usage intervals after a slow refresh completes", async () => { + let complete!: () => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + const refresh = vi.fn().mockReturnValueOnce(pending); + using _timers = fakeTimers({ now: 3_000_000 }); + // A bare hook probe; nothing here navigates. + await using _screen = await render( + , + { router: false }, + ); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(1000)); + await act(() => complete()); + await act(() => vi.advanceTimersByTime(299_000)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(1000)); + expect(refresh).toHaveBeenCalledTimes(2); + }); + it.each([true, false])( "refreshes a hidden mount on arrival with refreshOnShow=%s", async (refreshOnShow) => { diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 1b46529cefe15..f92a831095e5e 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -71,7 +71,7 @@ function watchInteraction(resume: () => void): () => void { } export function useLiveRefresh( - refresh: (() => void) | null, + refresh: (() => void | Promise) | null, options: { readonly enabled?: boolean; readonly key?: string; @@ -96,7 +96,7 @@ export function useLiveRefresh( const runRefresh = useEffectEvent((viewId: string, now: number) => { if (refresh === null) return; lastRefreshedAtByView.set(viewId, now); - refresh(); + return refresh(); }); const fallbackViewId = useId(); const viewId = key ?? fallbackViewId; @@ -104,7 +104,23 @@ export function useLiveRefresh( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refresh interval tied to page visibility and user interaction listeners useEffect(() => { if (!enabled) return; - const read = (now: number) => runRefresh(viewId, now); + let timer: ReturnType | undefined; + let disposed = false; + let pending = false; + const read = (now: number) => { + if (refreshWhileIdle && pending) return; + const result = runRefresh(viewId, now); + if (!refreshWhileIdle) return; + pending = true; + clearInterval(timer); + const completed = () => { + pending = false; + if (disposed) return; + lastRefreshedAtByView.set(viewId, Date.now()); + syncTimer(); + }; + void Promise.resolve(result).then(completed, completed); + }; const visible = () => document.visibilityState === "visible"; const onArrival = () => { const now = Date.now(); @@ -129,13 +145,17 @@ export function useLiveRefresh( }) ) { read(now); - } + } else if (refreshWhileIdle) syncTimer(); }; - let timer: ReturnType | undefined; const syncTimer = () => { clearInterval(timer); - timer = visible() && intervalMs !== null ? setInterval(onInterval, intervalMs) : undefined; + timer = + visible() && intervalMs !== null && !pending + ? refreshWhileIdle + ? setTimeout(onInterval, intervalMs) + : setInterval(onInterval, intervalMs) + : undefined; }; const onVisibilityChange = () => { onArrival(); @@ -157,6 +177,7 @@ export function useLiveRefresh( window.addEventListener("focus", onArrival); document.addEventListener("visibilitychange", onVisibilityChange); return () => { + disposed = true; clearInterval(timer); window.removeEventListener("focus", onArrival); document.removeEventListener("visibilitychange", onVisibilityChange); diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 437a2cf32d156..d5349612e6194 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -16,9 +16,14 @@ export function useUsageLimitsRefresh( const refresh = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false }); useLiveRefresh( () => { - if (instanceId !== null) void refresh({ environmentId, input: { instanceId, usage: true } }); - if (driver) - void refresh({ environmentId, input: { usageSources: true, usageProviders: [driver] } }); + return Promise.all([ + instanceId !== null + ? refresh({ environmentId, input: { instanceId, usage: true } }) + : undefined, + driver + ? refresh({ environmentId, input: { usageSources: true, usageProviders: [driver] } }) + : undefined, + ]); }, { enabled: enabled && connected && instanceId !== null, From d149f0e6c4135666bc5006a82354b29ac8184f04 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 14:56:52 -0500 Subject: [PATCH 06/95] fix(web): refresh usage when an instance changes driver --- apps/web/src/hooks/useUsageLimitsRefresh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index d5349612e6194..19174245bd78e 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -27,7 +27,7 @@ export function useUsageLimitsRefresh( }, { enabled: enabled && connected && instanceId !== null, - key: `usage:${environmentId}:${instanceId}`, + key: `usage:${environmentId}:${instanceId}:${driver ?? ""}`, refreshOnShow: true, refreshWhileIdle: true, }, From f9b4f40385f3024e7554a2aea284728914538c7f Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 15:13:18 -0500 Subject: [PATCH 07/95] fix: refresh pooled accounts and retain native sample times --- .../usage/useInstanceLimitsRefresh.ts | 25 +++++++----------- .../src/provider/ProviderRegistry.test.ts | 15 ++++++++--- apps/server/src/provider/ProviderRegistry.ts | 10 ++++--- .../src/provider/antigravityUsageLimits.ts | 23 +++++++++++++--- apps/server/src/ws.ts | 6 +++-- apps/web/src/hooks/useUsageLimitsRefresh.ts | 13 ++++------ .../src/server/usageHttpClient.test.ts | 6 +++-- .../src/server/usageHttpClient.ts | 26 +++++++++++++++++-- .../provider-core/src/server/usageLimits.ts | 2 +- .../provider-cursor/src/server/usageLimits.ts | 3 ++- .../provider-grok/src/server/usageLimits.ts | 3 ++- .../src/server/usageLimits.ts | 3 ++- 12 files changed, 90 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index 8ba3d2d793d3c..e59cd23cf2bec 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -28,22 +28,15 @@ export function useInstanceLimitsRefresh( pending = false; if (!disposed) timer = setTimeout(refresh, 5 * 60_000); }; - void Promise.all([ - runAtomCommand( - registry, - serverEnvironment.refreshProviders, - { environmentId, input: { instanceId, usage: true } }, - { reportFailure: false }, - ), - driver - ? runAtomCommand( - registry, - serverEnvironment.refreshProviders, - { environmentId, input: { usageSources: true, usageProviders: [driver] } }, - { reportFailure: false }, - ) - : undefined, - ]).then(completed, completed); + void runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { + environmentId, + input: driver ? { usage: true, usageProviders: [driver] } : { instanceId, usage: true }, + }, + { reportFailure: false }, + ).then(completed, completed); }; void refresh(); const unsubscribe = subscribeAppState(() => { diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index e318a3c79a66e..24a2f00003629 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1492,9 +1492,8 @@ it.layer( it.effect("invalidates model caches only for an explicit model refresh", () => Effect.gen(function* () { const forceRefreshCalls = yield* Ref.make(0); - const makeInstance = (name: string) => + const makeInstance = (name: string, driver = ProviderDriverKind.make("codex")) => Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make(name); const provider = { instanceId, @@ -1510,6 +1509,7 @@ it.layer( skills: [], } as const satisfies ServerProvider; const invalidations = yield* Ref.make(0); + const usageReads = yield* Ref.make(0); const maintenanceReads = yield* Ref.make>([]); const instance = { instanceId, @@ -1532,6 +1532,7 @@ it.layer( ), getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), + refreshUsage: Ref.update(usageReads, (count) => count + 1), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, @@ -1540,11 +1541,12 @@ it.layer( textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); - return { instance, invalidations, takeMaintenanceReads }; + return { instance, invalidations, takeMaintenanceReads, usageReads }; }); const first = yield* makeInstance("codex_first"); const second = yield* makeInstance("codex_second"); - const instances = [first.instance, second.instance]; + const other = yield* makeInstance("claude_other", ProviderDriverKind.make("claudeAgent")); + const instances = [first.instance, second.instance, other.instance]; const layerInstanceRegistry = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, { @@ -1569,6 +1571,7 @@ it.layer( ProviderLatestVersions.make([ ["codex_first-package", "1.0.0"], ["codex_second-package", "1.0.0"], + ["claude_other-package", "1.0.0"], ]), ); const readCachedVersion = (key: string) => @@ -1578,6 +1581,10 @@ it.layer( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; + yield* registry.refreshUsage(undefined, [ProviderDriverKind.make("codex")]); + assert.strictEqual(yield* Ref.get(first.usageReads), 1); + assert.strictEqual(yield* Ref.get(second.usageReads), 1); + assert.strictEqual(yield* Ref.get(other.usageReads), 0); yield* registry.refreshInstance(first.instance.instanceId); yield* registry.refresh(); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 929799eaff0a3..5b80db3889b57 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -61,6 +61,7 @@ export class ProviderRegistry extends Context.Service< readonly refreshUsage: ( instanceId?: ProviderInstanceId, + providers?: readonly ProviderDriverKind[], ) => Effect.Effect>; /** Unknown instance ids return the cached list without error. */ @@ -629,11 +630,14 @@ export const layer = Layer.effect( const refreshUsage = Effect.fn("ProviderRegistry.refreshUsage")(function* ( instanceId?: ProviderInstanceId, + providers?: readonly ProviderDriverKind[], ) { const instances = yield* instanceRegistry.listInstances; yield* Effect.forEach( instances.filter( - (instance) => instanceId === undefined || instance.instanceId === instanceId, + (instance) => + (instanceId === undefined || instance.instanceId === instanceId) && + (providers === undefined || providers.includes(instance.driverKind)), ), (instance) => refreshOneSource(instance, true).pipe(Effect.catchCause(recoverRefreshFailure)), @@ -1094,8 +1098,8 @@ export const layer = Layer.effect( return { getProviders: Ref.get(providersRef), - refreshUsage: (instanceId) => - refreshUsage(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshUsage: (instanceId, providers) => + refreshUsage(instanceId, providers).pipe(Effect.catchCause(recoverRefreshFailure)), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 5445c01432a2f..e65c2ade77c8c 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,3 +1,4 @@ +import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; @@ -206,11 +207,17 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ), ); - const request = (request: HttpClientRequest.HttpClientRequest) => + const request = ( + request: HttpClientRequest.HttpClientRequest, + onSample?: (checkedAt: string) => void, + ) => client.execute(request).pipe( Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.json), + Effect.flatMap((response) => { + onSample?.(usageResponseCheckedAt(response, "")); + return response.json; + }), Effect.timeout("10 seconds"), ); const read = Effect.gen(function* () { @@ -269,6 +276,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } + const sampleTimes: string[] = []; // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, @@ -282,7 +290,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( (endpoint) => Effect.gen(function* () { // Capture each request after auth, so refresh latency does not widen the tolerance. - const requestedAt = yield* Clock.currentTimeMillis; + let requestedAt = yield* Clock.currentTimeMillis; return yield* request( HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( HttpClientRequest.setHeaders({ @@ -293,6 +301,10 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( token.project_id ? { project: token.project_id } : {}, ), ), + (sampleAt) => { + if (sampleAt) requestedAt = Date.parse(sampleAt); + sampleTimes.push(sampleAt || DateTime.formatIso(DateTime.makeUnsafe(requestedAt))); + }, ).pipe( Effect.flatMap((response) => parse(response, requestedAt)), Effect.orElseSucceed(() => []), @@ -322,7 +334,10 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( message: "Antigravity did not return usable quota information.", }); } - return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account }; + return { + ...makeUsageLimits({ checkedAt: sampleTimes.toSorted()[0] ?? checkedAt, windows }), + credentialFingerprint: account, + }; }); const probe = read.pipe( Effect.timeout("30 seconds"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1600473b98257..30aa100b7b10a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2436,7 +2436,9 @@ export const layerWsRpc = ( } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.usage === true && input.instanceId === undefined) { - yield* usageLimitSources.refresh; + yield* usageLimitSources.refresh.pipe( + Effect.provideService(UsageProviderFilter, input.usageProviders), + ); } let providers = yield* ( input.cwd !== undefined && input.instanceId !== undefined @@ -2448,7 +2450,7 @@ export const layerWsRpc = ( : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) : input.usage === true - ? providerRegistry.refreshUsage(input.instanceId) + ? providerRegistry.refreshUsage(input.instanceId, input.usageProviders) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) : providerRegistry.refresh() diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 19174245bd78e..9094d9a4366da 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -16,14 +16,11 @@ export function useUsageLimitsRefresh( const refresh = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false }); useLiveRefresh( () => { - return Promise.all([ - instanceId !== null - ? refresh({ environmentId, input: { instanceId, usage: true } }) - : undefined, - driver - ? refresh({ environmentId, input: { usageSources: true, usageProviders: [driver] } }) - : undefined, - ]); + if (instanceId === null) return; + return refresh({ + environmentId, + input: driver ? { usage: true, usageProviders: [driver] } : { instanceId, usage: true }, + }); }, { enabled: enabled && connected && instanceId !== null, diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index 92c7de6312803..cd2de1f447e0e 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -4,7 +4,7 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { makeUsageHttpClient } from "./usageHttpClient.ts"; +import { makeUsageHttpClient, usageResponseCheckedAt } from "./usageHttpClient.ts"; const url = "https://api.anthropic.com/api/oauth/usage"; const request = (account: string, suffix = "") => @@ -31,8 +31,10 @@ it.effect( assert.deepStrictEqual(yield* responses[0]!.json, yield* responses[1]!.json); yield* client.execute(request("b")); assert.strictEqual(calls, 2); + const sampleAt = DateTime.formatIso(yield* DateTime.now); yield* TestClock.adjust("4 minutes"); - yield* client.execute(request("a")); + const cached = yield* client.execute(request("a")); + assert.strictEqual(usageResponseCheckedAt(cached, "missing"), sampleAt); assert.strictEqual(calls, 2); yield* TestClock.adjust("1 minute"); yield* client.execute(request("a")); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 5b38cd156ac3f..0f7aef3a16621 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -1,3 +1,4 @@ +import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -8,6 +9,15 @@ import type * as HttpClientError from "effect/http/HttpClientError"; const encodeKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const sampleTimes = new WeakMap(); + +export function usageResponseCheckedAt( + response: HttpClientResponse.HttpClientResponse, + fallback: string, +) { + return sampleTimes.get(response) ?? fallback; +} + const TTL_MS = 5 * 60_000; function isUsageUrl(url: string) { @@ -39,6 +49,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( ) { return Effect.sync(() => { type Snapshot = { + readonly checkedAt: string; readonly status: number; readonly headers: Readonly>; readonly body: ArrayBuffer; @@ -78,7 +89,12 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( const previous = cached.get(key); if (previous && now < previous.expiresAt) { const saved = yield* previous.result; - return HttpClientResponse.fromWeb(request, new Response(saved.body.slice(0), saved)); + const response = HttpClientResponse.fromWeb( + request, + new Response(saved.body.slice(0), saved), + ); + sampleTimes.set(response, saved.checkedAt); + return response; } if (now < state.retryAt) { return HttpClientResponse.fromWeb( @@ -104,6 +120,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( ); } return { + checkedAt: DateTime.formatIso(DateTime.makeUnsafe(now)), status: response.status, headers: response.headers, body: yield* response.arrayBuffer, @@ -119,7 +136,12 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), }); const saved = yield* result; - return HttpClientResponse.fromWeb(request, new Response(saved.body.slice(0), saved)); + const response = HttpClientResponse.fromWeb( + request, + new Response(saved.body.slice(0), saved), + ); + sampleTimes.set(response, saved.checkedAt); + return response; }), ); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 57d9e8bda828e..d7e04d9a2cb60 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -1,4 +1,4 @@ -export { makeUsageHttpClient, usageRetryDelay } from "./usageHttpClient.ts"; +export { makeUsageHttpClient, usageRetryDelay, usageResponseCheckedAt } from "./usageHttpClient.ts"; import type { ProviderUsageLimitsUpdate, ServerProviderUsageLimits, diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 87fcecef4e97f..1f6e8822cdc8b 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,3 +1,4 @@ +import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -146,7 +147,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); - return cursorUsageResponseToLimits(body, checkedAt); + return cursorUsageResponseToLimits(body, usageResponseCheckedAt(response, checkedAt)); }).pipe( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index fb6c08e1f6d25..a086be56aaa84 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,3 +1,4 @@ +import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -199,7 +200,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); - return grokUsageResponseToLimits(body, checkedAt); + return grokUsageResponseToLimits(body, usageResponseCheckedAt(response, checkedAt)); }).pipe( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => probeFailed), diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 0f97ea36cfefa..52f9a0c340dfa 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,3 +1,4 @@ +import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; import * as NodeSqlite from "node:sqlite"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -170,7 +171,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${apiKey}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); return { - ...makeUsageLimits({ checkedAt, windows }), + ...makeUsageLimits({ checkedAt: usageResponseCheckedAt(response, checkedAt), windows }), credentialFingerprint, }; }).pipe( From 0e899b9772322c852bec72950db168fc9fe22c06 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 15:30:47 -0500 Subject: [PATCH 08/95] fix(server): isolate Claude probes and retain source backoff --- .../src/provider/Drivers/ClaudeDriver.ts | 23 +++++++++++---- .../provider/ProviderInstanceRegistry.test.ts | 1 - .../src/provider/claudeResetCredits.test.ts | 24 +++++++++++++++- .../server/src/provider/claudeResetCredits.ts | 15 +++++++++- .../src/usage/UsageLimitSources.test.ts | 28 ++++++++++++++++--- apps/server/src/usage/UsageLimitSources.ts | 11 ++++---- apps/server/src/usage/cliproxyApi.test.ts | 8 +++--- apps/server/src/usage/cliproxyApi.ts | 8 +++--- 8 files changed, 93 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 36b19d85a6aa9..a6d03275ddd13 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -59,6 +59,9 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +const ProbeCacheKey = Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Boolean])); +const encodeProbeKey = Schema.encodeSync(ProbeCacheKey); +const decodeProbeKey = Schema.decodeSync(ProbeCacheKey); const withoutUsage = (capabilities: ClaudeCapabilitiesProbe): ClaudeCapabilitiesProbe => { const result = { ...capabilities }; @@ -177,8 +180,9 @@ export const ClaudeDriver: ProviderDriver(undefined); const capabilitiesProbeCache = yield* Cache.makeWith( - (includeUsage: boolean) => - probeClaudeCapabilities(effectiveConfig, processEnv, stateDir, includeUsage).pipe( + (key: string) => { + const [, includeUsage] = decodeProbeKey(key); + return probeClaudeCapabilities(effectiveConfig, processEnv, stateDir, includeUsage).pipe( Effect.flatMap((capabilities) => ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( Effect.map((workspaceId) => ({ @@ -209,14 +213,23 @@ export const ClaudeDriver: ProviderDriver CAPABILITIES_PROBE_TTL, }, ); let lastAccount: ClaudeCapabilitiesProbe | undefined; + let lastIdentity: string | undefined; const resolveCapabilities = Effect.gen(function* () { + const identity = yield* ClaudeResetCredits.readClaudeAccountCacheKey(accountConfigPath); + if (lastIdentity !== identity) { + yield* Cache.invalidateAll(capabilitiesProbeCache); + yield* Ref.set(lastGoodCapabilities, undefined); + lastAccount = undefined; + lastIdentity = identity; + } let includeUsage = yield* UsagePolling; if (includeUsage && usageHttpClient) { const token = yield* ClaudeResetCredits.readAccessToken(configDir).pipe( @@ -230,14 +243,14 @@ export const ClaudeDriver: ProviderDriver { accountConfigPath, JSON.stringify({ oauthAccount: { organizationUuid: "org-b" } }), ); - yield* claude!.invalidateCaches ?? Effect.void; failProbe = true; const accountChangedFailure = yield* claude!.snapshot.refresh; diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8db751657454c..4402d131a157f 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -90,7 +90,29 @@ describe("claudeResetCreditsToContract", () => { effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { it.effect("reads the grants with the CLI's request", () => Effect.gen(function* () { - const { configDir } = yield* writeLogin; + const { configDir, accountConfigPath } = yield* writeLogin; + const fs = yield* FileSystem.FileSystem; + const initialIdentity = + yield* ClaudeResetCredits.readClaudeAccountCacheKey(accountConfigPath); + yield* fs.writeFileString( + accountConfigPath, + '{"numStartups":42,"oauthAccount":{"organizationUuid":"org-1"}}', + ); + expect(yield* ClaudeResetCredits.readClaudeAccountCacheKey(accountConfigPath)).toBe( + initialIdentity, + ); + yield* fs.writeFileString( + accountConfigPath, + '{"oauthAccount":{"organizationUuid":"org-1","accountUuid":"account-a","emailAddress":"a@example.test"}}', + ); + const firstAccount = yield* ClaudeResetCredits.readClaudeAccountCacheKey(accountConfigPath); + yield* fs.writeFileString( + accountConfigPath, + '{"oauthAccount":{"organizationUuid":"org-1","accountUuid":"account-b","emailAddress":"b@example.test"}}', + ); + expect(yield* ClaudeResetCredits.readClaudeAccountCacheKey(accountConfigPath)).not.toBe( + firstAccount, + ); const client = HttpClient.make((request) => { expect(request.method).toBe("GET"); expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index a4ac2c917f083..5c9af74b3e7ac 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -22,7 +22,11 @@ const Credentials = Schema.Struct({ }); const Config = Schema.Struct({ oauthAccount: Schema.optional( - Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), + Schema.Struct({ + organizationUuid: Schema.optional(Schema.String), + accountUuid: Schema.optional(Schema.String), + emailAddress: Schema.optional(Schema.String), + }), ), }); const Grant = Schema.Struct({ @@ -172,6 +176,15 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( Effect.orElseSucceed(() => undefined), ); +const encodeAccountCacheKey = Schema.encodeSync(Schema.fromJsonString(Config)); +export const readClaudeAccountCacheKey = Effect.fn("claudeResetCredits.readAccountCacheKey")( + (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map(encodeAccountCacheKey), + Effect.orElseSucceed(() => "unreadable"), + ), +); + export const readClaudeOrganizationId = (accountConfigPath: string) => readJson(Config, accountConfigPath).pipe( Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 562ce90821dc2..481e5e5fe6c0d 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -163,8 +163,9 @@ it.effect("reads a management key from its secret provider", () => { ); }); -it.effect("retains another window's account when provider-filtered usage refreshes finish", () => - Effect.gen(function* () { +it.effect("retains another window's account when provider-filtered usage refreshes finish", () => { + let fail = false; + return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["claudeAgent"])); expect((yield* sources.current)[0]?.accounts.map((account) => account.driver)).toEqual([ @@ -175,6 +176,23 @@ it.effect("retains another window's account when provider-filtered usage refresh "claudeAgent", "codex", ]); + const prior = (yield* sources.current)[0]!; + fail = true; + yield* sources.refresh; + const failed = (yield* sources.current)[0]!; + expect(failed.accounts).toEqual( + prior.accounts.map((account) => ({ + ...account, + usageLimits: { ...account.usageLimits, stale: true }, + })), + ); + expect(failed.error).toContain("401"); + fail = false; + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); + expect( + (yield* sources.current)[0]?.accounts.find((account) => account.driver === "claudeAgent") + ?.usageLimits.stale, + ).toBe(true); }).pipe( Effect.provide([ ServerSettings.layerTest({ @@ -195,6 +213,8 @@ it.effect("retains another window's account when provider-filtered usage refresh HttpClient.HttpClient, HttpClient.make((request) => Effect.sync(() => { + if (fail) + return HttpClientResponse.fromWeb(request, Response.json({}, { status: 401 })); if (request.url.endsWith("/auth-files")) return HttpClientResponse.fromWeb( request, @@ -225,5 +245,5 @@ it.effect("retains another window's account when provider-filtered usage refresh NodeCrypto.layer, ]), Effect.scoped, - ), -); + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index c96e60592ef8c..a2c026b303099 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -91,11 +91,12 @@ export const make = Effect.gen(function* () { .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); - return { - ...base, - accounts: lastGoodSources.get(accountKey) ?? [], - error: accounts.failure.detail, - }; + const retained = (lastGoodSources.get(accountKey) ?? []).map((account) => ({ + ...account, + usageLimits: { ...account.usageLimits, stale: true }, + })); + lastGoodSources.set(accountKey, retained); + return { ...base, accounts: retained, error: accounts.failure.detail }; } const retained = providers === undefined diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 53509ab27903b..250839f74ad14 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -202,16 +202,16 @@ describe("CLIProxyAPI hub failures", () => { }); describe("CLIProxyAPI built-in management API", () => { - it.effect( - "paces filtered Claude accounts, preserves readings, and shares account cooldowns", - () => + it.effect.each([429, 503])( + "paces filtered Claude accounts, preserves readings, and shares HTTP %s cooldowns", + (status) => Effect.gen(function* () { let limited = false; const test = fixture({ accounts: accounts.map((account) => ({ ...account, provider: "claude" })), upstream: () => limited - ? { status: 429, body: {}, headers: { "Retry-After": ["600"] } } + ? { status, body: {}, headers: { "Retry-After": ["600"] } } : { status: 200, body: { five_hour: { utilization: 25, resets_at: null } } }, }); const api = yield* test.api; diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index fb352e4846964..abe3d9fb1ba1b 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -275,10 +275,10 @@ export const makeCliproxyApi = Effect.gen(function* () { ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); const response = yield* decodeApiResponse(raw); - if (response.status_code === 429) { - const value = Object.entries(response.header ?? {}).find( - ([name]) => name.toLowerCase() === "retry-after", - )?.[1]; + const value = Object.entries(response.header ?? {}).find( + ([name]) => name.toLowerCase() === "retry-after", + )?.[1]; + if (response.status_code === 429 || (response.status_code >= 500 && value !== undefined)) { cooldowns.set( accountKey, (yield* Clock.currentTimeMillis) + From bf5ee43f80788fb1d6ec516d753376e82359f531 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 15:37:54 -0500 Subject: [PATCH 09/95] fix(server): discard usage successes invalidated in flight --- .../src/server/usageHttpClient.test.ts | 34 +++++++++++++++++++ .../src/server/usageHttpClient.ts | 25 ++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index cd2de1f447e0e..815b2e520cc4a 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -2,6 +2,8 @@ import { assert, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { makeUsageHttpClient, usageResponseCheckedAt } from "./usageHttpClient.ts"; @@ -83,3 +85,35 @@ it.effect.each(["seconds", "date", "missing"] as const)( assert.strictEqual(calls, 3); }), ); + +it.effect.each([200, 503, 429])( + "invalidates in-flight successes without losing failed-read backoff (%s)", + (status) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + let calls = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((input) => + Effect.gen(function* () { + calls++; + const reading = calls; + if (reading === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(finish); + } + return HttpClientResponse.fromWeb(input, Response.json({ reading }, { status })); + }), + ), + ); + const pending = yield* client.execute(request("a")).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* client.invalidateSuccessfulReads("https://api.anthropic.com", "Bearer a"); + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(pending); + const refreshed = yield* client.execute(request("a")); + const expected = status < 400 ? 2 : 1; + assert.deepStrictEqual(yield* refreshed.json, { reading: expected }); + assert.strictEqual(calls, expected); + }), +); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 0f7aef3a16621..e522e4b31fe0f 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -62,7 +62,10 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( readonly expiresAt: number; } >(); - const accounts = new Map(); + const accounts = new Map< + string, + { readonly lock: Semaphore.Semaphore; retryAt: number; generation: number } + >(); const cachedClient = HttpClient.transform(client, (read, request) => { if (!isUsageUrl(request.url)) return read; return Effect.gen(function* () { @@ -72,7 +75,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( ]); let account = accounts.get(accountKey); if (!account) { - account = { lock: Semaphore.makeUnsafe(1), retryAt: 0 }; + account = { lock: Semaphore.makeUnsafe(1), retryAt: 0, generation: 0 }; accounts.set(accountKey, account); } const state = account; @@ -105,6 +108,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( }), ); } + const generation = state.generation; const result = yield* read.pipe( Effect.flatMap((response) => Effect.gen(function* () { @@ -130,11 +134,16 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( Effect.exit, ); const finishedAt = yield* Clock.currentTimeMillis; - cached.set(key, { - accountKey, - result, - expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), - }); + if ( + generation === state.generation || + Exit.isFailure(result) || + result.value.status >= 400 + ) + cached.set(key, { + accountKey, + result, + expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), + }); const saved = yield* result; const response = HttpClientResponse.fromWeb( request, @@ -150,6 +159,8 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( invalidateSuccessfulReads: (origin: string, authorization: string) => Effect.sync(() => { const accountKey = encodeKey([origin, authorization]); + const account = accounts.get(accountKey); + if (account) account.generation++; for (const [key, saved] of cached) { if ( saved.accountKey === accountKey && From 2a2a34984022d32ec59eaf27150cd428a0c872ef Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 15:59:22 -0500 Subject: [PATCH 10/95] fix(server): retain quota cooldowns through OAuth renewal --- .../provider/antigravityUsageLimits.test.ts | 56 ++++++++++++++++++- .../src/provider/antigravityUsageLimits.ts | 6 +- .../src/server/usageHttpClient.ts | 10 +++- .../provider-core/src/server/usageLimits.ts | 7 ++- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 612ea11efcefa..3b61498f31c1f 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -10,6 +10,8 @@ import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; + import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts"; const summary = { @@ -58,6 +60,8 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( summaryByHost?: Record; models?: unknown; summaryStatus?: number; + usageRetryAfter?: string; + rotateAccessToken?: boolean; /** Per-host summary statuses; hosts not listed fall back to `summaryStatus`. */ summaryStatusByHost?: Record; tokenStatus?: number; @@ -85,6 +89,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( ); if (!options.missingToken) yield* writeToken(options.refreshToken ?? "refresh-a"); const requests: Array<{ url: string; body: string }> = []; + let tokenNumber = 0; const http = HttpClient.make((request, url, _signal, fiber) => Effect.sync(() => { const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit); @@ -93,16 +98,22 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; requests.push({ url: request.url, body }); if (request.url.endsWith("/token")) { + tokenNumber++; expect(new URLSearchParams(body).get("grant_type")).toBe("refresh_token"); return HttpClientResponse.fromWeb( request, Response.json( - { access_token: "access-token", expires_in: 3600 }, + { + access_token: options.rotateAccessToken ? `access-${tokenNumber}` : "access-token", + expires_in: 3600, + }, { status: options.tokenStatus ?? 200 }, ), ); } - expect(request.headers.authorization).toBe("Bearer access-token"); + expect(request.headers.authorization).toBe( + options.rotateAccessToken ? `Bearer access-${tokenNumber}` : "Bearer access-token", + ); if (request.url.endsWith("/userinfo")) { return HttpClientResponse.fromWeb( request, @@ -118,6 +129,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( request.url.endsWith(":retrieveUserQuotaSummary") ? Response.json(options.summaryByHost?.[url.host] ?? options.summary ?? summary, { status: options.summaryStatusByHost?.[url.host] ?? options.summaryStatus ?? 200, + headers: options.usageRetryAfter ? { "retry-after": options.usageRetryAfter } : {}, }) : Response.json(options.models ?? { models: {} }), ); @@ -132,11 +144,49 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( const usage = yield* makeAntigravityUsageProbe({ profileDirectory, authMethod: options.authMethod ?? "oauth-personal", - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + options.usageRetryAfter ? yield* makeUsageHttpClient(http) : http, + ), + ); return { ...usage, requests, writeToken, tokenPath }; }); describe("Antigravity subscription limits", () => { + it.effect("retains account cooldowns when an expiring OAuth access token is renewed", () => + Effect.gen(function* () { + const options = { + rotateAccessToken: true, + usageRetryAfter: "600", + summaryStatus: 200, + userId: "google-account", + }; + const { probe, requests, writeToken } = yield* fixture(options); + yield* probe; + yield* TestClock.adjust("55 minutes"); + options.summaryStatus = 429; + yield* probe; + const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; + expect(quotaCalls()).toBe(4); + yield* TestClock.adjust("5 minutes"); + yield* probe; + expect(requests.filter(({ url }) => url.endsWith("/token"))).toHaveLength(2); + expect(quotaCalls()).toBe(4); + options.userId = "another-google-account"; + yield* writeToken("another-refresh-token"); + yield* probe; + expect(quotaCalls()).toBe(6); + options.userId = "google-account"; + yield* writeToken("refresh-a"); + yield* probe; + expect(quotaCalls()).toBe(6); + yield* TestClock.adjust("5 minutes"); + yield* probe; + expect(quotaCalls()).toBe(8); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + // Oracle: the 10:16 UTC payload and its narrow Workaround rule. it.effect("omits rolling resets for untouched summary buckets and keeps used Gemini resets", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index e65c2ade77c8c..bd633dae108cb 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,4 +1,7 @@ -import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; +import { + usageResponseCheckedAt, + UsageAccountIdentity, +} from "@t3tools/provider-core/server/usageLimits"; import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; @@ -306,6 +309,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( sampleTimes.push(sampleAt || DateTime.formatIso(DateTime.makeUnsafe(requestedAt))); }, ).pipe( + Effect.provideService(UsageAccountIdentity, `antigravity:${account}`), Effect.flatMap((response) => parse(response, requestedAt)), Effect.orElseSucceed(() => []), ); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index e522e4b31fe0f..56dbb8d9d6f62 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -1,5 +1,6 @@ import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Exit from "effect/Exit"; @@ -18,6 +19,11 @@ export function usageResponseCheckedAt( return sampleTimes.get(response) ?? fallback; } +export const UsageAccountIdentity = Context.Reference( + "t3/provider/UsageAccountIdentity", + { defaultValue: () => undefined }, +); + const TTL_MS = 5 * 60_000; function isUsageUrl(url: string) { @@ -69,9 +75,10 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( const cachedClient = HttpClient.transform(client, (read, request) => { if (!isUsageUrl(request.url)) return read; return Effect.gen(function* () { + const identity = yield* UsageAccountIdentity; const accountKey = encodeKey([ new URL(request.url).origin, - request.headers.authorization ?? "", + identity === undefined ? (request.headers.authorization ?? "") : ["account", identity], ]); let account = accounts.get(accountKey); if (!account) { @@ -81,6 +88,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( const state = account; const key = encodeKey([ accountKey, + request.headers.authorization ?? "", request.method, request.url, request.urlParams, diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index d7e04d9a2cb60..6186b2fdcc02e 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -1,4 +1,9 @@ -export { makeUsageHttpClient, usageRetryDelay, usageResponseCheckedAt } from "./usageHttpClient.ts"; +export { + makeUsageHttpClient, + usageRetryDelay, + usageResponseCheckedAt, + UsageAccountIdentity, +} from "./usageHttpClient.ts"; import type { ProviderUsageLimitsUpdate, ServerProviderUsageLimits, From 4d5df9426f4122eca4b7125f9b09dd8b28ceff36 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 16:21:36 -0500 Subject: [PATCH 11/95] fix(server): retain quota identity when userinfo fails --- .../provider/antigravityUsageLimits.test.ts | 67 ++++++++++--------- .../src/provider/antigravityUsageLimits.ts | 4 +- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 3b61498f31c1f..2e8fb5b8e5505 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -66,7 +66,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( summaryStatusByHost?: Record; tokenStatus?: number; refreshToken?: string; - userId?: string; + userId?: string | undefined; userInfoAt?: number; missingToken?: boolean; authMethod?: "oauth-personal" | "gemini-api-key"; @@ -154,37 +154,40 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( }); describe("Antigravity subscription limits", () => { - it.effect("retains account cooldowns when an expiring OAuth access token is renewed", () => - Effect.gen(function* () { - const options = { - rotateAccessToken: true, - usageRetryAfter: "600", - summaryStatus: 200, - userId: "google-account", - }; - const { probe, requests, writeToken } = yield* fixture(options); - yield* probe; - yield* TestClock.adjust("55 minutes"); - options.summaryStatus = 429; - yield* probe; - const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; - expect(quotaCalls()).toBe(4); - yield* TestClock.adjust("5 minutes"); - yield* probe; - expect(requests.filter(({ url }) => url.endsWith("/token"))).toHaveLength(2); - expect(quotaCalls()).toBe(4); - options.userId = "another-google-account"; - yield* writeToken("another-refresh-token"); - yield* probe; - expect(quotaCalls()).toBe(6); - options.userId = "google-account"; - yield* writeToken("refresh-a"); - yield* probe; - expect(quotaCalls()).toBe(6); - yield* TestClock.adjust("5 minutes"); - yield* probe; - expect(quotaCalls()).toBe(8); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + it.effect.each([true, false])( + "retains account cooldowns through OAuth renewal (userinfo available: %s)", + (userInfoAvailable) => + Effect.gen(function* () { + const options = { + rotateAccessToken: true, + usageRetryAfter: "600", + summaryStatus: 200, + userId: "google-account" as string | undefined, + }; + const { probe, requests, writeToken } = yield* fixture(options); + yield* probe; + yield* TestClock.adjust("55 minutes"); + options.summaryStatus = 429; + yield* probe; + const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; + expect(quotaCalls()).toBe(4); + yield* TestClock.adjust("5 minutes"); + if (!userInfoAvailable) options.userId = undefined; + yield* probe; + expect(requests.filter(({ url }) => url.endsWith("/token"))).toHaveLength(2); + expect(quotaCalls()).toBe(4); + options.userId = "another-google-account"; + yield* writeToken("another-refresh-token"); + yield* probe; + expect(quotaCalls()).toBe(6); + options.userId = "google-account"; + yield* writeToken("refresh-a"); + yield* probe; + expect(quotaCalls()).toBe(6); + yield* TestClock.adjust("5 minutes"); + yield* probe; + expect(quotaCalls()).toBe(8); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); // Oracle: the 10:16 UTC payload and its narrow Workaround rule. diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index bd633dae108cb..eff630e17c4d4 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -245,7 +245,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( let accessToken: string; let account: string; if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) { - cached = undefined; + const previousAccount = cached?.fingerprint === fingerprint ? cached.account : undefined; const refreshed = yield* request( HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe( HttpClientRequest.bodyUrlParams({ @@ -265,7 +265,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ).pipe( Effect.flatMap(decodeUserInfo), Effect.flatMap((info) => sha256(`google-account\0${info.id}`)), - Effect.orElseSucceed(() => fingerprint), + Effect.orElseSucceed(() => previousAccount ?? fingerprint), ); if (generation === startedGeneration) { cached = { From 068dfe36db902ea822e1ea547182a55284a307bd Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 16:29:04 -0500 Subject: [PATCH 12/95] fix(server): release expired usage response snapshots --- .../provider-core/src/server/usageHttpClient.test.ts | 11 +++++++++-- packages/provider-core/src/server/usageHttpClient.ts | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index 815b2e520cc4a..3d82079c8bd6c 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -16,6 +16,7 @@ it.effect( "deduplicates concurrent surfaces and windows, isolates accounts, and caches failed reads", () => Effect.gen(function* () { + using expiredReads = vi.spyOn(Map.prototype, "delete"); let calls = 0; const client = yield* makeUsageHttpClient( HttpClient.make((input) => @@ -39,8 +40,14 @@ it.effect( assert.strictEqual(usageResponseCheckedAt(cached, "missing"), sampleAt); assert.strictEqual(calls, 2); yield* TestClock.adjust("1 minute"); - yield* client.execute(request("a")); + yield* client.execute(request("renewed-token")); assert.strictEqual(calls, 3); + assert.isTrue( + expiredReads.mock.calls.some( + ([key]) => + typeof key === "string" && key.includes("Bearer a") && key.includes("/api/oauth/usage"), + ), + ); }), ); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 56dbb8d9d6f62..baa7c008c3d8b 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -97,6 +97,8 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( return yield* state.lock.withPermits(1)( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; + for (const [cachedKey, saved] of cached) + if (saved.expiresAt <= now) cached.delete(cachedKey); const previous = cached.get(key); if (previous && now < previous.expiresAt) { const saved = yield* previous.result; From f5bde45b650163d5948e4196b1f4a69193da9949 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 16:41:11 -0500 Subject: [PATCH 13/95] test(server): assert cached Antigravity sample age --- .../src/provider/antigravityUsageLimits.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 2e8fb5b8e5505..2d5eff5e48586 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -165,11 +165,16 @@ describe("Antigravity subscription limits", () => { userId: "google-account" as string | undefined, }; const { probe, requests, writeToken } = yield* fixture(options); - yield* probe; - yield* TestClock.adjust("55 minutes"); + const first = yield* probe; + const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; + expect(quotaCalls()).toBe(2); + yield* TestClock.adjust("4 minutes"); + const cached = yield* probe; + expect(cached.checkedAt).toBe(first.checkedAt); + expect(quotaCalls()).toBe(2); + yield* TestClock.adjust("51 minutes"); options.summaryStatus = 429; yield* probe; - const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; expect(quotaCalls()).toBe(4); yield* TestClock.adjust("5 minutes"); if (!userInfoAvailable) options.userId = undefined; From 1c3ff0c506d80840e82684266ce3f56b05c0d405 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 16:57:33 -0500 Subject: [PATCH 14/95] fix(server): defer Claude resets during usage cooldowns --- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/claudeResetCredits.test.ts | 49 ++++++++++++++++++- .../server/src/provider/claudeResetCredits.ts | 8 +++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index a6d03275ddd13..8a0b74dd1096e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -345,6 +345,7 @@ export const ClaudeDriver: ProviderDriver +const consume = ( + client: HttpClient.HttpClient, + ids: { + grantId: string; + requestId: string; + usageHttpClient?: Effect.Success>; + } = { grantId: "grant_a", requestId: "r-1" }, +) => Effect.gen(function* () { const login = yield* writeLogin; return yield* ClaudeResetCredits.consumeClaudeResetCredit({ @@ -173,6 +182,42 @@ const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requ }); effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { + it.effect("blocks redemption until the account usage cooldown expires", () => + Effect.gen(function* () { + let redemptions = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + if (new URL(request.url).pathname === "/api/oauth/usage") { + return HttpClientResponse.fromWeb( + request, + Response.json({}, { status: 429, headers: { "retry-after": "600" } }), + ); + } + redemptions++; + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + }), + ); + const usageHttpClient = yield* makeUsageHttpClient(client); + yield* usageHttpClient.execute( + HttpClientRequest.get("https://api.anthropic.com/api/oauth/usage").pipe( + HttpClientRequest.bearerToken("oauth-token"), + ), + ); + const ids = { grantId: "grant_a", requestId: "r-1", usageHttpClient }; + for (const elapsed of ["0 minutes", "9 minutes"] as const) { + yield* TestClock.adjust(elapsed); + expect(yield* consume(client, ids)).toMatchObject({ + _tag: "Failure", + failure: { reason: "coolingDown" }, + }); + expect(redemptions).toBe(0); + } + yield* TestClock.adjust("1 minute"); + expect(yield* consume(client, ids)).toMatchObject({ _tag: "Success", success: "reset" }); + expect(redemptions).toBe(1); + }), + ); + it.effect("claims the grant for the organization", () => Effect.gen(function* () { const client = HttpClient.make((request) => diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 5c9af74b3e7ac..49ca6181b220a 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -2,6 +2,7 @@ import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCredits, } from "@t3tools/contracts"; +import type { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -214,6 +215,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu readonly version: string; readonly grantId: string; readonly requestId: string; + readonly usageHttpClient?: Effect.Success>; }) { if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); @@ -228,6 +230,12 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (!token || !organization) { return yield* new ClaudeResetCreditError({ reason: "signedOut" }); } + if ( + input.usageHttpClient && + (yield* input.usageHttpClient.isCoolingDown(API_BASE, `Bearer ${token}`)) + ) { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } const client = yield* HttpClient.HttpClient; const response = yield* client .execute( From 29535d3bd1b87a2a90d1a06047eaee2607b0b3ae Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 17:20:24 -0500 Subject: [PATCH 15/95] fix(server): preserve pending resets and account usage clears --- .../src/provider/AntigravityProvider.test.ts | 2 +- .../src/provider/AntigravityProvider.ts | 2 + .../src/provider/claudeResetCredits.test.ts | 60 ++++++++++++++----- .../server/src/provider/claudeResetCredits.ts | 6 +- .../src/server/managedProvider.ts | 4 ++ .../provider-core/src/server/usageLimits.ts | 4 ++ 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index de157ea682024..32a80bdee80ce 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -872,7 +872,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { const republished = yield* harness.nextPublish( (snapshot) => snapshot.usageLimits?.checkedAt === failed.checkedAt, ); - yield* harness.provider.onSignedIn; + yield* harness.provider.onSignedIn.pipe(Effect.provideService(UsagePolling, false)); expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); yield* harness.provider.onSessionStarted(started); expect((yield* republished)?.usageLimits).toEqual(failed); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index a304d62d2ed8c..f13c3cb416c0e 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -1,5 +1,6 @@ import { UsagePolling, + UsageAccountReset, makeUnavailableUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import { @@ -545,6 +546,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func Deferred.await(republish).pipe( Effect.flatten, Effect.provideService(PublishingUsage, true), + Effect.provideService(UsageAccountReset, true), ), ), Effect.asVoid, diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 01c094fc09bde..8058eb6c3ea65 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as TestClock from "effect/testing/TestClock"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -182,40 +185,69 @@ const consume = ( }); effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { - it.effect("blocks redemption until the account usage cooldown expires", () => + it.effect("keeps a timed-out claim's request ID through a local usage cooldown", () => Effect.gen(function* () { - let redemptions = 0; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const login = yield* writeLogin; + const started = yield* Deferred.make(); + const requestIds: string[] = []; const client = HttpClient.make((request) => - Effect.sync(() => { + Effect.gen(function* () { if (new URL(request.url).pathname === "/api/oauth/usage") { return HttpClientResponse.fromWeb( request, Response.json({}, { status: 429, headers: { "retry-after": "600" } }), ); } - redemptions++; + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requestIds.push((yield* decodeClaimBody(body)).request_id); + if (requestIds.length === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); - }), + }).pipe(Effect.orDie), ); const usageHttpClient = yield* makeUsageHttpClient(client); + const claim = () => + redeem( + login.configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + grantId: "grant_a", + requestId, + usageHttpClient, + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, + ); + const pending = yield* claim().pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("25 seconds"); + expect(yield* Fiber.join(pending)).toMatchObject({ + _tag: "Failure", + failure: { reason: "requestFailed" }, + }); yield* usageHttpClient.execute( HttpClientRequest.get("https://api.anthropic.com/api/oauth/usage").pipe( HttpClientRequest.bearerToken("oauth-token"), ), ); - const ids = { grantId: "grant_a", requestId: "r-1", usageHttpClient }; for (const elapsed of ["0 minutes", "9 minutes"] as const) { yield* TestClock.adjust(elapsed); - expect(yield* consume(client, ids)).toMatchObject({ - _tag: "Failure", - failure: { reason: "coolingDown" }, - }); - expect(redemptions).toBe(0); + expect(yield* claim().pipe(Effect.result)).toMatchObject({ _tag: "Failure" }); + expect(requestIds).toHaveLength(1); } yield* TestClock.adjust("1 minute"); - expect(yield* consume(client, ids)).toMatchObject({ _tag: "Success", success: "reset" }); - expect(redemptions).toBe(1); - }), + expect(yield* claim()).toBe("reset"); + expect(requestIds).toHaveLength(2); + expect(requestIds[1]).toBe(requestIds[0]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); it.effect("claims the grant for the organization", () => diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 49ca6181b220a..4bd6be3609bc7 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -65,6 +65,7 @@ const RESET_CREDIT_FAILURES = { signedOut: "Sign in to Claude again to redeem resets.", rateLimited: "Claude is rate limiting resets. Try again soon.", coolingDown: "Claude resets are cooling down. Try again later.", + usageCoolingDown: "Claude usage is cooling down. Try again later.", unconfirmed: "Claude could not confirm the reset. If you are still limited in a moment, try again.", requestFailed: "Claude could not redeem the reset.", @@ -90,7 +91,8 @@ const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); export const isSettledClaudeResetCreditFailure = (error: unknown) => isClaudeResetCreditError(error) && error.reason !== "requestFailed" && - error.reason !== "unconfirmed"; + error.reason !== "unconfirmed" && + error.reason !== "usageCoolingDown"; const isFutureTimestamp = (value: string, nowMs: number) => { if (!COMPLETE_TIMESTAMP.test(value)) return false; @@ -234,7 +236,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu input.usageHttpClient && (yield* input.usageHttpClient.isCoolingDown(API_BASE, `Bearer ${token}`)) ) { - return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + return yield* new ClaudeResetCreditError({ reason: "usageCoolingDown" }); } const client = yield* HttpClient.HttpClient; const response = yield* client diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index ba4a4c7309445..d5b127aa65846 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -22,6 +22,7 @@ import { resolveUsageLimitsAfterProbe, UsagePolling, UsageAfterReset, + UsageAccountReset, } from "./usageLimits.ts"; import type { ManagedServerProvider } from "./snapshot.ts"; @@ -157,6 +158,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } + const accountReset = yield* UsageAccountReset; + if (accountReset) usageRefreshAfter = 0; const now = yield* Clock.currentTimeMillis; const current = (yield* Ref.get(snapshotStateRef)).snapshot; const pollingUsage = @@ -184,6 +187,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const differs = (a: string | undefined, b: string | undefined) => a !== undefined && b !== undefined && a !== b; const switchedAccount = + accountReset || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 6186b2fdcc02e..d18e78e5b5784 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -15,6 +15,10 @@ export const UsagePolling = Context.Reference("t3/provider/UsagePolling defaultValue: () => false, }); +export const UsageAccountReset = Context.Reference("t3/provider/UsageAccountReset", { + defaultValue: () => false, +}); + export const UsageAfterReset = Context.Reference("t3/provider/UsageAfterReset", { defaultValue: () => false, }); From b78986c73c83c926ccc97f295a6c1ff565f6c42d Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 17:47:23 -0500 Subject: [PATCH 16/95] fix(server): await quota reads and retain Claude account cooldowns --- .../src/provider/AntigravityProvider.test.ts | 11 +++- .../src/provider/AntigravityProvider.ts | 8 ++- .../src/provider/Drivers/ClaudeDriver.ts | 38 ++++++++++--- .../src/provider/claudeResetCredits.test.ts | 15 +++-- .../server/src/provider/claudeResetCredits.ts | 44 ++++++++++++--- .../src/server/usageHttpClient.ts | 56 +++++++++++-------- 6 files changed, 127 insertions(+), 45 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 32a80bdee80ce..681700746d033 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -171,7 +172,7 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( Effect.andThen( Effect.flatMap(UsagePolling, (visible) => visible && options.probeUsage - ? (provider.snapshot.refreshUsage ?? Effect.void) + ? (provider.snapshot.refreshUsage ?? Effect.void).pipe(Effect.forkScoped) : Effect.void, ), ), @@ -828,6 +829,13 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { expect(ready).toMatchObject({ status: "ready", auth: { status: "authenticated" } }); expect(ready.usageLimits?.unavailable?.reason).toBe("probeFailed"); expect(yield* Ref.get(reads)).toBe(1); + const demandFinished = yield* Deferred.make(); + const demand = yield* harness.provider.snapshot.refreshUsage!.pipe( + Effect.andThen(Deferred.succeed(demandFinished, undefined)), + Effect.forkChild, + ); + yield* TestClock.adjust("10 seconds"); + expect(yield* Deferred.isDone(demandFinished)).toBe(false); const limits = { checkedAt: "2026-10-09T12:00:00.000Z", windows: [ @@ -835,6 +843,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ], } satisfies ServerProviderUsageLimits; yield* Deferred.succeed(readResult, limits); + yield* Fiber.join(demand); expect((yield* published)?.usageLimits).toEqual(limits); }), ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index f13c3cb416c0e..c28a8650c18bf 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -239,7 +239,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func // The quota read can take 30 seconds, so it never blocks readiness or a session start. // One read runs at a time; sign-in and sign-out interrupt it so a stale account never lands. - const usageRead = yield* FiberHandle.make(); + const usageRead = yield* FiberHandle.make(); const republish = yield* Deferred.make>(); const scope = yield* Effect.scope; const refreshUsage = Effect.fn("AntigravityProvider.refreshUsage")(function* () { @@ -529,7 +529,11 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); return { - snapshot: { ...managed, getSnapshot }, + snapshot: { + ...managed, + getSnapshot, + refreshUsage: managed.refreshUsage!.pipe(Effect.andThen(FiberHandle.awaitEmpty(usageRead))), + }, onSessionStarted, onConfigOptionsUpdated, onAvailableCommands, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 8a0b74dd1096e..0f8e31678f4f5 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,4 +1,8 @@ -import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; +import { + UsagePolling, + UsageAfterReset, + UsageAccountIdentity, +} from "@t3tools/provider-core/server/usageLimits"; import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; @@ -237,7 +241,14 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + ClaudeResetCredits.readClaudeResetCredits( + configDir, + version, + accountConfigPath, + ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -376,10 +391,19 @@ export const ClaudeDriver: ProviderDriver undefined), ); if (token) - yield* usageHttpClient.invalidateSuccessfulReads( - "https://api.anthropic.com", - `Bearer ${token}`, - ); + yield* usageHttpClient + .invalidateSuccessfulReads("https://api.anthropic.com", `Bearer ${token}`) + .pipe( + Effect.provideService( + UsageAccountIdentity, + yield* ClaudeResetCredits.readClaudeUsageAccountIdentity( + accountConfigPath, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ), + ); } const refreshed = yield* snapshot.refresh.pipe( Effect.provideService(UsagePolling, true), diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8058eb6c3ea65..667daef614d9b 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -10,7 +10,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/http"; +import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; import * as ClaudeResetCredits from "./claudeResetCredits.ts"; @@ -233,10 +233,15 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { _tag: "Failure", failure: { reason: "requestFailed" }, }); - yield* usageHttpClient.execute( - HttpClientRequest.get("https://api.anthropic.com/api/oauth/usage").pipe( - HttpClientRequest.bearerToken("oauth-token"), - ), + yield* ClaudeResetCredits.readClaudeResetCredits(login.configDir, "2.1.0").pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, usageHttpClient), + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(login.configDir, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"renewed-token"}}', ); for (const elapsed of ["0 minutes", "9 minutes"] as const) { yield* TestClock.adjust(elapsed); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 4bd6be3609bc7..38ad13de85221 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -2,7 +2,10 @@ import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCredits, } from "@t3tools/contracts"; -import type { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import { + UsageAccountIdentity, + type makeUsageHttpClient, +} from "@t3tools/provider-core/server/usageLimits"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -158,15 +161,20 @@ const withClaudeHeaders = (token: string, version: string) => }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { + function* (configDir: string, version: string, accountConfigPath?: string) { const token = yield* readAccessToken(configDir); if (!token) return undefined; - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { - urlParams: { cedar_ember: "1", skip_spend: "1" }, - }).pipe(withClaudeHeaders(token, version)), + const identity = yield* readClaudeUsageAccountIdentity( + accountConfigPath ?? (yield* claudeAccountConfigPath(configDir)), ); + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { + urlParams: { cedar_ember: "1", skip_spend: "1" }, + }).pipe(withClaudeHeaders(token, version)), + ) + .pipe(Effect.provideService(UsageAccountIdentity, identity)); const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); @@ -188,6 +196,19 @@ export const readClaudeAccountCacheKey = Effect.fn("claudeResetCredits.readAccou ), ); +export const readClaudeUsageAccountIdentity = Effect.fn( + "claudeResetCredits.readUsageAccountIdentity", +)((accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => + config.oauthAccount && Object.values(config.oauthAccount).some((value) => value?.trim()) + ? `claude:${encodeAccountCacheKey(config)}` + : undefined, + ), + Effect.orElseSucceed(() => undefined), + ), +); + export const readClaudeOrganizationId = (accountConfigPath: string) => readJson(Config, accountConfigPath).pipe( Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), @@ -234,7 +255,14 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu } if ( input.usageHttpClient && - (yield* input.usageHttpClient.isCoolingDown(API_BASE, `Bearer ${token}`)) + (yield* input.usageHttpClient + .isCoolingDown(API_BASE, `Bearer ${token}`) + .pipe( + Effect.provideService( + UsageAccountIdentity, + yield* readClaudeUsageAccountIdentity(input.accountConfigPath), + ), + )) ) { return yield* new ClaudeResetCreditError({ reason: "usageCoolingDown" }); } diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index baa7c008c3d8b..139e0c657fac6 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -10,6 +10,9 @@ import type * as HttpClientError from "effect/http/HttpClientError"; const encodeKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const accountCacheKey = (origin: string, authorization: string, identity: string | undefined) => + encodeKey([origin, identity === undefined ? authorization : ["account", identity]]); + const sampleTimes = new WeakMap(); export function usageResponseCheckedAt( @@ -76,10 +79,11 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( if (!isUsageUrl(request.url)) return read; return Effect.gen(function* () { const identity = yield* UsageAccountIdentity; - const accountKey = encodeKey([ + const accountKey = accountCacheKey( new URL(request.url).origin, - identity === undefined ? (request.headers.authorization ?? "") : ["account", identity], - ]); + request.headers.authorization ?? "", + identity, + ); let account = accounts.get(accountKey); if (!account) { account = { lock: Semaphore.makeUnsafe(1), retryAt: 0, generation: 0 }; @@ -166,25 +170,33 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( }); }); return Object.assign(cachedClient, { - invalidateSuccessfulReads: (origin: string, authorization: string) => - Effect.sync(() => { - const accountKey = encodeKey([origin, authorization]); - const account = accounts.get(accountKey); - if (account) account.generation++; - for (const [key, saved] of cached) { - if ( - saved.accountKey === accountKey && - Exit.isSuccess(saved.result) && - saved.result.value.status < 400 - ) - cached.delete(key); - } - }), - isCoolingDown: (origin: string, authorization: string) => - Effect.map( - Clock.currentTimeMillis, - (now) => now < (accounts.get(encodeKey([origin, authorization]))?.retryAt ?? 0), - ), + invalidateSuccessfulReads: Effect.fn("UsageHttpClient.invalidateSuccessfulReads")(function* ( + origin: string, + authorization: string, + ) { + const accountKey = accountCacheKey(origin, authorization, yield* UsageAccountIdentity); + const account = accounts.get(accountKey); + if (account) account.generation++; + for (const [key, saved] of cached) { + if ( + saved.accountKey === accountKey && + Exit.isSuccess(saved.result) && + saved.result.value.status < 400 + ) + cached.delete(key); + } + }), + isCoolingDown: Effect.fn("UsageHttpClient.isCoolingDown")(function* ( + origin: string, + authorization: string, + ) { + const now = yield* Clock.currentTimeMillis; + return ( + now < + (accounts.get(accountCacheKey(origin, authorization, yield* UsageAccountIdentity)) + ?.retryAt ?? 0) + ); + }), }); }); }); From 622824ccd99f35dd873848b66ec6b4581c2eaec1 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 18:12:52 -0500 Subject: [PATCH 17/95] fix: preserve Cursor cooldowns and legacy usage refreshes --- apps/server/src/ws.test.ts | 28 ++++++++++++ apps/server/src/ws.ts | 8 ++-- packages/client-runtime/src/state/server.ts | 1 + .../src/server/usageLimits.test.ts | 44 +++++++++++++++++++ .../provider-cursor/src/server/usageLimits.ts | 31 ++++++++----- 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index d1d0d36414ab8..63dff16ceb127 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,3 +1,4 @@ +import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -1006,6 +1007,8 @@ it.effect.each([ it.effect("on-demand clients get provider config without workspace snapshots", () => Effect.gen(function* () { + const refreshModes: boolean[] = []; + let sourceRefreshes = 0; const instanceId = ProviderInstanceId.make("claudeAgent"); const snapshotFor = (cwd: string, checkedAt: string) => ({ cwd, @@ -1046,9 +1049,22 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( Stream.tap(() => Queue.offer(pulled, undefined)), ), refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + refresh: () => + Effect.map(UsagePolling, (usage) => { + refreshModes.push(usage); + return [provider]; + }), + refreshUsage: () => + Effect.map(UsagePolling, (usage) => { + refreshModes.push(usage); + return [provider]; + }), }), Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), + refresh: Effect.sync(() => { + sourceRefreshes++; + }), streamChanges: Stream.make([]), }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -1152,6 +1168,18 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], ); + for (const [index, payload] of [{}, { usage: false }, { usage: true }].entries()) { + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId(`quota-refresh-${index}`), + tag: WS_METHODS.serverRefreshProviders, + payload, + headers: Headers.empty, + }); + yield* Queue.take(responses); + } + assert.deepEqual(refreshModes, [true, false, true]); + assert.equal(sourceRefreshes, 2); yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); }).pipe( Effect.provide( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 30aa100b7b10a..8b15ad80df504 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2418,6 +2418,8 @@ export const layerWsRpc = ( [WS_METHODS.serverRefreshProviders]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { + // Older clients omit usage and expect a quota refresh; current clients opt out for health. + const usage = input.usage !== false; if (input.refreshModels) { yield* providerRegistry.invalidateModelCaches({ instanceId: input.instanceId, @@ -2435,7 +2437,7 @@ export const layerWsRpc = ( return { providers: yield* providerRegistry.getProviders }; } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. - if (input.usage === true && input.instanceId === undefined) { + if (usage && input.instanceId === undefined) { yield* usageLimitSources.refresh.pipe( Effect.provideService(UsageProviderFilter, input.usageProviders), ); @@ -2449,12 +2451,12 @@ export const layerWsRpc = ( }) : input.reloadSecrets === true ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) - : input.usage === true + : usage ? providerRegistry.refreshUsage(input.instanceId, input.usageProviders) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) : providerRegistry.refresh() - ).pipe(Effect.provideService(UsagePolling, input.usage === true)); + ).pipe(Effect.provideService(UsagePolling, usage)); if (input.refreshModels) { const instances = yield* providerInstances.listInstances; for (const instance of instances) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 81742973ce262..72f08fbe4ec66 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1399,6 +1399,7 @@ export function createServerEnvironmentAtoms( execute: (input) => requestGuarded(WS_METHODS.serverRefreshProviders, { ...input, + usage: input.usage ?? false, workspaceSnapshotsOnDemand: true, ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 617616d14bc25..de0e68f705112 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -182,6 +184,48 @@ describe("Cursor usage limits", () => { }), ); + it.effect("keeps API-key cooldowns across token exchanges and isolates other keys", () => + withNodeServices( + Effect.gen(function* () { + let exchanges = 0; + let quotaCalls = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/auth/exchange_user_api_key")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ accessToken: `token-${++exchanges}` }), + ); + } + quotaCalls++; + return HttpClientResponse.fromWeb( + request, + Response.json({}, { status: 429, headers: { "retry-after": "600" } }), + ); + }), + ), + ); + const read = (key: string) => + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: key }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + yield* read("key-a"); + expect(quotaCalls).toBe(1); + yield* TestClock.adjust("5 minutes"); + yield* read("key-a"); + expect(exchanges).toBe(2); + expect(quotaCalls).toBe(1); + yield* read("key-b"); + expect(quotaCalls).toBe(2); + yield* TestClock.adjust("5 minutes"); + yield* read("key-a"); + expect(quotaCalls).toBe(3); + }), + ), + ); + it.effect("reads an API key's own account instead of a stored login", () => Effect.gen(function* () { for (const platform of ["linux", "darwin"] as const) { diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 1f6e8822cdc8b..626bce287bc2c 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,4 +1,7 @@ -import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; +import { + usageResponseCheckedAt, + UsageAccountIdentity, +} from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -78,6 +81,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function DEFAULT_CURSOR_API_ENDPOINT ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); + let usageIdentity: string | undefined; const apiKey = environment.CURSOR_API_KEY?.trim(); const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; // An API key can name a different account from the stored login, so read the key's own. @@ -89,6 +93,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Cursor usage for an API key requires the default Cursor endpoint.", }); } + usageIdentity = `cursor-api-key:${apiKey}`; token = yield* exchangeCursorApiKey(apiKey); } else if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ @@ -134,16 +139,20 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( - HttpClientRequest.bearerToken(token), - HttpClientRequest.setHeaders({ - "connect-protocol-version": "1", - "x-cursor-client-type": "cli", - }), - HttpClientRequest.bodyJsonUnsafe({}), - ), - ); + const response = yield* client + .execute( + HttpClientRequest.post( + `${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`, + ).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeaders({ + "connect-protocol-version": "1", + "x-cursor-client-type": "cli", + }), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ) + .pipe(Effect.provideService(UsageAccountIdentity, usageIdentity)); const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); From eb0822684e0c8529788b64415c110d46a4718fd9 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 18:35:50 -0500 Subject: [PATCH 18/95] fix(server): coordinate refreshes and share account readings --- apps/server/src/provider/ProviderRefresh.ts | 142 ++++++++++++++++++ apps/server/src/ws.test.ts | 17 ++- apps/server/src/ws.ts | 111 +------------- .../src/server/usageHttpClient.test.ts | 32 +++- .../src/server/usageHttpClient.ts | 17 ++- .../src/server/usageLimits.test.ts | 81 +++++----- 6 files changed, 249 insertions(+), 151 deletions(-) create mode 100644 apps/server/src/provider/ProviderRefresh.ts diff --git a/apps/server/src/provider/ProviderRefresh.ts b/apps/server/src/provider/ProviderRefresh.ts new file mode 100644 index 0000000000000..d4c9db9fedd38 --- /dev/null +++ b/apps/server/src/provider/ProviderRefresh.ts @@ -0,0 +1,142 @@ +import { + ProviderSetupError, + type WsServerRefreshProvidersRpc, + type ServerProvider, +} from "@t3tools/contracts"; +import { UsagePolling, UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; +import { withUsageLimitsCommands } from "@t3tools/shared/usageLimits"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as UsageLimitSources from "../usage/UsageLimitSources.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { invalidateSettingsSecrets } from "../sourceControl/sourceControlHostSecrets.ts"; + +type RefreshInput = typeof WsServerRefreshProvidersRpc.payloadSchema.Type; + +export class ProviderRefresh extends Context.Service< + ProviderRefresh, + { + readonly refresh: ( + input: RefreshInput, + ) => Effect.Effect<{ readonly providers: ReadonlyArray }, ProviderSetupError>; + } +>()("t3/provider/ProviderRefresh") {} + +export function withoutWorkspaceSnapshots( + providers: ReadonlyArray, +): ServerProvider[] { + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); +} + +const make = Effect.gen(function* () { + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const secretResolver = yield* SecretResolver.SecretResolver; + const refresh = Effect.fn("ProviderRefresh.refresh")(function* (input: RefreshInput) { + // Older clients omit usage and expect a quota refresh; current clients opt out for health. + const usage = input.usage !== false; + const sourcesOnly = input.usageSources === true; + if (input.refreshModels) { + yield* providerRegistry.invalidateModelCaches({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh, + }); + } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); + } + if (input.usage === undefined && !sourcesOnly) { + yield* ( + input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() + ).pipe(Effect.provideService(UsagePolling, false)); + } + if (sourcesOnly) { + yield* usageLimitSources.refresh.pipe( + Effect.provideService(UsageProviderFilter, input.usageProviders), + ); + } + // Await refresh; RPC scope closure would interrupt a fork before the hub answered. + if (!sourcesOnly && usage && input.instanceId === undefined) { + yield* usageLimitSources.refresh.pipe( + Effect.provideService(UsageProviderFilter, input.usageProviders), + ); + } + let providers = yield* ( + sourcesOnly + ? providerRegistry.getProviders + : input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh === true, + }) + : input.reloadSecrets === true + ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) + : usage + ? providerRegistry.refreshUsage(input.instanceId, input.usageProviders) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() + ).pipe(Effect.provideService(UsagePolling, usage)); + if (input.refreshModels && !sourcesOnly) { + const instances = yield* providerInstances.listInstances; + for (const instance of instances) { + if ( + !instance.refreshModels || + (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || + !providers.some( + (provider) => + provider.instanceId === instance.instanceId && provider.enabled && provider.installed, + ) + ) + continue; + yield* instance.refreshModels().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refresh-models", + detail: error.detail, + }), + ), + ); + providers = yield* providerRegistry.refreshInstance(instance.instanceId); + } + } + if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const commandProviders = + input.usageLimitsCommand === true + ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) + : providers; + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) { + return { providers: withoutWorkspaceSnapshots(commandProviders) }; + } + return { + providers: commandProviders + .filter((provider) => provider.instanceId === instanceId) + .map((provider) => ({ + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd === cwd, + ), + })), + }; + }); + return ProviderRefresh.of({ refresh }); +}); + +export const layer = Layer.effect(ProviderRefresh, make); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 63dff16ceb127..faa64b761fd06 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1158,7 +1158,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( _tag: "Request", id: RpcMessage.RequestId("refresh"), tag: WS_METHODS.serverRefreshProviders, - payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + payload: { instanceId, cwd: "/b", usage: false, workspaceSnapshotsOnDemand: true }, headers: Headers.empty, }); const refreshed = (yield* Queue.take(responses)) as { @@ -1178,8 +1178,21 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( }); yield* Queue.take(responses); } - assert.deepEqual(refreshModes, [true, false, true]); + assert.deepEqual(refreshModes, [false, true, false, true]); assert.equal(sourceRefreshes, 2); + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("source-only-refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { usage: false, usageSources: true, workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const sourceOnly = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.isUndefined(sourceOnly.providers[0]?.workspaceSnapshots); + assert.equal(sourceRefreshes, 3); + assert.deepEqual(refreshModes, [false, true, false, true]); yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); }).pipe( Effect.provide( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8b15ad80df504..17b4caebdcc37 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,3 @@ -import { UsagePolling, UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -84,7 +83,6 @@ import { type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, - type ServerProvider, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -183,9 +181,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as NpmRelease from "./cloud/npmRelease.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; -import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as ServerSettings from "./serverSettings.ts"; -import { invalidateSettingsSecrets } from "./sourceControl/sourceControlHostSecrets.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -270,6 +266,7 @@ import { } from "@t3tools/shared/usageLimits"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; +import * as ProviderRefresh from "./provider/ProviderRefresh.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import { requestUrl } from "./requestUrl.ts"; @@ -423,10 +420,6 @@ export const resolveOpenDiscoveryForConfig = ({ ...editorConfig, ...endpoints }))); -function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { - return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); -} - function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1449,6 +1442,7 @@ export const layerWsRpc = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerRefresh = yield* ProviderRefresh.ProviderRefresh; const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const acpRegistryCatalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const acpRegistryRuntimeCoordinator = @@ -1463,7 +1457,6 @@ export const layerWsRpc = ( const storageCleanup = yield* StorageCleanup.StorageCleanup; const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; - const secretResolver = yield* SecretResolver.SecretResolver; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -1859,7 +1852,7 @@ export const layerWsRpc = ( ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; const providers = options.workspaceSnapshotsOnDemand - ? withoutWorkspaceSnapshots(commandProviders) + ? ProviderRefresh.withoutWorkspaceSnapshots(commandProviders) : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, @@ -2415,99 +2408,7 @@ export const layerWsRpc = ( }), ), ), - [WS_METHODS.serverRefreshProviders]: - /* Request instrumentation already owns the span; keep this helper in that context. */ - Effect.fnUntraced(function* (input) { - // Older clients omit usage and expect a quota refresh; current clients opt out for health. - const usage = input.usage !== false; - if (input.refreshModels) { - yield* providerRegistry.invalidateModelCaches({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh, - }); - } - if (input.reloadSecrets === true && input.instanceId === undefined) { - yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); - } - if (input.usageSources === true) { - yield* usageLimitSources.refresh.pipe( - Effect.provideService(UsageProviderFilter, input.usageProviders), - ); - return { providers: yield* providerRegistry.getProviders }; - } - // Await refresh; RPC scope closure would interrupt a fork before the hub answered. - if (usage && input.instanceId === undefined) { - yield* usageLimitSources.refresh.pipe( - Effect.provideService(UsageProviderFilter, input.usageProviders), - ); - } - let providers = yield* ( - input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh === true, - }) - : input.reloadSecrets === true - ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) - : usage - ? providerRegistry.refreshUsage(input.instanceId, input.usageProviders) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe(Effect.provideService(UsagePolling, usage)); - if (input.refreshModels) { - const instances = yield* providerInstances.listInstances; - for (const instance of instances) { - if ( - !instance.refreshModels || - (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || - !providers.some( - (provider) => - provider.instanceId === instance.instanceId && - provider.enabled && - provider.installed, - ) - ) - continue; - yield* instance.refreshModels().pipe( - Effect.mapError( - (error) => - new ProviderSetupError({ - instanceId: instance.instanceId, - operation: "refresh-models", - detail: error.detail, - }), - ), - ); - providers = yield* providerRegistry.refreshInstance(instance.instanceId); - } - } - if (input.workspaceSnapshotsOnDemand !== true) return { providers }; - const commandProviders = - input.usageLimitsCommand === true - ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) - : providers; - const { instanceId, cwd } = input; - if (instanceId === undefined || cwd === undefined) { - return { providers: withoutWorkspaceSnapshots(commandProviders) }; - } - return { - providers: commandProviders - .filter((provider) => provider.instanceId === instanceId) - .map((provider) => ({ - ...provider, - // Callers read only the cwd snapshot; the config stream carries the rest. - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( - (snapshot) => snapshot.cwd === cwd, - ), - })), - }; - }), + [WS_METHODS.serverRefreshProviders]: (input) => providerRefresh.refresh(input), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), [WS_METHODS.mcpAppsUpdateModelContext]: (input) => mcpAppRequests.updateModelContext(input), @@ -3273,7 +3174,7 @@ export const layerWsRpc = ( : providers; // Stripped before the de-dupe below, so snapshot-only changes send nothing. return workspaceSnapshotsOnDemand - ? withoutWorkspaceSnapshots(commandProviders) + ? ProviderRefresh.withoutWorkspaceSnapshots(commandProviders) : commandProviders; }, ).pipe( @@ -3421,7 +3322,7 @@ export const layerWsRpc = ( yield* OtherWsRpcGroup.toHandlers(otherHandlers), ); }), - ); + ).pipe(Layer.provide(ProviderRefresh.layer)); // Default socket-level Defect frames end every pending client request. export const WS_RPC_SERVER_OPTIONS = { diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index 3d82079c8bd6c..0cf9ce8bbfc73 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -6,7 +6,11 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { makeUsageHttpClient, usageResponseCheckedAt } from "./usageHttpClient.ts"; +import { + makeUsageHttpClient, + usageResponseCheckedAt, + UsageAccountIdentity, +} from "./usageHttpClient.ts"; const url = "https://api.anthropic.com/api/oauth/usage"; const request = (account: string, suffix = "") => @@ -124,3 +128,29 @@ it.effect.each([200, 503, 429])( assert.strictEqual(calls, expected); }), ); + +it.effect.each([401, 403])( + "does not share token authentication failures across a stable account (%s)", + (status) => + Effect.gen(function* () { + let calls = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((input) => + Effect.sync(() => { + calls++; + return HttpClientResponse.fromWeb(input, Response.json({}, { status })); + }), + ), + ); + yield* client + .execute(request("old-token")) + .pipe(Effect.provideService(UsageAccountIdentity, "account-a")); + yield* client + .execute(request("new-token")) + .pipe(Effect.provideService(UsageAccountIdentity, "account-a")); + yield* client + .execute(request("new-token")) + .pipe(Effect.provideService(UsageAccountIdentity, "account-a")); + assert.strictEqual(calls, 2); + }), +); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 139e0c657fac6..15c23e5919f0a 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -92,18 +92,18 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( const state = account; const key = encodeKey([ accountKey, - request.headers.authorization ?? "", request.method, request.url, request.urlParams, request.body, ]); + const tokenKey = encodeKey([key, request.headers.authorization ?? ""]); return yield* state.lock.withPermits(1)( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; for (const [cachedKey, saved] of cached) if (saved.expiresAt <= now) cached.delete(cachedKey); - const previous = cached.get(key); + const previous = cached.get(tokenKey) ?? cached.get(key); if (previous && now < previous.expiresAt) { const saved = yield* previous.result; const response = HttpClientResponse.fromWeb( @@ -153,11 +153,14 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( Exit.isFailure(result) || result.value.status >= 400 ) - cached.set(key, { - accountKey, - result, - expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), - }); + cached.set( + Exit.isSuccess(result) && [401, 403].includes(result.value.status) ? tokenKey : key, + { + accountKey, + result, + expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), + }, + ); const saved = yield* result; const response = HttpClientResponse.fromWeb( request, diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index de0e68f705112..ad307d32044b3 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -184,46 +184,55 @@ describe("Cursor usage limits", () => { }), ); - it.effect("keeps API-key cooldowns across token exchanges and isolates other keys", () => - withNodeServices( - Effect.gen(function* () { - let exchanges = 0; - let quotaCalls = 0; - const client = yield* makeUsageHttpClient( - HttpClient.make((request) => - Effect.sync(() => { - if (request.url.endsWith("/auth/exchange_user_api_key")) { + it.effect.each([200, 429])( + "shares API-key readings and cooldowns across token exchanges (%s)", + (status) => + withNodeServices( + Effect.gen(function* () { + let exchanges = 0; + let quotaCalls = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/auth/exchange_user_api_key")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ accessToken: `token-${++exchanges}` }), + ); + } + quotaCalls++; return HttpClientResponse.fromWeb( request, - Response.json({ accessToken: `token-${++exchanges}` }), + Response.json( + { planUsage: { totalPercentUsed: 37 } }, + { status, headers: { "retry-after": "600" } }, + ), ); - } - quotaCalls++; - return HttpClientResponse.fromWeb( - request, - Response.json({}, { status: 429, headers: { "retry-after": "600" } }), - ); - }), - ), - ); - const read = (key: string) => - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: key }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), + }), + ), ); - yield* read("key-a"); - expect(quotaCalls).toBe(1); - yield* TestClock.adjust("5 minutes"); - yield* read("key-a"); - expect(exchanges).toBe(2); - expect(quotaCalls).toBe(1); - yield* read("key-b"); - expect(quotaCalls).toBe(2); - yield* TestClock.adjust("5 minutes"); - yield* read("key-a"); - expect(quotaCalls).toBe(3); - }), - ), + const read = (key: string) => + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: key }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + const first = yield* read("key-a"); + expect(quotaCalls).toBe(1); + yield* TestClock.adjust("4 minutes"); + const cached = yield* read("key-a"); + expect(exchanges).toBe(2); + expect(quotaCalls).toBe(1); + if (status === 200) expect(cached.checkedAt).toBe(first.checkedAt); + yield* TestClock.adjust("1 minute"); + yield* read("key-a"); + expect(quotaCalls).toBe(status === 200 ? 2 : 1); + yield* read("key-b"); + expect(quotaCalls).toBe(status === 200 ? 3 : 2); + yield* TestClock.adjust("5 minutes"); + yield* read("key-a"); + expect(quotaCalls).toBe(status === 200 ? 4 : 3); + }), + ), ); it.effect("reads an API key's own account instead of a stored login", () => From d5ffe94db71af74757f9c8ba8da3362078589035 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 18:56:35 -0500 Subject: [PATCH 19/95] fix(server): preserve refresh order and discovered account cooldowns --- apps/server/src/provider/ProviderRefresh.ts | 12 +++++-- .../provider/antigravityUsageLimits.test.ts | 10 +++--- .../src/provider/antigravityUsageLimits.ts | 5 ++- apps/server/src/ws.test.ts | 31 +++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/ProviderRefresh.ts b/apps/server/src/provider/ProviderRefresh.ts index d4c9db9fedd38..4f790afdb73d1 100644 --- a/apps/server/src/provider/ProviderRefresh.ts +++ b/apps/server/src/provider/ProviderRefresh.ts @@ -52,7 +52,9 @@ const make = Effect.gen(function* () { if (input.reloadSecrets === true && input.instanceId === undefined) { yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); } - if (input.usage === undefined && !sourcesOnly) { + const reloadsSecretsBeforeRefresh = + input.reloadSecrets === true && !(input.cwd !== undefined && input.instanceId !== undefined); + if (input.usage === undefined && !sourcesOnly && !reloadsSecretsBeforeRefresh) { yield* ( input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) @@ -80,7 +82,13 @@ const make = Effect.gen(function* () { fresh: input.fresh === true, }) : input.reloadSecrets === true - ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) + ? providerRegistry.reloadSecretsAndRefresh(input.instanceId).pipe( + Effect.provideService(UsagePolling, false), + Effect.filterOrElse( + () => !usage, + () => providerRegistry.refreshUsage(input.instanceId, input.usageProviders), + ), + ) : usage ? providerRegistry.refreshUsage(input.instanceId, input.usageProviders) : input.instanceId !== undefined diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 2d5eff5e48586..bb2278b6ecc22 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -154,15 +154,15 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( }); describe("Antigravity subscription limits", () => { - it.effect.each([true, false])( - "retains account cooldowns through OAuth renewal (userinfo available: %s)", - (userInfoAvailable) => + it.effect.each(["available", "fails", "recovers"] as const)( + "retains account cooldowns through OAuth renewal (userinfo: %s)", + (discovery) => Effect.gen(function* () { const options = { rotateAccessToken: true, usageRetryAfter: "600", summaryStatus: 200, - userId: "google-account" as string | undefined, + userId: (discovery === "recovers" ? undefined : "google-account") as string | undefined, }; const { probe, requests, writeToken } = yield* fixture(options); const first = yield* probe; @@ -177,7 +177,7 @@ describe("Antigravity subscription limits", () => { yield* probe; expect(quotaCalls()).toBe(4); yield* TestClock.adjust("5 minutes"); - if (!userInfoAvailable) options.userId = undefined; + options.userId = discovery === "fails" ? undefined : "google-account"; yield* probe; expect(requests.filter(({ url }) => url.endsWith("/token"))).toHaveLength(2); expect(quotaCalls()).toBe(4); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index eff630e17c4d4..dba699c632fb6 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -200,6 +200,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( | { fingerprint: string; account: string; accessToken: string; expiresAt: number } | undefined; let generation = 0; + const throttleIdentities = new Map(); const sha256 = (text: string) => crypto @@ -279,6 +280,8 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } + const throttleIdentity = throttleIdentities.get(fingerprint) ?? account; + throttleIdentities.set(fingerprint, throttleIdentity); const sampleTimes: string[] = []; // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( @@ -309,7 +312,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( sampleTimes.push(sampleAt || DateTime.formatIso(DateTime.makeUnsafe(requestedAt))); }, ).pipe( - Effect.provideService(UsageAccountIdentity, `antigravity:${account}`), + Effect.provideService(UsageAccountIdentity, `antigravity:${throttleIdentity}`), Effect.flatMap((response) => parse(response, requestedAt)), Effect.orElseSucceed(() => []), ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index faa64b761fd06..1ad565082157f 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1008,6 +1008,8 @@ it.effect.each([ it.effect("on-demand clients get provider config without workspace snapshots", () => Effect.gen(function* () { const refreshModes: boolean[] = []; + const reloaded: Array = []; + const reloadModes: boolean[] = []; let sourceRefreshes = 0; const instanceId = ProviderInstanceId.make("claudeAgent"); const snapshotFor = (cwd: string, checkedAt: string) => ({ @@ -1054,6 +1056,17 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( refreshModes.push(usage); return [provider]; }), + refreshInstance: () => + Effect.map(UsagePolling, (usage) => { + refreshModes.push(usage); + return [provider]; + }), + reloadSecretsAndRefresh: (id) => + Effect.map(UsagePolling, (usage) => { + reloaded.push(id); + reloadModes.push(usage); + return [provider]; + }), refreshUsage: () => Effect.map(UsagePolling, (usage) => { refreshModes.push(usage); @@ -1193,6 +1206,24 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( assert.isUndefined(sourceOnly.providers[0]?.workspaceSnapshots); assert.equal(sourceRefreshes, 3); assert.deepEqual(refreshModes, [false, true, false, true]); + for (const [index, cwd] of [undefined, "/b"].entries()) { + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId(`secret-refresh-${index}`), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, reloadSecrets: true, ...(cwd === undefined ? {} : { cwd }) }, + headers: Headers.empty, + }); + yield* Queue.take(responses); + assert.deepEqual(reloaded, [instanceId]); + assert.deepEqual(reloadModes, [false]); + assert.deepEqual( + refreshModes, + cwd === undefined + ? [false, true, false, true, true] + : [false, true, false, true, true, false], + ); + } yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); }).pipe( Effect.provide( From 6a553d0f4157fb2744de72afc4f866a8db0eaeb8 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 19:19:06 -0500 Subject: [PATCH 20/95] fix(usage): invalidate rejected credits and batch Limits refreshes --- apps/server/src/usage/cliproxyApi.test.ts | 37 +++++++++++++++++++++ apps/server/src/usage/cliproxyApi.ts | 7 ++++ apps/web/src/components/usage/UsagePage.tsx | 35 ++++++------------- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 250839f74ad14..24a9c61743860 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -482,6 +482,43 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("drops only the disproven credit balance after no_credit", () => + Effect.gen(function* () { + let consumed = false; + const test = fixture({ + upstream: (request) => { + if (request.url?.endsWith("/consume")) { + consumed = true; + return { status: 200, body: { code: "no_credit" } }; + } + if (request.url?.endsWith("/rate-limit-reset-credits")) { + return request.auth_index === "b" + ? { status: 503, body: {} } + : { status: 200, body: { credits: consumed ? [] : [credit("first")] } }; + } + return { status: 200, body: { plan_type: "pro", rate_limit: null } }; + }, + }); + const api = yield* test.api; + const initial = yield* api.readAccounts(config); + expect(initial[0]?.usageLimits.resetCredits?.availableCount).toBe(1); + expect(yield* api.consume(config, "first.json", "first")).toEqual({ outcome: "noCredit" }); + const refreshed = yield* api.readAccounts(config); + expect(refreshed[0]?.usageLimits.resetCredits?.availableCount).toBe(0); + expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength( + 0, + ); + expect(test.requests.filter((request) => request.body?.url?.endsWith("/usage"))).toHaveLength( + 2, + ); + expect( + test.requests + .filter((request) => request.body?.url?.endsWith("/rate-limit-reset-credits")) + .map((request) => request.body?.auth_index), + ).toEqual(["a", "b", "a"]); + }), + ); + it.effect("reports redemption success even if cooldown clearing fails", () => Effect.gen(function* () { const api = yield* fixture({ cooldownStatus: 404 }).api; diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index abe3d9fb1ba1b..f055f9dc77152 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -508,6 +508,13 @@ export const makeCliproxyApi = Effect.gen(function* () { already_redeemed: "alreadyRedeemed", } as const )[response.code]; + if (outcome === "noCredit") { + yield* Cache.invalidateWhen( + usageCache, + yield* encodeJson({ hub, account, url: CREDIT_URL }), + () => true, + ); + } if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; yield* Cache.invalidateAll(usageCache); const cleared = yield* management(hub, "reset-quota", { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a4da0914b61f..8255528e387ce 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -255,32 +255,17 @@ export function UsagePage() { Array.from(presentationsWithProviders, ([environmentId, presentation]) => { if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - const providers = (presentation.serverConfig?.providers ?? []).filter( - (provider) => - ![...hiddenProviders].some( - (hidden) => - hidden === (provider.driver === "claudeAgent" ? "claude" : provider.driver), + return refreshProviders({ + environmentId, + input: { + usage: true, + usageProviders: PROVIDER_ORDER.filter( + (provider) => !hiddenProviders.has(provider), + ).map((provider) => + ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), ), - ); - return Promise.all([ - refreshProviders({ - environmentId, - input: { - usageSources: true, - usageProviders: PROVIDER_ORDER.filter( - (provider) => !hiddenProviders.has(provider), - ).map((provider) => - ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), - ), - }, - }), - ...providers.map((provider) => - refreshProviders({ - environmentId, - input: { instanceId: provider.instanceId, usage: true }, - }), - ), - ]); + }, + }); } }), ), From 0bbcbc66ed2a283a2ab1a0353b4dadfa3ef89d5b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 19:26:51 -0500 Subject: [PATCH 21/95] fix(usage): seed hub command availability once at startup --- .../src/usage/UsageLimitSources.test.ts | 20 ++++++++++++++----- apps/server/src/usage/UsageLimitSources.ts | 1 + 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 481e5e5fe6c0d..67b6e37b0b74a 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -20,11 +20,14 @@ const decodeSourceRequest = Schema.decodeUnknownSync( const encodeConfigEvent = Schema.encodeSync(ServerConfigStreamEvent); -it.effect("keeps malformed source URLs encodable for config subscribers", () => +it.effect("seeds hub sources at startup and keeps malformed URLs encodable for subscribers", () => Effect.gen(function* () { const sources = yield* UsageLimitSources.make; - yield* sources.refresh; - const snapshots = yield* sources.current; + const snapshots = yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + ); expect(snapshots.map((source) => source.label)).toEqual([ "local-hub", "opaque-hub", @@ -104,7 +107,6 @@ it.effect("reads a management key from its secret provider", () => { }); return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; - yield* sources.refresh; const snapshots = yield* sources.streamChanges.pipe( Stream.filter((next) => next.length > 0), Stream.runHead, @@ -165,8 +167,14 @@ it.effect("reads a management key from its secret provider", () => { it.effect("retains another window's account when provider-filtered usage refreshes finish", () => { let fail = false; + let includeCodex = false; return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; + yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + ); + includeCodex = true; yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["claudeAgent"])); expect((yield* sources.current)[0]?.accounts.map((account) => account.driver)).toEqual([ "claudeAgent", @@ -221,7 +229,9 @@ it.effect("retains another window's account when provider-filtered usage refresh Response.json({ files: [ { id: "claude", auth_index: "claude", provider: "claude" }, - { id: "codex", auth_index: "codex", provider: "codex" }, + ...(includeCodex + ? [{ id: "codex", auth_index: "codex", provider: "codex" }] + : []), ], }), ); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index a2c026b303099..3e9b6c6984d71 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -163,6 +163,7 @@ export const make = Effect.gen(function* () { return result; }, refreshLock.withPermits(1)); + yield* refresh.pipe(Effect.forkScoped); yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), Stream.changes, From b0b37a6874cc735ad778644139b37ac35187a211 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 19:47:29 -0500 Subject: [PATCH 22/95] fix(usage): queue Limits reads after enabling Cursor --- apps/web/src/components/usage/UsagePage.tsx | 38 ++++++++++++------- .../client-runtime/src/state/usage.test.ts | 12 +++++- packages/client-runtime/src/state/usage.ts | 18 +++++---- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8255528e387ce..efe8bdd235248 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -16,7 +16,10 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; -import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; +import { + cursorKeychainAccessEnvironments, + refreshUsageLimits, +} from "@t3tools/client-runtime/state/usage"; import { updatingProvidersLabel, usageEnvironmentProgress, @@ -248,24 +251,31 @@ export function UsagePage() { if (nextMetric === "limits") setLimitsNow(Date.now()); updatePreferences({ metric: nextMetric }); }; - const refreshLimits = () => + const refreshLimits = (afterPending = false) => // Settled so the timestamp still advances when a refresh throws. settlePromise(() => Promise.all( Array.from(presentationsWithProviders, ([environmentId, presentation]) => { if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshProviders({ + return refreshUsageLimits( environmentId, - input: { - usage: true, - usageProviders: PROVIDER_ORDER.filter( - (provider) => !hiddenProviders.has(provider), - ).map((provider) => - ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), - ), - }, - }); + () => + refreshProviders({ + environmentId, + input: { + usage: true, + usageProviders: PROVIDER_ORDER.filter( + (provider) => !hiddenProviders.has(provider), + ).map((provider) => + ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), + ), + }, + }), + false, + afterPending, + JSON.stringify([...hiddenProviders].sort()), + ); } }), ), @@ -536,7 +546,7 @@ export function UsagePage() { environments={cursorAccessEnvironments} onEnabled={() => { void refresh(); - void refreshLimits(); + void refreshLimits(true); }} /> ) : null @@ -569,7 +579,7 @@ export function UsagePage() { showChatGptSharing={showChatGptSharing} onCursorEnabled={() => { void refresh(); - void refreshLimits(); + void refreshLimits(true); }} onSelectModel={selectUsageModel} /> diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0650b1f2093c7..fe2524f0884df 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -202,12 +202,20 @@ describe("limits refresh cooldown", () => { it("runs a fresh check after an in-flight check when settings change", async () => { const id = EnvironmentId.make("limits-after-enable"); const oldCheck = Promise.withResolvers(); - const first = refreshUsageLimits(id, () => oldCheck.promise, true); + const first = refreshUsageLimits(id, () => oldCheck.promise, true, false, "cursor"); + const otherProviders = refreshUsageLimits( + id, + async () => "claude limits", + false, + false, + "claude", + ); const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true); + const afterEnable = refreshUsageLimits(id, newCheck, false, true, "cursor"); expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); + expect(await otherProviders).toBe("claude limits"); expect(await afterEnable).toBe("new limits"); expect(newCheck).toHaveBeenCalledTimes(1); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2dea725be1f14..ef36f4c08fa42 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -52,36 +52,38 @@ export function usageSummaryInput( return modelFilter === undefined ? input : { ...input, modelFilter }; } -const limitsRefreshAfter = new Map(); -const limitsRefreshes = new Map>(); +const limitsRefreshAfter = new Map(); +const limitsRefreshes = new Map>(); export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, afterPending = false, + selectionKey = "", ): Promise { - const pending = limitsRefreshes.get(environmentId); + const key = JSON.stringify([environmentId, selectionKey]); + const pending = limitsRefreshes.get(key); if (pending !== undefined) { if (afterPending) { try { await pending; } catch {} - return refreshUsageLimits(environmentId, refresh, false, true); + return refreshUsageLimits(environmentId, refresh, false, true, selectionKey); } return automatic ? undefined : ((await pending) as A); } - const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; + const refreshAfter = limitsRefreshAfter.get(key) ?? 0; // @effect-diagnostics-next-line globalDate:off if (automatic && Date.now() < refreshAfter) return; const current = Promise.resolve() .then(refresh) .finally(() => { - limitsRefreshes.delete(environmentId); + limitsRefreshes.delete(key); // @effect-diagnostics-next-line globalDate:off - limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); + limitsRefreshAfter.set(key, Date.now() + 5 * 60_000); }); - limitsRefreshes.set(environmentId, current); + limitsRefreshes.set(key, current); return await current; } From f795258592e4b24a163289b136770ef2d2a532a4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 20:10:32 -0500 Subject: [PATCH 23/95] fix(usage): honor retained-chat visibility and Cursor sign-in --- apps/web/src/components/usage/UsagePage.tsx | 5 +- .../useUsageLimitsRefresh.browser.test.tsx | 48 +++++++++++++++++++ apps/web/src/hooks/useUsageLimitsRefresh.ts | 7 ++- .../provider-cursor/src/server/driver.test.ts | 28 ++++++++--- packages/provider-cursor/src/server/driver.ts | 2 + 5 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index efe8bdd235248..1986ac3b33fef 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -352,7 +352,10 @@ export function UsagePage() { return refreshLimits(); }); useLiveRefresh(() => autoRefreshLimits(), { - enabled: showingLimits && connectedLimitsEnvironments !== "", + enabled: + showingLimits && + connectedLimitsEnvironments !== "" && + PROVIDER_ORDER.some((provider) => !hiddenProviders.has(provider)), key: `usage-page:${connectedLimitsEnvironments}:${[...hiddenProviders].sort().join(",")}`, refreshOnShow: true, refreshWhileIdle: true, diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx new file mode 100644 index 0000000000000..c010090ecd24f --- /dev/null +++ b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx @@ -0,0 +1,48 @@ +import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; +import { BackgroundThreadContext } from "../components/chat/backgroundThread"; +import { useUsageLimitsRefresh } from "./useUsageLimitsRefresh"; + +const mocks = vi.hoisted(() => ({ refresh: vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => mocks.refresh })); +vi.mock("../state/server", () => ({ serverEnvironment: { refreshProviders: {} } })); +vi.mock("../state/presentation", async () => { + const { Atom } = await import("effect/reactivity"); + const presentation = Atom.make({ connection: { phase: "connected" } }); + return { environmentPresentations: { presentationAtom: () => presentation } }; +}); + +function setup() { + mocks.refresh.mockReset(); + return mocks; +} + +function Probe() { + useUsageLimitsRefresh( + EnvironmentId.make("retained-usage"), + ProviderInstanceId.make("cursor-retained"), + true, + ProviderDriverKind.make("cursor"), + ); + return null; +} + +it("pauses a retained report under Settings and Usage, then refreshes on return", async () => { + const { refresh } = setup(); + using _timers = fakeTimers({ now: 4_000_000 }); + await using screen = await render( + + + , + { router: "/environment-one/thread-one" }, + ); + expect(refresh).toHaveBeenCalledTimes(1); + for (const href of ["/settings", "/usage"]) { + await act(() => screen.router!.navigate({ href })); + await act(() => vi.advanceTimersByTime(600_000)); + expect(refresh).toHaveBeenCalledTimes(1); + } + await act(() => screen.router!.navigate({ href: "/environment-one/thread-one" })); + expect(refresh).toHaveBeenCalledTimes(2); +}); diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 9094d9a4366da..3673ba9a49422 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -1,3 +1,5 @@ +import { useRouterState } from "@tanstack/react-router"; +import { isAppModalPage } from "../components/sidebar/mainAppLocation"; import { useAtomValue } from "@effect/atom-react"; import { environmentPresentations } from "../state/presentation"; import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; @@ -13,6 +15,9 @@ export function useUsageLimitsRefresh( ) { const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); const connected = presentation?.connection.phase === "connected"; + const chatVisible = useRouterState({ + select: (state) => !isAppModalPage(state.location.pathname), + }); const refresh = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false }); useLiveRefresh( () => { @@ -23,7 +28,7 @@ export function useUsageLimitsRefresh( }); }, { - enabled: enabled && connected && instanceId !== null, + enabled: enabled && connected && chatVisible && instanceId !== null, key: `usage:${environmentId}:${instanceId}:${driver ?? ""}`, refreshOnShow: true, refreshWhileIdle: true, diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index d273e4b278194..de31b25af02b0 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -76,17 +76,19 @@ it.layer(layerTest)("CursorDriver", (it) => { "persists browser credentials, uses them for chat, and closes the SDK session on logout", () => Effect.gen(function* () { + let loginKey = "instance-browser-key"; + let includeEmail = true; const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => { options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only"); await options?.store?.save({ version: 1, backendUrl: "https://api2.cursor.sh", - apiKey: "instance-browser-key", + apiKey: loginKey, createdAtMs: 0, apiKeyExpiresAtMs: 4_000_000_000_000, - email: "cursor@example.com", + ...(includeEmail ? { email: "cursor@example.com" } : {}), }); - return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 }; + return { apiKey: loginKey, apiKeyExpiresAtMs: 4_000_000_000_000 }; }); const requests: Array<{ url: string; authorization: string | undefined }> = []; const client = HttpClient.make((request) => { @@ -99,7 +101,7 @@ it.layer(layerTest)("CursorDriver", (it) => { ? { apiKeyName: "T3 Code", createdAt: "2026-01-01T00:00:00.000Z", - userEmail: "cursor@example.com", + ...(includeEmail ? { userEmail: "cursor@example.com" } : {}), } : { items: [{ id: "auto", displayName: "Auto" }] }, ), @@ -159,6 +161,20 @@ it.layer(layerTest)("CursorDriver", (it) => { }, setup: { canAuthenticate: true, canInstall: false }, }); + yield* instance.snapshot.applyUsageLimits({ + checkedAt: "2026-04-10T00:00:01.000Z", + windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 70 }], + }); + expect((yield* instance.snapshot.refresh).usageLimits?.windows[0]?.usedPercent).toBe(70); + loginKey = "replacement-browser-key"; + includeEmail = false; + yield* instance.auth!.start("replacement-client"); + yield* instance.auth!.subscribe("replacement-client").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect((yield* instance.snapshot.getSnapshot).usageLimits?.windows ?? []).toEqual([]); const threadId = ThreadId.make("cursor-browser-thread"); const modelSelection = { instanceId: input.instanceId, model: "auto" }; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -173,7 +189,7 @@ it.layer(layerTest)("CursorDriver", (it) => { runtimePolicy, }); yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); - expect(openedKeys).toEqual(["instance-browser-key"]); + expect(openedKeys).toEqual(["replacement-browser-key"]); expect(closed).toBe(0); const recreated = yield* CursorDriver.create(input).pipe( Effect.provideService(HttpClient.HttpClient, client), @@ -187,7 +203,7 @@ it.layer(layerTest)("CursorDriver", (it) => { (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy }))) ._tag, ).toBe("Failure"); - expect(openedKeys).toEqual(["instance-browser-key"]); + expect(openedKeys).toEqual(["replacement-browser-key"]); expect(closed).toBe(1); }).pipe(Effect.scoped), ); diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index b2dd128738e04..e58cd34941b97 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,5 +1,6 @@ import { UsagePolling, + UsageAccountReset, makeUnavailableUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import { @@ -147,6 +148,7 @@ export const CursorDriver: ProviderDriver< credentialBinding: credentials.binding, onChanged: (signedIn): Effect.Effect => snapshot.refresh.pipe( + Effect.provideService(UsageAccountReset, true), Effect.flatMap((provider) => !signedIn || provider.auth.status === "authenticated" ? Effect.void From a93758b588db9983e15c5d23201a54f0169f03e1 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 20:33:07 -0500 Subject: [PATCH 24/95] fix(usage): respect hidden columns and credential ownership --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/useUsageLimitsPanel.ts | 9 ++- .../useUsageLimitsRefresh.browser.test.tsx | 69 +++++++++++++++---- .../src/server/usageLimits.test.ts | 21 ++++++ .../provider-core/src/server/usageLimits.ts | 6 ++ 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d1225065aeeca..61a50dd4e241e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1396,6 +1396,7 @@ export default function ChatView(props: ChatViewProps) { environmentId, threadKey: routeThreadKey, panelKey: usageLimitsKey, + visible: !rightPanelMaximized, providerInstanceId: activeProviderInstanceId, providerDriver: activeProviderStatus?.driver ?? null, providers: providerStatuses, diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index 33a8b4f452ec9..30e53046388f4 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 environmentId: EnvironmentId; readonly threadKey: string; readonly panelKey: string | null; + readonly visible: boolean; readonly providerInstanceId: ProviderInstanceId | null; readonly providerDriver: ServerProvider["driver"] | null; readonly providers: readonly ServerProvider[]; @@ -25,6 +26,7 @@ export function useUsageLimitsPanel(input: { environmentId, threadKey, panelKey, + visible, providerInstanceId, providerDriver, providers, @@ -42,7 +44,12 @@ export function useUsageLimitsPanel(input: { panel !== null && panelKey !== null && panel.key === panelKey && providerInstanceId !== null ? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now) : null; - useUsageLimitsRefresh(environmentId, providerInstanceId, report !== null, providerDriver); + useUsageLimitsRefresh( + environmentId, + providerInstanceId, + visible && report !== null, + providerDriver, + ); const banner = report !== null && panel !== null ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx index c010090ecd24f..835627725310b 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx @@ -1,8 +1,12 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../test/render"; import { BackgroundThreadContext } from "../components/chat/backgroundThread"; -import { useUsageLimitsRefresh } from "./useUsageLimitsRefresh"; +import { useUsageLimitsPanel } from "../components/chat/useUsageLimitsPanel"; + +vi.mock("../components/chat/ComposerUsageLimits", () => ({ usageLimitsBannerItem: () => ({}) })); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); const mocks = vi.hoisted(() => ({ refresh: vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => mocks.refresh })); @@ -18,31 +22,66 @@ function setup() { return mocks; } -function Probe() { - useUsageLimitsRefresh( - EnvironmentId.make("retained-usage"), - ProviderInstanceId.make("cursor-retained"), - true, - ProviderDriverKind.make("cursor"), +function Probe({ visible }: { readonly visible: boolean }) { + const instanceId = ProviderInstanceId.make("cursor-retained"); + const driver = ProviderDriverKind.make("cursor"); + const panel = useUsageLimitsPanel({ + environmentId: EnvironmentId.make("retained-usage"), + threadKey: "thread-one", + panelKey: "report-one", + providerInstanceId: instanceId, + providerDriver: driver, + visible, + providers: [ + { + instanceId, + driver, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:01.000Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-04-10T00:00:01.000Z", + windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 70 }], + }, + }, + ], + sources: [], + }); + return ( + ); - return null; } -it("pauses a retained report under Settings and Usage, then refreshes on return", async () => { +it("pauses a retained report when its column is hidden or covered, then refreshes on return", async () => { const { refresh } = setup(); using _timers = fakeTimers({ now: 4_000_000 }); - await using screen = await render( + const probe = (visible: boolean) => ( - - , - { router: "/environment-one/thread-one" }, + + ); + await using screen = await render(probe(true), { router: "/environment-one/thread-one" }); + expect(refresh).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Open usage report" })); + expect(refresh).toHaveBeenCalledTimes(1); + await screen.rerender(probe(false)); + await act(() => vi.advanceTimersByTime(600_000)); expect(refresh).toHaveBeenCalledTimes(1); + await screen.rerender(probe(true)); + expect(refresh).toHaveBeenCalledTimes(2); for (const href of ["/settings", "/usage"]) { await act(() => screen.router!.navigate({ href })); await act(() => vi.advanceTimersByTime(600_000)); - expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(2); } await act(() => screen.router!.navigate({ href: "/environment-one/thread-one" })); - expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(3); }); diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 8df819ef1d36b..05ec8ff1fda00 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -106,6 +106,20 @@ describe("resolveUsageLimitsAfterProbe", () => { windows: live.windows, }); + const identifiedLive = { ...live, credentialFingerprint: "account-a" }; + const sameAccount = { ...probed, credentialFingerprint: "account-a" }; + const switchedAccount = { ...probed, credentialFingerprint: "account-b" }; + expect( + resolveUsageLimitsAfterProbe({ published: identifiedLive, probed: sameAccount }), + ).toEqual({ + ...sameAccount, + checkedAt: live.checkedAt, + windows: live.windows, + }); + expect( + resolveUsageLimitsAfterProbe({ published: identifiedLive, probed: switchedAccount }), + ).toBe(switchedAccount); + const sameTime = { ...probed, checkedAt: live.checkedAt }; const newer = { ...probed, checkedAt: "2026-09-03T12:00:06.000Z" }; expect(resolveUsageLimitsAfterProbe({ published: live, probed: sameTime })).toEqual(sameTime); @@ -119,6 +133,13 @@ describe("resolveUsageLimitsAfterProbe", () => { ...published, stale: true, }); + const otherAccountFailure = { ...failed, credentialFingerprint: "account-b" }; + expect( + resolveUsageLimitsAfterProbe({ + published: { ...published, credentialFingerprint: "account-a" }, + probed: otherAccountFailure, + }), + ).toBe(otherAccountFailure); expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index d18e78e5b5784..811845502dca1 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -131,6 +131,12 @@ export function resolveUsageLimitsAfterProbe(input: { readonly probed: ServerProviderUsageLimits | undefined; }): ServerProviderUsageLimits | undefined { const { published, probed } = input; + if ( + published?.credentialFingerprint !== undefined && + probed?.credentialFingerprint !== undefined && + published.credentialFingerprint !== probed.credentialFingerprint + ) + return probed; if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { return { ...published, stale: true }; } From aa55a91342030cf14c311b464d3d34ba5d77df7a Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 20:59:27 -0500 Subject: [PATCH 25/95] fix(usage): preserve account identity and refresh report time Keep selected Go credential fingerprints on failed requests and avoid retaining limits with unverified ownership. Pause covered composer reports and advance visible report timestamps without changing banner identity. --- apps/web/src/components/ChatView.tsx | 2 +- .../components/chat/useUsageLimitsPanel.ts | 5 +++- apps/web/src/hooks/useNowMinute.ts | 11 +++++++-- .../useUsageLimitsRefresh.browser.test.tsx | 16 ++++++++++--- .../src/server/usageLimits.test.ts | 17 +++++++++++++ .../provider-core/src/server/usageLimits.ts | 7 +----- .../src/server/status.test.ts | 19 ++++++++------- .../src/server/usageLimits.ts | 24 +++++++++++-------- 8 files changed, 70 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61a50dd4e241e..627b3e842c217 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1396,7 +1396,7 @@ export default function ChatView(props: ChatViewProps) { environmentId, threadKey: routeThreadKey, panelKey: usageLimitsKey, - visible: !rightPanelMaximized, + visible: embedded || (!rightPanelMaximized && !rightPanelControlsInPanel), providerInstanceId: activeProviderInstanceId, providerDriver: activeProviderStatus?.driver ?? null, providers: providerStatuses, diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index 30e53046388f4..9ce647f0a6593 100644 --- a/apps/web/src/components/chat/useUsageLimitsPanel.ts +++ b/apps/web/src/components/chat/useUsageLimitsPanel.ts @@ -1,3 +1,4 @@ +import { useNowMinute } from "../../hooks/useNowMinute"; import { useUsageLimitsRefresh } from "../../hooks/useUsageLimitsRefresh"; import type { EnvironmentId, @@ -40,9 +41,11 @@ export function useUsageLimitsPanel(input: { if (panel !== null && panelKey !== null && panel.key !== panelKey) { setPanel(null); } + const nowMinute = useNowMinute(visible && panel !== null); + const reportNow = nowMinute ? Date.parse(`${nowMinute}:00Z`) : (panel?.now ?? 0); const report = panel !== null && panelKey !== null && panel.key === panelKey && providerInstanceId !== null - ? collectProviderUsageLimits(providerInstanceId, providers, sources, panel.now) + ? collectProviderUsageLimits(providerInstanceId, providers, sources, reportNow) : null; useUsageLimitsRefresh( environmentId, diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index cfb12ddf977c2..db993a52f8b50 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,6 +1,13 @@ import { minuteTick } from "@t3tools/client-runtime/clock"; import { useSyncExternalStore } from "react"; -export function useNowMinute(): string { - return useSyncExternalStore(minuteTick.subscribe, minuteTick.getSnapshot, minuteTick.getSnapshot); +const noSubscribe = () => () => {}; +const noSnapshot = () => ""; + +export function useNowMinute(enabled = true): string { + return useSyncExternalStore( + enabled ? minuteTick.subscribe : noSubscribe, + enabled ? minuteTick.getSnapshot : noSnapshot, + enabled ? minuteTick.getSnapshot : noSnapshot, + ); } diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx index 835627725310b..78a1dcd782e23 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx @@ -5,10 +5,13 @@ import { act, fakeTimers, render } from "../../test/render"; import { BackgroundThreadContext } from "../components/chat/backgroundThread"; import { useUsageLimitsPanel } from "../components/chat/useUsageLimitsPanel"; -vi.mock("../components/chat/ComposerUsageLimits", () => ({ usageLimitsBannerItem: () => ({}) })); +vi.mock("../components/chat/ComposerUsageLimits", () => ({ usageLimitsBannerItem: mocks.banner })); vi.mock("../components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); -const mocks = vi.hoisted(() => ({ refresh: vi.fn() })); +const mocks = vi.hoisted(() => ({ + refresh: vi.fn(), + banner: vi.fn<(id: string, report: { createdAt: string }) => { id: string }>(), +})); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => mocks.refresh })); vi.mock("../state/server", () => ({ serverEnvironment: { refreshProviders: {} } })); vi.mock("../state/presentation", async () => { @@ -19,6 +22,7 @@ vi.mock("../state/presentation", async () => { function setup() { mocks.refresh.mockReset(); + mocks.banner.mockReset().mockImplementation((id) => ({ id })); return mocks; } @@ -61,7 +65,7 @@ function Probe({ visible }: { readonly visible: boolean }) { } it("pauses a retained report when its column is hidden or covered, then refreshes on return", async () => { - const { refresh } = setup(); + const { refresh, banner } = setup(); using _timers = fakeTimers({ now: 4_000_000 }); const probe = (visible: boolean) => ( @@ -72,6 +76,12 @@ it("pauses a retained report when its column is hidden or covered, then refreshe expect(refresh).not.toHaveBeenCalled(); await userEvent.click(screen.getByRole("button", { name: "Open usage report" })); expect(refresh).toHaveBeenCalledTimes(1); + const initialBannerId = banner.mock.lastCall?.[0]; + const initialReportTime = banner.mock.lastCall?.[1].createdAt; + await act(() => vi.advanceTimersByTime(60_000)); + expect(banner.mock.lastCall?.[1].createdAt).not.toBe(initialReportTime); + expect(banner.mock.lastCall?.[0]).toBe(initialBannerId); + expect(refresh).toHaveBeenCalledTimes(1); await screen.rerender(probe(false)); await act(() => vi.advanceTimersByTime(600_000)); expect(refresh).toHaveBeenCalledTimes(1); diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 05ec8ff1fda00..54e26ff6ac2d6 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -120,6 +120,10 @@ describe("resolveUsageLimitsAfterProbe", () => { resolveUsageLimitsAfterProbe({ published: identifiedLive, probed: switchedAccount }), ).toBe(switchedAccount); + expect(resolveUsageLimitsAfterProbe({ published: live, probed: switchedAccount })).toBe( + switchedAccount, + ); + const sameTime = { ...probed, checkedAt: live.checkedAt }; const newer = { ...probed, checkedAt: "2026-09-03T12:00:06.000Z" }; expect(resolveUsageLimitsAfterProbe({ published: live, probed: sameTime })).toEqual(sameTime); @@ -133,6 +137,19 @@ describe("resolveUsageLimitsAfterProbe", () => { ...published, stale: true, }); + expect( + resolveUsageLimitsAfterProbe({ + published: { ...published, credentialFingerprint: "account-a" }, + probed: failed, + }), + ).toBe(failed); + const sameAccountFailure = { ...failed, credentialFingerprint: "account-a" }; + expect( + resolveUsageLimitsAfterProbe({ + published: { ...published, credentialFingerprint: "account-a" }, + probed: sameAccountFailure, + }), + ).toEqual({ ...published, credentialFingerprint: "account-a", stale: true }); const otherAccountFailure = { ...failed, credentialFingerprint: "account-b" }; expect( resolveUsageLimitsAfterProbe({ diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 811845502dca1..33ab614815502 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -131,12 +131,7 @@ export function resolveUsageLimitsAfterProbe(input: { readonly probed: ServerProviderUsageLimits | undefined; }): ServerProviderUsageLimits | undefined { const { published, probed } = input; - if ( - published?.credentialFingerprint !== undefined && - probed?.credentialFingerprint !== undefined && - published.credentialFingerprint !== probed.credentialFingerprint - ) - return probed; + if (published?.credentialFingerprint !== probed?.credentialFingerprint) return probed; if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { return { ...published, stale: true }; } diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 802f544ae6f2d..0d6e2a2271e6e 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -38,6 +39,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => Effect.gen(function* () { + const pathService = yield* Path.Path; const resetsAt = "2026-09-17T12:00:00.000Z"; const limits = yield* readOpenCodeGoUsageLimits({ enabled: true, @@ -48,7 +50,7 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res FileSystem.FileSystem, FileSystem.makeNoop({ readFileString: (path) => { - NodeAssert.equal(path, "/instance/data/opencode/auth.json"); + NodeAssert.equal(path, pathService.normalize("/instance/data/opencode/auth.json")); return Effect.succeed( JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), ); @@ -74,7 +76,6 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res ); }), ), - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); NodeAssert.equal(limits.unavailable, undefined); NodeAssert.equal( @@ -93,7 +94,7 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res { kind: "monthly", usedPercent: 100, reset: resetsAt }, ], ); - }), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( @@ -219,10 +220,7 @@ it.effect( consoleStatus === 500 ? "probeFailed" : undefined, ); NodeAssert.equal(limits.windows.length, consoleStatus === 500 ? 0 : 3); - NodeAssert.equal( - limits.credentialFingerprint, - consoleStatus === 500 ? undefined : fingerprints.get(requested.at(-1)!), - ); + NodeAssert.equal(limits.credentialFingerprint, fingerprints.get(requested.at(-1)!)); } }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); @@ -349,7 +347,12 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); NodeAssert.equal(limits.unavailable?.reason, reason); - NodeAssert.equal(limits.credentialFingerprint, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + status === 403 + ? undefined + : "85649de18e63305548b6407e185f708483727ee6189cc4bc9512ddb17e23e776", + ); NodeAssert.deepEqual(limits.windows, []); } }), diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 52f9a0c340dfa..c7ed9471d77b7 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -82,6 +82,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); if (!input.enabled || input.serverUrl.trim()) return unsupported; + let credentialFingerprint: string | undefined; return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -123,12 +124,19 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( if (!apiKey) return unsupported; const client = yield* HttpClient.HttpClient; - const readUsage = (key: string) => - client.execute( + const digest = yield* Digest.Digest; + const readUsage = Effect.fn("readOpenCodeGoUsageLimits.readUsage")(function* (key: string) { + credentialFingerprint = undefined; + // Unkeyed hashes match across environments; random Go keys resist offline guessing. + credentialFingerprint = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${key}`)) + .pipe(Effect.map(Hex.encode), Effect.orDie); + return yield* client.execute( HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( HttpClientRequest.bearerToken(key), ), ); + }); let response = yield* readUsage(apiKey); for (const key of keys.slice(1)) { if (response.status !== 401 && response.status !== 403) break; @@ -165,23 +173,19 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), }, ]; - const digest = yield* Digest.Digest; - // Unkeyed hashes match across environments; random Go keys resist offline guessing. - const credentialFingerprint = yield* digest - .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${apiKey}`)) - .pipe(Effect.map(Hex.encode), Effect.orDie); return { ...makeUsageLimits({ checkedAt: usageResponseCheckedAt(response, checkedAt), windows }), credentialFingerprint, }; }).pipe( Effect.timeout("5 seconds"), - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ + Effect.orElseSucceed(() => ({ + ...makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", message: "OpenCode Go could not read usage.", }), - ), + ...(credentialFingerprint ? { credentialFingerprint } : {}), + })), ); }); From c418632cd849c236464ccef51257f8bd729c4e3b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 21:21:52 -0500 Subject: [PATCH 26/95] fix(usage): retain Antigravity identity during failures --- .../provider/antigravityUsageLimits.test.ts | 17 ++++++- .../src/provider/antigravityUsageLimits.ts | 51 ++++++++++++------- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index bb2278b6ecc22..41947840689c1 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -174,7 +174,9 @@ describe("Antigravity subscription limits", () => { expect(quotaCalls()).toBe(2); yield* TestClock.adjust("51 minutes"); options.summaryStatus = 429; - yield* probe; + const limited = yield* probe; + expect(limited.credentialFingerprint).toBeDefined(); + expect(limited.credentialFingerprint).toBe(first.credentialFingerprint); expect(quotaCalls()).toBe(4); yield* TestClock.adjust("5 minutes"); options.userId = discovery === "fails" ? undefined : "google-account"; @@ -656,11 +658,22 @@ describe("Antigravity subscription limits", () => { it.effect("returns a safe failure when OAuth refresh fails", () => Effect.gen(function* () { - const { probe } = yield* fixture({ tokenStatus: 401 }); + const options = { tokenStatus: 401, userId: "google-account" }; + const { probe, writeToken } = yield* fixture(options); const limits = yield* probe; expect(limits.windows).toEqual([]); expect(limits.unavailable?.reason).toBe("probeFailed"); expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/); + expect(limits.credentialFingerprint).toBeUndefined(); + options.tokenStatus = 200; + const successful = yield* probe; + yield* TestClock.adjust("60 minutes"); + options.tokenStatus = 401; + const renewalFailure = yield* probe; + expect(renewalFailure.unavailable?.reason).toBe("probeFailed"); + expect(renewalFailure.credentialFingerprint).toBe(successful.credentialFingerprint); + yield* writeToken("refresh-b"); + expect((yield* probe).credentialFingerprint).toBeUndefined(); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index dba699c632fb6..1fb25f1a0ee39 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -224,7 +224,9 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( }), Effect.timeout("10 seconds"), ); - const read = Effect.gen(function* () { + const read = Effect.fn("AntigravityUsageProbe.read")(function* ( + onAccount: (account: string) => void, + ) { const startedGeneration = generation; const checkedAt = DateTime.formatIso(yield* DateTime.now); if (input.authMethod !== "oauth-personal" && input.authMethod !== "oauth-business") { @@ -242,6 +244,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const fingerprint = yield* sha256( `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`, ); + if (cached?.fingerprint === fingerprint) onAccount(cached.account); const now = yield* Clock.currentTimeMillis; let accessToken: string; let account: string; @@ -280,6 +283,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } + onAccount(account); const throttleIdentity = throttleIdentities.get(fingerprint) ?? account; throttleIdentities.set(fingerprint, throttleIdentity); const sampleTimes: string[] = []; @@ -335,29 +339,40 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( decodeModels(response).pipe(Effect.map(modelWindows)), ); if (windows.length === 0) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Antigravity did not return usable quota information.", - }); + return { + ...makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Antigravity did not return usable quota information.", + }), + credentialFingerprint: account, + }; } return { ...makeUsageLimits({ checkedAt: sampleTimes.toSorted()[0] ?? checkedAt, windows }), credentialFingerprint: account, }; }); - const probe = read.pipe( - Effect.timeout("30 seconds"), - Effect.catch(() => - Effect.gen(function* () { - return makeUnavailableUsageLimits({ - checkedAt: DateTime.formatIso(yield* DateTime.now), - reason: "probeFailed", - message: "Could not read Antigravity limits. Refresh provider status to retry.", - }); - }), - ), - ); + const probe = Effect.gen(function* () { + let credentialFingerprint: string | undefined; + return yield* read((account) => { + credentialFingerprint = account; + }).pipe( + Effect.timeout("30 seconds"), + Effect.catch(() => + Effect.gen(function* () { + return { + ...makeUnavailableUsageLimits({ + checkedAt: DateTime.formatIso(yield* DateTime.now), + reason: "probeFailed", + message: "Could not read Antigravity limits. Refresh provider status to retry.", + }), + ...(credentialFingerprint ? { credentialFingerprint } : {}), + }; + }), + ), + ); + }); return { probe, clear: Effect.sync(() => { From b364bf4dcd50621fb52f665069d778cd8e9e26eb Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 21:52:47 -0500 Subject: [PATCH 27/95] fix(usage): await shared reads and recover from cancellation Do not cache interrupted HTTP exits. Await coalesced quota refreshes and let remaining callers restart an interrupted shared read. Keep Antigravity quota ownership stable through Google account discovery recovery. --- .../provider/antigravityUsageLimits.test.ts | 10 +++- .../src/provider/antigravityUsageLimits.ts | 11 +++- .../src/server/managedProvider.test.ts | 16 ++++- .../src/server/managedProvider.ts | 59 ++++++++++++------- .../src/server/usageHttpClient.test.ts | 31 ++++++---- .../src/server/usageHttpClient.ts | 7 ++- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 41947840689c1..e9bec24ab4e7a 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -180,7 +180,8 @@ describe("Antigravity subscription limits", () => { expect(quotaCalls()).toBe(4); yield* TestClock.adjust("5 minutes"); options.userId = discovery === "fails" ? undefined : "google-account"; - yield* probe; + const blockedRenewal = yield* probe; + expect(blockedRenewal.credentialFingerprint).toBe(first.credentialFingerprint); expect(requests.filter(({ url }) => url.endsWith("/token"))).toHaveLength(2); expect(quotaCalls()).toBe(4); options.userId = "another-google-account"; @@ -194,6 +195,13 @@ describe("Antigravity subscription limits", () => { yield* TestClock.adjust("5 minutes"); yield* probe; expect(quotaCalls()).toBe(8); + options.summaryStatus = 200; + yield* TestClock.adjust("10 minutes"); + const recovered = yield* probe; + expect(recovered.unavailable).toBeUndefined(); + options.summaryStatus = 503; + yield* TestClock.adjust("5 minutes"); + expect((yield* probe).credentialFingerprint).toBe(recovered.credentialFingerprint); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 1fb25f1a0ee39..b4d0701de3742 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -201,6 +201,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( | undefined; let generation = 0; const throttleIdentities = new Map(); + const quotaOwners = new Map(); const sha256 = (text: string) => crypto @@ -244,7 +245,8 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const fingerprint = yield* sha256( `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`, ); - if (cached?.fingerprint === fingerprint) onAccount(cached.account); + if (cached?.fingerprint === fingerprint) + onAccount(quotaOwners.get(fingerprint) ?? cached.account); const now = yield* Clock.currentTimeMillis; let accessToken: string; let account: string; @@ -283,7 +285,9 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } - onAccount(account); + // Google discovery can recover before quota access; failures still belong to the last sample. + const quotaOwner = quotaOwners.get(fingerprint) ?? account; + onAccount(quotaOwner); const throttleIdentity = throttleIdentities.get(fingerprint) ?? account; throttleIdentities.set(fingerprint, throttleIdentity); const sampleTimes: string[] = []; @@ -345,9 +349,10 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( reason: "probeFailed", message: "Antigravity did not return usable quota information.", }), - credentialFingerprint: account, + credentialFingerprint: quotaOwner, }; } + if (generation === startedGeneration) quotaOwners.set(fingerprint, account); return { ...makeUsageLimits({ checkedAt: sampleTimes.toSorted()[0] ?? checkedAt, windows }), credentialFingerprint: account, diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index d79f665c95452..133f8aa7215c7 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -11,6 +11,7 @@ import { UsagePolling } from "./usageLimits.ts"; import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -273,12 +274,19 @@ describe("makeManagedServerProvider", () => { const queued = yield* provider.refreshUsage!.pipe( Effect.forkChild({ startImmediately: true }), ); - yield* provider.refreshUsage!; + const completed = yield* Deferred.make(); + const coalesced = yield* provider.refreshUsage!.pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + assert.strictEqual(yield* Deferred.isDone(completed), false); assert.strictEqual(yield* Ref.get(calls), 2); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(refresh); yield* Fiber.join(queued); + yield* Fiber.join(coalesced); + assert.strictEqual(yield* Deferred.isDone(completed), true); assert.strictEqual(yield* Ref.get(calls), 3); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); }), @@ -323,12 +331,16 @@ describe("makeManagedServerProvider", () => { ); yield* Fiber.interrupt(running); yield* Deferred.await(statusProbeStarted); + const extraCompleted = yield* Deferred.make(); const extra = yield* provider.refreshUsage!.pipe( + Effect.andThen(Deferred.succeed(extraCompleted, undefined)), Effect.forkChild({ startImmediately: true }), ); + assert.strictEqual(yield* Deferred.isDone(extraCompleted), false); + yield* Fiber.interrupt(waiting); yield* Deferred.succeed(releaseStatusProbe, undefined); yield* Fiber.join(status); - yield* Fiber.join(waiting); + assert.strictEqual(Exit.hasInterrupts(yield* Fiber.await(waiting)), true); yield* Fiber.join(extra); assert.strictEqual(yield* Ref.get(calls), 4); }), diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index d5b127aa65846..59d4837482aa5 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -7,6 +7,8 @@ import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgro import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as Equal from "effect/Equal"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; @@ -70,7 +72,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const host = yield* ProviderHost.ProviderHost; let usageRefreshAfter = 0; const refreshSemaphore = yield* Semaphore.make(1); - const usageRefreshQueued = yield* Ref.make(null); + const usageRefreshQueued = yield* Ref.make + > | null>(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -257,28 +261,43 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const { snapshot } = yield* Ref.get(snapshotStateRef); if (!snapshot.enabled || snapshot.usageLimits?.externalUsage) return; // An in-flight refresh may have read cached usage, so one refresh waits behind it. - const claim = Symbol(); - const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); yield* Effect.uninterruptibleMask((restore) => - Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( - Effect.flatMap((held) => - held - ? Effect.void - : restore( - refreshSemaphore.withPermits(1)( - release.pipe( - Effect.andThen(input.invalidateUsageCache ?? Effect.void), - Effect.andThen(input.getSettings), - Effect.flatMap((settings) => - applySnapshotBase(settings, { forceRefresh: true }).pipe( - Effect.provideService(UsagePolling, true), - ), - ), + Effect.gen(function* () { + while (true) { + const claim = yield* Deferred.make>(); + const pending = yield* Ref.modify( + usageRefreshQueued, + (queued) => [queued, queued ?? claim] as const, + ); + if (pending !== null) { + const completed = yield* restore(Deferred.await(pending)); + // A cancelled owner cannot satisfy another caller's demand. + if (Exit.hasInterrupts(completed)) continue; + yield* completed; + return; + } + const release = Ref.update(usageRefreshQueued, (queued) => + queued === claim ? null : queued, + ); + yield* restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }).pipe( + Effect.provideService(UsagePolling, true), ), ), - ).pipe(Effect.onInterrupt(() => release)), - ), - ), + Effect.asVoid, + ), + ), + ).pipe( + Effect.onExit((exit) => release.pipe(Effect.andThen(Deferred.succeed(claim, exit)))), + ); + return; + } + }), ); }); diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index 0cf9ce8bbfc73..ca77e2e5367b4 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -2,6 +2,7 @@ import { assert, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; @@ -97,8 +98,8 @@ it.effect.each(["seconds", "date", "missing"] as const)( }), ); -it.effect.each([200, 503, 429])( - "invalidates in-flight successes without losing failed-read backoff (%s)", +it.effect.each([200, 503, 429, "interrupted"] as const)( + "does not reuse invalidated or interrupted reads while preserving failure backoff (%s)", (status) => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -113,18 +114,28 @@ it.effect.each([200, 503, 429])( yield* Deferred.succeed(started, undefined); yield* Deferred.await(finish); } - return HttpClientResponse.fromWeb(input, Response.json({ reading }, { status })); - }), + return HttpClientResponse.fromWeb( + input, + Response.json({ reading }, { status: typeof status === "number" ? status : 200 }), + ); + }).pipe(Effect.interruptible), ), ); - const pending = yield* client.execute(request("a")).pipe(Effect.forkChild); + const pending = yield* client + .execute(request("a")) + .pipe(Effect.uninterruptible, Effect.forkChild); yield* Deferred.await(started); yield* client.invalidateSuccessfulReads("https://api.anthropic.com", "Bearer a"); - yield* Deferred.succeed(finish, undefined); - yield* Fiber.join(pending); - const refreshed = yield* client.execute(request("a")); - const expected = status < 400 ? 2 : 1; - assert.deepStrictEqual(yield* refreshed.json, { reading: expected }); + if (status === "interrupted") yield* Fiber.interrupt(pending); + else { + yield* Deferred.succeed(finish, undefined); + yield* Fiber.join(pending); + } + const refreshed = yield* client.execute(request("a")).pipe(Effect.exit); + assert.strictEqual(refreshed._tag, "Success"); + if (Exit.isFailure(refreshed)) return; + const expected = status === "interrupted" || status < 400 ? 2 : 1; + assert.deepStrictEqual(yield* refreshed.value.json, { reading: expected }); assert.strictEqual(calls, expected); }), ); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 15c23e5919f0a..6ad4680f49365 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -149,9 +149,10 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( ); const finishedAt = yield* Clock.currentTimeMillis; if ( - generation === state.generation || - Exit.isFailure(result) || - result.value.status >= 400 + !Exit.hasInterrupts(result) && + (generation === state.generation || + Exit.isFailure(result) || + result.value.status >= 400) ) cached.set( Exit.isSuccess(result) && [401, 403].includes(result.value.status) ? tokenKey : key, From a4f2717b5b6d03902b8b7279e6256094f9614b01 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 22:16:40 -0500 Subject: [PATCH 28/95] fix(usage): await automatic client refresh coalescing --- packages/client-runtime/src/state/usage.test.ts | 4 ++++ packages/client-runtime/src/state/usage.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index fe2524f0884df..aa9109138472d 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -210,11 +210,15 @@ describe("limits refresh cooldown", () => { false, "claude", ); + const automaticCheck = vi.fn(async () => "unexpected extra limits"); + const coalescedAutomatic = refreshUsageLimits(id, automaticCheck, true, false, "cursor"); const newCheck = vi.fn(async () => "new limits"); const afterEnable = refreshUsageLimits(id, newCheck, false, true, "cursor"); expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); + expect(await coalescedAutomatic).toBe("old limits"); + expect(automaticCheck).not.toHaveBeenCalled(); expect(await otherProviders).toBe("claude limits"); expect(await afterEnable).toBe("new limits"); expect(newCheck).toHaveBeenCalledTimes(1); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index ef36f4c08fa42..c1b8a119bf48b 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -71,7 +71,7 @@ export async function refreshUsageLimits( } catch {} return refreshUsageLimits(environmentId, refresh, false, true, selectionKey); } - return automatic ? undefined : ((await pending) as A); + return (await pending) as A; } const refreshAfter = limitsRefreshAfter.get(key) ?? 0; // @effect-diagnostics-next-line globalDate:off From 81a2c4ac8b985dd1a082ab5b0e291a61ea21a0b6 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 22:38:09 -0500 Subject: [PATCH 29/95] fix(web): retain pending usage reads across remounts --- .../src/hooks/useLiveRefresh.browser.test.tsx | 55 ++++++++++++------- apps/web/src/hooks/useLiveRefresh.ts | 26 +++++++-- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 3e567e341a0ea..602999d7026df 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -22,26 +22,41 @@ function Probe(props: { } describe("useLiveRefresh", () => { - it("schedules usage intervals after a slow refresh completes", async () => { - let complete!: () => void; - const pending = new Promise((resolve) => { - complete = resolve; - }); - const refresh = vi.fn().mockReturnValueOnce(pending); - using _timers = fakeTimers({ now: 3_000_000 }); - // A bare hook probe; nothing here navigates. - await using _screen = await render( - , - { router: false }, - ); - expect(refresh).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTime(1000)); - await act(() => complete()); - await act(() => vi.advanceTimersByTime(299_000)); - expect(refresh).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTime(1000)); - expect(refresh).toHaveBeenCalledTimes(2); - }); + it.each(["continuous", "reenabled", "remounted"])( + "schedules usage intervals after a slow refresh completes (%s)", + async (lifecycle) => { + let complete!: () => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + const refresh = vi.fn().mockReturnValueOnce(pending); + using _timers = fakeTimers({ now: 3_000_000 }); + const probe = (enabled: boolean) => ( + + ); + // A bare hook probe; nothing here navigates. + await using screen = await render(probe(true), { router: false }); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(1000)); + if (lifecycle !== "continuous") { + await screen.rerender(lifecycle === "remounted" ? <> : probe(false)); + } + await act(() => vi.advanceTimersByTime(1000)); + if (lifecycle !== "continuous") await screen.rerender(probe(true)); + await act(() => vi.advanceTimersByTime(28_000)); + await act(() => complete()); + await act(() => vi.advanceTimersByTime(299_000)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(1000)); + expect(refresh).toHaveBeenCalledTimes(2); + }, + ); it.each([true, false])( "refreshes a hidden mount on arrival with refreshOnShow=%s", diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index f92a831095e5e..b87d52f88400e 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -42,6 +42,7 @@ function shouldRefreshOnInterval(input: { // Survive remounts so returning to a view does not make fresh reads look overdue. const lastRefreshedAtByView = new Map(); +const pendingRefreshesByView = new Map>(); let lastInteractedAt = 0; const interactionWatchers = new Set<() => void>(); @@ -109,14 +110,23 @@ export function useLiveRefresh( let pending = false; const read = (now: number) => { if (refreshWhileIdle && pending) return; - const result = runRefresh(viewId, now); - if (!refreshWhileIdle) return; + if (!refreshWhileIdle) { + runRefresh(viewId, now); + return; + } + let result = pendingRefreshesByView.get(viewId); + if (result === undefined) { + result = Promise.resolve(runRefresh(viewId, now)).finally(() => { + pendingRefreshesByView.delete(viewId); + lastRefreshedAtByView.set(viewId, Date.now()); + }); + pendingRefreshesByView.set(viewId, result); + } pending = true; clearInterval(timer); const completed = () => { pending = false; if (disposed) return; - lastRefreshedAtByView.set(viewId, Date.now()); syncTimer(); }; void Promise.resolve(result).then(completed, completed); @@ -125,6 +135,10 @@ export function useLiveRefresh( const onArrival = () => { const now = Date.now(); if (!refreshWhileIdle && now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return; + if (refreshWhileIdle && visible() && pendingRefreshesByView.has(viewId)) { + read(now); + return; + } const lastRefreshedAt = lastRefreshedAtByView.get(viewId); if (lastRefreshedAt === undefined) { if (!refreshOnShow) lastRefreshedAtByView.set(viewId, now); @@ -169,7 +183,11 @@ export function useLiveRefresh( if (refreshOnShow && visible()) { const now = Date.now(); const lastRefreshedAt = lastRefreshedAtByView.get(viewId); - if (lastRefreshedAt === undefined || now - lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS) { + if ( + (refreshWhileIdle && pendingRefreshesByView.has(viewId)) || + lastRefreshedAt === undefined || + now - lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS + ) { read(now); } } else onArrival(); From b225a28ba9970d65c0252e30b78e7b9e90454627 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 22:43:04 -0500 Subject: [PATCH 30/95] fix(mobile): throttle visible indicator refresh restarts --- .../usage/useInstanceLimitsRefresh.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index e59cd23cf2bec..64fb1cf578060 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -2,6 +2,7 @@ import { useIsFocused } from "@react-navigation/native"; import { useEffect } from "react"; import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import type { AtomRegistry } from "effect/reactivity"; import { isAppActive, subscribeAppState } from "../../lib/appState"; import { serverEnvironment } from "../../state/server"; @@ -28,14 +29,23 @@ export function useInstanceLimitsRefresh( pending = false; if (!disposed) timer = setTimeout(refresh, 5 * 60_000); }; - void runAtomCommand( - registry, - serverEnvironment.refreshProviders, - { - environmentId, - input: driver ? { usage: true, usageProviders: [driver] } : { instanceId, usage: true }, - }, - { reportFailure: false }, + void refreshUsageLimits( + environmentId, + () => + runAtomCommand( + registry, + serverEnvironment.refreshProviders, + { + environmentId, + input: driver + ? { usage: true, usageProviders: [driver] } + : { instanceId, usage: true }, + }, + { reportFailure: false }, + ), + true, + false, + JSON.stringify([instanceId, driver]), ).then(completed, completed); }; void refresh(); From 9e5069fab491993bd7c80110a0c477a7def00bcd Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 22:57:05 -0500 Subject: [PATCH 31/95] fix(server): use the environment service in Claude probe tests --- apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 298ecde43431a..26758396976ad 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -4,6 +4,7 @@ import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -78,7 +79,7 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi ); const capabilities = yield* probeClaudeCapabilities( yield* decodeClaudeSettings({ binaryPath: "claude" }), - { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: configDir }, + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, ); assert.deepEqual( capabilities.models?.map((model) => model.value), @@ -89,7 +90,7 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi assert.equal(abortSignal?.aborted, true); yield* probeClaudeCapabilities( yield* decodeClaudeSettings({ binaryPath: "claude" }), - { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: configDir }, + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, undefined, false, false, From 87ac21e0749d184a84080b237f58c7355a9fddc5 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 23:18:10 -0500 Subject: [PATCH 32/95] fix(mobile): preserve usage refresh deadlines after reopening --- .../features/usage/usageLimitsRefresh.test.ts | 38 ++++++++++++++----- .../src/features/usage/usageLimitsRefresh.ts | 4 +- .../usage/useInstanceLimitsRefresh.ts | 8 ++-- packages/client-runtime/src/state/usage.ts | 10 +++-- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 15c2ee1467af9..07d63bbf044e6 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -28,6 +28,7 @@ vi.mock("../../state/server", () => ({ import { environmentIdsKey, limitsRefreshFailuresAtom, + limitsAutoRefreshSetAtom, refreshEnvironmentLimits, } from "./usageLimitsRefresh"; @@ -90,15 +91,34 @@ it("does not let an older refresh clear a newer failure for the same environment expect(failures([a, b])).toEqual([a]); }); -it("skips an automatic refresh inside the throttle window without touching the outcome", async () => { +it("skips fresh automatic reads and resumes at the existing deadline", async () => { + vi.useFakeTimers({ now: 1_000_000 }); const a = EnvironmentId.make("mobile-limits-throttle"); const { registry, failures } = setup(() => Promise.resolve(failure)); - failures([a]); - await refreshEnvironmentLimits(registry, a); - expect(failures([a])).toEqual([a]); - state.refresh = () => Promise.resolve(success); - await refreshEnvironmentLimits(registry, a, { automatic: true }); - expect(failures([a])).toEqual([a]); - await refreshEnvironmentLimits(registry, a); - expect(failures([a])).toEqual([]); + let unmount: (() => void) | undefined; + try { + failures([a]); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([a]); + state.refresh = () => Promise.resolve(success); + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(failures([a])).toEqual([a]); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([]); + + const automatic = vi.fn(() => Promise.resolve(success)); + state.refresh = automatic; + await vi.advanceTimersByTimeAsync(4 * 60_000); + unmount = registry.mount(limitsAutoRefreshSetAtom(environmentIdsKey([a]))); + await vi.advanceTimersByTimeAsync(0); + expect(automatic).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(60_000); + expect(automatic).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(automatic).toHaveBeenCalledTimes(2); + } finally { + unmount?.(); + registry.dispose(); + vi.useRealTimers(); + } }); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 82aa15d171793..efe85e4c01305 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -1,7 +1,7 @@ import { isAppActive, subscribeAppState } from "../../lib/appState"; import { EnvironmentId } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; +import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; import { Atom, type AtomRegistry } from "effect/reactivity"; import { serverEnvironment } from "../../state/server"; @@ -56,7 +56,7 @@ const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => if (!disposed) timer = setTimeout(() => { void refresh(); - }, 5 * 60_000); + }, usageLimitsRefreshDelay(environmentId)); } }; void refresh(); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index 64fb1cf578060..adc989ddc4ab7 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -2,7 +2,7 @@ import { useIsFocused } from "@react-navigation/native"; import { useEffect } from "react"; import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; +import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; import type { AtomRegistry } from "effect/reactivity"; import { isAppActive, subscribeAppState } from "../../lib/appState"; import { serverEnvironment } from "../../state/server"; @@ -18,6 +18,7 @@ export function useInstanceLimitsRefresh( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle useEffect(() => { if (!visible || !focused) return; + const selectionKey = JSON.stringify([instanceId, driver]); let disposed = false; let pending = false; let timer: ReturnType | undefined; @@ -27,7 +28,8 @@ export function useInstanceLimitsRefresh( clearTimeout(timer); const completed = () => { pending = false; - if (!disposed) timer = setTimeout(refresh, 5 * 60_000); + if (!disposed) + timer = setTimeout(refresh, usageLimitsRefreshDelay(environmentId, selectionKey)); }; void refreshUsageLimits( environmentId, @@ -45,7 +47,7 @@ export function useInstanceLimitsRefresh( ), true, false, - JSON.stringify([instanceId, driver]), + selectionKey, ).then(completed, completed); }; void refresh(); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index c1b8a119bf48b..fd0500f040296 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -55,6 +55,12 @@ export function usageSummaryInput( const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); +export function usageLimitsRefreshDelay(environmentId: EnvironmentId, selectionKey = ""): number { + const refreshAfter = limitsRefreshAfter.get(JSON.stringify([environmentId, selectionKey])) ?? 0; + // @effect-diagnostics-next-line globalDate:off + return Math.max(0, refreshAfter - Date.now()); +} + export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, @@ -73,9 +79,7 @@ export async function refreshUsageLimits( } return (await pending) as A; } - const refreshAfter = limitsRefreshAfter.get(key) ?? 0; - // @effect-diagnostics-next-line globalDate:off - if (automatic && Date.now() < refreshAfter) return; + if (automatic && usageLimitsRefreshDelay(environmentId, selectionKey) > 0) return; const current = Promise.resolve() .then(refresh) .finally(() => { From 6809a1ec53dcdbd0dc9ac7844fcf8237eb50cb1e Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 23:32:11 -0500 Subject: [PATCH 33/95] fix(codex): omit usage failures from health-only probes --- apps/server/src/provider/CodexProvider.ts | 2 +- .../src/provider/ProviderRegistry.test.ts | 121 ++++++++++-------- 2 files changed, 67 insertions(+), 56 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 4be8b323abbff..8bca38b05636a 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -892,7 +892,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu : snapshot.models === undefined ? { message: "Codex did not return its model catalog in time." } : {}), - ...(managedAuth ? {} : { usageLimits }), + ...(managedAuth || skipUsage ? {} : { usageLimits }), }, }); }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 24a2f00003629..b6b96f6b5ba8d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -358,61 +358,72 @@ it.layer( Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), )("ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, "1.0.0"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "chatgpt"); - assert.strictEqual(status.auth.label, "ChatGPT Pro 200 Subscription"); - assert.strictEqual(status.auth.email, "test@example.com"); - assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); - assert.deepStrictEqual(status.models, [ - { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, - }, - ]); - assert.deepStrictEqual(status.skills, [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ]); - assert.deepStrictEqual(status.slashCommands.slice(1), [ - { - name: "goal", - description: "Set a goal Codex keeps working toward until it is done", - input: { hint: "Objective, or pause, resume, clear" }, - }, - { - name: "feedback", - description: "Send this thread and Codex logs to OpenAI", - input: { hint: "Describe the issue (optional)" }, - }, - ]); - }), + it.effect.each([false, true])( + "uses the app-server account and model list with skipUsage=%s", + (skipUsage) => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + assert.strictEqual(input.skipNativeUsage, skipUsage ? true : undefined); + return Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ); + }, + undefined, + undefined, + skipUsage, + ); + if (skipUsage) assert.strictEqual(status.usageLimits, undefined); + else assert.strictEqual(status.usageLimits?.unavailable?.reason, "probeFailed"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 200 Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + assert.deepStrictEqual(status.slashCommands.slice(1), [ + { + name: "goal", + description: "Set a goal Codex keeps working toward until it is done", + input: { hint: "Objective, or pause, resume, clear" }, + }, + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); + }), ); it.effect("labels the plan the usage read reports over the cached login plan", () => From 6759e6ad05589f2dc469c66559234a2be6347d81 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 23:58:01 -0500 Subject: [PATCH 34/95] fix(usage): clear switched accounts and retain refresh deadlines Advertise Codex usage capability before its first quota read so the usage command remains available without startup polling. Clear retained limits and cooldowns on known authentication-type changes, and reuse web view deadlines when returning to fresh usage. --- apps/server/src/provider/CodexProvider.ts | 7 ++- .../src/provider/ProviderRegistry.test.ts | 1 + .../components/chat/useUsageLimitsPanel.ts | 1 - .../src/hooks/useLiveRefresh.browser.test.tsx | 28 +++++++++++ apps/web/src/hooks/useLiveRefresh.ts | 20 ++++++-- packages/contracts/src/server.ts | 1 + .../src/server/managedProvider.test.ts | 47 +++++++++++++++++++ .../src/server/managedProvider.ts | 1 + packages/shared/src/usageLimits.test.ts | 9 +++- packages/shared/src/usageLimits.ts | 8 ++++ 10 files changed, 115 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 8bca38b05636a..3ff8c8f98b516 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -859,7 +859,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, }); - return buildServerProvider({ + const provider = buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, @@ -895,6 +895,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ...(managedAuth || skipUsage ? {} : { usageLimits }), }, }); + return { + ...provider, + supportsUsageLimits: + accountStatus.auth.status === "authenticated" && accountStatus.auth.type === "chatgpt", + }; }); export { makePendingCodexProvider }; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index b6b96f6b5ba8d..b45733eb73bdc 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -387,6 +387,7 @@ it.layer( if (skipUsage) assert.strictEqual(status.usageLimits, undefined); else assert.strictEqual(status.usageLimits?.unavailable?.reason, "probeFailed"); assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.supportsUsageLimits, true); assert.strictEqual(status.installed, true); assert.strictEqual(status.version, "1.0.0"); assert.strictEqual(status.auth.status, "authenticated"); diff --git a/apps/web/src/components/chat/useUsageLimitsPanel.ts b/apps/web/src/components/chat/useUsageLimitsPanel.ts index 9ce647f0a6593..7793f39828d83 100644 --- a/apps/web/src/components/chat/useUsageLimitsPanel.ts +++ b/apps/web/src/components/chat/useUsageLimitsPanel.ts @@ -60,7 +60,6 @@ export function useUsageLimitsPanel(input: { setPanel(null), ) : null; - // Intercept /usage-limits only when Limits has selected-provider data. const offered = providerDriver !== null && hasProviderUsageLimits(providerDriver, providers, sources); diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 602999d7026df..7e6da429e2ef3 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -58,6 +58,34 @@ describe("useLiveRefresh", () => { }, ); + it.each(["reenabled", "remounted"])( + "keeps the completed usage deadline when %s", + async (lifecycle) => { + const refresh = vi.fn(); + using _timers = fakeTimers({ now: 4_000_000 }); + const probe = (enabled: boolean) => ( + + ); + // A bare hook probe; nothing here navigates. + await using screen = await render(probe(true), { router: false }); + expect(refresh).toHaveBeenCalledTimes(1); + await screen.rerender(lifecycle === "remounted" ? <> : probe(false)); + await act(() => vi.advanceTimersByTime(4 * 60_000)); + await screen.rerender(probe(true)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(60_000)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTime(5 * 60_000)); + expect(refresh).toHaveBeenCalledTimes(3); + }, + ); + it.each([true, false])( "refreshes a hidden mount on arrival with refreshOnShow=%s", async (refreshOnShow) => { diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index b87d52f88400e..86170cbb733c6 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -79,9 +79,8 @@ export function useLiveRefresh( /** `null` turns off the periodic refresh, for a read too costly to repeat unasked. */ readonly intervalMs?: number | null; /** - * Refresh every time the view is enabled unless it read within the floor, skipping the - * first-read rule: for a view that stays mounted while hidden, so its mount-time read can be - * long stale when shown. + * Refresh on showing a view with stale data. Views that refresh while idle reuse their + * polling interval; other views use the minimum refresh floor. */ readonly refreshOnShow?: boolean; readonly refreshWhileIdle?: boolean; @@ -108,6 +107,10 @@ export function useLiveRefresh( let timer: ReturnType | undefined; let disposed = false; let pending = false; + const refreshFloorMs = + refreshWhileIdle && intervalMs !== null + ? Math.max(LIVE_REFRESH_MIN_INTERVAL_MS, intervalMs) + : LIVE_REFRESH_MIN_INTERVAL_MS; const read = (now: number) => { if (refreshWhileIdle && pending) return; if (!refreshWhileIdle) { @@ -145,6 +148,7 @@ export function useLiveRefresh( else if (visible()) read(now); return; } + if (refreshWhileIdle && now - lastRefreshedAt < refreshFloorMs) return; if (shouldRefreshOnArrival({ visible: visible(), now, lastRefreshedAt })) read(now); }; const onInterval = () => { @@ -167,7 +171,13 @@ export function useLiveRefresh( timer = visible() && intervalMs !== null && !pending ? refreshWhileIdle - ? setTimeout(onInterval, intervalMs) + ? setTimeout( + onInterval, + Math.max( + 0, + refreshFloorMs - (Date.now() - (lastRefreshedAtByView.get(viewId) ?? Date.now())), + ), + ) : setInterval(onInterval, intervalMs) : undefined; }; @@ -186,7 +196,7 @@ export function useLiveRefresh( if ( (refreshWhileIdle && pendingRefreshesByView.has(viewId)) || lastRefreshedAt === undefined || - now - lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS + now - lastRefreshedAt >= refreshFloorMs ) { read(now); } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e027930c116ba..2c36d62344e94 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -219,6 +219,7 @@ export const ServerProvider = Schema.Struct({ supportedRuntimeModes: Schema.optional(ForwardCompatibleArray(RuntimeMode)), supportsConversationRollback: Schema.optional(Schema.Boolean), supportsTextGeneration: Schema.optional(Schema.Boolean), + supportsUsageLimits: Schema.optional(Schema.Boolean), setup: Schema.optional( Schema.Struct({ canAuthenticate: Schema.Boolean, diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 133f8aa7215c7..5225d1a93ef2b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -174,6 +174,53 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect("clears retained limits and cooldown when authentication type changes", () => + Effect.scoped( + Effect.gen(function* () { + const auth = yield* Ref.make({ + status: "authenticated", + type: "chatgpt", + email: "account@example.com", + workspaceId: "ws-a", + }); + const reads = yield* Ref.make(0); + const limits = { + checkedAt: refreshedSnapshot.checkedAt, + windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 20 }], + } as const; + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const currentAuth = yield* Ref.get(auth); + const polling = yield* UsagePolling; + if (polling) yield* Ref.update(reads, (count) => count + 1); + return { + ...refreshedSnapshot, + auth: currentAuth, + ...(polling ? { usageLimits: limits } : {}), + }; + }), + }); + yield* provider.refresh; + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 1); + const unchanged = yield* provider.refresh; + assert.deepStrictEqual(unchanged.usageLimits, limits); + assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); + yield* Ref.set(auth, { status: "authenticated", type: "apiKey" }); + const switched = yield* provider.refresh; + assert.strictEqual(switched.usageLimits, undefined); + assert.strictEqual(switched.auth.workspaceId, undefined); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 2); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect( "can enable unsupported usage while failed probes still keep their cache lifetime", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 59d4837482aa5..11f7f187f1c00 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -192,6 +192,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( a !== undefined && b !== undefined && a !== b; const switchedAccount = accountReset || + differs(probedSnapshot.auth.type, previous.type) || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 72a17323386c8..e7b0d27cf69d7 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1385,7 +1385,7 @@ describe("/usage-limits", () => { expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); }); - it("advertises global and workspace commands only for providers present in Limits", () => { + it("advertises global and workspace commands for known usage support or Limits data", () => { const withWorkspace = provider({ workspaceSnapshots: [ { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, @@ -1397,6 +1397,13 @@ describe("/usage-limits", () => { supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + const unread = { ...withWorkspace, supportsUsageLimits: true }; + const [cold] = withUsageLimitsCommands([unread], []); + expect(cold?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect(cold?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name)).toEqual([ + "usage-limits", + ]); + expect(collectProviderUsageLimits(unread.instanceId, [unread], [], now)).not.toBeNull(); const ownCommand = provider({ slashCommands: [{ name: "usage-limits", description: "Provider's own" }], }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index c00f92a0fcea4..82797f0eabb38 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -709,6 +709,14 @@ export function hasProviderUsageLimits( ): boolean { return ( providersWithLimits(providers).some((provider) => provider.driver === driver) || + providers.some( + (provider) => + provider.driver === driver && + provider.supportsUsageLimits === true && + provider.enabled && + provider.installed && + isProviderAvailable(provider), + ) || sources.some( (source) => source.accounts.some((account) => account.driver === driver) || From 6aeafd0b77247fce4bb88b1ddc64d5a28a20d930 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 00:48:05 -0500 Subject: [PATCH 35/95] fix(providers): preserve usage ownership and shared refresh deadlines --- .../src/hooks/useLiveRefresh.browser.test.tsx | 34 ++++++ apps/web/src/hooks/useLiveRefresh.ts | 1 + .../src/server/managedProvider.test.ts | 112 +++++++++++------- .../src/server/managedProvider.ts | 1 + packages/provider-grok/src/server/driver.ts | 4 + .../src/server/usageLimits.test.ts | 88 ++++++++++++++ .../provider-grok/src/server/usageLimits.ts | 8 +- 7 files changed, 202 insertions(+), 46 deletions(-) create mode 100644 packages/provider-grok/src/server/usageLimits.test.ts diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 7e6da429e2ef3..c82d0382cd33c 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -86,6 +86,40 @@ describe("useLiveRefresh", () => { }, ); + it("rechecks the shared deadline between simultaneous usage consumers", async () => { + const reads: Array<{ promise: Promise; resolve: () => void }> = []; + const refresh = vi.fn(() => { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + const read = { promise, resolve }; + reads.push(read); + return read.promise; + }); + using _timers = fakeTimers({ now: 5_000_000 }); + // Bare hook probes share one usage surface key; nothing here navigates. + await using _screen = await render( + <> + + + , + { router: false }, + ); + expect(refresh).toHaveBeenCalledTimes(1); + await act(() => vi.advanceTimersByTime(1000)); + await act(() => reads[0]!.resolve()); + await act(() => vi.advanceTimersByTime(300_000)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTime(1000)); + await act(() => reads[1]!.resolve()); + await act(() => vi.advanceTimersByTime(299_000)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTime(1000)); + expect(refresh).toHaveBeenCalledTimes(3); + await act(() => reads[2]!.resolve()); + }); + it.each([true, false])( "refreshes a hidden mount on arrival with refreshOnShow=%s", async (refreshOnShow) => { diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 86170cbb733c6..062286d4e4a89 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -155,6 +155,7 @@ export function useLiveRefresh( const now = Date.now(); const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now; if ( + (!refreshWhileIdle || now - lastRefreshedAt >= refreshFloorMs) && shouldRefreshOnInterval({ visible: visible(), now, diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 5225d1a93ef2b..2a38d8b039a9c 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -174,51 +174,73 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); - it.effect("clears retained limits and cooldown when authentication type changes", () => - Effect.scoped( - Effect.gen(function* () { - const auth = yield* Ref.make({ - status: "authenticated", - type: "chatgpt", - email: "account@example.com", - workspaceId: "ws-a", - }); - const reads = yield* Ref.make(0); - const limits = { - checkedAt: refreshedSnapshot.checkedAt, - windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 20 }], - } as const; - const provider = yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), - getSettings: Effect.succeed({ enabled: true }), - streamSettings: Stream.empty, - haveSettingsChanged: () => false, - initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Effect.gen(function* () { - const currentAuth = yield* Ref.get(auth); - const polling = yield* UsagePolling; - if (polling) yield* Ref.update(reads, (count) => count + 1); - return { - ...refreshedSnapshot, - auth: currentAuth, - ...(polling ? { usageLimits: limits } : {}), - }; - }), - }); - yield* provider.refresh; - yield* provider.refreshUsage!; - assert.strictEqual(yield* Ref.get(reads), 1); - const unchanged = yield* provider.refresh; - assert.deepStrictEqual(unchanged.usageLimits, limits); - assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); - yield* Ref.set(auth, { status: "authenticated", type: "apiKey" }); - const switched = yield* provider.refresh; - assert.strictEqual(switched.usageLimits, undefined); - assert.strictEqual(switched.auth.workspaceId, undefined); - yield* provider.refreshUsage!; - assert.strictEqual(yield* Ref.get(reads), 2); - }), - ).pipe(Effect.provide(layerAlwaysRunTest)), + it.effect.each(["auth-type", "first-sign-in", "first-discovery"] as const)( + "resets usage on account transition: %s", + (transition) => + Effect.scoped( + Effect.gen(function* () { + const signedIn = { + status: "authenticated", + type: "chatgpt", + email: "account@example.com", + } as const; + const auth = yield* Ref.make( + transition === "auth-type" + ? { ...signedIn, workspaceId: "ws-a" } + : { status: transition === "first-sign-in" ? "unauthenticated" : "unknown" }, + ); + const reads = yield* Ref.make(0); + const limits = { + checkedAt: refreshedSnapshot.checkedAt, + windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 20 }], + } as const; + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const currentAuth = yield* Ref.get(auth); + const polling = yield* UsagePolling; + if (polling) yield* Ref.update(reads, (count) => count + 1); + return { + ...refreshedSnapshot, + auth: currentAuth, + ...(polling + ? { + usageLimits: + currentAuth.status === "authenticated" + ? limits + : { + checkedAt: refreshedSnapshot.checkedAt, + windows: [], + unavailable: { reason: "probeFailed" as const }, + }, + } + : {}), + }; + }), + }); + yield* provider.refresh; + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 1); + const unchanged = yield* provider.refresh; + if (transition === "auth-type") { + assert.deepStrictEqual(unchanged.usageLimits, limits); + assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); + } + yield* Ref.set( + auth, + transition === "auth-type" ? { status: "authenticated", type: "apiKey" } : signedIn, + ); + const switched = yield* provider.refresh; + assert.strictEqual(switched.usageLimits, undefined); + assert.strictEqual(switched.auth.workspaceId, undefined); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 2); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), ); it.effect( diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 11f7f187f1c00..d77014b17aba2 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -192,6 +192,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( a !== undefined && b !== undefined && a !== b; const switchedAccount = accountReset || + (probedSnapshot.auth.status === "authenticated" && previous.status !== "authenticated") || differs(probedSnapshot.auth.type, previous.type) || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 57101753c55fa..1dd652e84e682 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -2,6 +2,7 @@ import { UsagePolling } from "@t3tools/provider-core/server/usageLimits"; import { ProviderDriverKind } from "@t3tools/contracts"; import { GrokSettings } from "../settings.ts"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -95,6 +96,7 @@ export type GrokDriverEnv = | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom + | Digest.Digest | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -119,6 +121,7 @@ export const GrokDriver: ProviderDriver config, }) { const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = (yield* ProviderHost.ProviderHost).usageHttpClient ?? (yield* HttpClient.HttpClient); @@ -193,6 +196,7 @@ export const GrokDriver: ProviderDriver Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(Digest.Digest, digest), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ); diff --git a/packages/provider-grok/src/server/usageLimits.test.ts b/packages/provider-grok/src/server/usageLimits.test.ts new file mode 100644 index 0000000000000..edc2fad7d6787 --- /dev/null +++ b/packages/provider-grok/src/server/usageLimits.test.ts @@ -0,0 +1,88 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { + makeUsageHttpClient, + resolveUsageLimitsAfterProbe, +} from "@t3tools/provider-core/server/usageLimits"; +import { readGrokAccount } from "./usageLimits.ts"; + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "Grok usage account ownership", + (it) => { + it.effect("keeps credential ownership when an older cached account is selected", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + let requests = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.gen(function* () { + requests += 1; + if (requests > 2) + return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); + const first = request.headers.authorization === "Bearer account-a"; + if (first) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(finish); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: first ? 80 : 25 } }), + ); + }), + ), + ); + const read = (key: string) => + readGrokAccount({ + HOME: "/unused-grok-home", + GROK_AUTH: JSON.stringify({ "https://accounts.x.ai/sign-in": { key } }), + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ readFileString: () => Effect.succeed("") }), + ), + ); + const pending = yield* read("account-a").pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("1 second"); + const previous = yield* read("account-b"); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(finish, undefined); + const first = yield* Fiber.join(pending); + yield* TestClock.adjust("299 seconds"); + const cached = yield* read("account-a"); + expect(requests).toBe(2); + expect(cached.usageLimits.checkedAt).toBe(first.usageLimits.checkedAt); + const selected = resolveUsageLimitsAfterProbe({ + published: previous.usageLimits, + probed: cached.usageLimits, + }); + expect(selected?.windows[0]?.usedPercent).toBe(80); + const failed = yield* read("account-b"); + expect(failed.usageLimits.unavailable?.reason).toBe("probeFailed"); + expect(failed.usageLimits.credentialFingerprint).toBeTruthy(); + expect(failed.usageLimits.credentialFingerprint).toBe( + previous.usageLimits.credentialFingerprint, + ); + expect(failed.usageLimits.credentialFingerprint).not.toBe( + cached.usageLimits.credentialFingerprint, + ); + expect( + resolveUsageLimitsAfterProbe({ published: selected, probed: failed.usageLimits }) + ?.windows, + ).toEqual([]); + }), + ), + ); + }, +); diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index a086be56aaa84..c0d472f112683 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,6 +1,8 @@ import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Digest from "effect/crypto/Digest"; +import * as Hex from "effect/encoding/Hex"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -179,6 +181,10 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( if (!token) { return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; } + const digest = yield* Digest.Digest; + const credentialFingerprint = yield* digest + .digest(Digest.sha256, new TextEncoder().encode(`grok\0${token}`)) + .pipe(Effect.map(Hex.encode), Effect.orDie); const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( @@ -205,5 +211,5 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => probeFailed), ); - return { email, usageLimits }; + return { email, usageLimits: { ...usageLimits, credentialFingerprint } }; }); From ec6e515f4b1eeb73f8c85cf40d4060c1204653dd Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 01:06:57 -0500 Subject: [PATCH 36/95] fix(grok): omit usage failures from health-only checks --- .../provider-grok/src/server/driver.test.ts | 63 ++++++++++++++++++- packages/provider-grok/src/server/driver.ts | 2 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 5987a27c38ad2..051a5dd0e469e 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { expect, it, vi } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -10,12 +10,14 @@ import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { GrokDriver } from "./driver.ts"; +import * as GrokStatus from "./status.ts"; import { resolveLatestProviderVersion } from "@t3tools/provider-core/server/maintenanceResolver"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -173,6 +175,65 @@ it.layer(layerTest)("GrokDriver", (it) => { }); } + it.effect("omits usage failures from health-only checks", () => + Effect.scoped( + Effect.gen(function* () { + const config = GrokDriver.defaultConfig(); + const initial = yield* GrokStatus.buildInitialGrokProviderSnapshot(config); + using _status = vi.spyOn(GrokStatus, "checkGrokProviderStatus").mockReturnValue( + Effect.succeed({ + ...initial, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated", type: "cached_token" }, + }), + ); + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-usage-" }); + let requests = 0; + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-usage"), + displayName: "Grok test", + enabled: true, + config, + environment: [ + { name: "GROK_HOME", value: home, sensitive: false }, + { name: "XAI_API_KEY", value: "", sensitive: true }, + { + name: "GROK_AUTH", + value: JSON.stringify({ + "https://accounts.x.ai/sign-in": { + key: "test-token", + email: "account@example.com", + }, + }), + sensitive: true, + }, + ], + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); + }), + ), + ); + const health = yield* instance.snapshot.refresh; + expect(health.auth.email).toBe("account@example.com"); + expect(health.usageLimits).toBeUndefined(); + expect(requests).toBe(0); + yield* instance.snapshot.refreshUsage!; + const usage = yield* instance.snapshot.getSnapshot; + expect(usage.usageLimits?.unavailable?.reason).toBe("probeFailed"); + expect(requests).toBe(1); + }), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), + ); + it.effect("stays manual-only when the configured executable does not exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 1dd652e84e682..00610cde1d3e3 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -187,7 +187,7 @@ export const GrokDriver: ProviderDriver Effect.map(({ email, usageLimits }) => ({ ...snapshot, auth: email ? { ...snapshot.auth, email } : snapshot.auth, - usageLimits, + ...(includeUsage ? { usageLimits } : {}), })), ), ), From 1e9cf3cfaf11c85796a04809e8990872802a9001 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 01:13:08 -0500 Subject: [PATCH 37/95] fix(usage): discard redeemed credits from retained hub readings --- apps/server/src/usage/cliproxyApi.test.ts | 75 ++++++++++++++++------- apps/server/src/usage/cliproxyApi.ts | 12 +++- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 24a9c61743860..da41b647bb4d1 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -457,29 +457,58 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => - Effect.gen(function* () { - const test = fixture(); - const api = yield* test.api; - expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" }); - expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" }); - const redemptions = test.requests.filter((request) => - request.body?.url?.endsWith("/consume"), - ); - expect(redemptions).toHaveLength(2); - expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data); - expect(redemptions[0]?.body?.data).toBe( - encodeJson({ - redeem_request_id: "519d5243-011a-5b7b-91f3-44d85f095705", - credit_id: "credit-b", - }), - ); - expect( - test.requests - .filter((request) => request.path.endsWith("/reset-quota")) - .map((request) => request.body?.auth_index), - ).toEqual(["b", "b"]); - }), + it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( + "pins redemption and reconciles retained credit metadata after %s", + (code) => + Effect.gen(function* () { + let consumed = false; + const test = fixture({ + upstream: (request) => { + if (request.url?.endsWith("/consume")) { + consumed = true; + return { status: 200, body: { code } }; + } + if (consumed) return { status: 503, body: {} }; + if (request.url?.endsWith("/rate-limit-reset-credits")) + return { status: 200, body: { credits: [credit("credit-b")] } }; + return { + status: 200, + body: { plan_type: "pro", rate_limit: { primary_window: { used_percent: 80 } } }, + }; + }, + }); + const api = yield* test.api; + const initial = yield* api.readAccounts(config); + expect(initial[1]?.usageLimits.resetCredits?.nextCreditId).toBe("credit-b"); + const outcome = + code === "reset" ? "reset" : code === "already_redeemed" ? "alreadyRedeemed" : "noCredit"; + expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome }); + expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome }); + const redemptions = test.requests.filter((request) => + request.body?.url?.endsWith("/consume"), + ); + expect(redemptions).toHaveLength(2); + expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data); + expect(redemptions[0]?.body?.data).toBe( + encodeJson({ + redeem_request_id: "519d5243-011a-5b7b-91f3-44d85f095705", + credit_id: "credit-b", + }), + ); + expect( + test.requests + .filter((request) => request.path.endsWith("/reset-quota")) + .map((request) => request.body?.auth_index), + ).toEqual(code === "no_credit" ? [] : ["b", "b"]); + yield* TestClock.adjust("5 minutes"); + const refreshed = yield* api.readAccounts(config); + expect(refreshed[1]?.usageLimits.stale).toBe(true); + expect(refreshed[1]?.usageLimits.windows).toEqual(initial[1]?.usageLimits.windows); + expect(refreshed[1]?.usageLimits.resetCredits).toBeUndefined(); + expect(refreshed[0]?.usageLimits.resetCredits).toEqual( + initial[0]?.usageLimits.resetCredits, + ); + }), ); it.effect("drops only the disproven credit balance after no_credit", () => diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f055f9dc77152..81f2c210d7c56 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -336,13 +336,15 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* Cache.get(usageCache, yield* encodeJson({ hub, account, url }))).body; }); + const usageAccountKey = (hub: CliproxyHub, account: typeof AuthFile.Type) => + `${hub.url}\0${hub.managementKey}\0${account.auth_index}\0${account.id}\0${account.email ?? ""}\0${account.id_token?.chatgpt_account_id ?? ""}`; const lastGoodLimits = new Map(); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const accountKey = `${hub.url}\0${hub.managementKey}\0${account.auth_index}\0${account.id}\0${account.email ?? ""}\0${account.id_token?.chatgpt_account_id ?? ""}`; + const accountKey = usageAccountKey(hub, account); const base = { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), @@ -508,6 +510,14 @@ export const makeCliproxyApi = Effect.gen(function* () { already_redeemed: "alreadyRedeemed", } as const )[response.code]; + if (outcome === "reset" || outcome === "alreadyRedeemed" || outcome === "noCredit") { + const accountKey = usageAccountKey(hub, account); + const previous = lastGoodLimits.get(accountKey); + if (previous) { + const { resetCredits: _resetCredits, ...limits } = previous; + lastGoodLimits.set(accountKey, limits); + } + } if (outcome === "noCredit") { yield* Cache.invalidateWhen( usageCache, From 59a0c522946453dddbc6abf5b15507c647128d38 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 01:26:49 -0500 Subject: [PATCH 38/95] fix(grok): keep supported usage commands available before polling --- .../provider-grok/src/server/driver.test.ts | 125 ++++++++++-------- packages/provider-grok/src/server/driver.ts | 3 +- .../provider-grok/src/server/usageLimits.ts | 18 ++- 3 files changed, 86 insertions(+), 60 deletions(-) diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 051a5dd0e469e..839c0b343b3f6 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it, vi } from "@effect/vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { hasProviderUsageLimits } from "@t3tools/shared/usageLimits"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -175,63 +176,77 @@ it.layer(layerTest)("GrokDriver", (it) => { }); } - it.effect("omits usage failures from health-only checks", () => - Effect.scoped( - Effect.gen(function* () { - const config = GrokDriver.defaultConfig(); - const initial = yield* GrokStatus.buildInitialGrokProviderSnapshot(config); - using _status = vi.spyOn(GrokStatus, "checkGrokProviderStatus").mockReturnValue( - Effect.succeed({ - ...initial, + it.effect.each(["subscription", "api-key", "custom-auth", "missing-token"] as const)( + "keeps health-only usage capability accurate for %s", + (mode) => + Effect.scoped( + Effect.gen(function* () { + const config = GrokDriver.defaultConfig(); + const initial = yield* GrokStatus.buildInitialGrokProviderSnapshot(config); + using _status = vi.spyOn(GrokStatus, "checkGrokProviderStatus").mockReturnValue( + Effect.succeed({ + ...initial, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated", type: "cached_token" }, + }), + ); + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-usage-" }); + let requests = 0; + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-usage"), + displayName: "Grok test", enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated", type: "cached_token" }, - }), - ); - const fs = yield* FileSystem.FileSystem; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-usage-" }); - let requests = 0; - const instance = yield* GrokDriver.create({ - instanceId: ProviderInstanceId.make("grok-usage"), - displayName: "Grok test", - enabled: true, - config, - environment: [ - { name: "GROK_HOME", value: home, sensitive: false }, - { name: "XAI_API_KEY", value: "", sensitive: true }, - { - name: "GROK_AUTH", - value: JSON.stringify({ - "https://accounts.x.ai/sign-in": { - key: "test-token", - email: "account@example.com", - }, + config, + environment: [ + { name: "GROK_HOME", value: home, sensitive: false }, + { name: "XAI_API_KEY", value: mode === "api-key" ? "api-key" : "", sensitive: true }, + { + name: "GROK_OIDC_ISSUER", + value: mode === "custom-auth" ? "https://custom.test" : "", + sensitive: false, + }, + { + name: "GROK_AUTH", + value: JSON.stringify({ + "https://accounts.x.ai/sign-in": { + key: mode === "missing-token" ? "" : "test-token", + email: "account@example.com", + }, + }), + sensitive: true, + }, + ], + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); }), - sensitive: true, - }, - ], - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests += 1; - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), - ); - }), - ), - ); - const health = yield* instance.snapshot.refresh; - expect(health.auth.email).toBe("account@example.com"); - expect(health.usageLimits).toBeUndefined(); - expect(requests).toBe(0); - yield* instance.snapshot.refreshUsage!; - const usage = yield* instance.snapshot.getSnapshot; - expect(usage.usageLimits?.unavailable?.reason).toBe("probeFailed"); - expect(requests).toBe(1); - }), - ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), + ), + ); + const health = yield* instance.snapshot.refresh; + const supported = mode === "subscription"; + expect(health.auth.email).toBe( + mode === "api-key" || mode === "custom-auth" ? undefined : "account@example.com", + ); + expect(health.supportsUsageLimits).toBe(supported); + expect(hasProviderUsageLimits(health.driver, [health], [])).toBe(supported); + expect(health.usageLimits).toBeUndefined(); + expect(requests).toBe(0); + yield* instance.snapshot.refreshUsage!; + const usage = yield* instance.snapshot.getSnapshot; + expect(usage.usageLimits?.unavailable?.reason).toBe( + supported ? "probeFailed" : "unsupported", + ); + expect(requests).toBe(supported ? 1 : 0); + }), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), ); it.effect("stays manual-only when the configured executable does not exist", () => diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 00610cde1d3e3..9f9674295046e 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -184,10 +184,11 @@ export const GrokDriver: ProviderDriver ), (snapshot) => readGrokAccount(processEnv, includeUsage).pipe( - Effect.map(({ email, usageLimits }) => ({ + Effect.map(({ email, usageLimits, supportsUsageLimits }) => ({ ...snapshot, auth: email ? { ...snapshot.auth, email } : snapshot.auth, ...(includeUsage ? { usageLimits } : {}), + supportsUsageLimits, })), ), ), diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index c0d472f112683..a598450e24846 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -174,12 +174,18 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( const credential = yield* Effect.option( readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), ); - if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; + if (Option.isNone(credential)) + return { email: undefined, usageLimits: probeFailed, supportsUsageLimits: false }; const email = credential.value?.email?.trim() || undefined; - if (!includeUsage) return { email, usageLimits: probeFailed }; const token = credential.value?.key?.trim(); + if (!includeUsage) + return { email, usageLimits: probeFailed, supportsUsageLimits: Boolean(token) }; if (!token) { - return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; + return { + email, + usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), + supportsUsageLimits: false, + }; } const digest = yield* Digest.Digest; const credentialFingerprint = yield* digest @@ -211,5 +217,9 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( Effect.timeout("10 seconds"), Effect.orElseSucceed(() => probeFailed), ); - return { email, usageLimits: { ...usageLimits, credentialFingerprint } }; + return { + email, + usageLimits: { ...usageLimits, credentialFingerprint }, + supportsUsageLimits: true, + }; }); From 3f8ca2c8f7bda448660b1f5f0c7dee475abe965a Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 01:34:02 -0500 Subject: [PATCH 39/95] fix(usage): reconcile source credit snapshots after redemption --- .../src/usage/UsageLimitSources.test.ts | 111 ++++++++++++++++++ apps/server/src/usage/UsageLimitSources.ts | 24 +++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 67b6e37b0b74a..68b1d332e4eb9 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -257,3 +257,114 @@ it.effect("retains another window's account when provider-filtered usage refresh Effect.scoped, ); }); + +it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( + "removes retained credits after %s when account listing fails", + (code) => { + let consumed = false; + const sourceId = UsageLimitSourceId.make("hub"); + const otherId = UsageLimitSourceId.make("other-hub"); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + const initial = yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length === 2), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + ); + const before = initial.find((source) => source.id === sourceId)!; + expect(before.accounts[0]?.usageLimits.resetCredits?.nextCreditId).toBe("credit"); + const outcome = + code === "reset" ? "reset" : code === "already_redeemed" ? "alreadyRedeemed" : "noCredit"; + expect( + yield* sources.consumeResetCredit({ sourceId, accountId: "first", creditId: "credit" }), + ).toEqual({ outcome }); + for (let attempt = 0; attempt < 2; attempt++) { + const snapshots = yield* sources.current; + const after = snapshots.find((source) => source.id === sourceId)!; + expect(after.error).toContain("503"); + expect(after.accounts[0]?.usageLimits.stale).toBe(true); + expect(after.accounts[0]?.usageLimits.windows).toEqual( + before.accounts[0]?.usageLimits.windows, + ); + expect(after.accounts[0]?.usageLimits.resetCredits).toBeUndefined(); + expect(after.accounts[1]?.usageLimits.resetCredits).toEqual( + before.accounts[1]?.usageLimits.resetCredits, + ); + expect(snapshots.find((source) => source.id === otherId)?.accounts).toEqual( + initial.find((source) => source.id === otherId)?.accounts, + ); + if (attempt === 0) yield* sources.refresh; + } + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [sourceId]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "key", + enabled: true, + }, + [otherId]: { + kind: "cliproxy", + url: "https://other.test", + managementKey: "key", + enabled: true, + }, + }, + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: () => Effect.succeed([]), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/auth-files")) + return HttpClientResponse.fromWeb( + request, + consumed && new URL(request.url).host === "hub.test" + ? Response.json({}, { status: 503 }) + : Response.json({ + files: [ + { id: "first", auth_index: "a", provider: "codex" }, + { id: "second", auth_index: "b", provider: "codex" }, + ], + }), + ); + if (request.url.endsWith("/reset-quota")) + return HttpClientResponse.fromWeb(request, Response.json({})); + const input = decodeSourceRequest( + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : "{}", + ); + if (input.url.endsWith("/consume")) consumed = true; + const body = input.url.endsWith("/consume") + ? { code } + : input.url.endsWith("/rate-limit-reset-credits") + ? { + credits: [ + { + id: "credit", + expires_at: "2099-01-01T00:00:00Z", + status: "available", + reset_type: "codex_rate_limits", + }, + ], + } + : { rate_limit: { primary_window: { used_percent: 20 } } }; + return HttpClientResponse.fromWeb( + request, + Response.json({ status_code: 200, body: encodeSourceBody(body) }), + ); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 3e9b6c6984d71..7fba8afc13dc8 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -70,6 +70,11 @@ export const make = Effect.gen(function* () { PubSub.shutdown, ); + const sourceCacheKey = ( + id: UsageLimitSourceId, + config: UsageLimitSourceConfig, + managementKey: string, + ) => `${id}\0${config.url}\0${managementKey}`; const lastGoodSources = new Map< string, readonly UsageLimitSourceSnapshot["accounts"][number][] @@ -84,7 +89,7 @@ export const make = Effect.gen(function* () { if (managementKey.length === 0) { return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accountKey = `${id}\0${config.url}\0${managementKey}`; + const accountKey = sourceCacheKey(id, config, managementKey); const providers = yield* UsageProviderFilter; const accounts = yield* api .readAccounts({ url: config.url, managementKey }, providers) @@ -157,6 +162,23 @@ export const make = Effect.gen(function* () { input.accountId, input.creditId, ); + if ( + result.outcome === "reset" || + result.outcome === "alreadyRedeemed" || + result.outcome === "noCredit" + ) { + const cacheKey = sourceCacheKey(input.sourceId, config, managementKey); + const retained = lastGoodSources.get(cacheKey); + if (retained) + lastGoodSources.set( + cacheKey, + retained.map((account) => { + if (account.id !== input.accountId || account.driver !== "codex") return account; + const { resetCredits: _resetCredits, ...usageLimits } = account.usageLimits; + return { ...account, usageLimits }; + }), + ); + } const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); From 7c94e96e996a4f86801bcd65003ef6502110e3c0 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 16:46:00 -0500 Subject: [PATCH 40/95] refactor(usage): retain hub readings in one service --- .../src/usage/UsageLimitSources.test.ts | 275 +++++++++++------- apps/server/src/usage/UsageLimitSources.ts | 20 +- apps/server/src/usage/cliproxyApi.test.ts | 19 +- apps/server/src/usage/cliproxyApi.ts | 40 +-- 4 files changed, 210 insertions(+), 144 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 68b1d332e4eb9..d0d14a320087b 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -165,102 +166,137 @@ it.effect("reads a management key from its secret provider", () => { ); }); -it.effect("retains another window's account when provider-filtered usage refreshes finish", () => { - let fail = false; - let includeCodex = false; - return Effect.gen(function* () { - const sources = yield* UsageLimitSources.make; - yield* sources.streamChanges.pipe( - Stream.filter((next) => next.length > 0), - Stream.runHead, - ); - includeCodex = true; - yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["claudeAgent"])); - expect((yield* sources.current)[0]?.accounts.map((account) => account.driver)).toEqual([ - "claudeAgent", - ]); - yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); - expect((yield* sources.current)[0]?.accounts.map((account) => account.driver).sort()).toEqual([ - "claudeAgent", - "codex", - ]); - const prior = (yield* sources.current)[0]!; - fail = true; - yield* sources.refresh; - const failed = (yield* sources.current)[0]!; - expect(failed.accounts).toEqual( - prior.accounts.map((account) => ({ - ...account, - usageLimits: { ...account.usageLimits, stale: true }, - })), - ); - expect(failed.error).toContain("401"); - fail = false; - yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); - expect( - (yield* sources.current)[0]?.accounts.find((account) => account.driver === "claudeAgent") - ?.usageLimits.stale, - ).toBe(true); - }).pipe( - Effect.provide([ - ServerSettings.layerTest({ - usageLimitSources: { - [UsageLimitSourceId.make("hub")]: { - kind: "cliproxy", - url: "https://hub.test", - managementKey: "key", - enabled: true, +it.effect.each(["listing", "quota", "changed-account"] as const)( + "retains provider-filtered readings through %s failures with account isolation", + (failure) => { + let fail = false; + let includeCodex = false; + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + ); + includeCodex = true; + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["claudeAgent"])); + expect((yield* sources.current)[0]?.accounts.map((account) => account.driver)).toEqual([ + "claudeAgent", + ]); + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); + expect((yield* sources.current)[0]?.accounts.map((account) => account.driver).sort()).toEqual( + ["claudeAgent", "codex"], + ); + const prior = (yield* sources.current)[0]!; + yield* TestClock.adjust("5 minutes"); + fail = true; + yield* sources.refresh; + const failed = (yield* sources.current)[0]!; + if (failure === "changed-account") { + expect( + failed.accounts.every( + (account) => + account.usageLimits.windows.length === 0 && + account.usageLimits.unavailable?.reason === "probeFailed", + ), + ).toBe(true); + } else if (failure === "listing") + expect(failed.accounts).toEqual( + prior.accounts.map((account) => ({ + ...account, + usageLimits: { ...account.usageLimits, stale: true }, + })), + ); + if (failure === "quota") + expect(failed.accounts.map((account) => account.usageLimits)).toEqual( + prior.accounts.map((account) => ({ ...account.usageLimits, stale: true })), + ); + if (failure === "listing") expect(failed.error).toContain("401"); + else expect(failed.error).toBeUndefined(); + fail = false; + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["codex"])); + expect( + (yield* sources.current)[0]?.accounts.find((account) => account.driver === "claudeAgent") + ?.usageLimits.stale, + ).toBe(failure === "changed-account" ? undefined : true); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("hub")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "key", + enabled: true, + }, }, - }, - }), - Layer.succeed(SecretResolver.SecretResolver, { - read: () => Effect.succeed([]), - invalidate: () => Effect.void, - }), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - if (fail) - return HttpClientResponse.fromWeb(request, Response.json({}, { status: 401 })); - if (request.url.endsWith("/auth-files")) + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: () => Effect.succeed([]), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (fail && failure === "listing") + return HttpClientResponse.fromWeb(request, Response.json({}, { status: 401 })); + if (request.url.endsWith("/auth-files")) + return HttpClientResponse.fromWeb( + request, + Response.json({ + files: [ + { id: "claude", auth_index: "claude", provider: "claude" }, + ...(includeCodex + ? [{ id: "codex", auth_index: "codex", provider: "codex" }] + : []), + ].map((account) => ({ + ...account, + auth_index: + fail && failure === "changed-account" + ? `${account.auth_index}-new` + : account.auth_index, + })), + }), + ); + const input = decodeSourceRequest( + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : "{}", + ); + if (fail) + return HttpClientResponse.fromWeb( + request, + Response.json({ status_code: 503, body: "{}" }), + ); + const body = input.url.endsWith("/rate-limit-reset-credits") + ? { credits: [] } + : input.auth_index === "claude" + ? { five_hour: { utilization: 20, resets_at: null } } + : { rate_limit: { primary_window: { used_percent: 20 } } }; return HttpClientResponse.fromWeb( request, - Response.json({ - files: [ - { id: "claude", auth_index: "claude", provider: "claude" }, - ...(includeCodex - ? [{ id: "codex", auth_index: "codex", provider: "codex" }] - : []), - ], - }), + Response.json({ status_code: 200, body: encodeSourceBody(body) }), ); - const input = decodeSourceRequest( - request.body._tag === "Uint8Array" - ? new TextDecoder().decode(request.body.body) - : "{}", - ); - const body = input.url.endsWith("/rate-limit-reset-credits") - ? { credits: [] } - : input.auth_index === "claude" - ? { five_hour: { utilization: 20, resets_at: null } } - : { rate_limit: { primary_window: { used_percent: 20 } } }; - return HttpClientResponse.fromWeb( - request, - Response.json({ status_code: 200, body: encodeSourceBody(body) }), - ); - }), + }), + ), ), - ), - NodeCrypto.layer, - ]), - Effect.scoped, - ); -}); + NodeCrypto.layer, + ]), + Effect.scoped, + ); + }, +); -it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( - "removes retained credits after %s when account listing fails", - (code) => { +it.effect.each([ + { code: "reset", failure: "listing" }, + { code: "already_redeemed", failure: "listing" }, + { code: "no_credit", failure: "listing" }, + { code: "reset", failure: "quota" }, + { code: "already_redeemed", failure: "quota" }, + { code: "no_credit", failure: "quota" }, +] as const)( + "removes retained credits after $code during $failure failures", + ({ code, failure }) => { let consumed = false; const sourceId = UsageLimitSourceId.make("hub"); const otherId = UsageLimitSourceId.make("other-hub"); @@ -278,20 +314,38 @@ it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( expect( yield* sources.consumeResetCredit({ sourceId, accountId: "first", creditId: "credit" }), ).toEqual({ outcome }); + if (failure === "quota") { + yield* TestClock.adjust("5 minutes"); + yield* sources.refresh; + } for (let attempt = 0; attempt < 2; attempt++) { const snapshots = yield* sources.current; const after = snapshots.find((source) => source.id === sourceId)!; - expect(after.error).toContain("503"); + if (failure === "listing") expect(after.error).toContain("503"); + else expect(after.error).toBeUndefined(); expect(after.accounts[0]?.usageLimits.stale).toBe(true); expect(after.accounts[0]?.usageLimits.windows).toEqual( before.accounts[0]?.usageLimits.windows, ); - expect(after.accounts[0]?.usageLimits.resetCredits).toBeUndefined(); + expect(after.accounts[0]?.usageLimits.resetCredits?.nextCreditId).toBeUndefined(); + expect(after.accounts[0]?.usageLimits.resetCredits?.availableCount ?? 0).toBe(0); expect(after.accounts[1]?.usageLimits.resetCredits).toEqual( before.accounts[1]?.usageLimits.resetCredits, ); - expect(snapshots.find((source) => source.id === otherId)?.accounts).toEqual( - initial.find((source) => source.id === otherId)?.accounts, + expect( + snapshots + .find((source) => source.id === otherId) + ?.accounts.map((account) => [ + account.usageLimits.windows, + account.usageLimits.resetCredits, + ]), + ).toEqual( + initial + .find((source) => source.id === otherId) + ?.accounts.map((account) => [ + account.usageLimits.windows, + account.usageLimits.resetCredits, + ]), ); if (attempt === 0) yield* sources.refresh; } @@ -324,7 +378,7 @@ it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( if (request.url.endsWith("/auth-files")) return HttpClientResponse.fromWeb( request, - consumed && new URL(request.url).host === "hub.test" + consumed && failure === "listing" && new URL(request.url).host === "hub.test" ? Response.json({}, { status: 503 }) : Response.json({ files: [ @@ -341,18 +395,33 @@ it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( : "{}", ); if (input.url.endsWith("/consume")) consumed = true; + if ( + consumed && + failure === "quota" && + new URL(request.url).host === "hub.test" && + input.url.endsWith("/usage") + ) + return HttpClientResponse.fromWeb( + request, + Response.json({ status_code: 503, body: "{}" }), + ); const body = input.url.endsWith("/consume") ? { code } : input.url.endsWith("/rate-limit-reset-credits") ? { - credits: [ - { - id: "credit", - expires_at: "2099-01-01T00:00:00Z", - status: "available", - reset_type: "codex_rate_limits", - }, - ], + credits: + consumed && + input.auth_index === "a" && + new URL(request.url).host === "hub.test" + ? [] + : [ + { + id: "credit", + expires_at: "2099-01-01T00:00:00Z", + status: "available", + reset_type: "codex_rate_limits", + }, + ], } : { rate_limit: { primary_window: { used_percent: 20 } } }; return HttpClientResponse.fromWeb( diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7fba8afc13dc8..9efa52a96bd79 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -1,4 +1,7 @@ -import { UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; +import { + resolveUsageLimitsAfterProbe, + UsageProviderFilter, +} from "@t3tools/provider-core/server/usageLimits"; import { UsageLimitSourceError, type UsageLimitSourceConsumeResetCreditInput, @@ -109,7 +112,20 @@ export const make = Effect.gen(function* () { : (lastGoodSources.get(accountKey) ?? []).filter( (account) => !providers.includes(account.driver), ); - const merged = [...retained, ...accounts.success]; + const previous = lastGoodSources.get(accountKey) ?? []; + const merged = [ + ...retained, + ...accounts.success.map((account) => ({ + ...account, + usageLimits: + resolveUsageLimitsAfterProbe({ + published: previous.find( + (prior) => prior.id === account.id && prior.driver === account.driver, + )?.usageLimits, + probed: account.usageLimits, + }) ?? account.usageLimits, + })), + ]; lastGoodSources.set(accountKey, merged); return { ...base, accounts: merged }; }); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index da41b647bb4d1..2ee58c38524b5 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -203,7 +203,7 @@ describe("CLIProxyAPI hub failures", () => { describe("CLIProxyAPI built-in management API", () => { it.effect.each([429, 503])( - "paces filtered Claude accounts, preserves readings, and shares HTTP %s cooldowns", + "paces filtered Claude accounts, caches responses, and shares HTTP %s cooldowns", (status) => Effect.gen(function* () { let limited = false; @@ -231,9 +231,10 @@ describe("CLIProxyAPI built-in management API", () => { limited = true; yield* TestClock.adjust("4 minutes"); const stale = yield* api.readAccounts(config, ["claudeAgent"]); - expect(stale.map((account) => account.usageLimits)).toEqual( - first.map((account) => ({ ...account.usageLimits, stale: true })), - ); + expect(stale.map((account) => account.usageLimits.unavailable?.reason)).toEqual([ + "probeFailed", + "probeFailed", + ]); yield* TestClock.adjust("5 minutes"); yield* api.readAccounts(config, ["claudeAgent"]); expect( @@ -458,7 +459,7 @@ describe("CLIProxyAPI built-in management API", () => { ); it.effect.each(["reset", "already_redeemed", "no_credit"] as const)( - "pins redemption and reconciles retained credit metadata after %s", + "pins redemption and clears the correct routing cooldown after %s", (code) => Effect.gen(function* () { let consumed = false; @@ -500,14 +501,6 @@ describe("CLIProxyAPI built-in management API", () => { .filter((request) => request.path.endsWith("/reset-quota")) .map((request) => request.body?.auth_index), ).toEqual(code === "no_credit" ? [] : ["b", "b"]); - yield* TestClock.adjust("5 minutes"); - const refreshed = yield* api.readAccounts(config); - expect(refreshed[1]?.usageLimits.stale).toBe(true); - expect(refreshed[1]?.usageLimits.windows).toEqual(initial[1]?.usageLimits.windows); - expect(refreshed[1]?.usageLimits.resetCredits).toBeUndefined(); - expect(refreshed[0]?.usageLimits.resetCredits).toEqual( - initial[0]?.usageLimits.resetCredits, - ); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 81f2c210d7c56..45ba66e99a58d 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -8,7 +8,6 @@ import { type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, type UsageLimitSourceConfig, - type ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; @@ -21,10 +20,7 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { - makeUnavailableUsageLimits, - resolveUsageLimitsAfterProbe, -} from "@t3tools/provider-core/server/usageLimits"; +import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; /** A hub with its management key already read from wherever the setting keeps it. */ export interface CliproxyHub { @@ -336,15 +332,20 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* Cache.get(usageCache, yield* encodeJson({ hub, account, url }))).body; }); - const usageAccountKey = (hub: CliproxyHub, account: typeof AuthFile.Type) => - `${hub.url}\0${hub.managementKey}\0${account.auth_index}\0${account.id}\0${account.email ?? ""}\0${account.id_token?.chatgpt_account_id ?? ""}`; - const lastGoodLimits = new Map(); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const accountKey = usageAccountKey(hub, account); + const ownership = yield* encodeJson([ + account.auth_index, + account.id, + account.email, + account.id_token?.chatgpt_account_id, + ]).pipe(Effect.orDie); + const credentialFingerprint = Hex.encode( + yield* digest.digest(Digest.sha256, new TextEncoder().encode(ownership)).pipe(Effect.orDie), + ); const base = { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), @@ -446,15 +447,10 @@ export const makeCliproxyApi = Effect.gen(function* () { message: "The hub could not read this account's usage.", }), })), - Effect.map((result) => { - const usageLimits = - resolveUsageLimitsAfterProbe({ - published: lastGoodLimits.get(accountKey), - probed: result.usageLimits, - }) ?? result.usageLimits; - if (!usageLimits.unavailable) lastGoodLimits.set(accountKey, usageLimits); - return { ...result, usageLimits }; - }), + Effect.map((result) => ({ + ...result, + usageLimits: { ...result.usageLimits, credentialFingerprint }, + })), ); }); @@ -510,14 +506,6 @@ export const makeCliproxyApi = Effect.gen(function* () { already_redeemed: "alreadyRedeemed", } as const )[response.code]; - if (outcome === "reset" || outcome === "alreadyRedeemed" || outcome === "noCredit") { - const accountKey = usageAccountKey(hub, account); - const previous = lastGoodLimits.get(accountKey); - if (previous) { - const { resetCredits: _resetCredits, ...limits } = previous; - lastGoodLimits.set(accountKey, limits); - } - } if (outcome === "noCredit") { yield* Cache.invalidateWhen( usageCache, From d73ea03384ad4ee566086ca5ee5e0b380b8129d4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 17:04:08 -0500 Subject: [PATCH 41/95] fix(usage): isolate hub ownership and preserve failed probes --- .../src/usage/UsageLimitSources.test.ts | 24 ++++++++++++++++++- apps/server/src/usage/UsageLimitSources.ts | 13 ++++++++-- apps/server/src/usage/cliproxyApi.test.ts | 15 +++++++++++- apps/server/src/usage/cliproxyApi.ts | 8 ++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index d0d14a320087b..0b3238b810b67 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,7 +1,13 @@ import { UsageProviderFilter } from "@t3tools/provider-core/server/usageLimits"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { collectLimitAccounts } from "@t3tools/shared/usageLimits"; +import { + EnvironmentId, + 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"; @@ -307,6 +313,22 @@ it.effect.each([ Stream.runHead, Effect.flatMap(Effect.fromOption), ); + expect( + collectLimitAccounts( + new Map( + initial.map( + (source, index) => + [ + EnvironmentId.make(`env-${index}`), + { + entry: { target: { label: `Environment ${index}` } }, + serverConfig: { providers: [], usageLimitSources: [source] }, + }, + ] as const, + ), + ), + ), + ).toHaveLength(4); const before = initial.find((source) => source.id === sourceId)!; expect(before.accounts[0]?.usageLimits.resetCredits?.nextCreditId).toBe("credit"); const outcome = diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 9efa52a96bd79..59d5cbfa6352f 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -133,10 +133,19 @@ export const make = Effect.gen(function* () { const publish = Effect.fn("UsageLimitSources.publish")(function* ( next: ReadonlyArray, ) { + // Hub routing fingerprints identify local retention ownership, not a shared credential. + const snapshots = next.map((source) => ({ + ...source, + accounts: source.accounts.map((account) => { + const { credentialFingerprint: _credentialFingerprint, ...usageLimits } = + account.usageLimits; + return { ...account, usageLimits }; + }), + })); const changed = yield* Ref.modify(stateRef, (previous) => - Equal.equals(previous, next) ? [false, previous] : [true, next], + Equal.equals(previous, snapshots) ? [false, previous] : [true, snapshots], ); - if (changed) yield* PubSub.publish(changes, next); + if (changed) yield* PubSub.publish(changes, snapshots); }); // Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes. diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 2ee58c38524b5..2ff73296c7b3a 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -469,7 +469,7 @@ describe("CLIProxyAPI built-in management API", () => { consumed = true; return { status: 200, body: { code } }; } - if (consumed) return { status: 503, body: {} }; + if (request.auth_index === "a" || consumed) return { status: 503, body: {} }; if (request.url?.endsWith("/rate-limit-reset-credits")) return { status: 200, body: { credits: [credit("credit-b")] } }; return { @@ -501,6 +501,19 @@ describe("CLIProxyAPI built-in management API", () => { .filter((request) => request.path.endsWith("/reset-quota")) .map((request) => request.body?.auth_index), ).toEqual(code === "no_credit" ? [] : ["b", "b"]); + yield* api.readAccounts(config); + expect( + test.requests.filter( + (request) => request.body?.auth_index === "a" && request.body.url?.endsWith("/usage"), + ), + ).toHaveLength(1); + yield* TestClock.adjust("5 minutes"); + yield* api.readAccounts(config); + expect( + test.requests.filter( + (request) => request.body?.auth_index === "a" && request.body.url?.endsWith("/usage"), + ), + ).toHaveLength(2); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 45ba66e99a58d..e4bf3a88f1667 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -514,7 +514,13 @@ export const makeCliproxyApi = Effect.gen(function* () { ); } if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; - yield* Cache.invalidateAll(usageCache); + for (const url of [`${CODEX_BASE}/usage`, CREDIT_URL]) { + yield* Cache.invalidateWhen( + usageCache, + yield* encodeJson({ hub, account, url }), + () => true, + ); + } const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); From 9e5889eb749047009d6e92e538723c80ff68dd18 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 17:28:29 -0500 Subject: [PATCH 42/95] fix(web): share usage deadlines across entry points --- apps/web/src/components/usage/UsagePage.tsx | 24 +++++++-- .../src/hooks/useLiveRefresh.browser.test.tsx | 52 +++++++++++++++++-- apps/web/src/hooks/useLiveRefresh.ts | 44 ++++++++++------ apps/web/src/hooks/useUsageLimitsRefresh.ts | 4 ++ 4 files changed, 99 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1986ac3b33fef..a50fec61e25d9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -230,6 +230,10 @@ export function UsagePage() { reportFailure: false, }); + const visibleDrivers = PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider)).map( + (provider) => ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), + ); + const canReadDiagnostics = selectedEnvironments.some( (environment) => environment.canReadDiagnostics, ); @@ -265,11 +269,7 @@ export function UsagePage() { environmentId, input: { usage: true, - usageProviders: PROVIDER_ORDER.filter( - (provider) => !hiddenProviders.has(provider), - ).map((provider) => - ProviderDriverKind.make(provider === "claude" ? "claudeAgent" : provider), - ), + usageProviders: visibleDrivers, }, }), false, @@ -357,6 +357,20 @@ export function UsagePage() { connectedLimitsEnvironments !== "" && PROVIDER_ORDER.some((provider) => !hiddenProviders.has(provider)), key: `usage-page:${connectedLimitsEnvironments}:${[...hiddenProviders].sort().join(",")}`, + refreshKeys: Array.from(presentationsWithProviders).flatMap(([environmentId, presentation]) => + presentation.connection.phase === "connected" && + presentation.serverConfig !== null && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)) + ? [ + ...visibleDrivers.map((driver) => JSON.stringify([environmentId, driver])), + ...presentation.serverConfig.providers + .filter((provider) => visibleDrivers.includes(provider.driver)) + .map((provider) => + JSON.stringify([environmentId, provider.driver, provider.instanceId]), + ), + ] + : [], + ), refreshOnShow: true, refreshWhileIdle: true, }); diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index c82d0382cd33c..7aac282c5f288 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -10,12 +10,14 @@ function Probe(props: { readonly intervalMs?: number | null; readonly refreshOnShow?: boolean; readonly refreshWhileIdle?: boolean; + readonly refreshKeys?: readonly string[]; }) { useLiveRefresh(props.refresh, { key: props.viewKey, enabled: props.enabled ?? true, ...(props.intervalMs !== undefined ? { intervalMs: props.intervalMs } : {}), refreshWhileIdle: props.refreshWhileIdle ?? false, + ...(props.refreshKeys ? { refreshKeys: props.refreshKeys } : {}), ...(props.refreshOnShow !== undefined ? { refreshOnShow: props.refreshOnShow } : {}), }); return null; @@ -58,15 +60,17 @@ describe("useLiveRefresh", () => { }, ); - it.each(["reenabled", "remounted"])( + it.each(["reenabled", "remounted", "another entry point"])( "keeps the completed usage deadline when %s", async (lifecycle) => { const refresh = vi.fn(); + let otherEntry = false; using _timers = fakeTimers({ now: 4_000_000 }); const probe = (enabled: boolean) => ( { // A bare hook probe; nothing here navigates. await using screen = await render(probe(true), { router: false }); expect(refresh).toHaveBeenCalledTimes(1); - await screen.rerender(lifecycle === "remounted" ? <> : probe(false)); + await screen.rerender(lifecycle !== "reenabled" ? <> : probe(false)); await act(() => vi.advanceTimersByTime(4 * 60_000)); + otherEntry = lifecycle === "another entry point"; await screen.rerender(probe(true)); expect(refresh).toHaveBeenCalledTimes(1); await act(() => vi.advanceTimersByTime(60_000)); @@ -101,8 +106,20 @@ describe("useLiveRefresh", () => { // Bare hook probes share one usage surface key; nothing here navigates. await using _screen = await render( <> - - + + , { router: false }, ); @@ -120,6 +137,31 @@ describe("useLiveRefresh", () => { await act(() => reads[2]!.resolve()); }); + it("refreshes a newly visible provider without postponing another provider's deadline", async () => { + const refresh = vi.fn(); + using _timers = fakeTimers({ now: 6_000_000 }); + const probe = (keys: string[]) => ( + + ); + // Bare hook probes; no navigation occurs. + await using screen = await render(probe(["provider-a"]), { router: false }); + expect(refresh).toHaveBeenCalledTimes(1); + await screen.rerender(<>); + await act(() => vi.advanceTimersByTime(240_000)); + await screen.rerender(probe(["provider-a", "provider-b"])); + expect(refresh).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTime(60_000)); + expect(refresh).toHaveBeenCalledTimes(3); + await act(() => vi.advanceTimersByTime(240_000)); + expect(refresh).toHaveBeenCalledTimes(4); + }); + it.each([true, false])( "refreshes a hidden mount on arrival with refreshOnShow=%s", async (refreshOnShow) => { diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 062286d4e4a89..a536d1dc74428 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -76,6 +76,8 @@ export function useLiveRefresh( options: { readonly enabled?: boolean; readonly key?: string; + /** Shared deadlines for resources read by this view. */ + readonly refreshKeys?: readonly string[]; /** `null` turns off the periodic refresh, for a read too costly to repeat unasked. */ readonly intervalMs?: number | null; /** @@ -89,13 +91,16 @@ export function useLiveRefresh( const { enabled = true, key, + refreshKeys, intervalMs = LIVE_REFRESH_INTERVAL_MS, refreshOnShow = false, refreshWhileIdle = false, } = options; + const sharedKeys = + refreshWhileIdle && refreshKeys?.length ? JSON.stringify([...refreshKeys].sort()) : undefined; const runRefresh = useEffectEvent((viewId: string, now: number) => { if (refresh === null) return; - lastRefreshedAtByView.set(viewId, now); + if (sharedKeys === undefined) lastRefreshedAtByView.set(viewId, now); return refresh(); }); const fallbackViewId = useId(); @@ -104,6 +109,14 @@ export function useLiveRefresh( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refresh interval tied to page visibility and user interaction listeners useEffect(() => { if (!enabled) return; + const resourceKeys = sharedKeys === undefined ? [viewId] : (JSON.parse(sharedKeys) as string[]); + const requestKey = sharedKeys === undefined ? viewId : `resources:${sharedKeys}`; + const lastRefresh = () => { + const last = Math.min( + ...resourceKeys.map((key) => lastRefreshedAtByView.get(key) ?? -Infinity), + ); + return Number.isFinite(last) ? last : undefined; + }; let timer: ReturnType | undefined; let disposed = false; let pending = false; @@ -117,13 +130,17 @@ export function useLiveRefresh( runRefresh(viewId, now); return; } - let result = pendingRefreshesByView.get(viewId); + let result = pendingRefreshesByView.get(requestKey); if (result === undefined) { + const refreshedKeys = resourceKeys.filter( + (key) => now - (lastRefreshedAtByView.get(key) ?? -Infinity) >= refreshFloorMs, + ); + for (const key of refreshedKeys) lastRefreshedAtByView.set(key, now); result = Promise.resolve(runRefresh(viewId, now)).finally(() => { - pendingRefreshesByView.delete(viewId); - lastRefreshedAtByView.set(viewId, Date.now()); + pendingRefreshesByView.delete(requestKey); + for (const key of refreshedKeys) lastRefreshedAtByView.set(key, Date.now()); }); - pendingRefreshesByView.set(viewId, result); + pendingRefreshesByView.set(requestKey, result); } pending = true; clearInterval(timer); @@ -138,11 +155,11 @@ export function useLiveRefresh( const onArrival = () => { const now = Date.now(); if (!refreshWhileIdle && now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return; - if (refreshWhileIdle && visible() && pendingRefreshesByView.has(viewId)) { + if (refreshWhileIdle && visible() && pendingRefreshesByView.has(requestKey)) { read(now); return; } - const lastRefreshedAt = lastRefreshedAtByView.get(viewId); + const lastRefreshedAt = lastRefresh(); if (lastRefreshedAt === undefined) { if (!refreshOnShow) lastRefreshedAtByView.set(viewId, now); else if (visible()) read(now); @@ -153,7 +170,7 @@ export function useLiveRefresh( }; const onInterval = () => { const now = Date.now(); - const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now; + const lastRefreshedAt = lastRefresh() ?? now; if ( (!refreshWhileIdle || now - lastRefreshedAt >= refreshFloorMs) && shouldRefreshOnInterval({ @@ -174,10 +191,7 @@ export function useLiveRefresh( ? refreshWhileIdle ? setTimeout( onInterval, - Math.max( - 0, - refreshFloorMs - (Date.now() - (lastRefreshedAtByView.get(viewId) ?? Date.now())), - ), + Math.max(0, refreshFloorMs - (Date.now() - (lastRefresh() ?? Date.now()))), ) : setInterval(onInterval, intervalMs) : undefined; @@ -193,9 +207,9 @@ export function useLiveRefresh( }); if (refreshOnShow && visible()) { const now = Date.now(); - const lastRefreshedAt = lastRefreshedAtByView.get(viewId); + const lastRefreshedAt = lastRefresh(); if ( - (refreshWhileIdle && pendingRefreshesByView.has(viewId)) || + (refreshWhileIdle && pendingRefreshesByView.has(requestKey)) || lastRefreshedAt === undefined || now - lastRefreshedAt >= refreshFloorMs ) { @@ -212,5 +226,5 @@ export function useLiveRefresh( document.removeEventListener("visibilitychange", onVisibilityChange); stopWatchingInteraction(); }; - }, [enabled, viewId, intervalMs, refreshOnShow, refreshWhileIdle]); + }, [enabled, viewId, sharedKeys, intervalMs, refreshOnShow, refreshWhileIdle]); } diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 3673ba9a49422..2e5925e572843 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -30,6 +30,10 @@ export function useUsageLimitsRefresh( { enabled: enabled && connected && chatVisible && instanceId !== null, key: `usage:${environmentId}:${instanceId}:${driver ?? ""}`, + refreshKeys: [ + ...(driver ? [JSON.stringify([environmentId, driver])] : []), + JSON.stringify([environmentId, driver, instanceId]), + ], refreshOnShow: true, refreshWhileIdle: true, }, From e31a4ae3459a14139119923c254fef81ff311d54 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 17:44:37 -0500 Subject: [PATCH 43/95] fix(web): initialize shared refresh interval baselines --- apps/web/src/hooks/useLiveRefresh.browser.test.tsx | 10 ++++++++++ apps/web/src/hooks/useLiveRefresh.ts | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 7aac282c5f288..f8e0aca0551a3 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -213,21 +213,31 @@ describe("useLiveRefresh", () => { it("with intervalMs null, never refreshes on a timer", async () => { const periodic = vi.fn(); const untimed = vi.fn(); + const shared = vi.fn(); // The interaction clock starts when the first view mounts. using _timers = fakeTimers({ now: 1_000_000 }); await using _screen = await render( <> + , // A bare hook probe; nothing here navigates. { router: false }, ); + expect(shared).not.toHaveBeenCalled(); + // Past the default 5 minute interval, but inside the 6 minute idle cutoff. await act(() => vi.advanceTimersByTime(5.5 * 60_000)); expect(periodic).toHaveBeenCalledTimes(5); expect(untimed).not.toHaveBeenCalled(); + expect(shared).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index a536d1dc74428..84da830f663b4 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -161,8 +161,11 @@ export function useLiveRefresh( } const lastRefreshedAt = lastRefresh(); if (lastRefreshedAt === undefined) { - if (!refreshOnShow) lastRefreshedAtByView.set(viewId, now); - else if (visible()) read(now); + if (!refreshOnShow) { + for (const key of resourceKeys) { + if (!lastRefreshedAtByView.has(key)) lastRefreshedAtByView.set(key, now); + } + } else if (visible()) read(now); return; } if (refreshWhileIdle && now - lastRefreshedAt < refreshFloorMs) return; From f571073afb73d17065068d237c8ecb22cec2f512 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 22:57:32 +0000 Subject: [PATCH 44/95] fix(mobile): share usage deadlines between thread and Limits views --- .../features/usage/usageLimitsRefresh.test.ts | 3 +- .../src/features/usage/usageLimitsRefresh.ts | 21 ++++++++++-- .../usage/useInstanceLimitsRefresh.ts | 7 +++- .../client-runtime/src/state/usage.test.ts | 17 ++++++++++ packages/client-runtime/src/state/usage.ts | 33 ++++++++++++++++--- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 07d63bbf044e6..e2fab2f0d294e 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import * as Cause from "effect/Cause"; -import { AsyncResult, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; type RefreshResult = AsyncResult.AsyncResult; @@ -22,6 +22,7 @@ vi.mock("../../state/server", () => ({ run: (_registry: unknown, input: { readonly environmentId: string }) => state.refresh(input.environmentId), }, + configValueAtom: () => Atom.make(null), }, })); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index efe85e4c01305..e562b1f2157a4 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -14,6 +14,12 @@ const limitsRefreshFailedAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(false).pipe(Atom.withLabel(`mobile:limits-refresh-failed:${environmentId}`)), ); +// Shares per-provider deadlines with the thread usage panel, which refreshes one driver. +function limitsResources(registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId) { + const providers = registry.get(serverEnvironment.configValueAtom(environmentId))?.providers ?? []; + return [...new Set(providers.map((provider) => provider.driver))]; +} + export async function refreshEnvironmentLimits( registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, @@ -33,6 +39,8 @@ export async function refreshEnvironmentLimits( ), options.automatic, options.afterPending, + "", + limitsResources(registry, environmentId), ); if (result === undefined) return; registry.set(limitsRefreshFailedAtom(environmentId), result._tag === "Failure"); @@ -54,9 +62,16 @@ const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => } finally { pending = false; if (!disposed) - timer = setTimeout(() => { - void refresh(); - }, usageLimitsRefreshDelay(environmentId)); + timer = setTimeout( + () => { + void refresh(); + }, + usageLimitsRefreshDelay( + environmentId, + "", + limitsResources(get.registry, environmentId), + ), + ); } }; void refresh(); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index adc989ddc4ab7..e7914b15a179a 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -19,6 +19,7 @@ export function useInstanceLimitsRefresh( useEffect(() => { if (!visible || !focused) return; const selectionKey = JSON.stringify([instanceId, driver]); + const resources = [driver ?? instanceId]; let disposed = false; let pending = false; let timer: ReturnType | undefined; @@ -29,7 +30,10 @@ export function useInstanceLimitsRefresh( const completed = () => { pending = false; if (!disposed) - timer = setTimeout(refresh, usageLimitsRefreshDelay(environmentId, selectionKey)); + timer = setTimeout( + refresh, + usageLimitsRefreshDelay(environmentId, selectionKey, resources), + ); }; void refreshUsageLimits( environmentId, @@ -48,6 +52,7 @@ export function useInstanceLimitsRefresh( true, false, selectionKey, + resources, ).then(completed, completed); }; void refresh(); diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index aa9109138472d..f256ffa40873e 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -19,6 +19,7 @@ import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits, + usageLimitsRefreshDelay, } from "./usage.ts"; const input = { @@ -223,6 +224,22 @@ describe("limits refresh cooldown", () => { expect(await afterEnable).toBe("new limits"); expect(newCheck).toHaveBeenCalledTimes(1); }); + + it("keeps a provider's deadline when a wider refresh runs before it is due", async () => { + const id = EnvironmentId.make("limits-shared-deadlines"); + using now = vi.spyOn(Date, "now").mockReturnValue(0); + await refreshUsageLimits(id, async () => "claude", true, false, "thread", ["claude"]); + now.mockReturnValue(4 * 60_000); + await refreshUsageLimits(id, async () => "all", true, false, "", ["claude", "codex"]); + expect(usageLimitsRefreshDelay(id, "", ["claude", "codex"])).toBe(60_000); + now.mockReturnValue(5 * 60_000); + const due = vi.fn(async () => "claude due"); + expect(await refreshUsageLimits(id, due, true, false, "", ["claude", "codex"])).toBe( + "claude due", + ); + expect(usageLimitsRefreshDelay(id, "thread", ["claude"])).toBe(5 * 60_000); + expect(usageLimitsRefreshDelay(id, "", ["claude", "codex"])).toBe(4 * 60_000); + }); }); describe("needsCursorKeychainAccess", () => { diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index fd0500f040296..f9bdd7d56e093 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -55,8 +55,22 @@ export function usageSummaryInput( const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); -export function usageLimitsRefreshDelay(environmentId: EnvironmentId, selectionKey = ""): number { - const refreshAfter = limitsRefreshAfter.get(JSON.stringify([environmentId, selectionKey])) ?? 0; +const resourceRefreshAfter = new Map(); + +/** With `resources`, the delay ends when the first of those per-provider deadlines passes. */ +export function usageLimitsRefreshDelay( + environmentId: EnvironmentId, + selectionKey = "", + resources: readonly string[] = [], +): number { + const refreshAfter = + resources.length === 0 + ? (limitsRefreshAfter.get(JSON.stringify([environmentId, selectionKey])) ?? 0) + : Math.min( + ...resources.map( + (resource) => resourceRefreshAfter.get(JSON.stringify([environmentId, resource])) ?? 0, + ), + ); // @effect-diagnostics-next-line globalDate:off return Math.max(0, refreshAfter - Date.now()); } @@ -67,6 +81,7 @@ export async function refreshUsageLimits( automatic = false, afterPending = false, selectionKey = "", + resources: readonly string[] = [], ): Promise { const key = JSON.stringify([environmentId, selectionKey]); const pending = limitsRefreshes.get(key); @@ -75,17 +90,25 @@ export async function refreshUsageLimits( try { await pending; } catch {} - return refreshUsageLimits(environmentId, refresh, false, true, selectionKey); + return refreshUsageLimits(environmentId, refresh, false, true, selectionKey, resources); } return (await pending) as A; } - if (automatic && usageLimitsRefreshDelay(environmentId, selectionKey) > 0) return; + if (automatic && usageLimitsRefreshDelay(environmentId, selectionKey, resources) > 0) return; + // The server skips quota reads for providers read in the last five minutes, so only due + // providers move their deadline. + const dueResources = resources + .map((resource) => JSON.stringify([environmentId, resource])) + // @effect-diagnostics-next-line globalDate:off + .filter((resourceKey) => (resourceRefreshAfter.get(resourceKey) ?? 0) <= Date.now()); const current = Promise.resolve() .then(refresh) .finally(() => { limitsRefreshes.delete(key); // @effect-diagnostics-next-line globalDate:off - limitsRefreshAfter.set(key, Date.now() + 5 * 60_000); + const refreshAfter = Date.now() + 5 * 60_000; + limitsRefreshAfter.set(key, refreshAfter); + for (const resourceKey of dueResources) resourceRefreshAfter.set(resourceKey, refreshAfter); }); limitsRefreshes.set(key, current); return await current; From 1f841f3723912a992a63e6ef8f31ddad80261fbf Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 18:02:57 -0500 Subject: [PATCH 45/95] fix(mobile): share usage deadlines across entry points --- .../features/usage/usageLimitsRefresh.test.ts | 111 ++++++++++++------ .../src/features/usage/usageLimitsRefresh.ts | 49 +++++++- .../usage/useInstanceLimitsRefresh.ts | 8 +- .../client-runtime/src/state/usage.test.ts | 64 ++++++---- packages/client-runtime/src/state/usage.ts | 36 +++++- 5 files changed, 193 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 07d63bbf044e6..f72246740d511 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import * as Cause from "effect/Cause"; import { AsyncResult, AtomRegistry } from "effect/reactivity"; @@ -7,6 +7,7 @@ import { expect, it, vi } from "vite-plus/test"; type RefreshResult = AsyncResult.AsyncResult; const state = vi.hoisted(() => ({ + providers: [] as Array<{ instanceId: ProviderInstanceId; driver: ProviderDriverKind }>, refresh: (_environmentId: string): Promise => { throw new Error("refresh not set up"); }, @@ -15,17 +16,22 @@ vi.mock("../../lib/appState", () => ({ isAppActive: () => true, subscribeAppState: () => () => {}, })); -vi.mock("../../state/server", () => ({ - serverEnvironment: { - refreshProviders: { - label: "refresh providers", - run: (_registry: unknown, input: { readonly environmentId: string }) => - state.refresh(input.environmentId), +vi.mock("../../state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + serverEnvironment: { + configValueAtom: () => Atom.make({ providers: state.providers, usageLimitSources: [] }), + refreshProviders: { + label: "refresh providers", + run: (_registry: unknown, input: { readonly environmentId: string }) => + state.refresh(input.environmentId), + }, }, - }, -})); + }; +}); import { + instanceLimitsRefreshSelection, environmentIdsKey, limitsRefreshFailuresAtom, limitsAutoRefreshSetAtom, @@ -37,6 +43,12 @@ const failure: RefreshResult = AsyncResult.failure(Cause.fail("offline")); function setup(refresh: (environmentId: string) => Promise) { state.refresh = refresh; + state.providers = [ + { + instanceId: ProviderInstanceId.make("cursor-instance"), + driver: ProviderDriverKind.make("cursor"), + }, + ]; const registry = AtomRegistry.make(); return { registry, @@ -91,34 +103,55 @@ it("does not let an older refresh clear a newer failure for the same environment expect(failures([a, b])).toEqual([a]); }); -it("skips fresh automatic reads and resumes at the existing deadline", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const a = EnvironmentId.make("mobile-limits-throttle"); - const { registry, failures } = setup(() => Promise.resolve(failure)); - let unmount: (() => void) | undefined; - try { - failures([a]); - await refreshEnvironmentLimits(registry, a); - expect(failures([a])).toEqual([a]); - state.refresh = () => Promise.resolve(success); - await refreshEnvironmentLimits(registry, a, { automatic: true }); - expect(failures([a])).toEqual([a]); - await refreshEnvironmentLimits(registry, a); - expect(failures([a])).toEqual([]); +it.each(["limits", "thread", "thread-and-other-provider"] as const)( + "resumes at the shared deadline after %s", + async (entry) => { + vi.useFakeTimers({ now: 1_000_000 }); + const a = EnvironmentId.make(`mobile-limits-throttle-${entry}`); + const { registry, failures } = setup(() => Promise.resolve(failure)); + let unmount: (() => void) | undefined; + try { + failures([a]); + if (entry === "limits") { + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([a]); + state.refresh = () => Promise.resolve(success); + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(failures([a])).toEqual([a]); + await refreshEnvironmentLimits(registry, a); + expect(failures([a])).toEqual([]); + } else { + await refreshUsageLimits( + a, + () => Promise.resolve(success), + true, + false, + instanceLimitsRefreshSelection( + ProviderInstanceId.make("cursor-instance"), + ProviderDriverKind.make("cursor"), + ), + ); + if (entry === "thread-and-other-provider") + state.providers.push({ + instanceId: ProviderInstanceId.make("claude-instance"), + driver: ProviderDriverKind.make("claudeAgent"), + }); + } - const automatic = vi.fn(() => Promise.resolve(success)); - state.refresh = automatic; - await vi.advanceTimersByTimeAsync(4 * 60_000); - unmount = registry.mount(limitsAutoRefreshSetAtom(environmentIdsKey([a]))); - await vi.advanceTimersByTimeAsync(0); - expect(automatic).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(60_000); - expect(automatic).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(5 * 60_000); - expect(automatic).toHaveBeenCalledTimes(2); - } finally { - unmount?.(); - registry.dispose(); - vi.useRealTimers(); - } -}); + const automatic = vi.fn(() => Promise.resolve(success)); + state.refresh = automatic; + await vi.advanceTimersByTimeAsync(4 * 60_000); + unmount = registry.mount(limitsAutoRefreshSetAtom(environmentIdsKey([a]))); + await vi.advanceTimersByTimeAsync(0); + expect(automatic).toHaveBeenCalledTimes(entry === "thread-and-other-provider" ? 1 : 0); + await vi.advanceTimersByTimeAsync(60_000); + expect(automatic).toHaveBeenCalledTimes(entry === "thread-and-other-provider" ? 2 : 1); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(automatic).toHaveBeenCalledTimes(entry === "thread-and-other-provider" ? 4 : 2); + } finally { + unmount?.(); + registry.dispose(); + vi.useRealTimers(); + } + }, +); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index efe85e4c01305..588202a83ffa2 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -1,5 +1,9 @@ import { isAppActive, subscribeAppState } from "../../lib/appState"; -import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentId, + type ProviderInstanceId, + type ProviderDriverKind, +} from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; import { Atom, type AtomRegistry } from "effect/reactivity"; @@ -14,6 +18,36 @@ const limitsRefreshFailedAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(false).pipe(Atom.withLabel(`mobile:limits-refresh-failed:${environmentId}`)), ); +export function instanceLimitsRefreshSelection( + instanceId: ProviderInstanceId, + driver?: ProviderDriverKind, +) { + return { + key: JSON.stringify([instanceId, driver]), + resources: [ + ...(driver ? [JSON.stringify(["provider", driver])] : []), + JSON.stringify(["instance", instanceId, driver]), + ], + }; +} + +function environmentLimitsRefreshKeys( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, +) { + const config = registry.get(serverEnvironment.configValueAtom(environmentId)); + const keys = (config?.providers ?? []).flatMap( + (provider) => instanceLimitsRefreshSelection(provider.instanceId, provider.driver).resources, + ); + for (const source of config?.usageLimitSources ?? []) { + keys.push( + JSON.stringify(["source", source.id]), + ...source.accounts.map((account) => JSON.stringify(["provider", account.driver])), + ); + } + return keys.length ? [...new Set(keys)] : [""]; +} + export async function refreshEnvironmentLimits( registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, @@ -33,6 +67,7 @@ export async function refreshEnvironmentLimits( ), options.automatic, options.afterPending, + { key: "", resources: environmentLimitsRefreshKeys(registry, environmentId) }, ); if (result === undefined) return; registry.set(limitsRefreshFailedAtom(environmentId), result._tag === "Failure"); @@ -54,9 +89,15 @@ const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => } finally { pending = false; if (!disposed) - timer = setTimeout(() => { - void refresh(); - }, usageLimitsRefreshDelay(environmentId)); + timer = setTimeout( + () => { + void refresh(); + }, + usageLimitsRefreshDelay( + environmentId, + environmentLimitsRefreshKeys(get.registry, environmentId), + ), + ); } }; void refresh(); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index adc989ddc4ab7..8fe4965a0b0c3 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -6,6 +6,7 @@ import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-run import type { AtomRegistry } from "effect/reactivity"; import { isAppActive, subscribeAppState } from "../../lib/appState"; import { serverEnvironment } from "../../state/server"; +import { instanceLimitsRefreshSelection } from "./usageLimitsRefresh"; export function useInstanceLimitsRefresh( registry: AtomRegistry.AtomRegistry, @@ -18,7 +19,7 @@ export function useInstanceLimitsRefresh( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle useEffect(() => { if (!visible || !focused) return; - const selectionKey = JSON.stringify([instanceId, driver]); + const selectionKey = instanceLimitsRefreshSelection(instanceId, driver); let disposed = false; let pending = false; let timer: ReturnType | undefined; @@ -29,7 +30,10 @@ export function useInstanceLimitsRefresh( const completed = () => { pending = false; if (!disposed) - timer = setTimeout(refresh, usageLimitsRefreshDelay(environmentId, selectionKey)); + timer = setTimeout( + refresh, + usageLimitsRefreshDelay(environmentId, selectionKey.resources), + ); }; void refreshUsageLimits( environmentId, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index aa9109138472d..b53f45d9d5a7a 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -19,6 +19,7 @@ import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits, + usageLimitsRefreshDelay, } from "./usage.ts"; const input = { @@ -199,30 +200,45 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { - it("runs a fresh check after an in-flight check when settings change", async () => { - const id = EnvironmentId.make("limits-after-enable"); - const oldCheck = Promise.withResolvers(); - const first = refreshUsageLimits(id, () => oldCheck.promise, true, false, "cursor"); - const otherProviders = refreshUsageLimits( - id, - async () => "claude limits", - false, - false, - "claude", - ); - const automaticCheck = vi.fn(async () => "unexpected extra limits"); - const coalescedAutomatic = refreshUsageLimits(id, automaticCheck, true, false, "cursor"); - const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true, "cursor"); - expect(newCheck).not.toHaveBeenCalled(); - oldCheck.resolve("old limits"); - expect(await first).toBe("old limits"); - expect(await coalescedAutomatic).toBe("old limits"); - expect(automaticCheck).not.toHaveBeenCalled(); - expect(await otherProviders).toBe("claude limits"); - expect(await afterEnable).toBe("new limits"); - expect(newCheck).toHaveBeenCalledTimes(1); - }); + it.each(["legacy", "resources"] as const)( + "runs a fresh check after an in-flight check when settings change (%s)", + async (kind) => { + const id = EnvironmentId.make(`limits-after-enable-${kind}`); + const cursor = + kind === "legacy" ? "cursor" : { key: "cursor", resources: ["cursor", "instance-a"] }; + const claude = kind === "legacy" ? "claude" : { key: "claude", resources: ["claude"] }; + const oldCheck = Promise.withResolvers(); + const first = refreshUsageLimits(id, () => oldCheck.promise, true, false, cursor); + const otherProviders = refreshUsageLimits( + id, + async () => "claude limits", + false, + false, + claude, + ); + const automaticCheck = vi.fn(async () => "unexpected extra limits"); + const coalescedAutomatic = refreshUsageLimits( + id, + automaticCheck, + true, + false, + typeof cursor === "string" + ? cursor + : { ...cursor, resources: [...cursor.resources].reverse().concat("new-instance") }, + ); + const newCheck = vi.fn(async () => "new limits"); + const afterEnable = refreshUsageLimits(id, newCheck, false, true, cursor); + expect(newCheck).not.toHaveBeenCalled(); + oldCheck.resolve("old limits"); + expect(await first).toBe("old limits"); + expect(await coalescedAutomatic).toBe("old limits"); + expect(automaticCheck).not.toHaveBeenCalled(); + if (kind === "resources") expect(usageLimitsRefreshDelay(id, "new-instance")).toBe(0); + expect(await otherProviders).toBe("claude limits"); + expect(await afterEnable).toBe("new limits"); + expect(newCheck).toHaveBeenCalledTimes(1); + }, + ); }); describe("needsCursorKeychainAccess", () => { diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index fd0500f040296..2ffd5b6c45960 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -55,8 +55,17 @@ export function usageSummaryInput( const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); -export function usageLimitsRefreshDelay(environmentId: EnvironmentId, selectionKey = ""): number { - const refreshAfter = limitsRefreshAfter.get(JSON.stringify([environmentId, selectionKey])) ?? 0; +export function usageLimitsRefreshDelay( + environmentId: EnvironmentId, + selectionKey: string | readonly string[] = "", +): number { + const selections = + typeof selectionKey === "string" ? [selectionKey] : selectionKey.length ? selectionKey : [""]; + const refreshAfter = Math.min( + ...selections.map( + (selection) => limitsRefreshAfter.get(JSON.stringify([environmentId, selection])) ?? 0, + ), + ); // @effect-diagnostics-next-line globalDate:off return Math.max(0, refreshAfter - Date.now()); } @@ -66,9 +75,18 @@ export async function refreshUsageLimits( refresh: () => Promise, automatic = false, afterPending = false, - selectionKey = "", + selectionKey: string | { readonly key: string; readonly resources: readonly string[] } = "", ): Promise { - const key = JSON.stringify([environmentId, selectionKey]); + const selections = + typeof selectionKey === "string" + ? [selectionKey] + : selectionKey.resources.length + ? selectionKey.resources + : [""]; + const key = JSON.stringify([ + environmentId, + typeof selectionKey === "string" ? selectionKey : selectionKey.key, + ]); const pending = limitsRefreshes.get(key); if (pending !== undefined) { if (afterPending) { @@ -79,13 +97,19 @@ export async function refreshUsageLimits( } return (await pending) as A; } - if (automatic && usageLimitsRefreshDelay(environmentId, selectionKey) > 0) return; + if (automatic && usageLimitsRefreshDelay(environmentId, selections) > 0) return; + const refreshedSelections = + typeof selectionKey === "string" + ? selections + : selections.filter((selection) => usageLimitsRefreshDelay(environmentId, selection) === 0); const current = Promise.resolve() .then(refresh) .finally(() => { limitsRefreshes.delete(key); // @effect-diagnostics-next-line globalDate:off - limitsRefreshAfter.set(key, Date.now() + 5 * 60_000); + const refreshAfter = Date.now() + 5 * 60_000; + for (const selection of refreshedSelections) + limitsRefreshAfter.set(JSON.stringify([environmentId, selection]), refreshAfter); }); limitsRefreshes.set(key, current); return await current; From 75c948468a698eef7cbff59dd2088e1d8856ef4d Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 18:08:34 -0500 Subject: [PATCH 46/95] fix(grok): retain quota ownership across token renewal --- .../src/server/usageLimits.test.ts | 146 ++++++++++-------- .../provider-grok/src/server/usageLimits.ts | 5 +- 2 files changed, 86 insertions(+), 65 deletions(-) diff --git a/packages/provider-grok/src/server/usageLimits.test.ts b/packages/provider-grok/src/server/usageLimits.test.ts index edc2fad7d6787..09b0c266ecf2d 100644 --- a/packages/provider-grok/src/server/usageLimits.test.ts +++ b/packages/provider-grok/src/server/usageLimits.test.ts @@ -17,72 +17,90 @@ import { readGrokAccount } from "./usageLimits.ts"; it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( "Grok usage account ownership", (it) => { - it.effect("keeps credential ownership when an older cached account is selected", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make(); - const finish = yield* Deferred.make(); - let requests = 0; - const client = yield* makeUsageHttpClient( - HttpClient.make((request) => - Effect.gen(function* () { - requests += 1; - if (requests > 2) - return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); - const first = request.headers.authorization === "Bearer account-a"; - if (first) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(finish); - } - return HttpClientResponse.fromWeb( - request, - Response.json({ config: { creditUsagePercent: first ? 80 : 25 } }), - ); - }), - ), - ); - const read = (key: string) => - readGrokAccount({ - HOME: "/unused-grok-home", - GROK_AUTH: JSON.stringify({ "https://accounts.x.ai/sign-in": { key } }), - }).pipe( - Effect.provideService(HttpClient.HttpClient, client), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ readFileString: () => Effect.succeed("") }), + it.effect.each([false, true])( + "keeps quota ownership across cached reads and token renewal (email: %s)", + (withEmail) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + let requests = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.gen(function* () { + requests += 1; + if (requests > 2) + return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); + const first = request.headers.authorization === "Bearer account-a"; + if (first) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(finish); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: first ? 80 : 25 } }), + ); + }), ), ); - const pending = yield* read("account-a").pipe(Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust("1 second"); - const previous = yield* read("account-b"); - yield* TestClock.adjust("1 second"); - yield* Deferred.succeed(finish, undefined); - const first = yield* Fiber.join(pending); - yield* TestClock.adjust("299 seconds"); - const cached = yield* read("account-a"); - expect(requests).toBe(2); - expect(cached.usageLimits.checkedAt).toBe(first.usageLimits.checkedAt); - const selected = resolveUsageLimitsAfterProbe({ - published: previous.usageLimits, - probed: cached.usageLimits, - }); - expect(selected?.windows[0]?.usedPercent).toBe(80); - const failed = yield* read("account-b"); - expect(failed.usageLimits.unavailable?.reason).toBe("probeFailed"); - expect(failed.usageLimits.credentialFingerprint).toBeTruthy(); - expect(failed.usageLimits.credentialFingerprint).toBe( - previous.usageLimits.credentialFingerprint, - ); - expect(failed.usageLimits.credentialFingerprint).not.toBe( - cached.usageLimits.credentialFingerprint, - ); - expect( - resolveUsageLimitsAfterProbe({ published: selected, probed: failed.usageLimits }) - ?.windows, - ).toEqual([]); - }), - ), + const read = (key: string) => + readGrokAccount({ + HOME: "/unused-grok-home", + GROK_AUTH: JSON.stringify({ + "https://accounts.x.ai/sign-in": { + key, + ...(withEmail + ? { email: key.startsWith("account-a") ? "a@example.com" : "b@example.com" } + : {}), + }, + }), + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ readFileString: () => Effect.succeed("") }), + ), + ); + const pending = yield* read("account-a").pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("1 second"); + const previous = yield* read("account-b"); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(finish, undefined); + const first = yield* Fiber.join(pending); + yield* TestClock.adjust("299 seconds"); + const cached = yield* read("account-a"); + expect(requests).toBe(2); + expect(cached.usageLimits.checkedAt).toBe(first.usageLimits.checkedAt); + const selected = resolveUsageLimitsAfterProbe({ + published: previous.usageLimits, + probed: cached.usageLimits, + }); + expect(selected?.windows[0]?.usedPercent).toBe(80); + const failed = yield* read("account-b"); + expect(failed.usageLimits.unavailable?.reason).toBe("probeFailed"); + expect(failed.usageLimits.credentialFingerprint).toBeTruthy(); + expect(failed.usageLimits.credentialFingerprint).toBe( + previous.usageLimits.credentialFingerprint, + ); + expect(failed.usageLimits.credentialFingerprint).not.toBe( + cached.usageLimits.credentialFingerprint, + ); + expect( + resolveUsageLimitsAfterProbe({ published: selected, probed: failed.usageLimits }) + ?.windows, + ).toEqual([]); + const renewed = yield* read("account-b-renewed"); + const retained = resolveUsageLimitsAfterProbe({ + published: previous.usageLimits, + probed: renewed.usageLimits, + }); + expect(retained?.windows.map((window) => window.usedPercent)).toEqual( + withEmail ? [25] : [], + ); + expect(retained?.stale).toBe(withEmail ? true : undefined); + }), + ), ); }, ); diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index a598450e24846..0d61037d47795 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -189,7 +189,10 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( } const digest = yield* Digest.Digest; const credentialFingerprint = yield* digest - .digest(Digest.sha256, new TextEncoder().encode(`grok\0${token}`)) + .digest( + Digest.sha256, + new TextEncoder().encode(email ? `grok\0account\0${email}` : `grok\0token\0${token}`), + ) .pipe(Effect.map(Hex.encode), Effect.orDie); const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; From fda0613d3c77d9bab3c48338c30573626e2e5dad Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 18:21:08 -0500 Subject: [PATCH 47/95] fix(grok): preserve account cooldowns across token renewal --- packages/provider-grok/src/server/usageLimits.test.ts | 6 +++++- packages/provider-grok/src/server/usageLimits.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/provider-grok/src/server/usageLimits.test.ts b/packages/provider-grok/src/server/usageLimits.test.ts index 09b0c266ecf2d..e3dc2e473f48c 100644 --- a/packages/provider-grok/src/server/usageLimits.test.ts +++ b/packages/provider-grok/src/server/usageLimits.test.ts @@ -30,7 +30,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( Effect.gen(function* () { requests += 1; if (requests > 2) - return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 503, headers: { "retry-after": "600" } }), + ); const first = request.headers.authorization === "Bearer account-a"; if (first) { yield* Deferred.succeed(started, undefined); @@ -91,6 +94,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( ?.windows, ).toEqual([]); const renewed = yield* read("account-b-renewed"); + expect(requests).toBe(withEmail ? 3 : 4); const retained = resolveUsageLimitsAfterProbe({ published: previous.usageLimits, probed: renewed.usageLimits, diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 0d61037d47795..e6897dc9d1b3e 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,4 +1,7 @@ -import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; +import { + UsageAccountIdentity, + usageResponseCheckedAt, +} from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Digest from "effect/crypto/Digest"; @@ -217,6 +220,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( ); return grokUsageResponseToLimits(body, usageResponseCheckedAt(response, checkedAt)); }).pipe( + Effect.provideService(UsageAccountIdentity, credentialFingerprint), Effect.timeout("10 seconds"), Effect.orElseSucceed(() => probeFailed), ); From 9f56e38ebeb4e3e50e925981f261d12c7c896b6a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 23:43:15 +0000 Subject: [PATCH 48/95] test(server): add secret resolver label to usage source stubs --- apps/server/src/usage/UsageLimitSources.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index ef56929053cbb..d155d166efb33 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -239,6 +239,7 @@ it.effect.each(["listing", "quota", "changed-account"] as const)( }, }), Layer.succeed(SecretResolver.SecretResolver, { + label: (provider) => Effect.succeed(provider), read: () => Effect.succeed([]), invalidate: () => Effect.void, }), @@ -392,6 +393,7 @@ it.effect.each([ }, }), Layer.succeed(SecretResolver.SecretResolver, { + label: (provider) => Effect.succeed(provider), read: () => Effect.succeed([]), invalidate: () => Effect.void, }), From e7ec23b76c7c77731baae2916c46f7b6c027fca8 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 19:37:11 -0500 Subject: [PATCH 49/95] fix(server): refresh quotas before fallback routing --- .../src/mcp/toolkits/project/handlers.test.ts | 6 ++++- .../src/mcp/toolkits/project/handlers.ts | 7 ++++- .../src/provider/ProviderRegistry.test.ts | 27 +++++++++++++++++-- apps/server/src/provider/ProviderRegistry.ts | 25 +++++++++++++++++ .../providerMaintenanceRunner.test.ts | 1 + .../testUtils/providerRegistryMock.ts | 1 + apps/server/src/testUtils/processProbe.ts | 10 +++++-- .../CodexTextGeneration.test.ts | 15 ++++++++++- .../src/textGeneration/TextGeneration.ts | 20 ++++++++++---- 9 files changed, 100 insertions(+), 12 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 6721e89478550..5a8b3acd89a37 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -45,6 +45,7 @@ const clientLaunchHarness = (input: { readonly defaultRuntimeMode?: RuntimeMode; readonly fallbackModel?: ModelSelection; readonly providers?: readonly ServerProvider[]; + readonly refreshUsageForModelSelection?: ProviderRegistry.ProviderRegistry["Service"]["refreshUsageForModelSelection"]; /** Where Settings saves a project's model; the legacy project field stays null. */ readonly overrideModel?: ModelSelection; readonly launched: Array; @@ -66,6 +67,8 @@ const clientLaunchHarness = (input: { const layerDependencies = Layer.mergeAll( Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed(input.providers ?? []), + refreshUsageForModelSelection: + input.refreshUsageForModelSelection ?? (() => Effect.succeed(input.providers ?? [])), }), ServerSettings.layerTest({ fallbackModelSelection: input.fallbackModel ?? null, @@ -601,7 +604,8 @@ it.effect("a client launches with the fallback for an exhausted default", () => runtimeModeCeiling: "full-access", launched, fallbackModel, - providers, + providers: providers.map((provider) => ({ ...provider, usageLimits: undefined })), + refreshUsageForModelSelection: () => Effect.succeed(providers), }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a14ee03daa470..1bb1159574761 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -194,7 +194,12 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { defaultSelection: projectDefaults.defaultModelSelection, fallbackSelection: projectDefaults.fallbackModelSelection, providers: yield* ProviderRegistry.ProviderRegistry.pipe( - Effect.flatMap((registry) => registry.getProviders), + Effect.flatMap((registry) => + registry.refreshUsageForModelSelection( + projectDefaults.defaultModelSelection, + projectDefaults.fallbackModelSelection, + ), + ), ), settings: projectDefaults, projectId, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index faadb59d63fb6..09034566f86e8 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1504,7 +1504,11 @@ it.layer( it.effect("invalidates model caches only for an explicit model refresh", () => Effect.gen(function* () { const forceRefreshCalls = yield* Ref.make(0); - const makeInstance = (name: string, driver = ProviderDriverKind.make("codex")) => + const makeInstance = ( + name: string, + driver = ProviderDriverKind.make("codex"), + exhausted = false, + ) => Effect.gen(function* () { const instanceId = ProviderInstanceId.make(name); const provider = { @@ -1519,6 +1523,12 @@ it.layer( models: [], slashCommands: [], skills: [], + usageLimits: { + checkedAt: "2026-06-10T00:00:00.000Z", + windows: exhausted + ? [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }] + : [], + }, } as const satisfies ServerProvider; const invalidations = yield* Ref.make(0); const usageReads = yield* Ref.make(0); @@ -1555,7 +1565,7 @@ it.layer( const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); return { instance, invalidations, takeMaintenanceReads, usageReads }; }); - const first = yield* makeInstance("codex_first"); + const first = yield* makeInstance("codex_first", ProviderDriverKind.make("codex"), true); const second = yield* makeInstance("codex_second"); const other = yield* makeInstance("claude_other", ProviderDriverKind.make("claudeAgent")); const instances = [first.instance, second.instance, other.instance]; @@ -1597,6 +1607,19 @@ it.layer( assert.strictEqual(yield* Ref.get(first.usageReads), 1); assert.strictEqual(yield* Ref.get(second.usageReads), 1); assert.strictEqual(yield* Ref.get(other.usageReads), 0); + yield* registry.refreshUsageForModelSelection( + { instanceId: first.instance.instanceId, model: "gpt-6.1-sol" }, + { instanceId: other.instance.instanceId, model: "claude-haiku-5-5" }, + ); + assert.strictEqual(yield* Ref.get(first.usageReads), 2); + assert.strictEqual(yield* Ref.get(second.usageReads), 1); + assert.strictEqual(yield* Ref.get(other.usageReads), 1); + yield* registry.refreshUsageForModelSelection( + { instanceId: second.instance.instanceId, model: "gpt-6.1-sol" }, + { instanceId: other.instance.instanceId, model: "claude-haiku-5-5" }, + ); + assert.strictEqual(yield* Ref.get(second.usageReads), 2); + assert.strictEqual(yield* Ref.get(other.usageReads), 1); yield* registry.refreshInstance(first.instance.instanceId); yield* registry.refresh(); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 5b80db3889b57..c0bc2e7168e93 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -3,9 +3,11 @@ import { isProviderWorkspaceSnapshotCurrent, ProviderDriverKind, type ProviderInstanceId, + type ModelSelection, type ServerProvider, type ServerProviderUpdateState, } from "@t3tools/contracts"; +import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -64,6 +66,11 @@ export class ProviderRegistry extends Context.Service< providers?: readonly ProviderDriverKind[], ) => Effect.Effect>; + readonly refreshUsageForModelSelection: ( + primary: ModelSelection | null, + fallback: ModelSelection | null, + ) => Effect.Effect>; + /** Unknown instance ids return the cached list without error. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, @@ -646,6 +653,20 @@ export const layer = Layer.effect( return yield* Ref.get(providersRef); }); + const refreshUsageForModelSelection = Effect.fn( + "ProviderRegistry.refreshUsageForModelSelection", + )(function* (primary: ModelSelection | null, fallback: ModelSelection | null) { + if (!primary || !fallback) return yield* Ref.get(providersRef); + const providers = yield* refreshUsage(primary.instanceId); + return exhaustedUsageWindow( + providers.find((provider) => provider.instanceId === primary.instanceId), + DateTime.toEpochMillis(yield* DateTime.now), + primary.model, + ) + ? yield* refreshUsage(fallback.instanceId) + : providers; + }); + type RefreshAllCompletion = Deferred.Deferred>; const refreshAllInFlightRef = yield* Ref.make>( Option.none(), @@ -1098,6 +1119,10 @@ export const layer = Layer.effect( return { getProviders: Ref.get(providersRef), + refreshUsageForModelSelection: (primary, fallback) => + refreshUsageForModelSelection(primary, fallback).pipe( + Effect.catchCause(recoverRefreshFailure), + ), refreshUsage: (instanceId, providers) => refreshUsage(instanceId, providers).pipe(Effect.catchCause(recoverRefreshFailure)), refresh: (provider?: ProviderDriverKind) => diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index e701bd0ce02f0..867b54f1b908b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -197,6 +197,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshUsage: () => Ref.get(providersRef), + refreshUsageForModelSelection: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), reloadSecretsAndRefresh: () => Ref.get(providersRef), refreshAfterUpdate: () => { diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index e955e399897a6..ba5b915556c2f 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshUsage: () => Effect.succeed(providers), + refreshUsageForModelSelection: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), diff --git a/apps/server/src/testUtils/processProbe.ts b/apps/server/src/testUtils/processProbe.ts index 9970359244be3..c5a5e6de0df5d 100644 --- a/apps/server/src/testUtils/processProbe.ts +++ b/apps/server/src/testUtils/processProbe.ts @@ -5,12 +5,18 @@ import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; export const makeProcessProbe = Effect.gen(function* () { - const started = yield* Deferred.make(); - const exited = yield* Deferred.make(); + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); const sockets = new Set(); const server = NodeNet.createServer((socket) => { sockets.add(socket); let output = ""; + socket.on("error", (error: NodeJS.ErrnoException) => { + // Windows resets the observation socket when the fixture is forcefully terminated. + if (error.code === "ECONNRESET") return; + Deferred.doneUnsafe(started, Effect.fail(error)); + Deferred.doneUnsafe(exited, Effect.fail(error)); + }); socket.on("data", (chunk) => { output += chunk.toString(); if (output.includes("\n")) { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 04ae83952b3c4..787541876a0c7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; @@ -455,6 +456,7 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus }; const calls: ModelSelection[] = []; const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { + let quotaRead = false; const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); const snapshot: ServerProvider = { instanceId, @@ -483,7 +485,18 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus continuationIdentity: { driverKind: driver, continuationKey: instanceId }, displayName: undefined, enabled: true, - snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + snapshot: { + getSnapshot: Effect.sync(() => + quotaRead ? snapshot : { ...snapshot, usageLimits: undefined }, + ), + refreshUsage: Effect.sync(() => { + quotaRead = true; + }), + resolveMaintenance: () => Effect.die("unused"), + refresh: Effect.die("unused"), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: TextGeneration.TextGeneration.of({ generateCommitMessage: () => Effect.die("unused"), diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index fc8d0fb68da31..539725ff42a39 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -80,11 +80,13 @@ export const make = Effect.gen(function* () { modelSelection: ModelSelection, ) => Effect.Effect, ) { - const snapshots = yield* registry.listInstances.pipe( - Effect.flatMap((instances) => - Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot), - ), - ); + const instances = yield* registry.listInstances; + yield* ( + instances.find((instance) => instance.instanceId === selection.instanceId)?.snapshot + .refreshUsage ?? Effect.void + ).pipe(Effect.ignore); + const readSnapshots = Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot); + let snapshots = yield* readSnapshots; const now = DateTime.toEpochMillis(yield* DateTime.now); const limited = exhaustedUsageWindow( snapshots.find((provider) => provider.instanceId === selection.instanceId), @@ -92,6 +94,14 @@ export const make = Effect.gen(function* () { selection.model, ); if (limited) { + yield* Effect.forEach( + instances.filter( + (instance) => instance.enabled && instance.instanceId !== selection.instanceId, + ), + (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + snapshots = yield* readSnapshots; const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( (cause) => From 470c4f88492aa2f64c70a690f2eeb82df3e7d153 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 19:38:32 -0500 Subject: [PATCH 50/95] fix(usage): preserve server quota deadlines across clients --- .../features/usage/usageLimitsRefresh.test.ts | 11 +++- .../src/features/usage/usageLimitsRefresh.ts | 55 ++++++++++++++++--- .../usage/useInstanceLimitsRefresh.ts | 29 +++------- .../src/widgets/useSubscriptionUsage.test.ts | 2 +- .../src/usage/UsageLimitSources.test.ts | 6 +- apps/server/src/usage/cliproxyApi.test.ts | 3 + apps/server/src/usage/cliproxyApi.ts | 37 +++++++++---- apps/web/src/components/usage/UsagePage.tsx | 24 ++++++++ .../src/hooks/useLiveRefresh.browser.test.tsx | 21 +++++-- apps/web/src/hooks/useLiveRefresh.ts | 21 +++++-- .../useUsageLimitsRefresh.browser.test.tsx | 13 ++++- apps/web/src/hooks/useUsageLimitsRefresh.ts | 26 ++++++++- .../client-runtime/src/state/usage.test.ts | 9 +++ packages/client-runtime/src/state/usage.ts | 32 ++++++++++- packages/contracts/src/providerUsageLimits.ts | 2 + .../src/server/managedProvider.test.ts | 19 +++++-- .../src/server/managedProvider.ts | 4 +- .../provider-core/src/server/usageLimits.ts | 7 ++- 18 files changed, 253 insertions(+), 68 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index f72246740d511..f8e51d51a3bd3 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -1,10 +1,15 @@ -import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import * as Cause from "effect/Cause"; import { AsyncResult, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -type RefreshResult = AsyncResult.AsyncResult; +type RefreshResult = AsyncResult.AsyncResult<{ providers: readonly ServerProvider[] }, string>; const state = vi.hoisted(() => ({ providers: [] as Array<{ instanceId: ProviderInstanceId; driver: ProviderDriverKind }>, @@ -38,7 +43,7 @@ import { refreshEnvironmentLimits, } from "./usageLimitsRefresh"; -const success: RefreshResult = AsyncResult.success(undefined); +const success: RefreshResult = AsyncResult.success({ providers: [] }); const failure: RefreshResult = AsyncResult.failure(Cause.fail("offline")); function setup(refresh: (environmentId: string) => Promise) { diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 588202a83ffa2..5d9fea96b8a88 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -5,8 +5,12 @@ import { type ProviderDriverKind, } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; -import { Atom, type AtomRegistry } from "effect/reactivity"; +import { + refreshUsageLimits, + usageLimitsRefreshDelay, + usageRefreshDeadline, +} from "@t3tools/client-runtime/state/usage"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { serverEnvironment } from "../../state/server"; @@ -51,23 +55,60 @@ function environmentLimitsRefreshKeys( export async function refreshEnvironmentLimits( registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, - options: { readonly automatic?: boolean; readonly afterPending?: boolean } = {}, + options: { + readonly automatic?: boolean; + readonly afterPending?: boolean; + readonly instanceId?: ProviderInstanceId; + readonly driver?: ProviderDriverKind; + } = {}, ): Promise { if (!isAppActive()) return; - // One refresh runs per environment at a time, so results land in the order - // they were started and the newest outcome always wins. const result = await refreshUsageLimits( environmentId, () => runAtomCommand( registry, serverEnvironment.refreshProviders, - { environmentId, input: { usage: true } }, + { + environmentId, + input: options.driver + ? { usage: true, usageProviders: [options.driver] } + : { usage: true, ...(options.instanceId ? { instanceId: options.instanceId } : {}) }, + }, { reportFailure: false }, ), options.automatic, options.afterPending, - { key: "", resources: environmentLimitsRefreshKeys(registry, environmentId) }, + { + ...(options.instanceId + ? instanceLimitsRefreshSelection(options.instanceId, options.driver) + : { key: "", resources: environmentLimitsRefreshKeys(registry, environmentId) }), + refreshDeadline: (result) => + AsyncResult.isSuccess(result) + ? usageRefreshDeadline([ + ...result.value.providers + .filter((provider) => + options.driver + ? provider.driver === options.driver + : options.instanceId === undefined || + provider.instanceId === options.instanceId, + ) + .map((provider) => provider.usageLimits), + ...( + registry.get(serverEnvironment.configValueAtom(environmentId))?.usageLimitSources ?? + [] + ).flatMap((source) => + source.accounts + .filter((account) => + options.driver + ? account.driver === options.driver + : options.instanceId === undefined, + ) + .map((account) => account.usageLimits), + ), + ]) + : undefined, + }, ); if (result === undefined) return; registry.set(limitsRefreshFailedAtom(environmentId), result._tag === "Failure"); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index 8fe4965a0b0c3..9dd0f583e7d98 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -1,12 +1,10 @@ import { useIsFocused } from "@react-navigation/native"; import { useEffect } from "react"; import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { refreshUsageLimits, usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; +import { usageLimitsRefreshDelay } from "@t3tools/client-runtime/state/usage"; import type { AtomRegistry } from "effect/reactivity"; import { isAppActive, subscribeAppState } from "../../lib/appState"; -import { serverEnvironment } from "../../state/server"; -import { instanceLimitsRefreshSelection } from "./usageLimitsRefresh"; +import { instanceLimitsRefreshSelection, refreshEnvironmentLimits } from "./usageLimitsRefresh"; export function useInstanceLimitsRefresh( registry: AtomRegistry.AtomRegistry, @@ -35,24 +33,11 @@ export function useInstanceLimitsRefresh( usageLimitsRefreshDelay(environmentId, selectionKey.resources), ); }; - void refreshUsageLimits( - environmentId, - () => - runAtomCommand( - registry, - serverEnvironment.refreshProviders, - { - environmentId, - input: driver - ? { usage: true, usageProviders: [driver] } - : { instanceId, usage: true }, - }, - { reportFailure: false }, - ), - true, - false, - selectionKey, - ).then(completed, completed); + void refreshEnvironmentLimits(registry, environmentId, { + automatic: true, + instanceId, + ...(driver ? { driver } : {}), + }).then(completed, completed); }; void refresh(); const unsubscribe = subscribeAppState(() => { diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts index fac92ac0297ff..43b3d58f06ac2 100644 --- a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts +++ b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts @@ -59,7 +59,7 @@ vi.mock("@t3tools/client-runtime/state/runtime", () => ({ input: { readonly environmentId: string }, ) => { fixture.refreshed.push(input.environmentId); - return Promise.resolve(AsyncResult.success(undefined)); + return Promise.resolve(AsyncResult.success({ providers: [] })); }, })); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index d155d166efb33..fe2f5d61f5e27 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -216,7 +216,11 @@ it.effect.each(["listing", "quota", "changed-account"] as const)( ); if (failure === "quota") expect(failed.accounts.map((account) => account.usageLimits)).toEqual( - prior.accounts.map((account) => ({ ...account.usageLimits, stale: true })), + prior.accounts.map((account) => ({ + ...account.usageLimits, + refreshAfter: 600_000, + stale: true, + })), ); if (failure === "listing") expect(failed.error).toContain("401"); else expect(failed.error).toBeUndefined(); diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 2ff73296c7b3a..2ac956fb6c616 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -225,6 +225,9 @@ describe("CLIProxyAPI built-in management API", () => { expect(cached.map((account) => account.usageLimits.checkedAt)).toEqual( first.map((account) => account.usageLimits.checkedAt), ); + expect(cached.map((account) => account.usageLimits.refreshAfter)).toEqual([ + 300_000, 300_000, + ]); expect( test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), ).toHaveLength(2); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index e4bf3a88f1667..2c2d42b2d559e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -15,6 +15,8 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Schema from "effect/Schema"; +import * as MutableHashMap from "effect/MutableHashMap"; +import * as Option from "effect/Option"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; @@ -354,12 +356,17 @@ export const makeCliproxyApi = Effect.gen(function* () { ? { workspaceId: account.id_token.chatgpt_account_id.trim() } : {}), }; + const usageKey = yield* encodeJson({ + hub, + account, + url: + account.provider === "claude" + ? "https://api.anthropic.com/api/oauth/usage" + : `${CODEX_BASE}/usage`, + }).pipe(Effect.orDie); const read = Effect.gen(function* () { if (account.provider === "claude") { - const { body, checkedAt } = yield* Cache.get( - usageCache, - yield* encodeJson({ hub, account, url: "https://api.anthropic.com/api/oauth/usage" }), - ); + const { body, checkedAt } = yield* Cache.get(usageCache, usageKey); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -391,10 +398,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const { body, checkedAt } = yield* Cache.get( - usageCache, - yield* encodeJson({ hub, account, url: `${CODEX_BASE}/usage` }), - ); + const { body, checkedAt } = yield* Cache.get(usageCache, usageKey); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window @@ -447,10 +451,19 @@ export const makeCliproxyApi = Effect.gen(function* () { message: "The hub could not read this account's usage.", }), })), - Effect.map((result) => ({ - ...result, - usageLimits: { ...result.usageLimits, credentialFingerprint }, - })), + Effect.map((result) => { + const refreshAfter = Option.getOrUndefined( + MutableHashMap.get(usageCache.map, usageKey), + )?.expiresAt; + return { + ...result, + usageLimits: { + ...result.usageLimits, + credentialFingerprint, + ...(refreshAfter !== undefined ? { refreshAfter } : {}), + }, + }; + }), ); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a50fec61e25d9..4ae8b7e7eca07 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,6 +19,7 @@ import * as Cause from "effect/Cause"; import { cursorKeychainAccessEnvironments, refreshUsageLimits, + usageRefreshDeadline, } from "@t3tools/client-runtime/state/usage"; import { updatingProvidersLabel, @@ -282,6 +283,28 @@ export function UsagePage() { ).then((result) => { setLimitsNow(Date.now()); if (!AsyncResult.isSuccess(result)) throw Cause.squash(result.cause); + return usageRefreshDeadline([ + ...result.value.flatMap((response) => + response !== undefined && AsyncResult.isSuccess(response) + ? response.value.providers + .filter((provider) => visibleDrivers.includes(provider.driver)) + .map((provider) => provider.usageLimits) + : [], + ), + ...Array.from(presentationsWithProviders).flatMap(([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)) + ? ( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) + ?.usageLimitSources ?? [] + ).flatMap((source) => + source.accounts + .filter((account) => visibleDrivers.includes(account.driver)) + .map((account) => account.usageLimits), + ) + : [], + ), + ]); }); const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => { if ( @@ -371,6 +394,7 @@ export function UsagePage() { ] : [], ), + refreshDeadline: (result) => (typeof result === "number" ? result : undefined), refreshOnShow: true, refreshWhileIdle: true, }); diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index f8e0aca0551a3..0de960e1be83c 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -11,6 +11,7 @@ function Probe(props: { readonly refreshOnShow?: boolean; readonly refreshWhileIdle?: boolean; readonly refreshKeys?: readonly string[]; + readonly refreshDeadline?: () => number | undefined; }) { useLiveRefresh(props.refresh, { key: props.viewKey, @@ -18,6 +19,7 @@ function Probe(props: { ...(props.intervalMs !== undefined ? { intervalMs: props.intervalMs } : {}), refreshWhileIdle: props.refreshWhileIdle ?? false, ...(props.refreshKeys ? { refreshKeys: props.refreshKeys } : {}), + ...(props.refreshDeadline ? { refreshDeadline: props.refreshDeadline } : {}), ...(props.refreshOnShow !== undefined ? { refreshOnShow: props.refreshOnShow } : {}), }); return null; @@ -60,10 +62,14 @@ describe("useLiveRefresh", () => { }, ); - it.each(["reenabled", "remounted", "another entry point"])( + it.each(["reenabled", "remounted", "another entry point", "another client"])( "keeps the completed usage deadline when %s", async (lifecycle) => { - const refresh = vi.fn(); + let sampledAt = 4_000_000; + const refresh = vi.fn(() => { + if (lifecycle === "another client" && Date.now() - sampledAt >= 300_000) + sampledAt = Date.now(); + }); let otherEntry = false; using _timers = fakeTimers({ now: 4_000_000 }); const probe = (enabled: boolean) => ( @@ -71,6 +77,9 @@ describe("useLiveRefresh", () => { refresh={refresh} viewKey={`completed-usage:${lifecycle}:${otherEntry}`} {...(lifecycle === "another entry point" ? { refreshKeys: ["completed-provider"] } : {})} + {...(lifecycle === "another client" + ? { refreshDeadline: () => sampledAt + 300_000 } + : {})} enabled={enabled} refreshOnShow refreshWhileIdle @@ -81,13 +90,13 @@ describe("useLiveRefresh", () => { expect(refresh).toHaveBeenCalledTimes(1); await screen.rerender(lifecycle !== "reenabled" ? <> : probe(false)); await act(() => vi.advanceTimersByTime(4 * 60_000)); - otherEntry = lifecycle === "another entry point"; + otherEntry = lifecycle === "another entry point" || lifecycle === "another client"; await screen.rerender(probe(true)); - expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(lifecycle === "another client" ? 2 : 1); await act(() => vi.advanceTimersByTime(60_000)); - expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenCalledTimes(lifecycle === "another client" ? 3 : 2); await act(() => vi.advanceTimersByTime(5 * 60_000)); - expect(refresh).toHaveBeenCalledTimes(3); + expect(refresh).toHaveBeenCalledTimes(lifecycle === "another client" ? 4 : 3); }, ); diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 84da830f663b4..8486f73cf1a5a 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -78,6 +78,7 @@ export function useLiveRefresh( readonly key?: string; /** Shared deadlines for resources read by this view. */ readonly refreshKeys?: readonly string[]; + readonly refreshDeadline?: (result: unknown) => number | undefined; /** `null` turns off the periodic refresh, for a read too costly to repeat unasked. */ readonly intervalMs?: number | null; /** @@ -103,6 +104,7 @@ export function useLiveRefresh( if (sharedKeys === undefined) lastRefreshedAtByView.set(viewId, now); return refresh(); }); + const serverDeadline = useEffectEvent((result: unknown) => options.refreshDeadline?.(result)); const fallbackViewId = useId(); const viewId = key ?? fallbackViewId; @@ -136,10 +138,21 @@ export function useLiveRefresh( (key) => now - (lastRefreshedAtByView.get(key) ?? -Infinity) >= refreshFloorMs, ); for (const key of refreshedKeys) lastRefreshedAtByView.set(key, now); - result = Promise.resolve(runRefresh(viewId, now)).finally(() => { - pendingRefreshesByView.delete(requestKey); - for (const key of refreshedKeys) lastRefreshedAtByView.set(key, Date.now()); - }); + let deadline: number | undefined; + result = Promise.resolve(runRefresh(viewId, now)) + .then((value) => { + deadline = serverDeadline(value); + return value; + }) + .finally(() => { + pendingRefreshesByView.delete(requestKey); + const completedAt = Date.now(); + const observedAt = + deadline !== undefined && deadline > completedAt + ? Math.min(completedAt, deadline - refreshFloorMs) + : completedAt; + for (const key of refreshedKeys) lastRefreshedAtByView.set(key, observedAt); + }); pendingRefreshesByView.set(requestKey, result); } pending = true; diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx index 78a1dcd782e23..901d9ea3c3091 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useUsageLimitsRefresh.browser.test.tsx @@ -1,3 +1,4 @@ +import { AsyncResult } from "effect/reactivity"; import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -13,7 +14,15 @@ const mocks = vi.hoisted(() => ({ banner: vi.fn<(id: string, report: { createdAt: string }) => { id: string }>(), })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => mocks.refresh })); -vi.mock("../state/server", () => ({ serverEnvironment: { refreshProviders: {} } })); +vi.mock("../state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + serverEnvironment: { + refreshProviders: {}, + configValueAtom: () => Atom.make({ usageLimitSources: [] }), + }, + }; +}); vi.mock("../state/presentation", async () => { const { Atom } = await import("effect/reactivity"); const presentation = Atom.make({ connection: { phase: "connected" } }); @@ -21,7 +30,7 @@ vi.mock("../state/presentation", async () => { }); function setup() { - mocks.refresh.mockReset(); + mocks.refresh.mockReset().mockResolvedValue(AsyncResult.success({ providers: [] })); mocks.banner.mockReset().mockImplementation((id) => ({ id })); return mocks; } diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 2e5925e572843..3bb94729d98d1 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -1,3 +1,6 @@ +import { AsyncResult } from "effect/reactivity"; +import { usageRefreshDeadline } from "@t3tools/client-runtime/state/usage"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useRouterState } from "@tanstack/react-router"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; import { useAtomValue } from "@effect/atom-react"; @@ -25,7 +28,27 @@ export function useUsageLimitsRefresh( return refresh({ environmentId, input: driver ? { usage: true, usageProviders: [driver] } : { instanceId, usage: true }, - }); + }).then((result) => + AsyncResult.isSuccess(result) + ? usageRefreshDeadline([ + ...result.value.providers + .filter((provider) => + driver ? provider.driver === driver : provider.instanceId === instanceId, + ) + .map((provider) => provider.usageLimits), + ...(driver + ? ( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) + ?.usageLimitSources ?? [] + ).flatMap((source) => + source.accounts + .filter((account) => account.driver === driver) + .map((account) => account.usageLimits), + ) + : []), + ]) + : undefined, + ); }, { enabled: enabled && connected && chatVisible && instanceId !== null, @@ -34,6 +57,7 @@ export function useUsageLimitsRefresh( ...(driver ? [JSON.stringify([environmentId, driver])] : []), JSON.stringify([environmentId, driver, instanceId]), ], + refreshDeadline: (result) => (typeof result === "number" ? result : undefined), refreshOnShow: true, refreshWhileIdle: true, }, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 718d77bc84824..e4ec27568bf74 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -242,6 +242,15 @@ describe("limits refresh cooldown", () => { it("keeps a provider's deadline when a wider refresh runs before it is due", async () => { const id = EnvironmentId.make("limits-shared-deadlines"); using now = vi.spyOn(Date, "now").mockReturnValue(0); + const remote = EnvironmentId.make("limits-remote-client"); + now.mockReturnValue(4 * 60_000); + await refreshUsageLimits(remote, async () => 300_000, true, false, { + key: "", + resources: ["claude"], + refreshDeadline: (deadline) => deadline, + }); + expect(usageLimitsRefreshDelay(remote, "claude")).toBe(60_000); + now.mockReturnValue(0); await refreshUsageLimits(id, async () => "claude", true, false, { key: "thread", resources: ["claude"], diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2ffd5b6c45960..a7b829dace193 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ServerProvider, + ServerProviderUsageLimits, UsageModelFilter, UsageSummary, UsageSummaryInput, @@ -52,6 +53,18 @@ export function usageSummaryInput( return modelFilter === undefined ? input : { ...input, modelFilter }; } +export function usageRefreshDeadline( + limits: Iterable, +): number | undefined { + // @effect-diagnostics-next-line globalDate:off + const now = Date.now(); + const deadlines = Array.from(limits, (reading) => reading?.refreshAfter).filter( + (deadline): deadline is number => + deadline !== undefined && Number.isFinite(deadline) && deadline > now, + ); + return deadlines.length ? Math.min(...deadlines) : undefined; +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); @@ -75,7 +88,13 @@ export async function refreshUsageLimits( refresh: () => Promise, automatic = false, afterPending = false, - selectionKey: string | { readonly key: string; readonly resources: readonly string[] } = "", + selectionKey: + | string + | { + readonly key: string; + readonly resources: readonly string[]; + readonly refreshDeadline?: (result: A) => number | undefined; + } = "", ): Promise { const selections = typeof selectionKey === "string" @@ -102,12 +121,21 @@ export async function refreshUsageLimits( typeof selectionKey === "string" ? selections : selections.filter((selection) => usageLimitsRefreshDelay(environmentId, selection) === 0); + let deadline: number | undefined; const current = Promise.resolve() .then(refresh) + .then((result) => { + if (typeof selectionKey !== "string") deadline = selectionKey.refreshDeadline?.(result); + return result; + }) .finally(() => { limitsRefreshes.delete(key); // @effect-diagnostics-next-line globalDate:off - const refreshAfter = Date.now() + 5 * 60_000; + const finishedAt = Date.now(); + const refreshAfter = + deadline !== undefined && deadline > finishedAt + ? Math.min(deadline, finishedAt + 5 * 60_000) + : finishedAt + 5 * 60_000; for (const selection of refreshedSelections) limitsRefreshAfter.set(JSON.stringify([environmentId, selection]), refreshAfter); }); diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 85742820c1416..80ae7d94eb152 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -31,6 +31,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; /** Clear bars for unavailable accounts; retain last good bars after a failed probe. */ export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, + /** Server cache deadline in epoch milliseconds, independent of the sample time. */ + refreshAfter: Schema.optional(NonNegativeInt), stale: Schema.optional(Schema.Boolean), windows: ForwardCompatibleArray(ServerProviderUsageWindow), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 2a38d8b039a9c..15431dfece6d4 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -156,7 +156,10 @@ describe("makeManagedServerProvider", () => { initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Effect.gen(function* () { if (yield* UsagePolling) yield* Ref.update(reads, (n) => n + 1); - return refreshedSnapshot; + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + }; }), }).pipe(Effect.provideService(UsagePolling, true)); yield* provider.refresh; @@ -165,7 +168,12 @@ describe("makeManagedServerProvider", () => { concurrency: "unbounded", }).pipe(Effect.provideService(UsagePolling, true)); assert.strictEqual(yield* Ref.get(reads), 1); - yield* TestClock.adjust("5 minutes"); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.refreshAfter, 300_000); + yield* TestClock.adjust("4 minutes"); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(reads), 1); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.refreshAfter, 300_000); + yield* TestClock.adjust("1 minute"); yield* provider.refresh; assert.strictEqual(yield* Ref.get(reads), 1); yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true)); @@ -227,7 +235,7 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* Ref.get(reads), 1); const unchanged = yield* provider.refresh; if (transition === "auth-type") { - assert.deepStrictEqual(unchanged.usageLimits, limits); + assert.deepStrictEqual(unchanged.usageLimits, { ...limits, refreshAfter: 300_000 }); assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); } yield* Ref.set( @@ -288,6 +296,7 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* Ref.get(reads), 3); assert.deepStrictEqual((yield* provider.getSnapshot).usageLimits, { ...good, + refreshAfter: 600_000, stale: true, }); }), @@ -923,7 +932,7 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* provider.getSnapshot, partial); assert.deepStrictEqual( (yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true))).usageLimits, - failedLimits, + { ...failedLimits, refreshAfter: 300_000 }, ); const completeLimits = { @@ -937,7 +946,7 @@ describe("makeManagedServerProvider", () => { yield* TestClock.adjust("5 minutes"); assert.deepStrictEqual( (yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true))).usageLimits, - completeLimits, + { ...completeLimits, refreshAfter: 600_000 }, ); }), ).pipe(Effect.provide(layerAlwaysRunTest)), diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index d77014b17aba2..5cdb17b7cded0 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -217,7 +217,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( workspaceId && !probedSnapshot.auth.workspaceId ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } : probedSnapshot, - usageLimits, + usageLimits === undefined || usageRefreshAfter === 0 + ? usageLimits + : { ...usageLimits, refreshAfter: usageRefreshAfter }, ); return [ { snapshot, generation }, diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 33ab614815502..61575ca2f51f5 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -109,6 +109,7 @@ export function applyUsageLimitsUpdate(input: { previous === undefined ? { reason: "probeFailed" as const } : previous.unavailable; return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), + ...(previous?.refreshAfter !== undefined ? { refreshAfter: previous.refreshAfter } : {}), ...(previous?.stale ? { stale: true } : {}), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), ...(unavailable !== undefined ? { unavailable } : {}), @@ -133,7 +134,11 @@ export function resolveUsageLimitsAfterProbe(input: { const { published, probed } = input; if (published?.credentialFingerprint !== probed?.credentialFingerprint) return probed; if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { - return { ...published, stale: true }; + return { + ...published, + stale: true, + ...(probed.refreshAfter !== undefined ? { refreshAfter: probed.refreshAfter } : {}), + }; } if ( published && From d9952efade59c595c1dc645ade5c2fbdb43d4827 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 20:02:03 -0500 Subject: [PATCH 51/95] fix(antigravity): retain quota deadlines in direct snapshots --- .../src/provider/AntigravityProvider.test.ts | 22 +++++++++++++++---- .../src/provider/AntigravityProvider.ts | 12 +++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 681700746d033..b67432b275915 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -732,7 +733,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { snapshot.usageLimits !== undefined && snapshot.usageLimits.unavailable === undefined, ); yield* harness.initialize; - expect((yield* published)?.usageLimits).toEqual(limits); + expect((yield* published)?.usageLimits).toEqual({ + ...limits, + refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, + }); // The second health check is the one the failed background read triggers. const checks = yield* Ref.make(0); const failedReadApplied = yield* Deferred.make(); @@ -754,6 +758,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* Deferred.await(failedReadApplied); expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual({ ...limits, + refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, stale: true, }); yield* harness.provider.onSignedOut; @@ -844,7 +849,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { } satisfies ServerProviderUsageLimits; yield* Deferred.succeed(readResult, limits); yield* Fiber.join(demand); - expect((yield* published)?.usageLimits).toEqual(limits); + expect((yield* published)?.usageLimits).toEqual({ + ...limits, + refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, + }); }), ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), ); @@ -870,7 +878,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* Ref.set(usage, Effect.succeed(newLimits)); yield* harness.provider.onSignedIn; yield* harness.provider.onSessionStarted(started); - expect((yield* published)?.usageLimits).toEqual(newLimits); + expect((yield* published)?.usageLimits).toEqual({ + ...newLimits, + refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, + }); // Switching again must not republish the previous account's limits, even if the read fails. const failed = { checkedAt: "2026-10-09T12:02:00.000Z", @@ -884,7 +895,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { yield* harness.provider.onSignedIn.pipe(Effect.provideService(UsagePolling, false)); expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined(); yield* harness.provider.onSessionStarted(started); - expect((yield* republished)?.usageLimits).toEqual(failed); + expect((yield* republished)?.usageLimits).toEqual({ + ...failed, + refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, + }); }), ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), ); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index c28a8650c18bf..ff6a43c64d685 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -531,7 +531,17 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func return { snapshot: { ...managed, - getSnapshot, + getSnapshot: Effect.gen(function* () { + const snapshot = yield* getSnapshot; + const published = (yield* managed.getSnapshot).usageLimits; + const limits = snapshot.usageLimits; + return limits && + published?.refreshAfter !== undefined && + limits.checkedAt === published.checkedAt && + limits.credentialFingerprint === published.credentialFingerprint + ? { ...snapshot, usageLimits: { ...limits, refreshAfter: published.refreshAfter } } + : snapshot; + }), refreshUsage: managed.refreshUsage!.pipe(Effect.andThen(FiberHandle.awaitEmpty(usageRead))), }, onSessionStarted, From 7f9c061e4a259e36d5ac2969b4de335be3c29d9b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 20:06:22 -0500 Subject: [PATCH 52/95] test(server): await process lifecycle without fixture deadlines --- apps/server/src/testUtils/processProbe.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/src/testUtils/processProbe.ts b/apps/server/src/testUtils/processProbe.ts index c5a5e6de0df5d..a4bd0c90903b7 100644 --- a/apps/server/src/testUtils/processProbe.ts +++ b/apps/server/src/testUtils/processProbe.ts @@ -2,7 +2,6 @@ import * as NodeNet from "node:net"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; export const makeProcessProbe = Effect.gen(function* () { const started = yield* Deferred.make(); @@ -49,8 +48,6 @@ export const makeProcessProbe = Effect.gen(function* () { return { port: address.port, started: Deferred.await(started).pipe( - Effect.timeout("4 seconds"), - TestClock.withLive, Effect.tap((pid) => Effect.addFinalizer(() => Effect.gen(function* () { @@ -67,7 +64,7 @@ export const makeProcessProbe = Effect.gen(function* () { ), ), ), - exited: Deferred.await(exited).pipe(Effect.timeout("4 seconds"), TestClock.withLive), + exited: Deferred.await(exited), }; }); From 7f54b79a1a10b0d2974d89c4723fb1fc08e2a641 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 20:33:11 -0500 Subject: [PATCH 53/95] fix(usage): skip cached health probes and hidden mobile reads --- .../features/threads/ThreadDetailScreen.tsx | 1 + .../src/server/managedProvider.test.ts | 12 ++++++-- .../src/server/managedProvider.ts | 28 ++++++++++++------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0dbc01964ac94..2a8f9ee0a6394 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -975,6 +975,7 @@ function ThreadDetailBody( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, )?.driver, usageLimitsPanel !== null && + (props.showContent ?? true) && activeUserInputRequestId === null && props.connectionStateLabel === "connected", ); diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 15431dfece6d4..31fdb0b91c535 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -7,7 +7,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { UsagePolling } from "./usageLimits.ts"; +import { UsagePolling, UsageAfterReset, UsageAccountReset } from "./usageLimits.ts"; import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -148,6 +148,7 @@ describe("makeManagedServerProvider", () => { Effect.scoped( Effect.gen(function* () { const reads = yield* Ref.make(0); + const checks = yield* Ref.make(0); const provider = yield* makeManagedServerProvider({ resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), getSettings: Effect.succeed({ enabled: true }), @@ -155,6 +156,7 @@ describe("makeManagedServerProvider", () => { haveSettingsChanged: () => false, initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Effect.gen(function* () { + yield* Ref.update(checks, (n) => n + 1); if (yield* UsagePolling) yield* Ref.update(reads, (n) => n + 1); return { ...refreshedSnapshot, @@ -170,7 +172,9 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* Ref.get(reads), 1); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.refreshAfter, 300_000); yield* TestClock.adjust("4 minutes"); + const beforeChecks = yield* Ref.get(checks); yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(checks), beforeChecks); assert.strictEqual(yield* Ref.get(reads), 1); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.refreshAfter, 300_000); yield* TestClock.adjust("1 minute"); @@ -178,6 +182,10 @@ describe("makeManagedServerProvider", () => { assert.strictEqual(yield* Ref.get(reads), 1); yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true)); assert.strictEqual(yield* Ref.get(reads), 2); + yield* provider.refreshUsage!.pipe(Effect.provideService(UsageAfterReset, true)); + assert.strictEqual(yield* Ref.get(reads), 3); + yield* provider.refreshUsage!.pipe(Effect.provideService(UsageAccountReset, true)); + assert.strictEqual(yield* Ref.get(reads), 4); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); @@ -365,7 +373,7 @@ describe("makeManagedServerProvider", () => { yield* Fiber.join(queued); yield* Fiber.join(coalesced); assert.strictEqual(yield* Deferred.isDone(completed), true); - assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual(yield* Ref.get(calls), 2); assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); }), ).pipe(Effect.provide(layerAlwaysRunTest)), diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 5cdb17b7cded0..3b5cb051e9241 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -285,16 +285,24 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); yield* restore( refreshSemaphore.withPermits(1)( - release.pipe( - Effect.andThen(input.invalidateUsageCache ?? Effect.void), - Effect.andThen(input.getSettings), - Effect.flatMap((settings) => - applySnapshotBase(settings, { forceRefresh: true }).pipe( - Effect.provideService(UsagePolling, true), - ), - ), - Effect.asVoid, - ), + Effect.gen(function* () { + yield* release; + const settings = yield* input.getSettings; + const current = (yield* Ref.get(snapshotStateRef)).snapshot; + if ( + (yield* Clock.currentTimeMillis) < usageRefreshAfter && + current.usageLimits?.unavailable?.reason !== "unsupported" && + !(yield* UsageAfterReset) && + !(yield* UsageAccountReset) && + !input.haveSettingsChanged(yield* Ref.get(settingsRef), settings) + ) { + return; + } + yield* input.invalidateUsageCache ?? Effect.void; + yield* applySnapshotBase(settings, { forceRefresh: true }).pipe( + Effect.provideService(UsagePolling, true), + ); + }), ), ).pipe( Effect.onExit((exit) => release.pipe(Effect.andThen(Deferred.succeed(claim, exit)))), From 025fdeac7eb1e5c72b1757a59e7efb8469b7f7af Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:14:50 -0500 Subject: [PATCH 54/95] fix(usage): refresh client defaults and normalize remote deadlines --- .../features/threads/NewTaskDraftScreen.tsx | 5 +- .../threads/new-task-flow-provider.test.tsx | 8 ++ .../threads/new-task-flow-provider.tsx | 32 ++++++- .../src/features/usage/usageLimitsRefresh.ts | 43 +++++---- apps/server/src/provider/ProviderRefresh.ts | 12 ++- apps/web/src/components/ChatView.tsx | 18 +++- apps/web/src/components/usage/UsagePage.tsx | 45 ++++----- apps/web/src/hooks/useHandleNewThread.test.ts | 95 ++++++++++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 15 ++- apps/web/src/hooks/useNewThreadUsage.ts | 62 ++++++++++++ apps/web/src/hooks/useUsageLimitsRefresh.ts | 35 +++---- .../client-runtime/src/state/usage.test.ts | 10 ++ packages/client-runtime/src/state/usage.ts | 43 ++++++++- packages/contracts/src/rpc.ts | 5 +- 14 files changed, 346 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/hooks/useNewThreadUsage.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f02fe677cdd3..54c7af5bfefb7 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -643,14 +643,15 @@ export function NewTaskDraftScreen(props: { return null; } - const message = flow.buildPendingTaskMessage(creationMetadata, { + flow.setSubmitting(true); + const message = await flow.buildPendingTaskMessage(creationMetadata, { currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName, }); if (!message) { + flow.setSubmitting(false); return null; } // Persist locally before clearing or leaving the draft; server setup runs on the thread. - flow.setSubmitting(true); const queued = await settlePromise(() => enqueueThreadOutboxMessage(message)); flow.setSubmitting(false); if (AsyncResult.isFailure(queued)) { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 0b7ecf7900c2b..eb007b1a26338 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -58,6 +58,14 @@ vi.mock("../../state/session", () => ({ vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: mocks.presentation, })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + refreshProviders: { + label: "refresh providers", + run: async () => AsyncResult.success({ providers: [] }), + }, + }, +})); vi.mock("../../state/orchestration", () => ({ orchestrationEnvironment: {} })); vi.mock("../../state/threads", () => ({ olderSettledThreads: {}, threadFamilies: {} })); vi.mock("../../state/pull-requests", () => ({ composerPullRequests: {} })); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3876fd2f83dbb..50a0348ac52eb 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,5 +1,7 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { refreshNewThreadUsage } from "@t3tools/client-runtime/state/usage"; +import { serverEnvironment } from "../../state/server"; import { useNowMinuteMs } from "../../lib/clock"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -196,7 +198,7 @@ type NewTaskFlowContextValue = { options?: { readonly currentCheckoutBranch?: string | null; }, - ) => QueuedThreadMessage | null; + ) => Promise; readonly setPrompt: (value: string) => void; readonly replaceAttachments: (attachments: ReadonlyArray) => void; /** Appends draft attachments; returns how many the live cap rejected. */ @@ -717,21 +719,43 @@ export function NewTaskFlowProvider( } }; - const buildPendingTaskMessage = ( + const buildPendingTaskMessage = async ( metadata: TurnCommandMetadata, options?: { readonly currentCheckoutBranch?: string | null }, - ): QueuedThreadMessage | null => { + ): Promise => { if (!selectedProject || !selectedProjectDraftKey) { return null; } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); + const selectionInput = { + defaultSelection: selectedDefaultModel, + explicitSelection: + draft.modelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, + providers: selectedEnvironmentServerConfig?.providers ?? [], + ...(selectedEnvironmentServerConfig + ? { settings: selectedEnvironmentServerConfig.settings } + : {}), + projectId: selectedProject.id, + now: Date.now(), + }; + const providers = await refreshNewThreadUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + environmentId: selectedProject.environmentId, + input: selectionInput, + }); + const refreshedSelection = resolveNewThreadModelSelection({ + ...selectionInput, + providers, + }).selection; const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, draft.modelSelection ?? null, selectedProject.id, - ) ?? selectedModel; + ) ?? refreshedSelection; if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { return null; } diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 5d9fea96b8a88..d55629af79f40 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -85,28 +85,31 @@ export async function refreshEnvironmentLimits( : { key: "", resources: environmentLimitsRefreshKeys(registry, environmentId) }), refreshDeadline: (result) => AsyncResult.isSuccess(result) - ? usageRefreshDeadline([ - ...result.value.providers - .filter((provider) => - options.driver - ? provider.driver === options.driver - : options.instanceId === undefined || - provider.instanceId === options.instanceId, - ) - .map((provider) => provider.usageLimits), - ...( - registry.get(serverEnvironment.configValueAtom(environmentId))?.usageLimitSources ?? - [] - ).flatMap((source) => - source.accounts - .filter((account) => + ? usageRefreshDeadline( + [ + ...result.value.providers + .filter((provider) => options.driver - ? account.driver === options.driver - : options.instanceId === undefined, + ? provider.driver === options.driver + : options.instanceId === undefined || + provider.instanceId === options.instanceId, ) - .map((account) => account.usageLimits), - ), - ]) + .map((provider) => provider.usageLimits), + ...( + registry.get(serverEnvironment.configValueAtom(environmentId)) + ?.usageLimitSources ?? [] + ).flatMap((source) => + source.accounts + .filter((account) => + options.driver + ? account.driver === options.driver + : options.instanceId === undefined, + ) + .map((account) => account.usageLimits), + ), + ], + result.value.serverTime, + ) : undefined, }, ); diff --git a/apps/server/src/provider/ProviderRefresh.ts b/apps/server/src/provider/ProviderRefresh.ts index e02b68fa8f4fa..ca78dec0301c0 100644 --- a/apps/server/src/provider/ProviderRefresh.ts +++ b/apps/server/src/provider/ProviderRefresh.ts @@ -7,6 +7,7 @@ import { UsagePolling, UsageProviderFilter } from "@t3tools/provider-core/server import { withUsageLimitsCommands } from "@t3tools/shared/usageLimits"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ProviderRegistry from "./ProviderRegistry.ts"; @@ -22,7 +23,10 @@ export class ProviderRefresh extends Context.Service< { readonly refresh: ( input: RefreshInput, - ) => Effect.Effect<{ readonly providers: ReadonlyArray }, ProviderSetupError>; + ) => Effect.Effect< + { readonly providers: ReadonlyArray; readonly serverTime?: number }, + ProviderSetupError + >; } >()("t3/provider/ProviderRefresh") {} @@ -154,19 +158,21 @@ const make = Effect.gen(function* () { providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } - if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const serverTime = yield* Clock.currentTimeMillis; + if (input.workspaceSnapshotsOnDemand !== true) return { providers, serverTime }; const commandProviders = input.usageLimitsCommand === true ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) : providers; const { instanceId, cwd } = input; if (instanceId === undefined || cwd === undefined) { - return { providers: withoutWorkspaceSnapshots(commandProviders) }; + return { providers: withoutWorkspaceSnapshots(commandProviders), serverTime }; } const catalogOnDemand = input.providerCatalogsOnDemand === true && (yield* providerInstances.getInstance(instanceId))?.snapshotForCwd !== undefined; return { + serverTime, providers: commandProviders .filter((provider) => provider.instanceId === instanceId) .map((provider) => ({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 239e52f3032c2..b07183327f9f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useNewThreadUsage } from "../hooks/useNewThreadUsage"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; @@ -770,8 +771,8 @@ export default function ChatView(props: ChatViewProps) { fallbackDraftProject ?? undefined, ).settings.defaultModelSelection : null; - const draftModelResolution = draftThread - ? resolveNewThreadModelSelection({ + const draftSelectionInput = draftThread + ? { defaultSelection: draftDefaultModelSelection ?? resolveProjectDefaultProviderModelSelection( @@ -779,12 +780,20 @@ export default function ChatView(props: ChatViewProps) { settings, draftThread.projectId, ), + explicitSelection: composerModelSelectionExplicit ? composerSelectedModelSelection : null, fallbackSelection: settings.fallbackModelSelection, providers: providersByEnvironment.get(draftThread.environmentId) ?? [], settings, projectId: draftThread.projectId, now: Date.parse(`${nowMinute}:00Z`), - }) + } + : null; + const draftUsage = useNewThreadUsage( + draftThread?.environmentId ?? environmentId, + draftSelectionInput, + ); + const draftModelResolution = draftSelectionInput + ? resolveNewThreadModelSelection({ ...draftSelectionInput, providers: draftUsage.providers }) : null; const draftFallback = draftModelResolution?.fallback ? draftModelResolution : null; const localDraftThread = draftThread @@ -1416,7 +1425,7 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, localDispatchStartedAt, isPreparingWorktree: isLocallyPreparingWorktree, - isSendBusy, + isSendBusy: isDispatchBusy, backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, @@ -1428,6 +1437,7 @@ export default function ChatView(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); + const isSendBusy = isDispatchBusy || draftUsage.pending; const optimisticCompactionMessage = optimisticUserMessages.at(-1); const latestServerUserItem = serverVisibleTurnItems.findLast( (row) => row.item.type === "user_message", diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 4ae8b7e7eca07..aac7c6bac1599 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -276,6 +276,25 @@ export function UsagePage() { false, afterPending, JSON.stringify([...hiddenProviders].sort()), + ).then((response) => + response !== undefined && AsyncResult.isSuccess(response) + ? usageRefreshDeadline( + [ + ...response.value.providers + .filter((provider) => visibleDrivers.includes(provider.driver)) + .map((provider) => provider.usageLimits), + ...( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) + ?.usageLimitSources ?? [] + ).flatMap((source) => + source.accounts + .filter((account) => visibleDrivers.includes(account.driver)) + .map((account) => account.usageLimits), + ), + ], + response.value.serverTime, + ) + : undefined, ); } }), @@ -283,28 +302,10 @@ export function UsagePage() { ).then((result) => { setLimitsNow(Date.now()); if (!AsyncResult.isSuccess(result)) throw Cause.squash(result.cause); - return usageRefreshDeadline([ - ...result.value.flatMap((response) => - response !== undefined && AsyncResult.isSuccess(response) - ? response.value.providers - .filter((provider) => visibleDrivers.includes(provider.driver)) - .map((provider) => provider.usageLimits) - : [], - ), - ...Array.from(presentationsWithProviders).flatMap(([environmentId, presentation]) => - presentation.connection.phase === "connected" && - (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)) - ? ( - appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) - ?.usageLimitSources ?? [] - ).flatMap((source) => - source.accounts - .filter((account) => visibleDrivers.includes(account.driver)) - .map((account) => account.usageLimits), - ) - : [], - ), - ]); + const deadlines = result.value.filter( + (deadline): deadline is number => deadline !== undefined, + ); + return deadlines.length ? Math.min(...deadlines) : undefined; }); const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => { if ( diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6faf5bd76d73d..1780b5d3c5ee3 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import { + ProviderInstanceId, + ProviderDriverKind, + type ModelSelection, + type ServerProvider, + type RuntimeMode, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; @@ -9,9 +16,14 @@ const testState = vi.hoisted(() => { let targetSettings = { defaultThreadEnvMode: "local" as "local" | "worktree" | null, newWorktreesStartFromOrigin: false, - defaultModelSelection: null, + defaultModelSelection: null as ModelSelection | null, + fallbackModelSelection: null as ModelSelection | null, defaultRuntimeMode: "full-access" as RuntimeMode, + providerInstances: {}, + projectSettingsOverrides: {}, }; + let providers: ServerProvider[] = []; + const refreshProviders = vi.fn(async () => AsyncResult.success({ providers })); let storedDraft: { readonly draftId: string; readonly environmentId: string; @@ -55,6 +67,19 @@ const testState = vi.hoisted(() => { return { completeProjectFileRead: (value: null) => completeProjectFileRead(value), draftStore, + refreshProviders, + get providers() { + return providers; + }, + setDefaultModels( + primary: ModelSelection, + fallback: ModelSelection, + snapshots: ServerProvider[], + ) { + targetSettings.defaultModelSelection = primary; + targetSettings.fallbackModelSelection = fallback; + providers = snapshots; + }, get projectFileRead() { return projectFileRead; }, @@ -100,8 +125,15 @@ const testState = vi.hoisted(() => { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, defaultModelSelection: null, + fallbackModelSelection: null, defaultRuntimeMode: "full-access", + providerInstances: {}, + projectSettingsOverrides: {}, }; + providers = []; + refreshProviders.mockReset(); + refreshProviders.mockImplementation(async () => AsyncResult.success({ providers })); + draftStore.setModelSelection.mockClear(); router.state.location.href = "/"; router.navigate.mockClear(); draftStore.setDraftThreadContext.mockClear(); @@ -126,7 +158,7 @@ const testServerConfigs = () => }, }, ], - ["environment-ssh", { settings: testState.targetSettings }], + ["environment-ssh", { settings: testState.targetSettings, providers: testState.providers }], ]); vi.mock("../rpc/atomRegistry", () => ({ @@ -149,7 +181,8 @@ vi.mock("@t3tools/client-runtime/environment", () => ({ scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ +vi.mock("@t3tools/contracts", async (original) => ({ + ...(await original()), DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); @@ -198,7 +231,9 @@ vi.mock("../composerDraftStore", () => { vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ ...(await importOriginal()), hasExplicitComposerModelSelection: () => false, - resolveNewThreadModelSelectionOverride: () => null, + resolveNewThreadModelSelectionOverride: (input: { + projectDefaultSelection: ModelSelection | null; + }) => input.projectDefaultSelection, })); vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ @@ -237,6 +272,9 @@ vi.mock("../state/presentation", () => ({ vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", + serverEnvironment: { + refreshProviders: { label: "refresh", run: () => testState.refreshProviders() }, + }, })); vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => testState.openDraftRoute, @@ -365,3 +403,50 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).toHaveBeenCalledOnce(); }); }); + +it("reads cold native quotas before selecting a new draft's default model", async () => { + testState.reset(null); + const primary = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + const fallback = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }; + const providers: ServerProvider[] = [primary, fallback].map((selection) => ({ + instanceId: selection.instanceId, + driver: ProviderDriverKind.make(selection === primary ? "codex" : "claudeAgent"), + enabled: true, + installed: true, + version: "1", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + })); + testState.setDefaultModels(primary, fallback, providers); + testState.refreshProviders.mockImplementation(async () => + AsyncResult.success({ + providers: providers.map((provider) => ({ + ...provider, + usageLimits: { + checkedAt: provider.checkedAt, + windows: [ + { + id: "session", + kind: "session" as const, + label: "Session", + usedPercent: provider.instanceId === primary.instanceId ? 100 : 10, + }, + ], + }, + })), + }), + ); + const pending = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + const opened = await pending; + expect(testState.draftStore.setModelSelection).toHaveBeenCalledWith(opened?.draftId, fallback, { + replaceOptions: true, + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a43825472f357..2d29b4e6508b3 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -38,7 +38,8 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSummaries } from "../state/presentation"; -import { environmentServerConfigsAtom } from "../state/server"; +import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; +import { refreshNewThreadUsage } from "@t3tools/client-runtime/state/usage"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getClientSettings } from "./useSettings"; @@ -75,7 +76,7 @@ export function createNewThreadHandler(router: AnyRouter) { return resolveThreadRouteTarget(currentRouteParams); }; - return ( + return async ( projectRef: ScopedProjectRef, options?: { branch?: string | null; @@ -147,7 +148,7 @@ export function createNewThreadHandler(router: AnyRouter) { project, ); const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; - const fallbackResolution = resolveNewThreadModelSelection({ + const selectionInput = { defaultSelection: configuredDefaultModelSelection ?? (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null @@ -162,7 +163,15 @@ export function createNewThreadHandler(router: AnyRouter) { settings: targetServerSettings, projectId: projectRef.projectId, now: Date.now(), + }; + const providers = await refreshNewThreadUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + environmentId: projectRef.environmentId, + input: selectionInput, }); + if (abandonedSinceRequest()) return null; + const fallbackResolution = resolveNewThreadModelSelection({ ...selectionInput, providers }); const projectDefaultModelSelection = fallbackResolution.fallback ? fallbackResolution.selection : configuredDefaultModelSelection; diff --git a/apps/web/src/hooks/useNewThreadUsage.ts b/apps/web/src/hooks/useNewThreadUsage.ts new file mode 100644 index 0000000000000..ce63ecea34a88 --- /dev/null +++ b/apps/web/src/hooks/useNewThreadUsage.ts @@ -0,0 +1,62 @@ +import { useEffect, useEffectEvent, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { refreshNewThreadUsage } from "@t3tools/client-runtime/state/usage"; +import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; +import type { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "../state/presentation"; +import { serverEnvironment } from "../state/server"; + +type SelectionInput = Parameters[0]; + +export function useNewThreadUsage(environmentId: EnvironmentId, input: SelectionInput | null) { + const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); + const enabled = Boolean( + input?.defaultSelection && + input.fallbackSelection && + !input.explicitSelection && + presentation?.connection.phase === "connected", + ); + const key = JSON.stringify([ + environmentId, + input?.defaultSelection, + input?.fallbackSelection, + input?.providers.find((provider) => provider.instanceId === input.defaultSelection?.instanceId) + ?.auth, + ]); + const [completed, setCompleted] = useState<{ + key: string; + baseline: readonly ServerProvider[]; + providers: readonly ServerProvider[]; + } | null>(null); + const refresh = useEffectEvent( + () => + input && + refreshNewThreadUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + environmentId, + input, + }), + ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- default selection requests quota once per draft/account, without a polling timer + useEffect(() => { + if (!enabled || !input) return; + let active = true; + const baseline = input.providers; + void Promise.resolve(refresh()).then((providers) => { + if (active && providers) setCompleted({ key, baseline, providers }); + }); + return () => { + active = false; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- quota demand follows selection/account identity; config updates only replace its cached reading + }, [enabled, key]); + return { + providers: + completed?.key === key && completed.baseline === input?.providers + ? completed.providers + : (input?.providers ?? []), + pending: enabled && completed?.key !== key, + }; +} diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 3bb94729d98d1..78e75cd39241b 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -30,23 +30,26 @@ export function useUsageLimitsRefresh( input: driver ? { usage: true, usageProviders: [driver] } : { instanceId, usage: true }, }).then((result) => AsyncResult.isSuccess(result) - ? usageRefreshDeadline([ - ...result.value.providers - .filter((provider) => - driver ? provider.driver === driver : provider.instanceId === instanceId, - ) - .map((provider) => provider.usageLimits), - ...(driver - ? ( - appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) - ?.usageLimitSources ?? [] - ).flatMap((source) => - source.accounts - .filter((account) => account.driver === driver) - .map((account) => account.usageLimits), + ? usageRefreshDeadline( + [ + ...result.value.providers + .filter((provider) => + driver ? provider.driver === driver : provider.instanceId === instanceId, ) - : []), - ]) + .map((provider) => provider.usageLimits), + ...(driver + ? ( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)) + ?.usageLimitSources ?? [] + ).flatMap((source) => + source.accounts + .filter((account) => account.driver === driver) + .map((account) => account.usageLimits), + ) + : []), + ], + result.value.serverTime, + ) : undefined, ); }, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index e4ec27568bf74..0d41fe1f74e75 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -20,6 +20,7 @@ import { refreshUsage, refreshUsageLimits, usageLimitsRefreshDelay, + usageRefreshDeadline, } from "./usage.ts"; const input = { @@ -243,6 +244,15 @@ describe("limits refresh cooldown", () => { const id = EnvironmentId.make("limits-shared-deadlines"); using now = vi.spyOn(Date, "now").mockReturnValue(0); const remote = EnvironmentId.make("limits-remote-client"); + for (const skew of [-3_600_000, -1_000, 1_000, 3_600_000]) { + now.mockReturnValue(240_000 + skew); + expect( + usageRefreshDeadline( + [{ checkedAt: "2026-01-01T00:00:00Z", windows: [], refreshAfter: 300_000 }], + 240_000, + ), + ).toBe(300_000 + skew); + } now.mockReturnValue(4 * 60_000); await refreshUsageLimits(remote, async () => 300_000, true, false, { key: "", diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index a7b829dace193..2a26b56bca231 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, + ProviderInstanceId, ServerProvider, ServerProviderUsageLimits, UsageModelFilter, @@ -7,6 +8,8 @@ import type { UsageSummaryInput, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { exhaustedUsageWindow, resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { AsyncResult } from "effect/reactivity"; import type { AtomRegistry } from "effect/reactivity"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; @@ -53,16 +56,52 @@ export function usageSummaryInput( return modelFilter === undefined ? input : { ...input, modelFilter }; } +export async function refreshNewThreadUsage({ + registry, + server, + environmentId, + input, +}: { + readonly registry: AtomRegistry.AtomRegistry; + readonly server: Pick, "refreshProviders">; + readonly environmentId: EnvironmentId; + readonly input: Parameters[0]; +}): Promise { + if (input.explicitSelection || !input.defaultSelection || !input.fallbackSelection) + return input.providers; + const refresh = async (instanceId: ProviderInstanceId) => { + const result = await runAtomCommand( + registry, + server.refreshProviders, + { environmentId, input: { instanceId, usage: true } }, + { reportFailure: false }, + ); + return AsyncResult.isSuccess(result) ? result.value.providers : undefined; + }; + let providers = (await refresh(input.defaultSelection.instanceId)) ?? input.providers; + if ( + exhaustedUsageWindow( + providers.find((provider) => provider.instanceId === input.defaultSelection?.instanceId), + input.now, + input.defaultSelection.model, + ) + ) { + providers = (await refresh(input.fallbackSelection.instanceId)) ?? providers; + } + return providers; +} + export function usageRefreshDeadline( limits: Iterable, + serverTime?: number, ): number | undefined { // @effect-diagnostics-next-line globalDate:off const now = Date.now(); const deadlines = Array.from(limits, (reading) => reading?.refreshAfter).filter( (deadline): deadline is number => - deadline !== undefined && Number.isFinite(deadline) && deadline > now, + deadline !== undefined && Number.isFinite(deadline) && deadline > (serverTime ?? now), ); - return deadlines.length ? Math.min(...deadlines) : undefined; + return deadlines.length ? now + Math.min(...deadlines) - (serverTime ?? now) : undefined; } const limitsRefreshAfter = new Map(); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6df94b73a94c8..182814cb6a276 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -665,7 +665,10 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv /** As on `subscribeServerConfig`: a failed cwd scan replies with the machine catalog as a pending snapshot. */ providerCatalogsOnDemand: Schema.optional(Schema.Boolean), }), - success: ServerProviderUpdatedPayload, + success: Schema.Struct({ + ...ServerProviderUpdatedPayload.fields, + serverTime: Schema.optional(NonNegativeInt), + }), error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), }); From 39ee83b079074b6473f96b60de5549fd30766f1e Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:19:15 -0500 Subject: [PATCH 55/95] fix(web): scope default quota demand to each draft --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/hooks/useNewThreadUsage.ts | 7 ++++++- docs/user/usage.md | 9 +++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b07183327f9f3..453d7f4fe1021 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -791,6 +791,7 @@ export default function ChatView(props: ChatViewProps) { const draftUsage = useNewThreadUsage( draftThread?.environmentId ?? environmentId, draftSelectionInput, + routeThreadKey, ); const draftModelResolution = draftSelectionInput ? resolveNewThreadModelSelection({ ...draftSelectionInput, providers: draftUsage.providers }) diff --git a/apps/web/src/hooks/useNewThreadUsage.ts b/apps/web/src/hooks/useNewThreadUsage.ts index ce63ecea34a88..07d5c183b7582 100644 --- a/apps/web/src/hooks/useNewThreadUsage.ts +++ b/apps/web/src/hooks/useNewThreadUsage.ts @@ -9,7 +9,11 @@ import { serverEnvironment } from "../state/server"; type SelectionInput = Parameters[0]; -export function useNewThreadUsage(environmentId: EnvironmentId, input: SelectionInput | null) { +export function useNewThreadUsage( + environmentId: EnvironmentId, + input: SelectionInput | null, + draftKey: string, +) { const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); const enabled = Boolean( input?.defaultSelection && @@ -18,6 +22,7 @@ export function useNewThreadUsage(environmentId: EnvironmentId, input: Selection presentation?.connection.phase === "connected", ); const key = JSON.stringify([ + draftKey, environmentId, input?.defaultSelection, input?.fallbackSelection, diff --git a/docs/user/usage.md b/docs/user/usage.md index 583129c721f66..8daaf6c2c865d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -117,6 +117,7 @@ setting removes it from `settings.json`. Limits refresh every five minutes while a limits view is visible. Closing the view or hiding the window stops periodic usage requests; provider health checks continue. Refreshing can reuse a recent reading, and rate-limited accounts wait before retrying, including manual refreshes. +Choosing a default or fallback model can also request a reading while Limits is closed. **Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your enabled environments and hubs you read one number per window rather than a @@ -142,14 +143,14 @@ and a partly spent session may only regain its unused portion when the week rese stale reset times have no refill estimate; refresh Limits for a new reading. Claude's model-specific weekly allowances keep their reported values. -Opening Limits checks the selected connected environments automatically. Each client waits at -least five minutes between automatic checks of an environment, including after a failed check. +Opening Limits checks the selected connected environments automatically. Automatic checks +follow the shared five-minute cache deadline, including after failed probes. If a window still looks stale, refresh Limits to re-check every provider and hub. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and -closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh -anything. The command is offered only for providers that appear under **Usage → Limits**. +closes when you dismiss it or send your next message. It shares the reading and refresh schedule +with **Usage → Limits** without running the agent. The command is offered only for providers that appear under **Usage → Limits**. To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the From 0e2d2ef9a3409c34c0e32c013a3cab8bcac471d6 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:41:48 -0500 Subject: [PATCH 56/95] fix(usage): bound default-selection quota waits --- apps/web/src/hooks/useHandleNewThread.test.ts | 104 ++++++++++++------ docs/user/usage.md | 1 + packages/client-runtime/src/state/usage.ts | 43 ++++++-- 3 files changed, 104 insertions(+), 44 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 1780b5d3c5ee3..c3c463bc31c62 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -404,26 +404,28 @@ describe("useNewThreadHandler", () => { }); }); -it("reads cold native quotas before selecting a new draft's default model", async () => { - testState.reset(null); - const primary = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; - const fallback = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }; - const providers: ServerProvider[] = [primary, fallback].map((selection) => ({ - instanceId: selection.instanceId, - driver: ProviderDriverKind.make(selection === primary ? "codex" : "claudeAgent"), - enabled: true, - installed: true, - version: "1", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - })); - testState.setDefaultModels(primary, fallback, providers); - testState.refreshProviders.mockImplementation(async () => - AsyncResult.success({ +it.each(["fresh", "primary-blocked", "fallback-blocked"] as const)( + "new-draft quota preflight: %s", + async (mode) => { + vi.useFakeTimers(); + testState.reset(null); + const primary = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + const fallback = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }; + const providers: ServerProvider[] = [primary, fallback].map((selection) => ({ + instanceId: selection.instanceId, + driver: ProviderDriverKind.make(selection === primary ? "codex" : "claudeAgent"), + enabled: true, + installed: true, + version: "1", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + })); + testState.setDefaultModels(primary, fallback, providers); + const fresh = AsyncResult.success({ providers: providers.map((provider) => ({ ...provider, usageLimits: { @@ -438,15 +440,53 @@ it("reads cold native quotas before selecting a new draft's default model", asyn ], }, })), - }), - ); - const pending = useNewThreadHandler()({ - environmentId: "environment-ssh", - projectId: "project-remote", - } as never); - testState.completeProjectFileRead(null); - const opened = await pending; - expect(testState.draftStore.setModelSelection).toHaveBeenCalledWith(opened?.draftId, fallback, { - replaceOptions: true, - }); -}); + }); + const first = Promise.withResolvers(); + const blocked = Promise.withResolvers(); + let calls = 0; + testState.refreshProviders.mockImplementation(async () => { + calls++; + if (mode === "primary-blocked") return blocked.promise; + if (mode === "fallback-blocked") return calls === 1 ? first.promise : blocked.promise; + return fresh; + }); + let finished = false; + const pending = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never).then((opened) => { + finished = true; + return opened; + }); + testState.completeProjectFileRead(null); + try { + if (mode === "fallback-blocked") { + await vi.advanceTimersByTimeAsync(3_000); + first.resolve(fresh); + await vi.advanceTimersByTimeAsync(0); + expect(testState.refreshProviders).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1_999); + } else { + await vi.advanceTimersByTimeAsync(4_999); + } + expect(finished).toBe(mode === "fresh"); + await vi.advanceTimersByTimeAsync(1); + expect(finished).toBe(true); + const opened = await pending; + expect(testState.draftStore.setModelSelection).toHaveBeenCalledWith( + opened?.draftId, + mode === "primary-blocked" ? primary : fallback, + { replaceOptions: true }, + ); + expect(vi.getTimerCount()).toBe(0); + blocked.resolve(fresh); + await vi.advanceTimersByTimeAsync(0); + expect(testState.refreshProviders).toHaveBeenCalledTimes(mode === "primary-blocked" ? 1 : 2); + } finally { + first.resolve(fresh); + blocked.resolve(fresh); + await pending; + vi.useRealTimers(); + } + }, +); diff --git a/docs/user/usage.md b/docs/user/usage.md index 8daaf6c2c865d..f9eb218778c11 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -118,6 +118,7 @@ Limits refresh every five minutes while a limits view is visible. Closing the vi window stops periodic usage requests; provider health checks continue. Refreshing can reuse a recent reading, and rate-limited accounts wait before retrying, including manual refreshes. Choosing a default or fallback model can also request a reading while Limits is closed. +If that check takes more than five seconds, T3 continues with the information already available. **Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your enabled environments and hubs you read one number per window rather than a diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2a26b56bca231..6a3d98e22ab89 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -8,6 +8,7 @@ import type { UsageSummaryInput, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import { exhaustedUsageWindow, resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { AsyncResult } from "effect/reactivity"; import type { AtomRegistry } from "effect/reactivity"; @@ -56,6 +57,8 @@ export function usageSummaryInput( return modelFilter === undefined ? input : { ...input, modelFilter }; } +const DEFAULT_MODEL_USAGE_WAIT_MS = 5_000; + export async function refreshNewThreadUsage({ registry, server, @@ -67,8 +70,8 @@ export async function refreshNewThreadUsage({ readonly environmentId: EnvironmentId; readonly input: Parameters[0]; }): Promise { - if (input.explicitSelection || !input.defaultSelection || !input.fallbackSelection) - return input.providers; + const { defaultSelection, fallbackSelection } = input; + if (input.explicitSelection || !defaultSelection || !fallbackSelection) return input.providers; const refresh = async (instanceId: ProviderInstanceId) => { const result = await runAtomCommand( registry, @@ -78,16 +81,32 @@ export async function refreshNewThreadUsage({ ); return AsyncResult.isSuccess(result) ? result.value.providers : undefined; }; - let providers = (await refresh(input.defaultSelection.instanceId)) ?? input.providers; - if ( - exhaustedUsageWindow( - providers.find((provider) => provider.instanceId === input.defaultSelection?.instanceId), - input.now, - input.defaultSelection.model, - ) - ) { - providers = (await refresh(input.fallbackSelection.instanceId)) ?? providers; - } + let providers = input.providers; + let expired = false; + const read = async () => { + providers = (await refresh(defaultSelection.instanceId)) ?? providers; + if (expired) return; + if ( + exhaustedUsageWindow( + providers.find((provider) => provider.instanceId === defaultSelection.instanceId), + input.now, + defaultSelection.model, + ) + ) { + providers = (await refresh(fallbackSelection.instanceId)) ?? providers; + } + }; + // The UI wait is bounded; an already shared read can still finish into the cache. + await Effect.runPromise( + Effect.promise(read).pipe( + Effect.timeoutOption(DEFAULT_MODEL_USAGE_WAIT_MS), + Effect.ensuring( + Effect.sync(() => { + expired = true; + }), + ), + ), + ); return providers; } From cd28b412e11696086e28c7dbd4fa5c37b8be0444 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:44:01 -0500 Subject: [PATCH 57/95] test(web): use compatible deferred promises --- apps/web/src/hooks/useHandleNewThread.test.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index c3c463bc31c62..dea545ac0868b 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -441,13 +441,19 @@ it.each(["fresh", "primary-blocked", "fallback-blocked"] as const)( }, })), }); - const first = Promise.withResolvers(); - const blocked = Promise.withResolvers(); + let completeFirst: (value: typeof fresh) => void = () => undefined; + let completeBlocked: (value: typeof fresh) => void = () => undefined; + const first = new Promise((resolve) => { + completeFirst = resolve; + }); + const blocked = new Promise((resolve) => { + completeBlocked = resolve; + }); let calls = 0; testState.refreshProviders.mockImplementation(async () => { calls++; - if (mode === "primary-blocked") return blocked.promise; - if (mode === "fallback-blocked") return calls === 1 ? first.promise : blocked.promise; + if (mode === "primary-blocked") return blocked; + if (mode === "fallback-blocked") return calls === 1 ? first : blocked; return fresh; }); let finished = false; @@ -462,7 +468,7 @@ it.each(["fresh", "primary-blocked", "fallback-blocked"] as const)( try { if (mode === "fallback-blocked") { await vi.advanceTimersByTimeAsync(3_000); - first.resolve(fresh); + completeFirst(fresh); await vi.advanceTimersByTimeAsync(0); expect(testState.refreshProviders).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1_999); @@ -479,12 +485,12 @@ it.each(["fresh", "primary-blocked", "fallback-blocked"] as const)( { replaceOptions: true }, ); expect(vi.getTimerCount()).toBe(0); - blocked.resolve(fresh); + completeBlocked(fresh); await vi.advanceTimersByTimeAsync(0); expect(testState.refreshProviders).toHaveBeenCalledTimes(mode === "primary-blocked" ? 1 : 2); } finally { - first.resolve(fresh); - blocked.resolve(fresh); + completeFirst(fresh); + completeBlocked(fresh); await pending; vi.useRealTimers(); } From c19636df600529699f056b56bac7a61940b2a541 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:53:28 -0500 Subject: [PATCH 58/95] fix(server): refresh quotas only for eligible fallbacks --- .../CodexTextGeneration.test.ts | 202 +++++++++++------- .../src/textGeneration/TextGeneration.ts | 41 ++-- 2 files changed, 153 insertions(+), 90 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 882743d103dc7..d056d3875f28a 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -449,69 +449,85 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus Effect.gen(function* () { const primary = ProviderInstanceId.make("codex"); const fallback = ProviderInstanceId.make("claude_work"); + const unrelated = ProviderInstanceId.make("antigravity"); + const disabled = ProviderInstanceId.make("claude_disabled"); const selection = { instanceId: primary, model: "chat-model", options: [{ id: "effort", value: "high" }], }; const calls: ModelSelection[] = []; - const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { - let quotaRead = false; - const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); - const snapshot: ServerProvider = { - instanceId, - driver, - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - version: null, - checkedAt: "1970-01-01T00:00:00Z", - models: [], - slashCommands: [], - skills: [], - ...(exhausted && instanceId === primary - ? { - usageLimits: { - checkedAt: "1970-01-01T00:00:00Z", - windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], - }, - } - : {}), - }; - return { - instanceId, - driverKind: driver, - continuationIdentity: { driverKind: driver, continuationKey: instanceId }, - displayName: undefined, - enabled: true, - snapshot: { - getSnapshot: Effect.sync(() => - quotaRead ? snapshot : { ...snapshot, usageLimits: undefined }, - ), - refreshUsage: Effect.sync(() => { - quotaRead = true; - }), - resolveMaintenance: () => Effect.die("unused"), - refresh: Effect.die("unused"), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: TextGeneration.TextGeneration.of({ - generateCommitMessage: () => Effect.die("unused"), - generatePrContent: () => Effect.die("unused"), - generateBranchName: () => Effect.die("unused"), - generateReviewGuide: () => Effect.die("unused"), - generateTranscriptionPostProcessing: () => Effect.die("unused"), - generateThreadTitle: (input) => - Effect.sync(() => { - calls.push(input.modelSelection); - return { title: "Generated title" }; + const reads: ProviderInstanceId[] = []; + const instances: ProviderInstance[] = [primary, fallback, unrelated, disabled].map( + (instanceId) => { + let quotaRead = false; + const driver = ProviderDriverKind.make( + instanceId === primary + ? "codex" + : instanceId === unrelated + ? "antigravity" + : "claudeAgent", + ); + const snapshot: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(exhausted && instanceId === primary + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }; + return { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + snapshot: { + getSnapshot: Effect.sync(() => + quotaRead ? snapshot : { ...snapshot, usageLimits: undefined }, + ), + refreshUsage: Effect.sync(() => { + reads.push(instanceId); + quotaRead = true; }), - }), - }; - }); + resolveMaintenance: () => Effect.die("unused"), + refresh: Effect.die("unused"), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { stops: [] }; + }), + generateTranscriptionPostProcessing: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { title: "Generated title" }; + }), + }), + }; + }, + ); const service = yield* TextGeneration.make.pipe( Effect.provide( Layer.mergeAll( @@ -523,6 +539,14 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus ServerSettings.layerTest({ providerInstances: { [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + [unrelated]: { + driver: ProviderDriverKind.make("antigravity"), + config: { enabled: true }, + }, + [disabled]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { enabled: false }, + }, }, }), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -531,22 +555,54 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus ), ), ); - expect( - yield* service.generateThreadTitle({ - cwd: "/tmp", - message: "hello", - linkedContext: "", - modelSelection: selection, - }), - ).toEqual({ title: "Generated title" }); - expect(calls).toEqual([ - exhausted - ? { - instanceId: fallback, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - } - : selection, - ]); + const actualReads: ProviderInstanceId[][] = []; + for (const operation of ["review", "title"] as const) { + calls.length = 0; + reads.length = 0; + if (operation === "title") { + expect( + yield* service.generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: selection, + }), + ).toEqual({ title: "Generated title" }); + } else { + expect( + yield* service.generateReviewGuide({ + cwd: "/tmp", + number: 1, + title: "Change", + description: "", + baseBranch: "main", + baseCommit: "base", + settledReview: "", + modelSelection: selection, + }), + ).toEqual({ stops: [] }); + } + expect(calls).toEqual([ + exhausted + ? { + instanceId: operation === "title" ? unrelated : fallback, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ + ProviderDriverKind.make(operation === "title" ? "antigravity" : "claudeAgent") + ], + } + : selection, + ]); + actualReads.push([...reads].sort()); + } + expect(actualReads).toEqual( + (exhausted + ? [ + [primary, fallback, unrelated], + [primary, fallback], + ] + : [[primary], [primary]] + ).map((ids) => ids.sort()), + ); }), ); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 31dcd8af496fe..3b178ca38fc96 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import { isProviderAvailable, isProviderTextGenerationCapable, @@ -97,14 +97,6 @@ export const make = Effect.gen(function* () { selection.model, ); if (limited) { - yield* Effect.forEach( - instances.filter( - (instance) => instance.enabled && instance.instanceId !== selection.instanceId, - ), - (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), - { concurrency: "unbounded", discard: true }, - ); - snapshots = yield* readSnapshots; const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( (cause) => @@ -115,20 +107,35 @@ export const make = Effect.gen(function* () { }), ), ); + const canServe = (provider: ServerProvider) => + provider.instanceId !== selection.instanceId && + provider.enabled && + resolveProjectProviderInstanceEnabled(settings, null, provider.instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(provider.driver)); + yield* Effect.forEach( + instances.filter( + (instance) => + instance.enabled && + snapshots.some( + (provider) => provider.instanceId === instance.instanceId && canServe(provider), + ), + ), + (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + snapshots = yield* readSnapshots; selection = fallbackTextGenerationModelSelection( settings.providerInstances, (instanceId, driver) => { const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); return ( - instanceId !== selection.instanceId && - provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, null, instanceId) && - provider.installed && - provider.auth.status !== "unauthenticated" && - isProviderAvailable(provider) && - isProviderTextGenerationCapable(provider) && - (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + provider !== undefined && + canServe(provider) && !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) ); }, From c1fcd0410df305c3072ce3c1f360141f31a0bf90 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 21:59:56 -0500 Subject: [PATCH 59/95] test(server): align cold preflight expectation order --- apps/server/src/textGeneration/CodexTextGeneration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index d056d3875f28a..f3be693802247 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -598,8 +598,8 @@ it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhaus expect(actualReads).toEqual( (exhausted ? [ - [primary, fallback, unrelated], [primary, fallback], + [primary, fallback, unrelated], ] : [[primary], [primary]] ).map((ids) => ids.sort()), From 77be7e14199a8f0e98177d491f0c6d48ff803e6b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 22:21:29 -0500 Subject: [PATCH 60/95] fix(usage): validate saved picks and preserve quota cache deadlines --- .../threads/new-task-flow-provider.test.tsx | 57 ++++++++++++++++++- .../threads/new-task-flow-provider.tsx | 14 ++--- .../src/provider/AntigravityProvider.test.ts | 8 ++- .../src/provider/AntigravityProvider.ts | 37 ++++++------ .../provider/antigravityUsageLimits.test.ts | 4 ++ .../src/provider/antigravityUsageLimits.ts | 10 +++- .../src/server/managedProvider.ts | 4 +- .../src/server/usageHttpClient.ts | 21 +++++-- .../provider-core/src/server/usageLimits.ts | 1 + 9 files changed, 117 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index eb007b1a26338..9f28bb36d38e9 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -4,6 +4,10 @@ import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { createEnvironmentSubscriptionAtomFamily } from "@t3tools/client-runtime/state/runtime"; import { AuthFilesystemReadScope, + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, EnvironmentId, ProjectId, type VcsListRefsResult, @@ -20,6 +24,7 @@ import type { ComposerDraft } from "../../state/use-composer-drafts"; const mocks = vi.hoisted(() => ({ projects: vi.fn(), + config: vi.fn(), status: vi.fn(), refs: vi.fn(), readFile: vi.fn(), @@ -43,7 +48,7 @@ vi.mock("../../state/atom-registry", () => ({ vi.mock("../../state/entities", () => ({ useProjects: mocks.projects, useThreadShells: () => [], - useEnvironmentServerConfig: () => null, + useEnvironmentServerConfig: mocks.config, useServerConfigs: () => new Map(), })); vi.mock("../../state/vcs", () => ({ @@ -140,6 +145,7 @@ function setup() { vi.clearAllMocks(); const registry = AtomRegistry.make(); mocks.registry.mockReturnValue(registry); + mocks.config.mockReturnValue(null); const projects = [project("one", "a"), project("one", "b"), project("two", "b")]; mocks.projects.mockReturnValue(projects); mocks.presentation.mockReturnValue({ @@ -373,3 +379,52 @@ it("uses cached repository capability and branch defaults while offline", async expect(screen.current.draftKey).toBe(draftKey); expect(screen.current.selectedBranchName).toBe("main"); }); + +it.each(["removed", "disabled", "valid"] as const)( + "queues the displayed model when the saved pick is %s", + async (state) => { + using screen = setup(); + const primary = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + const saved = { instanceId: ProviderInstanceId.make("claude_work"), model: "sonnet" }; + const providers: ServerProvider[] = [primary, ...(state === "removed" ? [] : [saved])].map( + (selection) => ({ + instanceId: selection.instanceId, + driver: ProviderDriverKind.make(selection === primary ? "codex" : "claudeAgent"), + enabled: selection === primary || state !== "disabled", + installed: true, + version: "1", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + }), + ); + mocks.config.mockReturnValue({ + providers, + settings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: primary, + providerInstances: { + [primary.instanceId]: { driver: "codex", config: {} }, + [saved.instanceId]: { driver: "claudeAgent", config: { enabled: state !== "disabled" } }, + }, + }, + }); + await screen.render(true); + act(() => { + screen.current.setPrompt("hello"); + mocks.writeSettings(screen.current.draftKey!, { modelSelection: saved }); + }); + const shown = state === "valid" ? saved : primary; + expect(screen.current.selectedModel).toEqual(shown); + const message = await screen.current.buildPendingTaskMessage({ + commandId: "command", + messageId: "message", + threadId: "thread", + createdAt: "2026-01-01T00:00:00Z", + }); + expect(message?.modelSelection).toEqual(shown); + }, +); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 50a0348ac52eb..7d9a7d43608b3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -728,10 +728,15 @@ export function NewTaskFlowProvider( } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); + const savedSelection = resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + selectedProject.id, + ); const selectionInput = { defaultSelection: selectedDefaultModel, explicitSelection: - draft.modelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + savedSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, providers: selectedEnvironmentServerConfig?.providers ?? [], ...(selectedEnvironmentServerConfig @@ -750,12 +755,7 @@ export function NewTaskFlowProvider( ...selectionInput, providers, }).selection; - const draftModelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - selectedProject.id, - ) ?? refreshedSelection; + const draftModelSelection = savedSelection ?? refreshedSelection; if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { return null; } diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index b67432b275915..f85701fe94cec 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -853,8 +853,14 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ...limits, refreshAfter: (yield* Clock.currentTimeMillis) + 300_000, }); + yield* TestClock.adjust("290 seconds"); + yield* harness.provider.snapshot.refreshUsage!; + expect(yield* Ref.get(reads)).toBe(1); + yield* TestClock.adjust("10 seconds"); + yield* harness.provider.snapshot.refreshUsage!; + expect(yield* Ref.get(reads)).toBe(2); }), - ).pipe(Effect.provideService(UsagePolling, true), Effect.provideService(UsageAfterReset, true)), + ).pipe(Effect.provideService(UsagePolling, true)), ); it.effect("a quota read for the previous account is dropped after a new sign-in", () => diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ff6a43c64d685..6b03e301a20a1 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -14,6 +14,7 @@ import { type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -248,16 +249,20 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func usageRead, options.probeUsage.pipe( Effect.flatMap((probed) => - SubscriptionRef.update(metadata, (state) => ({ - ...state, - draft: { - ...state.draft, - usageLimits: resolveUsageLimitsAfterProbe({ - published: state.draft.usageLimits, - probed, - }), - }, - })), + Effect.gen(function* () { + const refreshAfter = + probed.refreshAfter ?? (yield* Clock.currentTimeMillis) + 5 * 60_000; + yield* SubscriptionRef.update(metadata, (state) => ({ + ...state, + draft: { + ...state.draft, + usageLimits: resolveUsageLimitsAfterProbe({ + published: state.draft.usageLimits, + probed: { ...probed, refreshAfter }, + }), + }, + })); + }), ), // Enrichment cannot change published limits; a health check publishes them. Effect.andThen( @@ -531,17 +536,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func return { snapshot: { ...managed, - getSnapshot: Effect.gen(function* () { - const snapshot = yield* getSnapshot; - const published = (yield* managed.getSnapshot).usageLimits; - const limits = snapshot.usageLimits; - return limits && - published?.refreshAfter !== undefined && - limits.checkedAt === published.checkedAt && - limits.credentialFingerprint === published.credentialFingerprint - ? { ...snapshot, usageLimits: { ...limits, refreshAfter: published.refreshAfter } } - : snapshot; - }), + getSnapshot, refreshUsage: managed.refreshUsage!.pipe(Effect.andThen(FiberHandle.awaitEmpty(usageRead))), }, onSessionStarted, diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index e9bec24ab4e7a..ef1b9b4beda00 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import { describe, expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -171,6 +172,9 @@ describe("Antigravity subscription limits", () => { yield* TestClock.adjust("4 minutes"); const cached = yield* probe; expect(cached.checkedAt).toBe(first.checkedAt); + expect(first.refreshAfter).toBe(300_000); + expect(cached.refreshAfter).toBe(first.refreshAfter); + expect(cached.refreshAfter).toBeLessThan((yield* Clock.currentTimeMillis) + 300_000); expect(quotaCalls()).toBe(2); yield* TestClock.adjust("51 minutes"); options.summaryStatus = 429; diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index b4d0701de3742..40fdf9fe15084 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,5 +1,6 @@ import { usageResponseCheckedAt, + usageResponseRefreshAfter, UsageAccountIdentity, } from "@t3tools/provider-core/server/usageLimits"; import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -214,13 +215,13 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const request = ( request: HttpClientRequest.HttpClientRequest, - onSample?: (checkedAt: string) => void, + onSample?: (checkedAt: string, refreshAfter: number | undefined) => void, ) => client.execute(request).pipe( Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => { - onSample?.(usageResponseCheckedAt(response, "")); + onSample?.(usageResponseCheckedAt(response, ""), usageResponseRefreshAfter(response)); return response.json; }), Effect.timeout("10 seconds"), @@ -291,6 +292,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const throttleIdentity = throttleIdentities.get(fingerprint) ?? account; throttleIdentities.set(fingerprint, throttleIdentity); const sampleTimes: string[] = []; + const sampleDeadlines: number[] = []; // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, @@ -315,7 +317,8 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( token.project_id ? { project: token.project_id } : {}, ), ), - (sampleAt) => { + (sampleAt, refreshAfter) => { + if (refreshAfter !== undefined) sampleDeadlines.push(refreshAfter); if (sampleAt) requestedAt = Date.parse(sampleAt); sampleTimes.push(sampleAt || DateTime.formatIso(DateTime.makeUnsafe(requestedAt))); }, @@ -356,6 +359,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( return { ...makeUsageLimits({ checkedAt: sampleTimes.toSorted()[0] ?? checkedAt, windows }), credentialFingerprint: account, + ...(sampleDeadlines.length ? { refreshAfter: Math.min(...sampleDeadlines) } : {}), }; }); const probe = Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 3b5cb051e9241..081f9d439e847 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -179,7 +179,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( current.auth.status === "authenticated" ? withUsageLimits(current, yield* input.checkUsageLimits) : yield* input.checkProvider.pipe(Effect.provideService(UsagePolling, pollingUsage)); - if (pollingUsage) usageRefreshAfter = (yield* Clock.currentTimeMillis) + 5 * 60_000; + if (probedSnapshot.usageLimits?.refreshAfter !== undefined) { + usageRefreshAfter = probedSnapshot.usageLimits.refreshAfter; + } else if (pollingUsage) usageRefreshAfter = (yield* Clock.currentTimeMillis) + 5 * 60_000; const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( snapshotStateRef, (state) => { diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 6ad4680f49365..bac70d28fe0c5 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -13,13 +13,20 @@ const encodeKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const accountCacheKey = (origin: string, authorization: string, identity: string | undefined) => encodeKey([origin, identity === undefined ? authorization : ["account", identity]]); -const sampleTimes = new WeakMap(); +const samples = new WeakMap< + HttpClientResponse.HttpClientResponse, + { readonly checkedAt: string; readonly refreshAfter: number } +>(); export function usageResponseCheckedAt( response: HttpClientResponse.HttpClientResponse, fallback: string, ) { - return sampleTimes.get(response) ?? fallback; + return samples.get(response)?.checkedAt ?? fallback; +} + +export function usageResponseRefreshAfter(response: HttpClientResponse.HttpClientResponse) { + return samples.get(response)?.refreshAfter; } export const UsageAccountIdentity = Context.Reference( @@ -110,7 +117,10 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( request, new Response(saved.body.slice(0), saved), ); - sampleTimes.set(response, saved.checkedAt); + samples.set(response, { + checkedAt: saved.checkedAt, + refreshAfter: previous.expiresAt, + }); return response; } if (now < state.retryAt) { @@ -148,6 +158,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( Effect.exit, ); const finishedAt = yield* Clock.currentTimeMillis; + const refreshAfter = Math.max(finishedAt + TTL_MS, state.retryAt); if ( !Exit.hasInterrupts(result) && (generation === state.generation || @@ -159,7 +170,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( { accountKey, result, - expiresAt: Math.max(finishedAt + TTL_MS, state.retryAt), + expiresAt: refreshAfter, }, ); const saved = yield* result; @@ -167,7 +178,7 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( request, new Response(saved.body.slice(0), saved), ); - sampleTimes.set(response, saved.checkedAt); + samples.set(response, { checkedAt: saved.checkedAt, refreshAfter }); return response; }), ); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 61575ca2f51f5..fbb33b63c5866 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -2,6 +2,7 @@ export { makeUsageHttpClient, usageRetryDelay, usageResponseCheckedAt, + usageResponseRefreshAfter, UsageAccountIdentity, } from "./usageHttpClient.ts"; import type { From a9e771292ecdb49c69dd5046dae805c08adcaa8e Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 22:39:02 -0500 Subject: [PATCH 61/95] fix(usage): preserve cache expiry across instances and sign-ins --- .../features/usage/usageLimitsRefresh.test.ts | 34 ++++++++++++- .../src/features/usage/usageLimitsRefresh.ts | 35 ++++++++++--- .../usage/useInstanceLimitsRefresh.ts | 12 ++++- apps/web/src/components/usage/UsagePage.tsx | 25 +++++++-- .../src/hooks/useLiveRefresh.browser.test.tsx | 28 ++++++++++ apps/web/src/hooks/useUsageLimitsRefresh.ts | 14 +++-- packages/client-runtime/src/state/usage.ts | 24 +++++++++ .../provider-cursor/src/server/usageLimits.ts | 7 +++ .../provider-grok/src/server/usageLimits.ts | 9 +++- .../src/server/usageLimits.test.ts | 51 +++++++++++++++++++ .../src/server/usageLimits.ts | 11 +++- 11 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 packages/provider-opencode/src/server/usageLimits.test.ts diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index f8e51d51a3bd3..8735873f3b70e 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -12,7 +12,11 @@ import { expect, it, vi } from "vite-plus/test"; type RefreshResult = AsyncResult.AsyncResult<{ providers: readonly ServerProvider[] }, string>; const state = vi.hoisted(() => ({ - providers: [] as Array<{ instanceId: ProviderInstanceId; driver: ProviderDriverKind }>, + providers: [] as Array<{ + instanceId: ProviderInstanceId; + driver: ProviderDriverKind; + auth?: ServerProvider["auth"]; + }>, refresh: (_environmentId: string): Promise => { throw new Error("refresh not set up"); }, @@ -134,6 +138,7 @@ it.each(["limits", "thread", "thread-and-other-provider"] as const)( instanceLimitsRefreshSelection( ProviderInstanceId.make("cursor-instance"), ProviderDriverKind.make("cursor"), + state.providers as ServerProvider[], ), ); if (entry === "thread-and-other-provider") @@ -160,3 +165,30 @@ it.each(["limits", "thread", "thread-and-other-provider"] as const)( } }, ); + +it.each(["sign-in", "account-switch"] as const)( + "reads fresh quotas after %s", + async (transition) => { + vi.useFakeTimers({ now: 8_000_000 }); + const a = EnvironmentId.make(`mobile-limits-auth-${transition}`); + const refresh = vi.fn(() => Promise.resolve(success)); + const { registry } = setup(refresh); + try { + state.providers[0]!.auth = + transition === "sign-in" + ? { status: "unauthenticated" } + : { status: "authenticated", email: "old@example.com" }; + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60_000); + state.providers[0]!.auth = { status: "authenticated", email: "new@example.com" }; + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(refresh).toHaveBeenCalledTimes(2); + await refreshEnvironmentLimits(registry, a, { automatic: true }); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + registry.dispose(); + vi.useRealTimers(); + } + }, +); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index d55629af79f40..6f406441aecfe 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -3,12 +3,14 @@ import { EnvironmentId, type ProviderInstanceId, type ProviderDriverKind, + type ServerProvider, } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { refreshUsageLimits, usageLimitsRefreshDelay, usageRefreshDeadline, + usageRefreshAccountKey, } from "@t3tools/client-runtime/state/usage"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -25,12 +27,20 @@ const limitsRefreshFailedAtom = Atom.family((environmentId: EnvironmentId) => export function instanceLimitsRefreshSelection( instanceId: ProviderInstanceId, driver?: ProviderDriverKind, + providers: readonly Pick[] = [], ) { return { - key: JSON.stringify([instanceId, driver]), + key: JSON.stringify([instanceId, driver, usageRefreshAccountKey(providers, driver)]), resources: [ - ...(driver ? [JSON.stringify(["provider", driver])] : []), - JSON.stringify(["instance", instanceId, driver]), + ...(driver + ? [JSON.stringify(["provider", driver, usageRefreshAccountKey(providers, driver)])] + : []), + JSON.stringify([ + "instance", + instanceId, + driver, + usageRefreshAccountKey(providers, driver, instanceId), + ]), ], }; } @@ -41,7 +51,9 @@ function environmentLimitsRefreshKeys( ) { const config = registry.get(serverEnvironment.configValueAtom(environmentId)); const keys = (config?.providers ?? []).flatMap( - (provider) => instanceLimitsRefreshSelection(provider.instanceId, provider.driver).resources, + (provider) => + instanceLimitsRefreshSelection(provider.instanceId, provider.driver, config?.providers ?? []) + .resources, ); for (const source of config?.usageLimitSources ?? []) { keys.push( @@ -63,6 +75,7 @@ export async function refreshEnvironmentLimits( } = {}, ): Promise { if (!isAppActive()) return; + const providers = registry.get(serverEnvironment.configValueAtom(environmentId))?.providers ?? []; const result = await refreshUsageLimits( environmentId, () => @@ -81,8 +94,11 @@ export async function refreshEnvironmentLimits( options.afterPending, { ...(options.instanceId - ? instanceLimitsRefreshSelection(options.instanceId, options.driver) - : { key: "", resources: environmentLimitsRefreshKeys(registry, environmentId) }), + ? instanceLimitsRefreshSelection(options.instanceId, options.driver, providers) + : { + key: usageRefreshAccountKey(providers), + resources: environmentLimitsRefreshKeys(registry, environmentId), + }), refreshDeadline: (result) => AsyncResult.isSuccess(result) ? usageRefreshDeadline( @@ -117,10 +133,17 @@ export async function refreshEnvironmentLimits( registry.set(limitsRefreshFailedAtom(environmentId), result._tag === "Failure"); } +const limitsAccountKeyAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + usageRefreshAccountKey(get(serverEnvironment.configValueAtom(environmentId))?.providers ?? []), + ), +); + // Mounting starts an automatic refresh, which refreshUsageLimits throttles to // one every few minutes per environment. const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { + get(limitsAccountKeyAtom(environmentId)); let disposed = false; let pending = false; let timer: ReturnType | undefined; diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index 9dd0f583e7d98..f24b9b7d72639 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { serverEnvironment } from "../../state/server"; import { useIsFocused } from "@react-navigation/native"; import { useEffect } from "react"; import type { EnvironmentId, ProviderInstanceId, ProviderDriverKind } from "@t3tools/contracts"; @@ -14,10 +16,16 @@ export function useInstanceLimitsRefresh( visible: boolean, ) { const focused = useIsFocused(); + const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const selectionKeyJson = JSON.stringify( + instanceLimitsRefreshSelection(instanceId, driver, config?.providers ?? []), + ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle useEffect(() => { if (!visible || !focused) return; - const selectionKey = instanceLimitsRefreshSelection(instanceId, driver); + const selectionKey = JSON.parse(selectionKeyJson) as ReturnType< + typeof instanceLimitsRefreshSelection + >; let disposed = false; let pending = false; let timer: ReturnType | undefined; @@ -48,5 +56,5 @@ export function useInstanceLimitsRefresh( unsubscribe(); clearTimeout(timer); }; - }, [registry, environmentId, instanceId, driver, visible, focused]); + }, [registry, environmentId, instanceId, driver, visible, focused, selectionKeyJson]); } diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index aac7c6bac1599..b8340025efe14 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -20,6 +20,7 @@ import { cursorKeychainAccessEnvironments, refreshUsageLimits, usageRefreshDeadline, + usageRefreshAccountKey, } from "@t3tools/client-runtime/state/usage"; import { updatingProvidersLabel, @@ -275,7 +276,10 @@ export function UsagePage() { }), false, afterPending, - JSON.stringify([...hiddenProviders].sort()), + JSON.stringify([ + [...hiddenProviders].sort(), + usageRefreshAccountKey(presentation.serverConfig?.providers ?? []), + ]), ).then((response) => response !== undefined && AsyncResult.isSuccess(response) ? usageRefreshDeadline( @@ -386,11 +390,26 @@ export function UsagePage() { presentation.serverConfig !== null && (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)) ? [ - ...visibleDrivers.map((driver) => JSON.stringify([environmentId, driver])), + ...visibleDrivers.map((driver) => + JSON.stringify([ + environmentId, + driver, + usageRefreshAccountKey(presentation.serverConfig?.providers ?? [], driver), + ]), + ), ...presentation.serverConfig.providers .filter((provider) => visibleDrivers.includes(provider.driver)) .map((provider) => - JSON.stringify([environmentId, provider.driver, provider.instanceId]), + JSON.stringify([ + environmentId, + provider.driver, + provider.instanceId, + usageRefreshAccountKey( + presentation.serverConfig?.providers ?? [], + provider.driver, + provider.instanceId, + ), + ]), ), ] : [], diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 0de960e1be83c..3e253549a20ac 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -1,3 +1,5 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { usageRefreshAccountKey } from "@t3tools/client-runtime/state/usage"; import { describe, expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../test/render"; @@ -26,6 +28,32 @@ function Probe(props: { } describe("useLiveRefresh", () => { + it("reads a newly signed-in account without reusing its signed-out deadline", async () => { + const refresh = vi.fn(); + using _timers = fakeTimers({ now: 9_000_000 }); + const instanceId = ProviderInstanceId.make("web-auth-change"); + const driver = ProviderDriverKind.make("codex"); + const probe = (auth: ServerProvider["auth"]) => ( + + ); + // Bare usage hook; nothing here navigates. + await using screen = await render(probe({ status: "unauthenticated" }), { router: false }); + expect(refresh).toHaveBeenCalledTimes(1); + await screen.rerender(<>); + await act(() => vi.advanceTimersByTime(60_000)); + await screen.rerender(probe({ status: "authenticated", email: "new@example.com" })); + expect(refresh).toHaveBeenCalledTimes(2); + await screen.rerender(<>); + await screen.rerender(probe({ status: "authenticated", email: "new@example.com" })); + expect(refresh).toHaveBeenCalledTimes(2); + }); + it.each(["continuous", "reenabled", "remounted"])( "schedules usage intervals after a slow refresh completes (%s)", async (lifecycle) => { diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index 78e75cd39241b..e9b23e303f5e9 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -1,5 +1,5 @@ import { AsyncResult } from "effect/reactivity"; -import { usageRefreshDeadline } from "@t3tools/client-runtime/state/usage"; +import { usageRefreshDeadline, usageRefreshAccountKey } from "@t3tools/client-runtime/state/usage"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useRouterState } from "@tanstack/react-router"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; @@ -21,6 +21,7 @@ export function useUsageLimitsRefresh( const chatVisible = useRouterState({ select: (state) => !isAppModalPage(state.location.pathname), }); + const providers = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.providers ?? []; const refresh = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false }); useLiveRefresh( () => { @@ -57,8 +58,15 @@ export function useUsageLimitsRefresh( enabled: enabled && connected && chatVisible && instanceId !== null, key: `usage:${environmentId}:${instanceId}:${driver ?? ""}`, refreshKeys: [ - ...(driver ? [JSON.stringify([environmentId, driver])] : []), - JSON.stringify([environmentId, driver, instanceId]), + ...(driver + ? [JSON.stringify([environmentId, driver, usageRefreshAccountKey(providers, driver)])] + : []), + JSON.stringify([ + environmentId, + driver, + instanceId, + usageRefreshAccountKey(providers, driver, instanceId), + ]), ], refreshDeadline: (result) => (typeof result === "number" ? result : undefined), refreshOnShow: true, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 6a3d98e22ab89..72dba8d6b8852 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ProviderInstanceId, + ProviderDriverKind, ServerProvider, ServerProviderUsageLimits, UsageModelFilter, @@ -123,6 +124,29 @@ export function usageRefreshDeadline( return deadlines.length ? now + Math.min(...deadlines) - (serverTime ?? now) : undefined; } +export function usageRefreshAccountKey( + providers: readonly Pick[], + driver?: ProviderDriverKind | null, + instanceId?: ProviderInstanceId | null, +) { + return JSON.stringify( + providers + .filter( + (provider) => + (!driver || provider.driver === driver) && + (!instanceId || provider.instanceId === instanceId), + ) + .sort((a, b) => a.instanceId.localeCompare(b.instanceId)) + .map((provider) => [ + provider.instanceId, + provider.auth?.status, + provider.auth?.type, + provider.auth?.email?.toLowerCase(), + provider.auth?.workspaceId, + ]), + ); +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 626bce287bc2c..dfa6a196df9fa 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,5 +1,6 @@ import { usageResponseCheckedAt, + usageResponseRefreshAfter, UsageAccountIdentity, } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -71,6 +72,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function allowKeychain = false, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); + let refreshAfter: number | undefined; return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -153,6 +155,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function ), ) .pipe(Effect.provideService(UsageAccountIdentity, usageIdentity)); + refreshAfter = usageResponseRefreshAfter(response); const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); @@ -166,5 +169,9 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Cursor could not read usage limits.", }), ), + Effect.map((limits) => ({ + ...limits, + ...(refreshAfter !== undefined ? { refreshAfter } : {}), + })), ); }); diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index e6897dc9d1b3e..2c8bd24120640 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,6 +1,7 @@ import { UsageAccountIdentity, usageResponseCheckedAt, + usageResponseRefreshAfter, } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -197,6 +198,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( new TextEncoder().encode(email ? `grok\0account\0${email}` : `grok\0token\0${token}`), ) .pipe(Effect.map(Hex.encode), Effect.orDie); + let refreshAfter: number | undefined; const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( @@ -204,6 +206,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( HttpClientRequest.bearerToken(token), ), ); + refreshAfter = usageResponseRefreshAfter(response); if (response.status === 403) { const { error } = yield* HttpClientResponse.schemaBodyJson(GrokErrorResponse)(response); // xAI refuses Grok Build while the account's privacy setting is a legacy mode. @@ -226,7 +229,11 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( ); return { email, - usageLimits: { ...usageLimits, credentialFingerprint }, + usageLimits: { + ...usageLimits, + credentialFingerprint, + ...(refreshAfter !== undefined ? { refreshAfter } : {}), + }, supportsUsageLimits: true, }; }); diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts new file mode 100644 index 0000000000000..f0de8ae540ad6 --- /dev/null +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -0,0 +1,51 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; + +it.effect.each([200, 503])( + "preserves the original Go cache deadline for another instance (HTTP %s)", + (status) => + Effect.gen(function* () { + let reads = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.sync(() => { + reads++; + const window = { percent: 10, resetsAt: "2026-11-01T00:00:00Z" }; + return HttpClientResponse.fromWeb( + request, + Response.json( + { usage: { rolling: window, weekly: window, monthly: window } }, + { status }, + ), + ); + }), + ), + ); + const probe = readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + XDG_DATA_HOME: "/unused", + OPENCODE_AUTH_CONTENT: JSON.stringify({ + "opencode-go": { type: "api", key: "shared-key" }, + }), + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const first = yield* probe; + yield* TestClock.adjust("4 minutes"); + const cached = yield* probe; + assert.strictEqual(reads, 1); + if (status === 200) assert.strictEqual(cached.checkedAt, first.checkedAt); + assert.strictEqual(cached.refreshAfter, 300_000); + yield* TestClock.adjust("1 minute"); + const fresh = yield* probe; + assert.strictEqual(reads, 2); + assert.strictEqual(fresh.refreshAfter, 600_000); + }).pipe(Effect.provide([NodeServices.layer, NodeCrypto.layer])), +); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index cb500f4df47cc..9b3658d182190 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,4 +1,7 @@ -import { usageResponseCheckedAt } from "@t3tools/provider-core/server/usageLimits"; +import { + usageResponseCheckedAt, + usageResponseRefreshAfter, +} from "@t3tools/provider-core/server/usageLimits"; import * as NodeSqlite from "node:sqlite"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -95,6 +98,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( if (!input.enabled || input.serverUrl.trim()) return unsupported; let credentialFingerprint: string | undefined; + let refreshAfter: number | undefined; return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -155,6 +159,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( apiKey = key; response = yield* readUsage(apiKey); } + refreshAfter = usageResponseRefreshAfter(response); // A valid Zen key can exist without a Go subscription. if (response.status === 403) return unsupported; const body = yield* HttpClientResponse.filterStatusOk(response).pipe( @@ -199,5 +204,9 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( }), ...(credentialFingerprint ? { credentialFingerprint } : {}), })), + Effect.map((limits) => ({ + ...limits, + ...(refreshAfter !== undefined ? { refreshAfter } : {}), + })), ); }); From 7184407b3cf7a284f092e80c5c60ec1f2f2da698 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 22:49:49 -0500 Subject: [PATCH 62/95] fix(usage): isolate Claude credentials and skip hidden hub reads --- .../src/provider/claudeResetCredits.test.ts | 56 +++++++++++++++++++ .../server/src/provider/claudeResetCredits.ts | 2 +- .../src/usage/UsageLimitSources.test.ts | 6 ++ apps/server/src/usage/UsageLimitSources.ts | 6 ++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 667daef614d9b..5acf1c530a784 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -93,6 +93,58 @@ describe("claudeResetCreditsToContract", () => { }); effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { + it.effect.each(["organization-only", "account", "email"] as const)( + "isolates credentials unless a user identity is known (%s)", + (identity) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* writeLogin; + const second = yield* writeLogin; + const oauthAccount = { + organizationUuid: "org-1", + ...(identity === "account" ? { accountUuid: "same-account" } : {}), + ...(identity === "email" ? { emailAddress: "same@example.test" } : {}), + }; + for (const login of [first, second]) + yield* fs.writeFileString(login.accountConfigPath, JSON.stringify({ oauthAccount })); + yield* fs.writeFileString( + path.join(second.configDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "other-token" } }), + ); + let reads = 0; + const client = yield* makeUsageHttpClient( + HttpClient.make((request) => + Effect.sync(() => { + reads++; + const id = + request.headers.authorization === "Bearer oauth-token" ? "grant_a" : "grant_b"; + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: id, grants: [grant({ id })] }, + }), + ); + }), + ), + ); + const read = (login: typeof first) => + ClaudeResetCredits.readClaudeResetCredits( + login.configDir, + "2.1.0", + login.accountConfigPath, + ).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect((yield* read(first))?.nextCreditId).toBe("grant_a"); + expect((yield* read(second))?.nextCreditId).toBe( + identity === "organization-only" ? "grant_b" : "grant_a", + ); + expect(reads).toBe(identity === "organization-only" ? 2 : 1); + }), + ); + it.effect("reads the grants with the CLI's request", () => Effect.gen(function* () { const { configDir, accountConfigPath } = yield* writeLogin; @@ -189,6 +241,10 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { Effect.gen(function* () { const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const login = yield* writeLogin; + yield* (yield* FileSystem.FileSystem).writeFileString( + login.accountConfigPath, + '{"oauthAccount":{"organizationUuid":"org-1","accountUuid":"account-a"}}', + ); const started = yield* Deferred.make(); const requestIds: string[] = []; const client = HttpClient.make((request) => diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 38ad13de85221..75e5b77872388 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -201,7 +201,7 @@ export const readClaudeUsageAccountIdentity = Effect.fn( )((accountConfigPath: string) => readJson(Config, accountConfigPath).pipe( Effect.map((config) => - config.oauthAccount && Object.values(config.oauthAccount).some((value) => value?.trim()) + config.oauthAccount?.accountUuid?.trim() || config.oauthAccount?.emailAddress?.trim() ? `claude:${encodeAccountCacheKey(config)}` : undefined, ), diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index fe2f5d61f5e27..71bee873ff396 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -177,6 +177,7 @@ it.effect("reads a management key from its secret provider", () => { it.effect.each(["listing", "quota", "changed-account"] as const)( "retains provider-filtered readings through %s failures with account isolation", (failure) => { + let requests = 0; let fail = false; let includeCodex = false; return Effect.gen(function* () { @@ -195,6 +196,10 @@ it.effect.each(["listing", "quota", "changed-account"] as const)( ["claudeAgent", "codex"], ); const prior = (yield* sources.current)[0]!; + const beforeHiddenRefresh = requests; + yield* sources.refresh.pipe(Effect.provideService(UsageProviderFilter, ["cursor"])); + expect(requests).toBe(beforeHiddenRefresh); + expect((yield* sources.current)[0]).toEqual(prior); yield* TestClock.adjust("5 minutes"); fail = true; yield* sources.refresh; @@ -251,6 +256,7 @@ it.effect.each(["listing", "quota", "changed-account"] as const)( HttpClient.HttpClient, HttpClient.make((request) => Effect.sync(() => { + requests++; if (fail && failure === "listing") return HttpClientResponse.fromWeb(request, Response.json({}, { status: 401 })); if (request.url.endsWith("/auth-files")) diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 59d5cbfa6352f..48dfb7f1f2e78 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -151,6 +151,12 @@ export const make = Effect.gen(function* () { // Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes. const refreshLock = yield* Semaphore.make(1); const refresh = Effect.gen(function* () { + const providers = yield* UsageProviderFilter; + if ( + providers !== undefined && + !providers.some((driver) => driver === "codex" || driver === "claudeAgent") + ) + return; const settings = yield* settingsService.getSettings.pipe( Effect.orElseSucceed((): ServerSettings | null => null), ); From 4c2f73a95e2ae9a25f22a4d17de895a9897500df Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 23:22:05 -0500 Subject: [PATCH 63/95] fix(usage): retain failure expiry and refresh after reconnects --- .../features/usage/usageLimitsRefresh.test.ts | 15 +++++- .../src/features/usage/usageLimitsRefresh.ts | 53 +++++++++++++++---- .../usage/useInstanceLimitsRefresh.ts | 9 +++- .../src/provider/antigravityUsageLimits.ts | 17 +++--- apps/web/src/components/usage/UsagePage.tsx | 15 +++++- .../src/hooks/useLiveRefresh.browser.test.tsx | 19 +++++-- apps/web/src/hooks/useUsageLimitsRefresh.ts | 11 +++- .../src/connection/presentation.ts | 3 +- .../client-runtime/src/state/presentation.ts | 7 ++- packages/client-runtime/src/state/usage.ts | 6 ++- .../src/server/usageHttpClient.ts | 24 ++++++--- .../provider-core/src/server/usageLimits.ts | 2 +- .../provider-cursor/src/server/usageLimits.ts | 9 +++- .../provider-grok/src/server/usageLimits.ts | 9 +++- .../src/server/usageLimits.test.ts | 10 ++-- .../src/server/usageLimits.ts | 9 +++- 16 files changed, 171 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 8735873f3b70e..b333d4f436df5 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -12,6 +12,7 @@ import { expect, it, vi } from "vite-plus/test"; type RefreshResult = AsyncResult.AsyncResult<{ providers: readonly ServerProvider[] }, string>; const state = vi.hoisted(() => ({ + generation: 0, providers: [] as Array<{ instanceId: ProviderInstanceId; driver: ProviderDriverKind; @@ -21,6 +22,14 @@ const state = vi.hoisted(() => ({ throw new Error("refresh not set up"); }, })); +vi.mock("../../state/presentation", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentPresentations: { + presentationAtom: () => Atom.make({ connection: { generation: state.generation } }), + }, + }; +}); vi.mock("../../lib/appState", () => ({ isAppActive: () => true, subscribeAppState: () => () => {}, @@ -52,6 +61,7 @@ const failure: RefreshResult = AsyncResult.failure(Cause.fail("offline")); function setup(refresh: (environmentId: string) => Promise) { state.refresh = refresh; + state.generation = 0; state.providers = [ { instanceId: ProviderInstanceId.make("cursor-instance"), @@ -166,7 +176,7 @@ it.each(["limits", "thread", "thread-and-other-provider"] as const)( }, ); -it.each(["sign-in", "account-switch"] as const)( +it.each(["sign-in", "account-switch", "restart"] as const)( "reads fresh quotas after %s", async (transition) => { vi.useFakeTimers({ now: 8_000_000 }); @@ -181,7 +191,8 @@ it.each(["sign-in", "account-switch"] as const)( await refreshEnvironmentLimits(registry, a, { automatic: true }); expect(refresh).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(60_000); - state.providers[0]!.auth = { status: "authenticated", email: "new@example.com" }; + if (transition === "restart") state.generation++; + else state.providers[0]!.auth = { status: "authenticated", email: "new@example.com" }; await refreshEnvironmentLimits(registry, a, { automatic: true }); expect(refresh).toHaveBeenCalledTimes(2); await refreshEnvironmentLimits(registry, a, { automatic: true }); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 6f406441aecfe..36208f466a723 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/client-runtime/state/usage"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; +import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; /** @@ -28,18 +29,29 @@ export function instanceLimitsRefreshSelection( instanceId: ProviderInstanceId, driver?: ProviderDriverKind, providers: readonly Pick[] = [], + generation = 0, ) { return { - key: JSON.stringify([instanceId, driver, usageRefreshAccountKey(providers, driver)]), + key: JSON.stringify([ + instanceId, + driver, + usageRefreshAccountKey(providers, driver, null, generation), + ]), resources: [ ...(driver - ? [JSON.stringify(["provider", driver, usageRefreshAccountKey(providers, driver)])] + ? [ + JSON.stringify([ + "provider", + driver, + usageRefreshAccountKey(providers, driver, null, generation), + ]), + ] : []), JSON.stringify([ "instance", instanceId, driver, - usageRefreshAccountKey(providers, driver, instanceId), + usageRefreshAccountKey(providers, driver, instanceId, generation), ]), ], }; @@ -50,15 +62,28 @@ function environmentLimitsRefreshKeys( environmentId: EnvironmentId, ) { const config = registry.get(serverEnvironment.configValueAtom(environmentId)); + const generation = + registry.get(environmentPresentations.presentationAtom(environmentId))?.connection.generation ?? + 0; const keys = (config?.providers ?? []).flatMap( (provider) => - instanceLimitsRefreshSelection(provider.instanceId, provider.driver, config?.providers ?? []) - .resources, + instanceLimitsRefreshSelection( + provider.instanceId, + provider.driver, + config?.providers ?? [], + generation, + ).resources, ); for (const source of config?.usageLimitSources ?? []) { keys.push( - JSON.stringify(["source", source.id]), - ...source.accounts.map((account) => JSON.stringify(["provider", account.driver])), + JSON.stringify(["source", source.id, generation]), + ...source.accounts.map((account) => + JSON.stringify([ + "provider", + account.driver, + usageRefreshAccountKey(config?.providers ?? [], account.driver, null, generation), + ]), + ), ); } return keys.length ? [...new Set(keys)] : [""]; @@ -76,6 +101,9 @@ export async function refreshEnvironmentLimits( ): Promise { if (!isAppActive()) return; const providers = registry.get(serverEnvironment.configValueAtom(environmentId))?.providers ?? []; + const generation = + registry.get(environmentPresentations.presentationAtom(environmentId))?.connection.generation ?? + 0; const result = await refreshUsageLimits( environmentId, () => @@ -94,9 +122,9 @@ export async function refreshEnvironmentLimits( options.afterPending, { ...(options.instanceId - ? instanceLimitsRefreshSelection(options.instanceId, options.driver, providers) + ? instanceLimitsRefreshSelection(options.instanceId, options.driver, providers, generation) : { - key: usageRefreshAccountKey(providers), + key: usageRefreshAccountKey(providers, null, null, generation), resources: environmentLimitsRefreshKeys(registry, environmentId), }), refreshDeadline: (result) => @@ -135,7 +163,12 @@ export async function refreshEnvironmentLimits( const limitsAccountKeyAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => - usageRefreshAccountKey(get(serverEnvironment.configValueAtom(environmentId))?.providers ?? []), + usageRefreshAccountKey( + get(serverEnvironment.configValueAtom(environmentId))?.providers ?? [], + null, + null, + get(environmentPresentations.presentationAtom(environmentId))?.connection.generation, + ), ), ); diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index f24b9b7d72639..11e8b3590e165 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useIsFocused } from "@react-navigation/native"; import { useEffect } from "react"; @@ -17,8 +18,14 @@ export function useInstanceLimitsRefresh( ) { const focused = useIsFocused(); const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); const selectionKeyJson = JSON.stringify( - instanceLimitsRefreshSelection(instanceId, driver, config?.providers ?? []), + instanceLimitsRefreshSelection( + instanceId, + driver, + config?.providers ?? [], + presentation?.connection.generation, + ), ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- quota demand follows the native panel and app lifecycle useEffect(() => { diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 40fdf9fe15084..8adeb5004c0d6 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -1,6 +1,6 @@ import { usageResponseCheckedAt, - usageResponseRefreshAfter, + usageReadRefreshAfter, UsageAccountIdentity, } from "@t3tools/provider-core/server/usageLimits"; import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -219,11 +219,14 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ) => client.execute(request).pipe( Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), + Effect.tap((response) => + Effect.sync(() => + onSample?.(usageResponseCheckedAt(response, ""), usageReadRefreshAfter(response)), + ), + ), + Effect.tapError((error) => Effect.sync(() => onSample?.("", usageReadRefreshAfter(error)))), Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => { - onSample?.(usageResponseCheckedAt(response, ""), usageResponseRefreshAfter(response)); - return response.json; - }), + Effect.flatMap((response) => response.json), Effect.timeout("10 seconds"), ); const read = Effect.fn("AntigravityUsageProbe.read")(function* ( @@ -345,6 +348,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( : yield* fetchWindows("fetchAvailableModels", (response) => decodeModels(response).pipe(Effect.map(modelWindows)), ); + const refreshAfter = sampleDeadlines.length ? Math.min(...sampleDeadlines) : undefined; if (windows.length === 0) { return { ...makeUnavailableUsageLimits({ @@ -353,13 +357,14 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( message: "Antigravity did not return usable quota information.", }), credentialFingerprint: quotaOwner, + ...(refreshAfter !== undefined ? { refreshAfter } : {}), }; } if (generation === startedGeneration) quotaOwners.set(fingerprint, account); return { ...makeUsageLimits({ checkedAt: sampleTimes.toSorted()[0] ?? checkedAt, windows }), credentialFingerprint: account, - ...(sampleDeadlines.length ? { refreshAfter: Math.min(...sampleDeadlines) } : {}), + ...(refreshAfter !== undefined ? { refreshAfter } : {}), }; }); const probe = Effect.gen(function* () { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b8340025efe14..c3787fa3eabce 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -278,7 +278,12 @@ export function UsagePage() { afterPending, JSON.stringify([ [...hiddenProviders].sort(), - usageRefreshAccountKey(presentation.serverConfig?.providers ?? []), + usageRefreshAccountKey( + presentation.serverConfig?.providers ?? [], + null, + null, + presentation.connection.generation, + ), ]), ).then((response) => response !== undefined && AsyncResult.isSuccess(response) @@ -394,7 +399,12 @@ export function UsagePage() { JSON.stringify([ environmentId, driver, - usageRefreshAccountKey(presentation.serverConfig?.providers ?? [], driver), + usageRefreshAccountKey( + presentation.serverConfig?.providers ?? [], + driver, + null, + presentation.connection.generation, + ), ]), ), ...presentation.serverConfig.providers @@ -408,6 +418,7 @@ export function UsagePage() { presentation.serverConfig?.providers ?? [], provider.driver, provider.instanceId, + presentation.connection.generation, ), ]), ), diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 3e253549a20ac..fe84766638994 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -28,25 +28,36 @@ function Probe(props: { } describe("useLiveRefresh", () => { - it("reads a newly signed-in account without reusing its signed-out deadline", async () => { + it.each(["sign-in", "reconnect"] as const)("reads fresh quotas after %s", async (transition) => { const refresh = vi.fn(); using _timers = fakeTimers({ now: 9_000_000 }); - const instanceId = ProviderInstanceId.make("web-auth-change"); + const instanceId = ProviderInstanceId.make(`web-auth-change-${transition}`); const driver = ProviderDriverKind.make("codex"); + let generation = 0; const probe = (auth: ServerProvider["auth"]) => ( ); // Bare usage hook; nothing here navigates. - await using screen = await render(probe({ status: "unauthenticated" }), { router: false }); + await using screen = await render( + probe( + transition === "sign-in" + ? { status: "unauthenticated" } + : { status: "authenticated", email: "new@example.com" }, + ), + { router: false }, + ); expect(refresh).toHaveBeenCalledTimes(1); await screen.rerender(<>); await act(() => vi.advanceTimersByTime(60_000)); + if (transition === "reconnect") generation++; await screen.rerender(probe({ status: "authenticated", email: "new@example.com" })); expect(refresh).toHaveBeenCalledTimes(2); await screen.rerender(<>); diff --git a/apps/web/src/hooks/useUsageLimitsRefresh.ts b/apps/web/src/hooks/useUsageLimitsRefresh.ts index e9b23e303f5e9..6b2da96c65861 100644 --- a/apps/web/src/hooks/useUsageLimitsRefresh.ts +++ b/apps/web/src/hooks/useUsageLimitsRefresh.ts @@ -18,6 +18,7 @@ export function useUsageLimitsRefresh( ) { const presentation = useAtomValue(environmentPresentations.presentationAtom(environmentId)); const connected = presentation?.connection.phase === "connected"; + const generation = presentation?.connection.generation ?? 0; const chatVisible = useRouterState({ select: (state) => !isAppModalPage(state.location.pathname), }); @@ -59,13 +60,19 @@ export function useUsageLimitsRefresh( key: `usage:${environmentId}:${instanceId}:${driver ?? ""}`, refreshKeys: [ ...(driver - ? [JSON.stringify([environmentId, driver, usageRefreshAccountKey(providers, driver)])] + ? [ + JSON.stringify([ + environmentId, + driver, + usageRefreshAccountKey(providers, driver, null, generation), + ]), + ] : []), JSON.stringify([ environmentId, driver, instanceId, - usageRefreshAccountKey(providers, driver, instanceId), + usageRefreshAccountKey(providers, driver, instanceId, generation), ]), ], refreshDeadline: (result) => (typeof result === "number" ? result : undefined), diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index c579f9587a3df..8fa190838097f 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -18,6 +18,7 @@ export interface EnvironmentConnectionPresentation { readonly phase: EnvironmentConnectionPhase; readonly error: string | null; readonly traceId: string | null; + readonly generation?: number; } /** @@ -48,7 +49,7 @@ export function presentConnectionState( traceId: state.lastFailure?.traceId ?? null, }; case "connected": - return { phase: "connected", error: null, traceId: null }; + return { phase: "connected", error: null, traceId: null, generation: state.generation }; case "backoff": return { phase: "reconnecting", diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index f3ffb03b0bcfe..7dcc32b56e670 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -36,7 +36,12 @@ function sameConnection( left: EnvironmentConnectionPresentation, right: EnvironmentConnectionPresentation, ): boolean { - return left.phase === right.phase && left.error === right.error && left.traceId === right.traceId; + return ( + left.phase === right.phase && + left.error === right.error && + left.traceId === right.traceId && + left.generation === right.generation + ); } function withoutProviders( diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 72dba8d6b8852..8bffd160c6501 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -128,8 +128,10 @@ export function usageRefreshAccountKey( providers: readonly Pick[], driver?: ProviderDriverKind | null, instanceId?: ProviderInstanceId | null, + generation = 0, ) { - return JSON.stringify( + return JSON.stringify([ + generation, providers .filter( (provider) => @@ -144,7 +146,7 @@ export function usageRefreshAccountKey( provider.auth?.email?.toLowerCase(), provider.auth?.workspaceId, ]), - ); + ]); } const limitsRefreshAfter = new Map(); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index bac70d28fe0c5..60ba6173afdeb 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -14,8 +14,8 @@ const accountCacheKey = (origin: string, authorization: string, identity: string encodeKey([origin, identity === undefined ? authorization : ["account", identity]]); const samples = new WeakMap< - HttpClientResponse.HttpClientResponse, - { readonly checkedAt: string; readonly refreshAfter: number } + object, + { readonly checkedAt?: string; readonly refreshAfter: number } >(); export function usageResponseCheckedAt( @@ -25,8 +25,8 @@ export function usageResponseCheckedAt( return samples.get(response)?.checkedAt ?? fallback; } -export function usageResponseRefreshAfter(response: HttpClientResponse.HttpClientResponse) { - return samples.get(response)?.refreshAfter; +export function usageReadRefreshAfter(read: object) { + return samples.get(read)?.refreshAfter; } export const UsageAccountIdentity = Context.Reference( @@ -112,7 +112,13 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( if (saved.expiresAt <= now) cached.delete(cachedKey); const previous = cached.get(tokenKey) ?? cached.get(key); if (previous && now < previous.expiresAt) { - const saved = yield* previous.result; + const saved = yield* previous.result.pipe( + Effect.tapError((error) => + Effect.sync(() => { + samples.set(error, { refreshAfter: previous.expiresAt }); + }), + ), + ); const response = HttpClientResponse.fromWeb( request, new Response(saved.body.slice(0), saved), @@ -173,7 +179,13 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( expiresAt: refreshAfter, }, ); - const saved = yield* result; + const saved = yield* result.pipe( + Effect.tapError((error) => + Effect.sync(() => { + samples.set(error, { refreshAfter }); + }), + ), + ); const response = HttpClientResponse.fromWeb( request, new Response(saved.body.slice(0), saved), diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index fbb33b63c5866..de8d94af47e05 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -2,7 +2,7 @@ export { makeUsageHttpClient, usageRetryDelay, usageResponseCheckedAt, - usageResponseRefreshAfter, + usageReadRefreshAfter, UsageAccountIdentity, } from "./usageHttpClient.ts"; import type { diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index dfa6a196df9fa..827612a1e422f 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,6 +1,6 @@ import { usageResponseCheckedAt, - usageResponseRefreshAfter, + usageReadRefreshAfter, UsageAccountIdentity, } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -155,13 +155,18 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function ), ) .pipe(Effect.provideService(UsageAccountIdentity, usageIdentity)); - refreshAfter = usageResponseRefreshAfter(response); + refreshAfter = usageReadRefreshAfter(response); const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( yield* HttpClientResponse.filterStatusOk(response), ); return cursorUsageResponseToLimits(body, usageResponseCheckedAt(response, checkedAt)); }).pipe( Effect.timeout("10 seconds"), + Effect.tapError((error) => + Effect.sync(() => { + refreshAfter ??= usageReadRefreshAfter(error); + }), + ), Effect.orElseSucceed(() => makeUnavailableUsageLimits({ checkedAt, diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 2c8bd24120640..f8cbb4f9e1c61 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,7 +1,7 @@ import { UsageAccountIdentity, usageResponseCheckedAt, - usageResponseRefreshAfter, + usageReadRefreshAfter, } from "@t3tools/provider-core/server/usageLimits"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -206,7 +206,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( HttpClientRequest.bearerToken(token), ), ); - refreshAfter = usageResponseRefreshAfter(response); + refreshAfter = usageReadRefreshAfter(response); if (response.status === 403) { const { error } = yield* HttpClientResponse.schemaBodyJson(GrokErrorResponse)(response); // xAI refuses Grok Build while the account's privacy setting is a legacy mode. @@ -225,6 +225,11 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( }).pipe( Effect.provideService(UsageAccountIdentity, credentialFingerprint), Effect.timeout("10 seconds"), + Effect.tapError((error) => + Effect.sync(() => { + refreshAfter ??= usageReadRefreshAfter(error); + }), + ), Effect.orElseSucceed(() => probeFailed), ); return { diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index f0de8ae540ad6..233889c5d3660 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -4,18 +4,22 @@ import { assert, it } from "@effect/vitest"; import { makeUsageHttpClient } from "@t3tools/provider-core/server/usageLimits"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientResponse, HttpClientError } from "effect/http"; import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; -it.effect.each([200, 503])( +it.effect.each([200, 503, "transport"] as const)( "preserves the original Go cache deadline for another instance (HTTP %s)", (status) => Effect.gen(function* () { let reads = 0; const client = yield* makeUsageHttpClient( HttpClient.make((request) => - Effect.sync(() => { + Effect.gen(function* () { reads++; + if (status === "transport") + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description: "offline" }), + }); const window = { percent: 10, resetsAt: "2026-11-01T00:00:00Z" }; return HttpClientResponse.fromWeb( request, diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 9b3658d182190..73f12cc3b763b 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,6 +1,6 @@ import { usageResponseCheckedAt, - usageResponseRefreshAfter, + usageReadRefreshAfter, } from "@t3tools/provider-core/server/usageLimits"; import * as NodeSqlite from "node:sqlite"; @@ -159,7 +159,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( apiKey = key; response = yield* readUsage(apiKey); } - refreshAfter = usageResponseRefreshAfter(response); + refreshAfter = usageReadRefreshAfter(response); // A valid Zen key can exist without a Go subscription. if (response.status === 403) return unsupported; const body = yield* HttpClientResponse.filterStatusOk(response).pipe( @@ -196,6 +196,11 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( }; }).pipe( Effect.timeout("5 seconds"), + Effect.tapError((error) => + Effect.sync(() => { + refreshAfter ??= usageReadRefreshAfter(error); + }), + ), Effect.orElseSucceed(() => ({ ...makeUnavailableUsageLimits({ checkedAt, From 2b14c6ee7902e15e6fb03ed522fb761207017340 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 5 Nov 2026 23:31:25 -0500 Subject: [PATCH 64/95] test(mobile): supply per-environment widget presentations --- apps/mobile/src/widgets/useSubscriptionUsage.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts index 43b3d58f06ac2..6bd3df021d89a 100644 --- a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts +++ b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts @@ -33,8 +33,14 @@ vi.mock("../connection/catalog", async () => { vi.mock("../state/presentation", async () => { const { Atom } = await import("effect/reactivity"); + const presentationsAtom = Atom.make(new Map()); return { - environmentPresentations: { presentationsAtom: Atom.make(new Map()) }, + environmentPresentations: { + presentationsAtom, + presentationAtom: Atom.family((id: string) => + Atom.make((get) => get(presentationsAtom).get(id) ?? null), + ), + }, }; }); From 2029b7d6d751fdfa7920410441f1654f9651fb3a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 04:57:21 +0000 Subject: [PATCH 65/95] fix(server): bound quota waits before generation and launches --- apps/server/src/provider/ProviderRegistry.ts | 5 +++ .../src/textGeneration/TextGeneration.ts | 35 ++++++++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index c0bc2e7168e93..cf5148f98852c 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -1122,6 +1122,11 @@ export const layer = Layer.effect( refreshUsageForModelSelection: (primary, fallback) => refreshUsageForModelSelection(primary, fallback).pipe( Effect.catchCause(recoverRefreshFailure), + // Launches wait at most as long as the client's new-thread bound, then use current snapshots. + Effect.timeoutOption(5_000), + Effect.flatMap( + Option.match({ onNone: () => Ref.get(providersRef), onSome: Effect.succeed }), + ), ), refreshUsage: (instanceId, providers) => refreshUsage(instanceId, providers).pipe(Effect.catchCause(recoverRefreshFailure)), diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 41d1f3af4a873..c88961fe24553 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -70,6 +71,9 @@ const resolveInstance = ( // Review guides investigate the checkout read-only; the other drivers reject that request. const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]); +// Both quota stages share one wait, matching the client's new-thread bound. +const USAGE_WAIT_MS = 5_000; + /** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; @@ -84,10 +88,15 @@ export const make = Effect.gen(function* () { ) => Effect.Effect, ) { const instances = yield* registry.listInstances; - yield* ( + const usageDeadline = (yield* Clock.currentTimeMillis) + USAGE_WAIT_MS; + const awaitUsage = (refresh: Effect.Effect) => + Effect.flatMap(Clock.currentTimeMillis, (now) => + refresh.pipe(Effect.ignore, Effect.timeoutOption(Math.max(0, usageDeadline - now))), + ); + yield* awaitUsage( instances.find((instance) => instance.instanceId === selection.instanceId)?.snapshot - .refreshUsage ?? Effect.void - ).pipe(Effect.ignore); + .refreshUsage ?? Effect.void, + ); const readSnapshots = Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot); let snapshots = yield* readSnapshots; const now = DateTime.toEpochMillis(yield* DateTime.now); @@ -118,16 +127,18 @@ export const make = Effect.gen(function* () { isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(provider.driver)); - yield* Effect.forEach( - instances.filter( - (instance) => - instance.enabled && - snapshots.some( - (provider) => provider.instanceId === instance.instanceId && canServe(provider), - ), + yield* awaitUsage( + Effect.forEach( + instances.filter( + (instance) => + instance.enabled && + snapshots.some( + (provider) => provider.instanceId === instance.instanceId && canServe(provider), + ), + ), + (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, ), - (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), - { concurrency: "unbounded", discard: true }, ); snapshots = yield* readSnapshots; selection = From d1c0085bac364ae4d42a68f2dee2e9cacb3060ac Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 00:05:36 -0500 Subject: [PATCH 66/95] fix(usage): bound server selection quota preflights --- .../src/provider/ProviderRegistry.test.ts | 78 ++++++++++++++++++- apps/server/src/provider/ProviderRegistry.ts | 26 ++++--- .../CodexTextGeneration.test.ts | 71 +++++++++++------ .../src/textGeneration/TextGeneration.ts | 31 +++++--- 4 files changed, 156 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 09034566f86e8..404e6a20e2e78 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1501,7 +1501,7 @@ it.layer( ); }); - it.effect("invalidates model caches only for an explicit model refresh", () => + it.effect("bounds quota reads and refreshes model caches explicitly", () => Effect.gen(function* () { const forceRefreshCalls = yield* Ref.make(0); const makeInstance = ( @@ -1532,6 +1532,8 @@ it.layer( } as const satisfies ServerProvider; const invalidations = yield* Ref.make(0); const usageReads = yield* Ref.make(0); + const usageEffect = yield* Ref.make>(Effect.void); + const sampledAt = yield* Ref.make(provider.checkedAt); const maintenanceReads = yield* Ref.make>([]); const instance = { instanceId, @@ -1552,9 +1554,14 @@ it.layer( }), ), ), - getSnapshot: Effect.succeed(provider), + getSnapshot: Ref.get(sampledAt).pipe( + Effect.map((checkedAt) => ({ ...provider, checkedAt })), + ), refresh: Effect.succeed(provider), - refreshUsage: Ref.update(usageReads, (count) => count + 1), + refreshUsage: Ref.update(usageReads, (count) => count + 1).pipe( + Effect.andThen(Ref.get(usageEffect)), + Effect.flatten, + ), streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, @@ -1563,7 +1570,14 @@ it.layer( textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; const takeMaintenanceReads = Ref.getAndSet(maintenanceReads, []); - return { instance, invalidations, takeMaintenanceReads, usageReads }; + return { + instance, + invalidations, + takeMaintenanceReads, + usageReads, + usageEffect, + sampledAt, + }; }); const first = yield* makeInstance("codex_first", ProviderDriverKind.make("codex"), true); const second = yield* makeInstance("codex_second"); @@ -1621,6 +1635,62 @@ it.layer( assert.strictEqual(yield* Ref.get(second.usageReads), 2); assert.strictEqual(yield* Ref.get(other.usageReads), 1); + for (const stage of ["primary", "fallback"] as const) { + const started = yield* Deferred.make(); + const releasePrimary = yield* Deferred.make(); + const releaseFallback = yield* Deferred.make(); + const fallbackStarted = yield* Deferred.make(); + yield* Ref.set( + first.usageEffect, + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + stage === "primary" + ? Deferred.await(releasePrimary) + : Effect.sleep("3 seconds").pipe( + Effect.andThen(Ref.set(first.sampledAt, "2026-06-10T00:05:00.000Z")), + ), + ), + ), + ); + yield* Ref.set( + other.usageEffect, + Deferred.succeed(fallbackStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFallback)), + ), + ); + const finished = yield* Deferred.make(); + const waiting = yield* registry + .refreshUsageForModelSelection( + { instanceId: first.instance.instanceId, model: "gpt-6.1-sol" }, + { instanceId: other.instance.instanceId, model: "claude-haiku-5-5" }, + ) + .pipe( + Effect.tap(() => Deferred.succeed(finished, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(started); + if (stage === "fallback") { + yield* TestClock.adjust("3 seconds"); + yield* Deferred.await(fallbackStarted); + } + yield* TestClock.adjust(stage === "fallback" ? "2 seconds" : "5 seconds"); + assert.isTrue(yield* Deferred.isDone(finished)); + const latest = yield* Fiber.join(waiting); + assert.deepStrictEqual(latest, yield* registry.getProviders); + if (stage === "fallback") + assert.strictEqual( + latest.find((provider) => provider.instanceId === first.instance.instanceId) + ?.checkedAt, + "2026-06-10T00:05:00.000Z", + ); + yield* Ref.set(first.usageEffect, Effect.void); + yield* Ref.set(other.usageEffect, Effect.void); + yield* Deferred.succeed(releasePrimary, undefined); + yield* Deferred.succeed(releaseFallback, undefined); + yield* registry.refreshUsage(first.instance.instanceId); + yield* registry.refreshUsage(other.instance.instanceId); + } + yield* registry.refreshInstance(first.instance.instanceId); yield* registry.refresh(); assert.strictEqual(yield* Ref.get(forceRefreshCalls), 0); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index c0bc2e7168e93..f1e8439923313 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -655,17 +655,21 @@ export const layer = Layer.effect( const refreshUsageForModelSelection = Effect.fn( "ProviderRegistry.refreshUsageForModelSelection", - )(function* (primary: ModelSelection | null, fallback: ModelSelection | null) { - if (!primary || !fallback) return yield* Ref.get(providersRef); - const providers = yield* refreshUsage(primary.instanceId); - return exhaustedUsageWindow( - providers.find((provider) => provider.instanceId === primary.instanceId), - DateTime.toEpochMillis(yield* DateTime.now), - primary.model, - ) - ? yield* refreshUsage(fallback.instanceId) - : providers; - }); + )( + function* (primary: ModelSelection | null, fallback: ModelSelection | null) { + if (!primary || !fallback) return yield* Ref.get(providersRef); + const providers = yield* refreshUsage(primary.instanceId); + return exhaustedUsageWindow( + providers.find((provider) => provider.instanceId === primary.instanceId), + DateTime.toEpochMillis(yield* DateTime.now), + primary.model, + ) + ? yield* refreshUsage(fallback.instanceId) + : providers; + }, + Effect.timeoutOption("5 seconds"), + Effect.flatMap(Option.match({ onNone: () => Ref.get(providersRef), onSome: Effect.succeed })), + ); type RefreshAllCompletion = Deferred.Deferred>; const refreshAllInFlightRef = yield* Ref.make>( diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 5ad26db0f746f..1893f9850ac3f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,3 +1,5 @@ +import * as TestClock from "effect/testing/TestClock"; +import * as Deferred from "effect/Deferred"; import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProviderDriverKind, @@ -527,11 +529,18 @@ const PREFLIGHT_PRIMARY_STATES = [ "not installed", "signed out", "signed out, fallback failed", + "primary slow", + "fallback slow", + "both slow", ] as const; it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) => Effect.gen(function* () { - const exhausted = primaryState === "exhausted"; + const slow = primaryState.endsWith("slow"); + const primaryBlocked = primaryState === "primary slow"; + const fallbackBlocked = primaryState === "fallback slow" || primaryState === "both slow"; + const exhausted = primaryState === "exhausted" || slow; + let readStarted = yield* Deferred.make(); const fallbackFailed = primaryState === "signed out, fallback failed"; const needsFallback = exhausted || primaryState === "not installed" || primaryState.startsWith("signed out"); @@ -548,7 +557,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima const reads: ProviderInstanceId[] = []; const instances: ProviderInstance[] = [primary, fallback, unrelated, disabled].map( (instanceId) => { - let quotaRead = false; + let quotaRead = slow; const driver = ProviderDriverKind.make( instanceId === primary ? "codex" @@ -597,8 +606,13 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima getSnapshot: Effect.sync(() => quotaRead ? snapshot : { ...snapshot, usageLimits: undefined }, ), - refreshUsage: Effect.sync(() => { + refreshUsage: Effect.gen(function* () { reads.push(instanceId); + yield* Deferred.succeed(readStarted, undefined); + if (instanceId === primary && primaryBlocked) return yield* Effect.never; + if (instanceId === primary && primaryState === "both slow") + yield* Effect.sleep("3 seconds"); + if (instanceId !== primary && fallbackBlocked) return yield* Effect.never; quotaRead = true; }), resolveMaintenance: () => Effect.die("unused"), @@ -657,29 +671,40 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima for (const operation of ["review", "title"] as const) { calls.length = 0; reads.length = 0; - if (operation === "title") { - expect( - yield* service.generateThreadTitle({ + readStarted = yield* Deferred.make(); + const generate = Effect.gen(function* () { + if (operation === "title") + return yield* service.generateThreadTitle({ cwd: "/tmp", message: "hello", linkedContext: "", modelSelection: selection, - }), - ).toEqual({ title: "Generated title" }); - } else { - expect( - yield* service.generateReviewGuide({ - cwd: "/tmp", - number: 1, - title: "Change", - description: "", - baseBranch: "main", - baseCommit: "base", - settledReview: "", - modelSelection: selection, - }), - ).toEqual({ stops: [] }); - } + }); + return yield* service.generateReviewGuide({ + cwd: "/tmp", + number: 1, + title: "Change", + description: "", + baseBranch: "main", + baseCommit: "base", + settledReview: "", + modelSelection: selection, + }); + }); + let result; + if (slow) { + const finished = yield* Deferred.make(); + const fiber = yield* generate.pipe( + Effect.tap(() => Deferred.succeed(finished, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + if (primaryState === "both slow") yield* TestClock.adjust("3 seconds"); + yield* TestClock.adjust(primaryState === "both slow" ? "2 seconds" : "5 seconds"); + expect(yield* Deferred.isDone(finished)).toBe(true); + result = yield* Fiber.join(fiber); + } else result = yield* generate; + expect(result).toEqual(operation === "title" ? { title: "Generated title" } : { stops: [] }); expect(calls).toEqual([ needsFallback && !fallbackFailed ? { @@ -694,7 +719,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima actualReads.push([...reads].sort()); } expect(actualReads).toEqual( - (needsFallback && !fallbackFailed + (needsFallback && !fallbackFailed && !primaryBlocked ? [ [primary, fallback], [primary, fallback, unrelated], diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 41d1f3af4a873..20e81a8baef66 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -84,10 +85,14 @@ export const make = Effect.gen(function* () { ) => Effect.Effect, ) { const instances = yield* registry.listInstances; + const usageDeadline = (yield* Clock.currentTimeMillis) + 5_000; yield* ( instances.find((instance) => instance.instanceId === selection.instanceId)?.snapshot .refreshUsage ?? Effect.void - ).pipe(Effect.ignore); + ).pipe( + Effect.ignore, + Effect.timeoutOption(Math.max(0, usageDeadline - (yield* Clock.currentTimeMillis))), + ); const readSnapshots = Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot); let snapshots = yield* readSnapshots; const now = DateTime.toEpochMillis(yield* DateTime.now); @@ -118,17 +123,19 @@ export const make = Effect.gen(function* () { isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(provider.driver)); - yield* Effect.forEach( - instances.filter( - (instance) => - instance.enabled && - snapshots.some( - (provider) => provider.instanceId === instance.instanceId && canServe(provider), - ), - ), - (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), - { concurrency: "unbounded", discard: true }, - ); + const remaining = usageDeadline - (yield* Clock.currentTimeMillis); + if (remaining > 0) + yield* Effect.forEach( + instances.filter( + (instance) => + instance.enabled && + snapshots.some( + (provider) => provider.instanceId === instance.instanceId && canServe(provider), + ), + ), + (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.timeoutOption(remaining)); snapshots = yield* readSnapshots; selection = fallbackTextGenerationModelSelection( From 48be95f285a6ff0d766bd69c0adf6f74abd1e154 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 05:13:21 +0000 Subject: [PATCH 67/95] fix(server): refresh quotas only for fallback candidates --- apps/server/src/textGeneration/TextGeneration.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index c88961fe24553..7be12a0d8526e 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -132,6 +132,11 @@ export const make = Effect.gen(function* () { instances.filter( (instance) => instance.enabled && + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId) => instanceId === instance.instanceId, + { includeConfiguredInstances: true }, + ) !== null && snapshots.some( (provider) => provider.instanceId === instance.instanceId && canServe(provider), ), From 200576c463df30cd78770c4db16ef39f1ca7c000 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 00:33:52 -0500 Subject: [PATCH 68/95] fix(web): keep quota waits out of draft working state --- apps/web/src/components/ChatView.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ba1e8c4283956..2de7f23132ae7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1427,7 +1427,7 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, localDispatchStartedAt, isPreparingWorktree: isLocallyPreparingWorktree, - isSendBusy: isDispatchBusy, + isSendBusy, backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, @@ -1439,7 +1439,6 @@ export default function ChatView(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isSendBusy = isDispatchBusy || draftUsage.pending; const optimisticCompactionMessage = optimisticUserMessages.at(-1); const latestServerUserItem = serverVisibleTurnItems.findLast( (row) => row.item.type === "user_message", @@ -2961,7 +2960,7 @@ export default function ChatView(props: ChatViewProps) { isRevertingCheckpoint, threadDetailLoading, activeEnvironmentUnavailable, - isSendBusy, + isSendBusy: isSendBusy || draftUsage.pending, sendQueuesBehindSetup, isDraftHeroState, panelAnimationsActive, @@ -3089,6 +3088,7 @@ export default function ChatView(props: ChatViewProps) { workLocallyResendDraftId === draftId && isLocalDraftThread && !isSendBusy && + !draftUsage.pending && !isRevertingCheckpoint && !threadDetailLoading && clientSettingsHydrated && @@ -3653,6 +3653,7 @@ export default function ChatView(props: ChatViewProps) { isCompacting={isCompacting} canInterrupt={canInterruptRunningThread} isSendBusy={ + draftUsage.pending || (isSendBusy && !sendQueuesBehindSetup) || (setupQueueSendThreadKey !== null && setupQueueSendThreadKey === activeThreadKey) || From 0614dd88de2def687858efcf7c9c79d96da8cdf9 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 00:53:12 -0500 Subject: [PATCH 69/95] fix(web): reconcile draft quota results across snapshot updates --- .../hooks/useNewThreadUsage.browser.test.tsx | 108 ++++++++++++++++++ apps/web/src/hooks/useNewThreadUsage.ts | 31 +++-- 2 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/hooks/useNewThreadUsage.browser.test.tsx diff --git a/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx b/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx new file mode 100644 index 0000000000000..682e5e2096cdf --- /dev/null +++ b/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx @@ -0,0 +1,108 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; +import { useNewThreadUsage } from "./useNewThreadUsage"; + +const mocks = vi.hoisted(() => ({ refresh: vi.fn() })); +vi.mock("@t3tools/client-runtime/state/usage", () => ({ refreshNewThreadUsage: mocks.refresh })); +vi.mock("../state/server", () => ({ serverEnvironment: {} })); +vi.mock("../state/presentation", async () => { + const { Atom } = await import("effect/reactivity"); + const presentation = Atom.make({ connection: { phase: "connected" } }); + return { environmentPresentations: { presentationAtom: () => presentation } }; +}); + +function setup() { + mocks.refresh.mockReset(); + return mocks; +} + +const primary = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; +const fallback = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }; +function provider( + selection: typeof primary, + exhausted: boolean, + checkedAt: string, +): ServerProvider { + return { + instanceId: selection.instanceId, + driver: ProviderDriverKind.make( + selection.instanceId === primary.instanceId ? "codex" : "claudeAgent", + ), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "first@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt, + windows: [ + { id: "primary", kind: "session", label: "Session", usedPercent: exhausted ? 100 : 0 }, + ], + }, + }; +} +function Probe({ providers }: { readonly providers: readonly ServerProvider[] }) { + const input = { defaultSelection: primary, fallbackSelection: fallback, providers, now: 0 }; + const usage = useNewThreadUsage(EnvironmentId.make("draft-quota-race"), input, "draft-one"); + const resolved = resolveNewThreadModelSelection({ ...input, providers: usage.providers }); + return ( + + {usage.pending ? "waiting" : "ready"}:{resolved.selection?.instanceId} + + ); +} + +it("reconciles fallback quota results across subscription updates before allowing send", async () => { + const { refresh } = setup(); + let complete!: (providers: readonly ServerProvider[]) => void; + refresh.mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + const initial = [ + provider(primary, false, "2026-06-10T00:00:00.000Z"), + provider(fallback, false, "2026-06-10T00:00:00.000Z"), + ]; + // This probe only observes quota reconciliation, without navigation. + await using screen = await render(, { router: false }); + await expect.element(screen.getByText("waiting:codex")).toBeVisible(); + const primaryUpdate = [provider(primary, true, "2026-06-10T00:01:00.000Z"), initial[1]!]; + await screen.rerender(); + await expect.element(screen.getByText("waiting:claudeAgent")).toBeVisible(); + await act(() => + complete([primaryUpdate[0]!, provider(fallback, true, "2026-06-10T00:01:00.000Z")]), + ); + await expect.element(screen.getByText("ready:codex")).toBeVisible(); + await screen.rerender( + , + ); + await expect.element(screen.getByText("ready:codex")).toBeVisible(); + await screen.rerender( + , + ); + await expect.element(screen.getByText("ready:claudeAgent")).toBeVisible(); + refresh.mockReturnValue(new Promise(() => {})); + await screen.rerender( + , + ); + await expect.element(screen.getByText("waiting:claudeAgent")).toBeVisible(); +}); diff --git a/apps/web/src/hooks/useNewThreadUsage.ts b/apps/web/src/hooks/useNewThreadUsage.ts index 07d5c183b7582..ff013dd62169e 100644 --- a/apps/web/src/hooks/useNewThreadUsage.ts +++ b/apps/web/src/hooks/useNewThreadUsage.ts @@ -26,12 +26,16 @@ export function useNewThreadUsage( environmentId, input?.defaultSelection, input?.fallbackSelection, - input?.providers.find((provider) => provider.instanceId === input.defaultSelection?.instanceId) - ?.auth, + input?.providers + .filter( + (provider) => + provider.instanceId === input.defaultSelection?.instanceId || + provider.instanceId === input.fallbackSelection?.instanceId, + ) + .map((provider) => [provider.instanceId, provider.auth]), ]); const [completed, setCompleted] = useState<{ key: string; - baseline: readonly ServerProvider[]; providers: readonly ServerProvider[]; } | null>(null); const refresh = useEffectEvent( @@ -48,9 +52,8 @@ export function useNewThreadUsage( useEffect(() => { if (!enabled || !input) return; let active = true; - const baseline = input.providers; void Promise.resolve(refresh()).then((providers) => { - if (active && providers) setCompleted({ key, baseline, providers }); + if (active && providers) setCompleted({ key, providers }); }); return () => { active = false; @@ -58,10 +61,20 @@ export function useNewThreadUsage( // oxlint-disable-next-line react/exhaustive-effect-dependencies -- quota demand follows selection/account identity; config updates only replace its cached reading }, [enabled, key]); return { - providers: - completed?.key === key && completed.baseline === input?.providers - ? completed.providers - : (input?.providers ?? []), + providers: (input?.providers ?? []).map((provider) => { + const reading = + completed?.key === key && + (provider.instanceId === input?.defaultSelection?.instanceId || + provider.instanceId === input?.fallbackSelection?.instanceId) + ? completed.providers.find((result) => result.instanceId === provider.instanceId) + ?.usageLimits + : undefined; + return reading && + (!provider.usageLimits || + Date.parse(reading.checkedAt) > Date.parse(provider.usageLimits.checkedAt)) + ? { ...provider, usageLimits: reading } + : provider; + }), pending: enabled && completed?.key !== key, }; } From c1fac06af6bb48dc27863d1e9b9d256e1bf9c9d1 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 01:05:58 -0500 Subject: [PATCH 70/95] fix(usage): revalidate due quotas at client demand --- apps/web/src/components/ChatView.tsx | 1 + .../src/components/chat/useComposerSend.ts | 12 +++++++ .../src/hooks/useLiveRefresh.browser.test.tsx | 24 +++++++++++++ apps/web/src/hooks/useLiveRefresh.ts | 2 +- .../hooks/useNewThreadUsage.browser.test.tsx | 35 ++++++++++++++++--- apps/web/src/hooks/useNewThreadUsage.ts | 33 +++++++++++------ .../client-runtime/src/state/usage.test.ts | 15 ++++++++ packages/client-runtime/src/state/usage.ts | 5 ++- 8 files changed, 108 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2de7f23132ae7..bedf52c5fcff0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2941,6 +2941,7 @@ export default function ChatView(props: ChatViewProps) { activeMessageCount, isServerThread, isLocalDraftThread, + prepareDraftSend: multipleModelSelections === null ? draftUsage.prepareSend : undefined, isGitRepo, phase, runtimeMode, diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 7e0b410134c5e..4c778a311ecbe 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -180,6 +180,7 @@ interface ComposerSendInput { readonly activeMessageCount: number; readonly isServerThread: boolean; readonly isLocalDraftThread: boolean; + readonly prepareDraftSend: (() => Promise) | undefined; readonly isGitRepo: boolean; readonly phase: SessionPhase; readonly runtimeMode: RuntimeMode; @@ -280,6 +281,7 @@ export function useComposerSend(input: ComposerSendInput) { activeMessageCount, isServerThread, isLocalDraftThread, + prepareDraftSend, isGitRepo, phase, runtimeMode, @@ -486,6 +488,16 @@ export function useComposerSend(input: ComposerSendInput) { }); return; } + if (isLocalDraftThread && prepareDraftSend) { + sendInFlightRef.current = true; + try { + await prepareDraftSend(); + } finally { + sendInFlightRef.current = false; + } + if (currentRouteThreadKeyRef.current !== routeThreadKey || promptRef.current !== promptAtSend) + return; + } const sendCtx = composerRef.current?.getSendContext(); // `/side` beats an open question and a plan follow-up, which would send the text to main. const sideChatCommand = diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index fe84766638994..822d93470c7d7 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -139,6 +139,30 @@ describe("useLiveRefresh", () => { }, ); + it("rechecks an overdue server deadline immediately after a wider read", async () => { + let complete!: () => void; + const refresh = vi.fn().mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + // This probe only observes the scheduler. + await using _screen = await render( + (refresh.mock.calls.length === 1 ? 300_000 : 610_000)} + />, + { router: false }, + ); + using _timers = fakeTimers({ now: 310_000 }); + await act(() => complete()); + await act(() => vi.advanceTimersByTime(0)); + expect(refresh).toHaveBeenCalledTimes(2); + }); + it("rechecks the shared deadline between simultaneous usage consumers", async () => { const reads: Array<{ promise: Promise; resolve: () => void }> = []; const refresh = vi.fn(() => { diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index 8486f73cf1a5a..da5a5d4b019b3 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -148,7 +148,7 @@ export function useLiveRefresh( pendingRefreshesByView.delete(requestKey); const completedAt = Date.now(); const observedAt = - deadline !== undefined && deadline > completedAt + deadline !== undefined ? Math.min(completedAt, deadline - refreshFloorMs) : completedAt; for (const key of refreshedKeys) lastRefreshedAtByView.set(key, observedAt); diff --git a/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx b/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx index 682e5e2096cdf..cf2f7b8d30618 100644 --- a/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx +++ b/apps/web/src/hooks/useNewThreadUsage.browser.test.tsx @@ -6,7 +6,8 @@ import { } from "@t3tools/contracts"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { expect, it, vi } from "vite-plus/test"; -import { act, render } from "../../test/render"; +import { userEvent } from "vite-plus/test/browser"; +import { act, fakeTimers, render } from "../../test/render"; import { useNewThreadUsage } from "./useNewThreadUsage"; const mocks = vi.hoisted(() => ({ refresh: vi.fn() })); @@ -57,9 +58,14 @@ function Probe({ providers }: { readonly providers: readonly ServerProvider[] }) const usage = useNewThreadUsage(EnvironmentId.make("draft-quota-race"), input, "draft-one"); const resolved = resolveNewThreadModelSelection({ ...input, providers: usage.providers }); return ( - - {usage.pending ? "waiting" : "ready"}:{resolved.selection?.instanceId} - + <> + + + {usage.pending ? "waiting" : "ready"}:{resolved.selection?.instanceId} + + ); } @@ -106,3 +112,24 @@ it("reconciles fallback quota results across subscription updates before allowin ); await expect.element(screen.getByText("waiting:claudeAgent")).toBeVisible(); }); + +it("revalidates an open draft at submission without background polling", async () => { + const { refresh } = setup(); + const initial = [ + provider(primary, false, "2026-06-10T00:00:00.000Z"), + provider(fallback, false, "2026-06-10T00:00:00.000Z"), + ]; + refresh.mockResolvedValue(initial); + using _timers = fakeTimers({ now: 0 }); + // This probe only observes draft selection, without navigation. + await using screen = await render(, { router: false }); + await expect.element(screen.getByText("ready:codex")).toBeVisible(); + await act(() => vi.advanceTimersByTime(360_000)); + expect(refresh).toHaveBeenCalledTimes(1); + refresh.mockResolvedValue([ + provider(primary, true, "2026-06-10T00:06:00.000Z"), + provider(fallback, false, "2026-06-10T00:06:00.000Z"), + ]); + await userEvent.click(screen.getByRole("button", { name: "Send" })); + await expect.element(screen.getByText("ready:claudeAgent")).toBeVisible(); +}); diff --git a/apps/web/src/hooks/useNewThreadUsage.ts b/apps/web/src/hooks/useNewThreadUsage.ts index ff013dd62169e..6c5df9b0248a3 100644 --- a/apps/web/src/hooks/useNewThreadUsage.ts +++ b/apps/web/src/hooks/useNewThreadUsage.ts @@ -1,4 +1,5 @@ import { useEffect, useEffectEvent, useState } from "react"; +import { flushSync } from "react-dom"; import { useAtomValue } from "@effect/atom-react"; import { refreshNewThreadUsage } from "@t3tools/client-runtime/state/usage"; import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; @@ -38,21 +39,20 @@ export function useNewThreadUsage( key: string; providers: readonly ServerProvider[]; } | null>(null); - const refresh = useEffectEvent( - () => - input && - refreshNewThreadUsage({ - registry: appAtomRegistry, - server: serverEnvironment, - environmentId, - input, - }), - ); + const refresh = () => + input && + refreshNewThreadUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + environmentId, + input: { ...input, now: Date.now() }, + }); + const refreshOnMount = useEffectEvent(refresh); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- default selection requests quota once per draft/account, without a polling timer useEffect(() => { if (!enabled || !input) return; let active = true; - void Promise.resolve(refresh()).then((providers) => { + void Promise.resolve(refreshOnMount()).then((providers) => { if (active && providers) setCompleted({ key, providers }); }); return () => { @@ -61,6 +61,17 @@ export function useNewThreadUsage( // oxlint-disable-next-line react/exhaustive-effect-dependencies -- quota demand follows selection/account identity; config updates only replace its cached reading }, [enabled, key]); return { + prepareSend: async () => { + if (!enabled) return; + setCompleted(null); + const providers = await refresh(); + if (providers) + flushSync(() => + setCompleted((current) => + current && current.key !== key ? current : { key, providers }, + ), + ); + }, providers: (input?.providers ?? []).map((provider) => { const reading = completed?.key === key && diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0d41fe1f74e75..992749099aa4a 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -253,6 +253,21 @@ describe("limits refresh cooldown", () => { ), ).toBe(300_000 + skew); } + now.mockReturnValue(310_000); + const overdue = usageRefreshDeadline( + [ + { checkedAt: "2026-01-01T00:00:00Z", windows: [], refreshAfter: 300_000 }, + { checkedAt: "2026-01-01T00:00:00Z", windows: [], refreshAfter: 610_000 }, + ], + 310_000, + ); + expect(overdue).toBe(300_000); + await refreshUsageLimits(remote, async () => overdue, true, false, { + key: "overdue", + resources: ["expired", "new"], + refreshDeadline: (deadline) => deadline, + }); + expect(usageLimitsRefreshDelay(remote, "expired")).toBe(0); now.mockReturnValue(4 * 60_000); await refreshUsageLimits(remote, async () => 300_000, true, false, { key: "", diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 8bffd160c6501..1871b4dfb8159 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -118,8 +118,7 @@ export function usageRefreshDeadline( // @effect-diagnostics-next-line globalDate:off const now = Date.now(); const deadlines = Array.from(limits, (reading) => reading?.refreshAfter).filter( - (deadline): deadline is number => - deadline !== undefined && Number.isFinite(deadline) && deadline > (serverTime ?? now), + (deadline): deadline is number => deadline !== undefined && Number.isFinite(deadline), ); return deadlines.length ? now + Math.min(...deadlines) - (serverTime ?? now) : undefined; } @@ -217,7 +216,7 @@ export async function refreshUsageLimits( // @effect-diagnostics-next-line globalDate:off const finishedAt = Date.now(); const refreshAfter = - deadline !== undefined && deadline > finishedAt + deadline !== undefined ? Math.min(deadline, finishedAt + 5 * 60_000) : finishedAt + 5 * 60_000; for (const selection of refreshedSelections) From ac39762e17307de3e469cbd1714741f12aaee2bb Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 01:16:36 -0500 Subject: [PATCH 71/95] fix(web): compile quota preflights and normalize coverage paths Use promise cleanup to preserve the send guard without a React compiler bailout. Normalize compiler case paths so Windows changed-file filters select every intended source. Fixes #31841 --- apps/web/src/components/chat/useComposerSend.ts | 6 ++---- apps/web/src/reactCompilerCoverage.test.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 4c778a311ecbe..74fcaa0ce9c61 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -490,11 +490,9 @@ export function useComposerSend(input: ComposerSendInput) { } if (isLocalDraftThread && prepareDraftSend) { sendInFlightRef.current = true; - try { - await prepareDraftSend(); - } finally { + await prepareDraftSend().finally(() => { sendInFlightRef.current = false; - } + }); if (currentRouteThreadKeyRef.current !== routeThreadKey || promptRef.current !== promptAtSend) return; } diff --git a/apps/web/src/reactCompilerCoverage.test.ts b/apps/web/src/reactCompilerCoverage.test.ts index b0e4d9dfb9a55..b072693fe838c 100644 --- a/apps/web/src/reactCompilerCoverage.test.ts +++ b/apps/web/src/reactCompilerCoverage.test.ts @@ -13,7 +13,7 @@ const repoRoot = NodePath.resolve( const webSourceFiles = NodeFS.readdirSync(NodePath.join(repoRoot, "apps/web/src"), { recursive: true, }) - .map(String) + .map((file) => String(file).split(NodePath.sep).join("/")) .filter((file) => /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file)) .filter((file) => !file.endsWith(".gen.ts")) .map((file) => `apps/web/src/${file}`); From ee96852693e284a636c5faec883f77ed6ec8bd4e Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 01:34:29 -0500 Subject: [PATCH 72/95] fix(usage): advance retained hub deadlines after listing failures --- apps/server/src/usage/UsageLimitSources.test.ts | 2 +- apps/server/src/usage/UsageLimitSources.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 71bee873ff396..a3db214c6f925 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -216,7 +216,7 @@ it.effect.each(["listing", "quota", "changed-account"] as const)( expect(failed.accounts).toEqual( prior.accounts.map((account) => ({ ...account, - usageLimits: { ...account.usageLimits, stale: true }, + usageLimits: { ...account.usageLimits, stale: true, refreshAfter: 600_000 }, })), ); if (failure === "quota") diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 48dfb7f1f2e78..fa127bdc19e93 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -99,9 +99,14 @@ export const make = Effect.gen(function* () { .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); + const refreshAfter = DateTime.toEpochMillis(yield* DateTime.now) + 5 * 60_000; const retained = (lastGoodSources.get(accountKey) ?? []).map((account) => ({ ...account, - usageLimits: { ...account.usageLimits, stale: true }, + usageLimits: { + ...account.usageLimits, + stale: true, + refreshAfter: Math.max(refreshAfter, account.usageLimits.refreshAfter ?? 0), + }, })); lastGoodSources.set(accountKey, retained); return { ...base, accounts: retained, error: accounts.failure.detail }; From f40a6e2d3fe0fe97af9b145e8e99bf098f0c8c9a Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 02:17:17 -0500 Subject: [PATCH 73/95] fix(usage): preserve Antigravity expiry and refresh Cursor opt-in --- .../provider/antigravityUsageLimits.test.ts | 46 +++++++++++++++---- .../src/provider/antigravityUsageLimits.ts | 2 +- .../settings/UsageProviderSettings.tsx | 7 ++- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index ef1b9b4beda00..40210798eab08 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -60,6 +60,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( /** Per-host quota summaries; hosts not listed fall back to `summary`. */ summaryByHost?: Record; models?: unknown; + modelsStatus?: number; summaryStatus?: number; usageRetryAfter?: string; rotateAccessToken?: boolean; @@ -132,7 +133,9 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( status: options.summaryStatusByHost?.[url.host] ?? options.summaryStatus ?? 200, headers: options.usageRetryAfter ? { "retry-after": options.usageRetryAfter } : {}, }) - : Response.json(options.models ?? { models: {} }), + : Response.json(options.models ?? { models: {} }, { + status: options.modelsStatus ?? 200, + }), ); }).pipe( Effect.tap(() => @@ -572,15 +575,38 @@ describe("Antigravity subscription limits", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("keeps one endpoint's quotas when the other fails", () => - Effect.gen(function* () { - const { probe } = yield* fixture({ - summaryStatusByHost: { "daily-cloudcode-pa.googleapis.com": 503 }, - }); - const limits = yield* probe; - expect(limits.unavailable).toBeUndefined(); - expect(limits.windows).toHaveLength(4); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + it.effect.each(["one", "all"] as const)( + "preserves quota cache expiry when %s endpoints fail", + (failure) => + Effect.gen(function* () { + const options = { + summaryStatusByHost: { + "daily-cloudcode-pa.googleapis.com": 503, + "cloudcode-pa.googleapis.com": failure === "all" ? 503 : 200, + }, + modelsStatus: failure === "all" ? 503 : 200, + usageRetryAfter: "300", + }; + const { probe, requests } = yield* fixture(options); + const limits = yield* probe; + expect(limits.unavailable?.reason).toBe(failure === "all" ? "probeFailed" : undefined); + expect(limits.windows).toHaveLength(failure === "all" ? 0 : 4); + expect(limits.refreshAfter).toBe(300_000); + const quotaCalls = () => requests.filter(({ url }) => url.includes("/v1internal:")).length; + const firstCalls = quotaCalls(); + yield* TestClock.adjust("4 minutes"); + expect((yield* probe).refreshAfter).toBe(300_000); + expect(quotaCalls()).toBe(firstCalls); + options.summaryStatusByHost["daily-cloudcode-pa.googleapis.com"] = 200; + options.summaryStatusByHost["cloudcode-pa.googleapis.com"] = 200; + options.modelsStatus = 200; + yield* TestClock.adjust("1 minute"); + const recovered = yield* probe; + expect(recovered.unavailable).toBeUndefined(); + expect(recovered.windows).toHaveLength(4); + expect(recovered.refreshAfter).toBe(600_000); + expect(quotaCalls()).toBe(firstCalls + 2); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("re-reads changed credentials and clears cached access after sign-out", () => diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 8adeb5004c0d6..0923342019eaa 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -192,7 +192,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const client = yield* HttpClient.HttpClient; const crypto = yield* Digest.Digest; const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index f238850ae04d0..432733c354cb4 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import { ProviderDriverKind, type EnvironmentId, type UnifiedSettings } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; @@ -54,7 +54,10 @@ export function UsageProviderSettings({ input: { patch: { cursorKeychainUsageEnabled: enabled } }, }); if (result._tag === "Success") { - await refreshProviders({ environmentId, input: {} }); + await refreshProviders({ + environmentId, + input: { usage: enabled, usageProviders: [ProviderDriverKind.make("cursor")] }, + }); } setUpdatingCursor(false); }; From b299f9580c13ca548ae8a08aff70c2d90accd4b4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 03:31:11 -0500 Subject: [PATCH 74/95] fix(usage): back off Antigravity quota demand after health failures --- .../src/provider/AntigravityProvider.test.ts | 36 +++++++++++++++++-- .../src/provider/AntigravityProvider.ts | 14 +++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index f85701fe94cec..17db73da2cfd9 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -549,15 +549,29 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { it.effect("retains known account metadata when a local health check fails", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeHarness(); + const limits: ServerProviderUsageLimits = { + checkedAt: "1970-01-01T00:00:00.000Z", + windows: [{ id: "gemini-5h", kind: "session", label: "Gemini", usedPercent: 25 }], + }; + const usageReads = yield* Ref.make(0); + const harness = yield* makeHarness({ + probeUsage: Ref.update(usageReads, (count) => count + 1).pipe(Effect.as(limits)), + }); yield* harness.initialize; yield* harness.provider.onSessionStarted(started, "/workspace"); yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* harness.provider.snapshot.refreshUsage!; + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual({ + ...limits, + refreshAfter: 300_000, + }); + yield* TestClock.adjust("5 minutes"); yield* Ref.set( harness.probe, Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), ); - const snapshot = yield* harness.provider.snapshot.refresh; + yield* harness.provider.snapshot.refreshUsage!; + const snapshot = yield* harness.provider.snapshot.getSnapshot; expect(snapshot).toMatchObject({ installed: true, status: "error", @@ -566,6 +580,24 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); expect(snapshot.slashCommands).toEqual(expectedCommands); expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); + expect(snapshot.usageLimits).toEqual({ ...limits, stale: true, refreshAfter: 600_000 }); + const failedChecks = yield* Ref.get(harness.probeCalls); + yield* harness.provider.snapshot.refreshUsage!; + expect(yield* Ref.get(harness.probeCalls)).toBe(failedChecks); + yield* TestClock.adjust("4 minutes"); + expect((yield* harness.provider.snapshot.refresh).usageLimits?.refreshAfter).toBe(600_000); + const healthChecks = yield* Ref.get(harness.probeCalls); + yield* harness.provider.snapshot.refreshUsage!; + expect(yield* Ref.get(harness.probeCalls)).toBe(healthChecks); + yield* TestClock.adjust("1 minute"); + yield* harness.provider.snapshot.refreshUsage!; + expect(yield* Ref.get(harness.probeCalls)).toBe(healthChecks + 1); + expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toEqual({ + ...limits, + stale: true, + refreshAfter: 900_000, + }); + expect(yield* Ref.get(usageReads)).toBe(1); }), ), ); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 6b03e301a20a1..780574e4ab699 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -308,6 +308,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; const updatedAt = DateTime.formatIso(yield* DateTime.now); + const usageRetryAfter = + initialized === undefined && (yield* UsagePolling) && !(yield* PublishingUsage) + ? (yield* Clock.currentTimeMillis) + 5 * 60_000 + : undefined; const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { if (state.authRevision !== before.authRevision) return state; const { message: _previousMessage, ...draft } = state.draft; @@ -328,7 +332,15 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, - usageLimits: missingInstallation ? undefined : draft.usageLimits, + usageLimits: missingInstallation + ? undefined + : usageRetryAfter !== undefined && draft.usageLimits + ? { + ...draft.usageLimits, + stale: true, + refreshAfter: Math.max(usageRetryAfter, draft.usageLimits.refreshAfter ?? 0), + } + : draft.usageLimits, ...(missingInstallation ? { models: [], From 48ae5a010d62b2d225e30a5c5ba9045750acc5d4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 04:00:03 -0500 Subject: [PATCH 75/95] fix(provider): exclude external-only usage from quota deadlines --- .../src/server/managedProvider.test.ts | 42 +++++++++++++++---- .../src/server/managedProvider.ts | 7 ++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 31fdb0b91c535..01fbfa08947fa 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -432,13 +432,24 @@ describe("makeManagedServerProvider", () => { }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); - it.effect( - "runs the initial provider check in the background and streams the refreshed snapshot", - () => + it.effect.each([false, true])( + "runs the initial provider check in the background (external usage: %s)", + (externalUsage) => Effect.scoped( Effect.gen(function* () { const checkCalls = yield* Ref.make(0); const releaseCheck = yield* Deferred.make(); + const checkStarted = yield* Deferred.make(); + const expectedSnapshot = externalUsage + ? { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [], + externalUsage: { label: "Usage dashboard", url: "https://example.com/usage" }, + }, + } + : refreshedSnapshot; const provider = yield* makeManagedServerProvider({ resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), getSettings: Effect.succeed({ enabled: true }), @@ -446,8 +457,9 @@ describe("makeManagedServerProvider", () => { haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( - Effect.flatMap(() => Deferred.await(releaseCheck)), - Effect.as(refreshedSnapshot), + Effect.andThen(Deferred.succeed(checkStarted, undefined)), + Effect.andThen(Deferred.await(releaseCheck)), + Effect.as(expectedSnapshot), ), refreshInterval: "1 hour", }); @@ -457,19 +469,31 @@ describe("makeManagedServerProvider", () => { const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( Stream.runCollect, - Effect.forkChild, + Effect.forkChild({ startImmediately: true }), ); - yield* Effect.yieldNow; + yield* Deferred.await(checkStarted); + const demand = externalUsage + ? yield* provider.refreshUsage!.pipe(Effect.forkChild({ startImmediately: true })) + : undefined; assert.strictEqual(yield* Ref.get(checkCalls), 1); yield* Deferred.succeed(releaseCheck, undefined); const updates = Array.from(yield* Fiber.join(updatesFiber)); + if (demand) yield* Fiber.join(demand); const latest = yield* provider.getSnapshot; - assert.deepStrictEqual(updates, [refreshedSnapshot]); - assert.deepStrictEqual(latest, refreshedSnapshot); + assert.deepStrictEqual(updates, [expectedSnapshot]); + assert.deepStrictEqual(latest, expectedSnapshot); assert.strictEqual(yield* Ref.get(checkCalls), 1); + if (externalUsage) { + yield* provider.refresh.pipe(Effect.provideService(UsagePolling, true)); + assert.deepStrictEqual(yield* provider.getSnapshot, expectedSnapshot); + yield* TestClock.adjust("5 minutes"); + yield* provider.refreshUsage!; + assert.deepStrictEqual(yield* provider.getSnapshot, expectedSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + } }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 081f9d439e847..0d936bd7b5d60 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -179,7 +179,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( current.auth.status === "authenticated" ? withUsageLimits(current, yield* input.checkUsageLimits) : yield* input.checkProvider.pipe(Effect.provideService(UsagePolling, pollingUsage)); - if (probedSnapshot.usageLimits?.refreshAfter !== undefined) { + if (probedSnapshot.usageLimits?.externalUsage) { + usageRefreshAfter = 0; + } else if (probedSnapshot.usageLimits?.refreshAfter !== undefined) { usageRefreshAfter = probedSnapshot.usageLimits.refreshAfter; } else if (pollingUsage) usageRefreshAfter = (yield* Clock.currentTimeMillis) + 5 * 60_000; const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( @@ -264,8 +266,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( }); const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { - const { snapshot } = yield* Ref.get(snapshotStateRef); - if (!snapshot.enabled || snapshot.usageLimits?.externalUsage) return; // An in-flight refresh may have read cached usage, so one refresh waits behind it. yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -291,6 +291,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* release; const settings = yield* input.getSettings; const current = (yield* Ref.get(snapshotStateRef)).snapshot; + if (!current.enabled || current.usageLimits?.externalUsage) return; if ( (yield* Clock.currentTimeMillis) < usageRefreshAfter && current.usageLimits?.unavailable?.reason !== "unsupported" && From a792901de02d6e54493c31f0d427abdc07cf3496 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 04:41:11 -0500 Subject: [PATCH 76/95] fix(opencode): invalidate quota freshness when Go credentials change --- .../src/server/driver.test.ts | 88 +++++++++++++++---- .../provider-opencode/src/server/driver.ts | 16 +++- .../src/server/usageLimits.ts | 84 ++++++++++-------- 3 files changed, 134 insertions(+), 54 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 232007f110fb9..528a18b3cf129 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -358,33 +359,58 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { { name: "respects a disabled interval", intervalOff: true }, { name: "skips disabled instances", enabled: false }, { name: "skips unsupported usage", unsupported: true }, + { name: "invalidates replaced Go credentials", switchCredentials: true }, ].map((scenario) => ({ enabled: true, demand: true, intervalOff: false, unsupported: false, + switchCredentials: false, ...scenario, })), )( - "Go usage recovery $name only on visible refresh without restarting a ready catalog", + "Go usage recovery $name keeps health discovery separate from demand-driven quota reads", (scenario) => Effect.gen(function* () { const usageStarted = yield* Deferred.make(); const healthGate = yield* Deferred.make(); + const replacementHealthStarted = yield* Deferred.make(); + let replacingCredential = false; let usageReads = 0; let serverStarts = 0; let modelReads = 0; + const fs = yield* FileSystem.FileSystem; + const dataHome = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(`${dataHome}/opencode`); + const database = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(`${dataHome}/opencode/opencode.db`)), + (database) => Effect.sync(() => database.close()), + ); + database.exec( + "CREATE TABLE credential (id text PRIMARY KEY, integration_id text, value text, active integer, time_created integer)", + ); + const replaceCredential = (key: string) => { + database.exec("DELETE FROM credential"); + database + .prepare("INSERT INTO credential VALUES ('go', 'opencode-go', ?, 1, 0)") + .run(JSON.stringify({ type: "key", key })); + }; + replaceCredential("test-go-key"); + let currentKey = "test-go-key"; const server = HttpClient.make((request) => { if (request.url === "https://opencode.ai/zen/go/v1/usage") { usageReads += 1; - assert.strictEqual(request.headers.authorization, "Bearer test-go-key"); + assert.strictEqual(request.headers.authorization, `Bearer ${currentKey}`); if (scenario.unsupported) - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 403 })), + return Deferred.succeed(usageStarted, undefined).pipe( + Effect.as(HttpClientResponse.fromWeb(request, new Response(null, { status: 403 }))), ); if (usageReads === 1) return Deferred.succeed(usageStarted, undefined).pipe(Effect.andThen(Effect.never)); - const window = { percent: 10, resetsAt: "2026-11-01T00:00:00.000Z" }; + const window = { + percent: currentKey === "test-go-key" ? 10 : 80, + resetsAt: "2026-11-01T00:00:00.000Z", + }; return Effect.succeed( HttpClientResponse.fromWeb( request, @@ -422,6 +448,11 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { : [], }; return Deferred.await(healthGate).pipe( + Effect.andThen( + replacingCredential && path === "/api/model" + ? Deferred.succeed(replacementHealthStarted, undefined) + : Effect.void, + ), Effect.as(HttpClientResponse.fromWeb(request, Response.json(body))), ); }); @@ -443,13 +474,10 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { instanceId: ProviderInstanceId.make("opencode-usage-recovery"), displayName: undefined, environment: [ - { - name: "OPENCODE_AUTH_CONTENT", - sensitive: true, - value: JSON.stringify({ - "opencode-go": { type: "api", key: "test-go-key" }, - }), - }, + { name: "XDG_DATA_HOME", value: dataHome, sensitive: false }, + { name: "OPENCODE_DB", value: "opencode.db", sensitive: false }, + { name: "OPENCODE_AUTH_CONTENT", value: "", sensitive: true }, + { name: "OPENCODE_API_KEY", value: "", sensitive: true }, ], enabled: scenario.enabled, config: OpenCodeDriver.defaultConfig(), @@ -485,7 +513,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { if (ready) yield* Fiber.join(ready); assert.strictEqual(usageReads, 0); const initial = yield* instance.snapshot.refreshUsage!.pipe(Effect.forkChild); - if (scenario.enabled && !scenario.unsupported) yield* Deferred.await(usageStarted); + if (scenario.enabled) yield* Deferred.await(usageStarted); yield* TestClock.adjust("5 seconds"); yield* Fiber.join(initial); const failed = yield* instance.snapshot.getSnapshot; @@ -520,10 +548,38 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { ); assert.strictEqual(modelReads, catalogReads); assert.strictEqual(serverStarts, starts); + if (scenario.switchCredentials) { + const previousAuth = recovered.auth; + yield* TestClock.adjust("1 minute"); + currentKey = "replacement-go-key"; + replaceCredential(currentKey); + replacingCredential = true; + const health = yield* instance.snapshot.refresh.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(replacementHealthStarted); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(health); + const changed = yield* instance.snapshot.getSnapshot; + assert.strictEqual(usageReads, expectedReads, "health discovery must not read quota"); + assert.notStrictEqual(changed.auth.workspaceId, previousAuth.workspaceId); + assert.deepStrictEqual(changed.usageLimits?.windows, []); + assert.strictEqual(changed.usageLimits?.refreshAfter, undefined); + yield* instance.snapshot.refreshUsage!; + const fresh = yield* instance.snapshot.getSnapshot; + assert.strictEqual(usageReads, expectedReads + 1); + assert.strictEqual(fresh.usageLimits?.windows[0]?.usedPercent, 80); + assert.notStrictEqual( + fresh.usageLimits?.credentialFingerprint, + recovered.usageLimits?.credentialFingerprint, + ); + } + const finalModelReads = modelReads; + const finalServerStarts = serverStarts; yield* TestClock.adjust("15 minutes"); - assert.strictEqual(usageReads, expectedReads); - assert.strictEqual(modelReads, catalogReads); - assert.strictEqual(serverStarts, starts); + assert.strictEqual(usageReads, expectedReads + Number(scenario.switchCredentials)); + assert.strictEqual(modelReads, finalModelReads); + assert.strictEqual(serverStarts, finalServerStarts); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 705da4fbd9dd0..f1f8bc4d0cae0 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -28,7 +28,7 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; -import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { readOpenCodeGoUsageLimits, readOpenCodeGoCredentialFingerprint } from "./usageLimits.ts"; import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; import { checkOpenCodeProviderStatus, @@ -377,14 +377,26 @@ export const OpenCodeDriver: ProviderDriver< Effect.annotateLogs({ providerInstanceId: instanceId }), Effect.annotateSpans({ providerInstanceId: instanceId }), ), + credentialFingerprint: + effectiveConfig.enabled && !effectiveConfig.serverUrl.trim() + ? readOpenCodeGoCredentialFingerprint(processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(Digest.Digest, digest), + ) + : Effect.undefined, usageLimits: Effect.flatMap(UsagePolling, (enabled) => enabled ? checkUsageLimits : Effect.succeed(null), ), }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ + Effect.map(({ provider, usageLimits, credentialFingerprint }) => ({ ...provider, + // OpenCode 2 omits account identity; local key selection owns cached Go quotas. + auth: credentialFingerprint + ? { ...provider.auth, workspaceId: credentialFingerprint } + : provider.auth, usageLimits: usageLimits ?? (provider.auth.status === "authenticated" diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 73f12cc3b763b..8d56424594e0e 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -28,6 +28,7 @@ const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknow const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); +const encodeKeys = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); // OpenCode 2's database tags a stored key "key"; auth.json tags it "api". const StoredKey = Schema.Struct({ type: Schema.Literal("key"), @@ -87,6 +88,52 @@ const readStoredApiKey = (databasePath: string, integrationId: "opencode" | "ope catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), }).pipe(Effect.orElseSucceed(() => undefined)); +const readOpenCodeGoKeys = Effect.fn("readOpenCodeGoKeys")(function* (env: NodeJS.ProcessEnv) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dataHome = + env.XDG_DATA_HOME || + path.join(env.HOME || env.USERPROFILE || (yield* HostProcess.HomeDirectory), ".local", "share"); + const authPath = path.join(dataHome, "opencode", "auth.json"); + const contents = + env.OPENCODE_AUTH_CONTENT || + (yield* fs.readFileString(authPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const auth = yield* decodeAuthFile(contents); + const apiAuth = decodeApiAuth(auth["opencode-go"]); + // OpenCode overlays stored API credentials after environment credentials. + const goKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); + // Console supplies Go credentials in OpenCode 2. Inline auth explicitly + // overrides local credentials, including the Console connection. + const databaseFile = env.OPENCODE_DB ?? "opencode.db"; + const databasePath = path.resolve(dataHome, "opencode", databaseFile); + const storedKeys = + env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:" + ? [] + : [ + yield* readStoredApiKey(databasePath, "opencode"), + // OpenCode 2 imports auth.json once, so a replaced Go key only exists here. + yield* readStoredApiKey(databasePath, "opencode-go"), + ]; + return [...new Set([...storedKeys, goKey].filter((key): key is string => Boolean(key)))]; +}); + +export const readOpenCodeGoCredentialFingerprint = Effect.fn("readOpenCodeGoCredentialFingerprint")( + function* (environment: NodeJS.ProcessEnv) { + const keys = yield* readOpenCodeGoKeys(environment); + const digest = yield* Digest.Digest; + const serialized = yield* encodeKeys(keys); + return yield* digest + .digest(Digest.sha256, new TextEncoder().encode(serialized)) + .pipe(Effect.map(Hex.encode)); + }, + Effect.orElseSucceed(() => undefined), +); + /** External OpenCode servers own their credentials; never read the host's account for them. */ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { readonly enabled: boolean; @@ -100,42 +147,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( let credentialFingerprint: string | undefined; let refreshAfter: number | undefined; return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = input.environment; - const dataHome = - env.XDG_DATA_HOME || - path.join( - env.HOME || env.USERPROFILE || (yield* HostProcess.HomeDirectory), - ".local", - "share", - ); - const authPath = path.join(dataHome, "opencode", "auth.json"); - const contents = - env.OPENCODE_AUTH_CONTENT || - (yield* fs.readFileString(authPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const auth = yield* decodeAuthFile(contents); - const apiAuth = decodeApiAuth(auth["opencode-go"]); - // OpenCode overlays stored API credentials after environment credentials. - const goKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); - // Console supplies Go credentials in OpenCode 2. Inline auth explicitly - // overrides local credentials, including the Console connection. - const databaseFile = env.OPENCODE_DB ?? "opencode.db"; - const databasePath = path.resolve(dataHome, "opencode", databaseFile); - const storedKeys = - env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:" - ? [] - : [ - yield* readStoredApiKey(databasePath, "opencode"), - // OpenCode 2 imports auth.json once, so a replaced Go key only exists here. - yield* readStoredApiKey(databasePath, "opencode-go"), - ]; - const keys = [...new Set([...storedKeys, goKey].filter((key): key is string => Boolean(key)))]; + const keys = yield* readOpenCodeGoKeys(input.environment); let apiKey = keys[0]; if (!apiKey) return unsupported; From d6c879ac97d6fbaa219fb1661ca7ecb5c9ca5261 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 05:06:29 -0500 Subject: [PATCH 77/95] fix(usage): separate local freshness keys from workspace identity --- packages/client-runtime/src/state/usage.ts | 1 + packages/contracts/src/server.ts | 2 ++ .../provider-core/src/server/managedProvider.test.ts | 12 +++++++++--- packages/provider-core/src/server/managedProvider.ts | 1 + packages/provider-opencode/src/server/driver.test.ts | 3 ++- packages/provider-opencode/src/server/driver.ts | 2 +- packages/shared/src/usageLimits.test.ts | 3 ++- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 1871b4dfb8159..1f01cb22da58e 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -144,6 +144,7 @@ export function usageRefreshAccountKey( provider.auth?.type, provider.auth?.email?.toLowerCase(), provider.auth?.workspaceId, + provider.auth?.usageRefreshKey, ]), ]); } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 7a97d5403ce5d..4092432e340ce 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), + // Local credential selection invalidates freshness without changing quota account identity. + usageRefreshKey: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 01fbfa08947fa..f50183bef5ff6 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -190,7 +190,7 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); - it.effect.each(["auth-type", "first-sign-in", "first-discovery"] as const)( + it.effect.each(["auth-type", "first-sign-in", "first-discovery", "usage-credentials"] as const)( "resets usage on account transition: %s", (transition) => Effect.scoped( @@ -203,7 +203,9 @@ describe("makeManagedServerProvider", () => { const auth = yield* Ref.make( transition === "auth-type" ? { ...signedIn, workspaceId: "ws-a" } - : { status: transition === "first-sign-in" ? "unauthenticated" : "unknown" }, + : transition === "usage-credentials" + ? { ...signedIn, usageRefreshKey: "local-a" } + : { status: transition === "first-sign-in" ? "unauthenticated" : "unknown" }, ); const reads = yield* Ref.make(0); const limits = { @@ -248,7 +250,11 @@ describe("makeManagedServerProvider", () => { } yield* Ref.set( auth, - transition === "auth-type" ? { status: "authenticated", type: "apiKey" } : signedIn, + transition === "auth-type" + ? { status: "authenticated", type: "apiKey" } + : transition === "usage-credentials" + ? { ...signedIn, usageRefreshKey: "local-b" } + : signedIn, ); const switched = yield* provider.refresh; assert.strictEqual(switched.usageLimits, undefined); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 0d936bd7b5d60..712c8512f22a2 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -198,6 +198,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( accountReset || (probedSnapshot.auth.status === "authenticated" && previous.status !== "authenticated") || differs(probedSnapshot.auth.type, previous.type) || + differs(probedSnapshot.auth.usageRefreshKey, previous.usageRefreshKey) || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 528a18b3cf129..7146c5ec7c536 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -550,6 +550,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { assert.strictEqual(serverStarts, starts); if (scenario.switchCredentials) { const previousAuth = recovered.auth; + assert.strictEqual(previousAuth.workspaceId, undefined); yield* TestClock.adjust("1 minute"); currentKey = "replacement-go-key"; replaceCredential(currentKey); @@ -562,7 +563,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { yield* Fiber.join(health); const changed = yield* instance.snapshot.getSnapshot; assert.strictEqual(usageReads, expectedReads, "health discovery must not read quota"); - assert.notStrictEqual(changed.auth.workspaceId, previousAuth.workspaceId); + assert.notStrictEqual(changed.auth.usageRefreshKey, previousAuth.usageRefreshKey); assert.deepStrictEqual(changed.usageLimits?.windows, []); assert.strictEqual(changed.usageLimits?.refreshAfter, undefined); yield* instance.snapshot.refreshUsage!; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index f1f8bc4d0cae0..a7a6d7fe2bd02 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -395,7 +395,7 @@ export const OpenCodeDriver: ProviderDriver< ...provider, // OpenCode 2 omits account identity; local key selection owns cached Go quotas. auth: credentialFingerprint - ? { ...provider.auth, workspaceId: credentialFingerprint } + ? { ...provider.auth, usageRefreshKey: credentialFingerprint } : provider.auth, usageLimits: usageLimits ?? diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e7b0d27cf69d7..f7719794a7e3d 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -280,7 +280,7 @@ describe("pools", () => { const go = provider({ driver: ProviderDriverKind.make("opencode"), instanceId: ProviderInstanceId.make("opencode"), - auth: { status: "authenticated" }, + auth: { status: "authenticated", usageRefreshKey: "local-credentials-a" }, usageLimits: { checkedAt, credentialFingerprint: "shared-go-key", @@ -297,6 +297,7 @@ describe("pools", () => { providers: [ { ...go, + auth: { ...go.auth, usageRefreshKey: "local-credentials-b" }, usageLimits: { ...go.usageLimits!, checkedAt: "2026-09-03T11:30:00.000Z", From 60d258c42f243eee4f2af9298cc6ba775892eee3 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 05:25:08 -0500 Subject: [PATCH 78/95] fix(opencode): check quota ownership before reusing freshness --- .../src/server/managedProvider.ts | 22 ++++++++--- .../src/server/driver.test.ts | 38 +++++++++++++------ .../provider-opencode/src/server/driver.ts | 18 +++++---- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 712c8512f22a2..731d71d0f99b6 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -54,6 +54,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; readonly checkUsageLimits?: Effect.Effect; + readonly getUsageRefreshKey?: Effect.Effect; readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; @@ -138,7 +139,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, - options?: { readonly forceRefresh?: boolean }, + options?: { readonly forceRefresh?: boolean; readonly usageRefreshKey?: string | undefined }, ) { const forceRefresh = options?.forceRefresh === true; const previousSettings = yield* Ref.get(settingsRef); @@ -162,10 +163,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } - const accountReset = yield* UsageAccountReset; + const current = (yield* Ref.get(snapshotStateRef)).snapshot; + const accountReset = + (yield* UsageAccountReset) || + (options?.usageRefreshKey !== undefined && + options.usageRefreshKey !== current.auth.usageRefreshKey); if (accountReset) usageRefreshAfter = 0; const now = yield* Clock.currentTimeMillis; - const current = (yield* Ref.get(snapshotStateRef)).snapshot; const pollingUsage = (yield* UsagePolling) && ((yield* UsageAfterReset) || @@ -177,7 +181,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( current.installed && current.status === "ready" && current.auth.status === "authenticated" - ? withUsageLimits(current, yield* input.checkUsageLimits) + ? withUsageLimits( + options?.usageRefreshKey === undefined + ? current + : { ...current, auth: { ...current.auth, usageRefreshKey: options.usageRefreshKey } }, + yield* input.checkUsageLimits, + ) : yield* input.checkProvider.pipe(Effect.provideService(UsagePolling, pollingUsage)); if (probedSnapshot.usageLimits?.externalUsage) { usageRefreshAfter = 0; @@ -293,7 +302,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const settings = yield* input.getSettings; const current = (yield* Ref.get(snapshotStateRef)).snapshot; if (!current.enabled || current.usageLimits?.externalUsage) return; + const usageRefreshKey = yield* input.getUsageRefreshKey ?? Effect.undefined; if ( + (usageRefreshKey === undefined || + usageRefreshKey === current.auth.usageRefreshKey) && (yield* Clock.currentTimeMillis) < usageRefreshAfter && current.usageLimits?.unavailable?.reason !== "unsupported" && !(yield* UsageAfterReset) && @@ -303,7 +315,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return; } yield* input.invalidateUsageCache ?? Effect.void; - yield* applySnapshotBase(settings, { forceRefresh: true }).pipe( + yield* applySnapshotBase(settings, { forceRefresh: true, usageRefreshKey }).pipe( Effect.provideService(UsagePolling, true), ); }), diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 7146c5ec7c536..5d9c21ce2035f 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -360,12 +360,18 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { { name: "skips disabled instances", enabled: false }, { name: "skips unsupported usage", unsupported: true }, { name: "invalidates replaced Go credentials", switchCredentials: true }, + { + name: "invalidates credentials without health", + switchCredentials: true, + withoutHealth: true, + }, ].map((scenario) => ({ enabled: true, demand: true, intervalOff: false, unsupported: false, switchCredentials: false, + withoutHealth: false, ...scenario, })), )( @@ -536,6 +542,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { assert.strictEqual(usageReads, scenario.enabled ? 1 : 0); if (shouldRecover) yield* instance.snapshot.refreshUsage!; const recovered = yield* instance.snapshot.getSnapshot; + if (shouldRecover) yield* instance.snapshot.refreshUsage!; const expectedReads = !scenario.enabled ? 0 : shouldRecover ? 2 : 1; assert.strictEqual(usageReads, expectedReads); assert.strictEqual( @@ -554,22 +561,29 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { yield* TestClock.adjust("1 minute"); currentKey = "replacement-go-key"; replaceCredential(currentKey); - replacingCredential = true; - const health = yield* instance.snapshot.refresh.pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(replacementHealthStarted); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(health); - const changed = yield* instance.snapshot.getSnapshot; - assert.strictEqual(usageReads, expectedReads, "health discovery must not read quota"); - assert.notStrictEqual(changed.auth.usageRefreshKey, previousAuth.usageRefreshKey); - assert.deepStrictEqual(changed.usageLimits?.windows, []); - assert.strictEqual(changed.usageLimits?.refreshAfter, undefined); + if (!scenario.withoutHealth) { + replacingCredential = true; + const health = yield* instance.snapshot.refresh.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(replacementHealthStarted); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(health); + const changed = yield* instance.snapshot.getSnapshot; + assert.strictEqual(usageReads, expectedReads, "health discovery must not read quota"); + assert.notStrictEqual(changed.auth.usageRefreshKey, previousAuth.usageRefreshKey); + assert.deepStrictEqual(changed.usageLimits?.windows, []); + assert.strictEqual(changed.usageLimits?.refreshAfter, undefined); + } yield* instance.snapshot.refreshUsage!; const fresh = yield* instance.snapshot.getSnapshot; assert.strictEqual(usageReads, expectedReads + 1); assert.strictEqual(fresh.usageLimits?.windows[0]?.usedPercent, 80); + assert.notStrictEqual(fresh.auth.usageRefreshKey, previousAuth.usageRefreshKey); + if (scenario.withoutHealth) { + assert.strictEqual(modelReads, catalogReads); + assert.strictEqual(serverStarts, starts); + } assert.notStrictEqual( fresh.usageLimits?.credentialFingerprint, recovered.usageLimits?.credentialFingerprint, diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index a7a6d7fe2bd02..3021d3296da20 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -366,6 +366,14 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(Digest.Digest, digest), ); + const getUsageRefreshKey = + effectiveConfig.enabled && !effectiveConfig.serverUrl.trim() + ? readOpenCodeGoCredentialFingerprint(processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(Digest.Digest, digest), + ) + : Effect.undefined; const checkProvider = Effect.all( { provider: checkOpenCodeProviderStatus( @@ -377,14 +385,7 @@ export const OpenCodeDriver: ProviderDriver< Effect.annotateLogs({ providerInstanceId: instanceId }), Effect.annotateSpans({ providerInstanceId: instanceId }), ), - credentialFingerprint: - effectiveConfig.enabled && !effectiveConfig.serverUrl.trim() - ? readOpenCodeGoCredentialFingerprint(processEnv).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.provideService(Digest.Digest, digest), - ) - : Effect.undefined, + credentialFingerprint: getUsageRefreshKey, usageLimits: Effect.flatMap(UsagePolling, (enabled) => enabled ? checkUsageLimits : Effect.succeed(null), ), @@ -490,6 +491,7 @@ export const OpenCodeDriver: ProviderDriver< makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, checkUsageLimits, + getUsageRefreshKey, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => From 81f8385b54c68bffc9582e13fd25add6b6b3fb88 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 05:49:57 -0500 Subject: [PATCH 79/95] fix(usage): retain failed quotas when credential ownership matches --- .../src/server/managedProvider.test.ts | 51 ++++++++++++++++--- .../src/server/managedProvider.ts | 17 +++++-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index f50183bef5ff6..d61d874521e8b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -190,11 +190,20 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); - it.effect.each(["auth-type", "first-sign-in", "first-discovery", "usage-credentials"] as const)( - "resets usage on account transition: %s", + it.effect.each([ + "auth-type", + "first-sign-in", + "first-discovery", + "usage-credentials", + "unused-credentials", + "unverified-credentials", + ] as const)( + "keeps quota ownership across authentication changes and local credential changes: %s", (transition) => Effect.scoped( Effect.gen(function* () { + const usageOnly = + transition === "unused-credentials" || transition === "unverified-credentials"; const signedIn = { status: "authenticated", type: "chatgpt", @@ -203,13 +212,15 @@ describe("makeManagedServerProvider", () => { const auth = yield* Ref.make( transition === "auth-type" ? { ...signedIn, workspaceId: "ws-a" } - : transition === "usage-credentials" + : transition === "usage-credentials" || usageOnly ? { ...signedIn, usageRefreshKey: "local-a" } : { status: transition === "first-sign-in" ? "unauthenticated" : "unknown" }, ); const reads = yield* Ref.make(0); const limits = { checkedAt: refreshedSnapshot.checkedAt, + credentialFingerprint: + transition === "unverified-credentials" ? undefined : "quota-owner", windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 20 }], } as const; const provider = yield* makeManagedServerProvider({ @@ -218,6 +229,7 @@ describe("makeManagedServerProvider", () => { streamSettings: Stream.empty, haveSettingsChanged: () => false, initialSnapshot: () => Effect.succeed(initialSnapshot), + getUsageRefreshKey: Ref.get(auth).pipe(Effect.map((value) => value.usageRefreshKey)), checkProvider: Effect.gen(function* () { const currentAuth = yield* Ref.get(auth); const polling = yield* UsagePolling; @@ -229,7 +241,14 @@ describe("makeManagedServerProvider", () => { ? { usageLimits: currentAuth.status === "authenticated" - ? limits + ? usageOnly && currentAuth.usageRefreshKey === "local-b" + ? { + checkedAt: limits.checkedAt, + credentialFingerprint: limits.credentialFingerprint, + windows: [], + unavailable: { reason: "probeFailed" as const }, + } + : limits : { checkedAt: refreshedSnapshot.checkedAt, windows: [], @@ -244,18 +263,36 @@ describe("makeManagedServerProvider", () => { yield* provider.refreshUsage!; assert.strictEqual(yield* Ref.get(reads), 1); const unchanged = yield* provider.refresh; - if (transition === "auth-type") { + if (transition === "auth-type" || usageOnly) { assert.deepStrictEqual(unchanged.usageLimits, { ...limits, refreshAfter: 300_000 }); - assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); + if (transition === "auth-type") assert.strictEqual(unchanged.auth.workspaceId, "ws-a"); } + if (usageOnly) yield* TestClock.adjust("1 minute"); yield* Ref.set( auth, transition === "auth-type" ? { status: "authenticated", type: "apiKey" } - : transition === "usage-credentials" + : transition === "usage-credentials" || usageOnly ? { ...signedIn, usageRefreshKey: "local-b" } : signedIn, ); + if (usageOnly) { + yield* provider.refreshUsage!; + const retained = yield* provider.getSnapshot; + if (transition === "unused-credentials") { + assert.deepStrictEqual(retained.usageLimits, { + ...limits, + stale: true, + refreshAfter: 360_000, + }); + } else { + assert.deepStrictEqual(retained.usageLimits?.windows, []); + assert.strictEqual(retained.usageLimits?.unavailable?.reason, "probeFailed"); + } + assert.strictEqual(retained.auth.usageRefreshKey, "local-b"); + assert.strictEqual(yield* Ref.get(reads), 2); + return; + } const switched = yield* provider.refresh; assert.strictEqual(switched.usageLimits, undefined); assert.strictEqual(switched.auth.workspaceId, undefined); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 731d71d0f99b6..bece263a1e678 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -164,11 +164,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( } const current = (yield* Ref.get(snapshotStateRef)).snapshot; - const accountReset = - (yield* UsageAccountReset) || + const accountReset = yield* UsageAccountReset; + if ( + accountReset || (options?.usageRefreshKey !== undefined && - options.usageRefreshKey !== current.auth.usageRefreshKey); - if (accountReset) usageRefreshAfter = 0; + options.usageRefreshKey !== current.auth.usageRefreshKey) + ) + usageRefreshAfter = 0; const now = yield* Clock.currentTimeMillis; const pollingUsage = (yield* UsagePolling) && @@ -203,11 +205,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const previous = state.snapshot.auth; const differs = (a: string | undefined, b: string | undefined) => a !== undefined && b !== undefined && a !== b; + const sameQuotaOwner = + state.snapshot.usageLimits?.credentialFingerprint !== undefined && + state.snapshot.usageLimits.credentialFingerprint === + probedSnapshot.usageLimits?.credentialFingerprint; const switchedAccount = accountReset || (probedSnapshot.auth.status === "authenticated" && previous.status !== "authenticated") || differs(probedSnapshot.auth.type, previous.type) || - differs(probedSnapshot.auth.usageRefreshKey, previous.usageRefreshKey) || + (differs(probedSnapshot.auth.usageRefreshKey, previous.usageRefreshKey) && + (!pollingUsage || !sameQuotaOwner)) || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); From 44b314699f5e4f052876f751ffba0fd6813c53b5 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Fri, 6 Nov 2026 06:21:56 -0500 Subject: [PATCH 80/95] fix(usage): invalidate freshness on Grok account and instance changes --- .../client-runtime/src/state/usage.test.ts | 19 ++++++ packages/client-runtime/src/state/usage.ts | 3 +- packages/contracts/src/server.ts | 2 + .../src/server/instanceIdentity.test.ts | 31 ++++++++++ .../src/server/instanceIdentity.ts | 22 ++++--- .../provider-grok/src/server/driver.test.ts | 61 +++++++++++++++---- packages/provider-grok/src/server/driver.ts | 13 +++- .../provider-grok/src/server/usageLimits.ts | 18 +++++- 8 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 packages/provider-core/src/server/instanceIdentity.test.ts diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 992749099aa4a..117b96bc98dbe 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -21,6 +21,7 @@ import { refreshUsageLimits, usageLimitsRefreshDelay, usageRefreshDeadline, + usageRefreshAccountKey, } from "./usage.ts"; const input = { @@ -293,6 +294,24 @@ describe("limits refresh cooldown", () => { ).toBe("claude due"); expect(usageLimitsRefreshDelay(id, ["claude"])).toBe(5 * 60_000); expect(usageLimitsRefreshDelay(id, ["claude", "codex"])).toBe(4 * 60_000); + const rebuilt = EnvironmentId.make("limits-rebuilt-instance"); + const provider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + auth: { status: "authenticated" as const }, + instanceRevision: 1, + }; + now.mockReturnValue(0); + const oldKey = usageRefreshAccountKey([provider]); + await refreshUsageLimits(rebuilt, async () => "old quota", true, false, oldKey); + now.mockReturnValue(60_000); + const currentKey = usageRefreshAccountKey([{ ...provider, instanceRevision: 2 }]); + const readRebuilt = vi.fn(async () => "rebuilt quota"); + expect(await refreshUsageLimits(rebuilt, readRebuilt, true, false, currentKey)).toBe( + "rebuilt quota", + ); + expect(await refreshUsageLimits(rebuilt, readRebuilt, true, false, currentKey)).toBeUndefined(); + expect(readRebuilt).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 1f01cb22da58e..5ef3575e119c5 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -124,7 +124,7 @@ export function usageRefreshDeadline( } export function usageRefreshAccountKey( - providers: readonly Pick[], + providers: readonly Pick[], driver?: ProviderDriverKind | null, instanceId?: ProviderInstanceId | null, generation = 0, @@ -140,6 +140,7 @@ export function usageRefreshAccountKey( .sort((a, b) => a.instanceId.localeCompare(b.instanceId)) .map((provider) => [ provider.instanceId, + provider.instanceRevision, provider.auth?.status, provider.auth?.type, provider.auth?.email?.toLowerCase(), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 4092432e340ce..77406537eec1b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -209,6 +209,8 @@ export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; export const ServerProvider = Schema.Struct({ // Only instanceId is stable enough for provider routing. instanceId: ProviderInstanceId, + // Stable across snapshots; changes when the provider instance is rebuilt. + instanceRevision: Schema.optional(NonNegativeInt), driver: ProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), accentColor: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/provider-core/src/server/instanceIdentity.test.ts b/packages/provider-core/src/server/instanceIdentity.test.ts new file mode 100644 index 0000000000000..d9d1b182fb9b1 --- /dev/null +++ b/packages/provider-core/src/server/instanceIdentity.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vite-plus/test"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; + +it("keeps a revision across snapshots and replaces it when the instance is rebuilt", () => { + const input = { + instanceId: ProviderInstanceId.make("opencode"), + driverKind: ProviderDriverKind.make("opencode"), + displayName: undefined, + accentColor: undefined, + continuationGroupKey: "opencode", + }; + const snapshot = { + enabled: true, + installed: true, + version: null, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-11-06T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + }; + const stamp = withInstanceIdentity(input); + const initial = stamp(snapshot); + const updated = stamp({ ...snapshot, checkedAt: "2026-11-06T00:01:00Z" }); + const rebuilt = withInstanceIdentity({ ...input, displayName: "Renamed" })(snapshot); + expect(initial.instanceRevision).toBeDefined(); + expect(updated.instanceRevision).toBe(initial.instanceRevision); + expect(rebuilt.instanceRevision).not.toBe(initial.instanceRevision); +}); diff --git a/packages/provider-core/src/server/instanceIdentity.ts b/packages/provider-core/src/server/instanceIdentity.ts index 5c54617f9c516..a44795d6255f6 100644 --- a/packages/provider-core/src/server/instanceIdentity.ts +++ b/packages/provider-core/src/server/instanceIdentity.ts @@ -3,19 +3,23 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; -export const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly driverKind: ProviderDriverKind; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ +let nextInstanceRevision = 0; + +export const withInstanceIdentity = (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; +}) => { + const instanceRevision = ++nextInstanceRevision; + return (snapshot: ServerProviderDraft): ServerProvider => ({ ...snapshot, instanceId: input.instanceId, + instanceRevision, driver: input.driverKind, ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), continuation: { groupKey: input.continuationGroupKey }, }); +}; diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 839c0b343b3f6..5c1a099341952 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -176,7 +176,7 @@ it.layer(layerTest)("GrokDriver", (it) => { }); } - it.effect.each(["subscription", "api-key", "custom-auth", "missing-token"] as const)( + it.effect.each(["subscription", "api-key", "custom-auth", "missing-token", "key-only"] as const)( "keeps health-only usage capability accurate for %s", (mode) => Effect.scoped( @@ -194,6 +194,12 @@ it.layer(layerTest)("GrokDriver", (it) => { ); const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-usage-" }); + const writeCredential = (key: string) => + fs.writeFileString( + `${home}/auth.json`, + JSON.stringify({ "https://accounts.x.ai/sign-in": { key } }), + ); + if (mode === "key-only") yield* writeCredential("test-token"); let requests = 0; const instance = yield* GrokDriver.create({ instanceId: ProviderInstanceId.make("grok-usage"), @@ -210,12 +216,15 @@ it.layer(layerTest)("GrokDriver", (it) => { }, { name: "GROK_AUTH", - value: JSON.stringify({ - "https://accounts.x.ai/sign-in": { - key: mode === "missing-token" ? "" : "test-token", - email: "account@example.com", - }, - }), + value: + mode === "key-only" + ? "" + : JSON.stringify({ + "https://accounts.x.ai/sign-in": { + key: mode === "missing-token" ? "" : "test-token", + email: "account@example.com", + }, + }), sensitive: true, }, ], @@ -225,15 +234,27 @@ it.layer(layerTest)("GrokDriver", (it) => { HttpClient.make((request) => { requests += 1; return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + HttpClientResponse.fromWeb( + request, + mode === "key-only" + ? Response.json({ + config: { + creditUsagePercent: + request.headers.authorization === "Bearer test-token" ? 80 : 25, + }, + }) + : new Response(null, { status: 503 }), + ), ); }), ), ); const health = yield* instance.snapshot.refresh; - const supported = mode === "subscription"; + const supported = mode === "subscription" || mode === "key-only"; expect(health.auth.email).toBe( - mode === "api-key" || mode === "custom-auth" ? undefined : "account@example.com", + mode === "api-key" || mode === "custom-auth" || mode === "key-only" + ? undefined + : "account@example.com", ); expect(health.supportsUsageLimits).toBe(supported); expect(hasProviderUsageLimits(health.driver, [health], [])).toBe(supported); @@ -242,9 +263,27 @@ it.layer(layerTest)("GrokDriver", (it) => { yield* instance.snapshot.refreshUsage!; const usage = yield* instance.snapshot.getSnapshot; expect(usage.usageLimits?.unavailable?.reason).toBe( - supported ? "probeFailed" : "unsupported", + mode === "key-only" ? undefined : supported ? "probeFailed" : "unsupported", ); expect(requests).toBe(supported ? 1 : 0); + if (mode === "key-only") { + expect(usage.usageLimits?.windows[0]?.usedPercent).toBe(80); + yield* writeCredential("account-b"); + const changed = yield* instance.snapshot.refresh; + expect(requests).toBe(1); + expect(changed.auth.usageRefreshKey).not.toBe(health.auth.usageRefreshKey); + expect(changed.usageLimits?.windows.length ?? 0).toBe(0); + yield* instance.snapshot.refreshUsage!; + const fresh = yield* instance.snapshot.getSnapshot; + expect(requests).toBe(2); + expect(fresh.usageLimits?.windows[0]?.usedPercent).toBe(25); + yield* writeCredential("account-c"); + yield* instance.snapshot.refreshUsage!; + expect(requests).toBe(3); + expect((yield* instance.snapshot.getSnapshot).auth.usageRefreshKey).not.toBe( + fresh.auth.usageRefreshKey, + ); + } }), ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), ); diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 9f9674295046e..1cef3ee053694 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -173,6 +173,14 @@ export const GrokDriver: ProviderDriver ); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); + const getUsageRefreshKey = readGrokAccount(processEnv, false).pipe( + Effect.map((account) => account.usageRefreshKey), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Digest.Digest, digest), + ); + const checkProvider = Effect.flatMap(UsagePolling, (includeUsage) => checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.filterOrElse( @@ -184,9 +192,9 @@ export const GrokDriver: ProviderDriver ), (snapshot) => readGrokAccount(processEnv, includeUsage).pipe( - Effect.map(({ email, usageLimits, supportsUsageLimits }) => ({ + Effect.map(({ email, usageLimits, supportsUsageLimits, usageRefreshKey }) => ({ ...snapshot, - auth: email ? { ...snapshot.auth, email } : snapshot.auth, + auth: { ...snapshot.auth, ...(email ? { email } : {}), usageRefreshKey }, ...(includeUsage ? { usageLimits } : {}), supportsUsageLimits, })), @@ -211,6 +219,7 @@ export const GrokDriver: ProviderDriver initialSnapshot: (settings) => buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, + getUsageRefreshKey, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index f8cbb4f9e1c61..fdfaf50558494 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -179,16 +179,20 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), ); if (Option.isNone(credential)) - return { email: undefined, usageLimits: probeFailed, supportsUsageLimits: false }; + return { + email: undefined, + usageLimits: probeFailed, + supportsUsageLimits: false, + usageRefreshKey: undefined, + }; const email = credential.value?.email?.trim() || undefined; const token = credential.value?.key?.trim(); - if (!includeUsage) - return { email, usageLimits: probeFailed, supportsUsageLimits: Boolean(token) }; if (!token) { return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), supportsUsageLimits: false, + usageRefreshKey: undefined, }; } const digest = yield* Digest.Digest; @@ -198,6 +202,13 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( new TextEncoder().encode(email ? `grok\0account\0${email}` : `grok\0token\0${token}`), ) .pipe(Effect.map(Hex.encode), Effect.orDie); + if (!includeUsage) + return { + email, + usageLimits: probeFailed, + supportsUsageLimits: true, + usageRefreshKey: credentialFingerprint, + }; let refreshAfter: number | undefined; const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -240,5 +251,6 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( ...(refreshAfter !== undefined ? { refreshAfter } : {}), }, supportsUsageLimits: true, + usageRefreshKey: credentialFingerprint, }; }); From d761d8138393bc9fdd12babd35176accabd82305 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 07:06:02 -0500 Subject: [PATCH 81/95] fix(usage): preserve visible provider filter after main integration --- apps/web/src/components/usage/UsagePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2bdaea5b6db24..80eec86064011 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -6,6 +6,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { USAGE_CONTRACT_VERSION, + ProviderDriverKind, type EnvironmentId, type UsageProviderKind, type UsageSummaryInput, From ac58a17c0ed162e3204bb4f10f04fb09b1ed4216 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 07:18:26 -0500 Subject: [PATCH 82/95] fix(usage): retain cold-start command availability --- packages/shared/src/usageLimits.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index eea07fdfa8289..d7fe9b04d86c9 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -749,6 +749,15 @@ export function withUsageLimitsCommands( ): ServerProvider[] { const supportedDrivers = new Set([ ...providersWithLimits(providers).map((provider) => provider.driver), + ...providers + .filter( + (provider) => + provider.supportsUsageLimits === true && + provider.enabled && + provider.installed && + isProviderAvailable(provider), + ) + .map((provider) => provider.driver), ...sources.flatMap((source) => source.accounts.map((account) => account.driver)), ]); const unreadableSource = sources.some( From d672184ceafd99995c537d9b24783189894610c4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 07:41:34 -0500 Subject: [PATCH 83/95] fix(mobile): pace retries of overdue usage readings --- .../features/usage/usageLimitsRefresh.test.ts | 37 +++++++++++++++++++ .../src/features/usage/usageLimitsRefresh.ts | 9 +++-- .../usage/useInstanceLimitsRefresh.ts | 2 +- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index b333d4f436df5..17f98b56cc5be 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -203,3 +203,40 @@ it.each(["sign-in", "account-switch", "restart"] as const)( } }, ); + +it("paces automatic retries when the server retains an overdue reading", async () => { + vi.useFakeTimers({ now: 9_000_000 }); + const environmentId = EnvironmentId.make("mobile-limits-overdue-retry"); + const refresh = vi.fn(() => + Promise.resolve( + AsyncResult.success({ + providers: state.providers.map((provider): ServerProvider => ({ + ...provider, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { checkedAt: "2026-01-01T00:00:00Z", windows: [], refreshAfter: 8_999_000 }, + })), + }), + ), + ); + const { registry } = setup(refresh); + const unmount = registry.mount(limitsAutoRefreshSetAtom(environmentIdsKey([environmentId]))); + try { + await refreshEnvironmentLimits(registry, environmentId, { automatic: true }); + await vi.advanceTimersByTimeAsync(29_999); + expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + unmount(); + registry.dispose(); + vi.useRealTimers(); + } +}); diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 36208f466a723..6e456e88aba91 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -193,9 +193,12 @@ const limitsAutoRefreshAtom = Atom.family((environmentId: EnvironmentId) => () => { void refresh(); }, - usageLimitsRefreshDelay( - environmentId, - environmentLimitsRefreshKeys(get.registry, environmentId), + Math.max( + 30_000, + usageLimitsRefreshDelay( + environmentId, + environmentLimitsRefreshKeys(get.registry, environmentId), + ), ), ); } diff --git a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts index 11e8b3590e165..4260e0c6654ed 100644 --- a/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/useInstanceLimitsRefresh.ts @@ -45,7 +45,7 @@ export function useInstanceLimitsRefresh( if (!disposed) timer = setTimeout( refresh, - usageLimitsRefreshDelay(environmentId, selectionKey.resources), + Math.max(30_000, usageLimitsRefreshDelay(environmentId, selectionKey.resources)), ); }; void refreshEnvironmentLimits(registry, environmentId, { From 6ba05592409f49a24f6e04883d2ec122f3c5c672 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 07:47:33 -0500 Subject: [PATCH 84/95] fix(usage): retain owned quotas through health-only marker changes --- .../src/server/managedProvider.test.ts | 25 ++++++++++++---- .../src/server/managedProvider.ts | 30 ++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index d61d874521e8b..7b7a2d596bb1c 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -197,13 +197,18 @@ describe("makeManagedServerProvider", () => { "usage-credentials", "unused-credentials", "unverified-credentials", + "unused-credentials-health", + "unverified-credentials-health", + "changed-credentials-health", ] as const)( "keeps quota ownership across authentication changes and local credential changes: %s", (transition) => Effect.scoped( Effect.gen(function* () { const usageOnly = - transition === "unused-credentials" || transition === "unverified-credentials"; + transition === "unused-credentials" || + transition === "unverified-credentials" || + transition.endsWith("-health"); const signedIn = { status: "authenticated", type: "chatgpt", @@ -219,8 +224,9 @@ describe("makeManagedServerProvider", () => { const reads = yield* Ref.make(0); const limits = { checkedAt: refreshedSnapshot.checkedAt, - credentialFingerprint: - transition === "unverified-credentials" ? undefined : "quota-owner", + credentialFingerprint: transition.startsWith("unverified-credentials") + ? undefined + : "quota-owner", windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 20 }], } as const; const provider = yield* makeManagedServerProvider({ @@ -244,7 +250,10 @@ describe("makeManagedServerProvider", () => { ? usageOnly && currentAuth.usageRefreshKey === "local-b" ? { checkedAt: limits.checkedAt, - credentialFingerprint: limits.credentialFingerprint, + credentialFingerprint: + transition === "changed-credentials-health" + ? "other-owner" + : limits.credentialFingerprint, windows: [], unavailable: { reason: "probeFailed" as const }, } @@ -277,9 +286,15 @@ describe("makeManagedServerProvider", () => { : signedIn, ); if (usageOnly) { + if (transition.endsWith("-health")) { + const health = yield* provider.refresh; + assert.deepStrictEqual(health.usageLimits?.windows ?? [], []); + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(reads), 1); + } yield* provider.refreshUsage!; const retained = yield* provider.getSnapshot; - if (transition === "unused-credentials") { + if (transition.startsWith("unused-credentials")) { assert.deepStrictEqual(retained.usageLimits, { ...limits, stale: true, diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index bece263a1e678..e31dbbd400223 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -31,6 +31,7 @@ import type { ManagedServerProvider } from "./snapshot.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; readonly enrichmentGeneration: number; + readonly retainedUsageLimits?: ServerProvider["usageLimits"]; } function withUsageLimits( @@ -205,24 +206,39 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const previous = state.snapshot.auth; const differs = (a: string | undefined, b: string | undefined) => a !== undefined && b !== undefined && a !== b; + const previousLimits = state.retainedUsageLimits ?? state.snapshot.usageLimits; const sameQuotaOwner = - state.snapshot.usageLimits?.credentialFingerprint !== undefined && - state.snapshot.usageLimits.credentialFingerprint === + previousLimits?.credentialFingerprint !== undefined && + previousLimits.credentialFingerprint === probedSnapshot.usageLimits?.credentialFingerprint; - const switchedAccount = + const changedUsageKey = differs( + probedSnapshot.auth.usageRefreshKey, + previous.usageRefreshKey, + ); + const changedAccount = accountReset || (probedSnapshot.auth.status === "authenticated" && previous.status !== "authenticated") || differs(probedSnapshot.auth.type, previous.type) || - (differs(probedSnapshot.auth.usageRefreshKey, previous.usageRefreshKey) && - (!pollingUsage || !sameQuotaOwner)) || differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && probedSnapshot.auth.workspaceId !== previous.workspaceId); + const switchedAccount = + changedAccount || (changedUsageKey && (!pollingUsage || !sameQuotaOwner)); + // A health-only marker change cannot establish ownership of the selected quota credential. + const retainedUsageLimits = + !pollingUsage && + !changedAccount && + probedSnapshot.auth.status === "authenticated" && + (changedUsageKey || state.retainedUsageLimits !== undefined) && + previousLimits?.credentialFingerprint !== undefined + ? previousLimits + : undefined; if (switchedAccount && !pollingUsage) usageRefreshAfter = 0; const usageLimits = switchedAccount ? probedSnapshot.usageLimits : resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, + published: + pollingUsage && sameQuotaOwner ? previousLimits : state.snapshot.usageLimits, probed: !pollingUsage && probedSnapshot.auth.status === "authenticated" && @@ -244,7 +260,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); return [ { snapshot, generation }, - { snapshot, enrichmentGeneration: generation }, + { snapshot, enrichmentGeneration: generation, retainedUsageLimits }, ] as const; }, ); From 09a65ab95439ab8b16c44ae4214255f7100b5c2b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 08:03:24 -0500 Subject: [PATCH 85/95] fix(grok): clear subscription quotas for unsupported auth --- .../provider-grok/src/server/driver.test.ts | 28 +++++++++++++++++-- packages/provider-grok/src/server/driver.ts | 4 ++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 5c1a099341952..70b7814d7f9af 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -19,6 +19,7 @@ import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEve import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { GrokDriver } from "./driver.ts"; import * as GrokStatus from "./status.ts"; +import * as GrokUsage from "./usageLimits.ts"; import { resolveLatestProviderVersion } from "@t3tools/provider-core/server/maintenanceResolver"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -182,6 +183,21 @@ it.layer(layerTest)("GrokDriver", (it) => { Effect.scoped( Effect.gen(function* () { const config = GrokDriver.defaultConfig(); + let customAuth = false; + const readAccount = GrokUsage.readGrokAccount; + using _account = vi + .spyOn(GrokUsage, "readGrokAccount") + .mockImplementation((environment, includeUsage) => + Effect.suspend(() => + readAccount( + { + ...environment, + ...(customAuth ? { GROK_OIDC_ISSUER: "https://custom.test" } : {}), + }, + includeUsage, + ), + ), + ); const initial = yield* GrokStatus.buildInitialGrokProviderSnapshot(config); using _status = vi.spyOn(GrokStatus, "checkGrokProviderStatus").mockReturnValue( Effect.succeed({ @@ -257,8 +273,9 @@ it.layer(layerTest)("GrokDriver", (it) => { : "account@example.com", ); expect(health.supportsUsageLimits).toBe(supported); - expect(hasProviderUsageLimits(health.driver, [health], [])).toBe(supported); - expect(health.usageLimits).toBeUndefined(); + expect(hasProviderUsageLimits(health.driver, [health], [])).toBe(true); + if (supported) expect(health.usageLimits).toBeUndefined(); + else expect(health.usageLimits?.unavailable?.reason).toBe("unsupported"); expect(requests).toBe(0); yield* instance.snapshot.refreshUsage!; const usage = yield* instance.snapshot.getSnapshot; @@ -283,6 +300,13 @@ it.layer(layerTest)("GrokDriver", (it) => { expect((yield* instance.snapshot.getSnapshot).auth.usageRefreshKey).not.toBe( fresh.auth.usageRefreshKey, ); + customAuth = true; + const unsupported = yield* instance.snapshot.refresh; + expect(unsupported.auth.type).toBe("cached_token"); + expect(unsupported.supportsUsageLimits).toBe(false); + expect(unsupported.usageLimits?.windows).toEqual([]); + expect(unsupported.usageLimits?.unavailable?.reason).toBe("unsupported"); + expect(requests).toBe(3); } }), ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 1cef3ee053694..836200df7871e 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -195,7 +195,9 @@ export const GrokDriver: ProviderDriver Effect.map(({ email, usageLimits, supportsUsageLimits, usageRefreshKey }) => ({ ...snapshot, auth: { ...snapshot.auth, ...(email ? { email } : {}), usageRefreshKey }, - ...(includeUsage ? { usageLimits } : {}), + ...(includeUsage || usageLimits.unavailable?.reason === "unsupported" + ? { usageLimits } + : {}), supportsUsageLimits, })), ), From febc487fa963845890de3e06de2312d734fd862b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 08:04:11 -0500 Subject: [PATCH 86/95] test(codex): report failures before subprocess startup --- .../CodexTextGeneration.test.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index c091c818aea08..1c95cc02ebb7a 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -214,13 +214,18 @@ function withFakeCodexEnv( } it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { - it.effect.each(["exec", "mcp list"] as const)( - "kills a stalled %s CLI when its request is interrupted", + it.effect.each(["exec", "mcp list", "startup failure"] as const)( + "observes subprocess startup and cancellation: %s", (command) => Effect.gen(function* () { const probe = yield* makeProcessProbe; yield* withFakeCodexEnv( - { output: "", lifecycle: { port: probe.port, command } }, + { + output: "", + ...(command === "startup failure" + ? { exitCode: 1, stderr: "fixture failed before startup" } + : { lifecycle: { port: probe.port, command } }), + }, (textGeneration) => Effect.gen(function* () { const fiber = yield* textGeneration @@ -230,7 +235,22 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { modelSelection: DEFAULT_TEST_MODEL_SELECTION, }) .pipe(Effect.forkScoped); - yield* probe.started; + const startup = probe.started.pipe( + Effect.raceFirst( + Fiber.join(fiber).pipe( + Effect.andThen(Effect.die("Generation completed before fixture startup")), + ), + ), + ); + if (command === "startup failure") { + const exit = yield* Effect.exit(startup); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("fixture failed before startup"); + } + return; + } + yield* startup; const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkScoped); yield* probe.exited; From 2a2863c7b4cee6170b016cb36de6a00444ff7a87 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 08:16:07 -0500 Subject: [PATCH 87/95] fix(web): pace repeated overdue quota refreshes --- .../src/hooks/useLiveRefresh.browser.test.tsx | 65 ++++++++++++------- apps/web/src/hooks/useLiveRefresh.ts | 7 ++ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 822d93470c7d7..a03a841b5201c 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -139,29 +139,48 @@ describe("useLiveRefresh", () => { }, ); - it("rechecks an overdue server deadline immediately after a wider read", async () => { - let complete!: () => void; - const refresh = vi.fn().mockReturnValueOnce( - new Promise((resolve) => { - complete = resolve; - }), - ); - // This probe only observes the scheduler. - await using _screen = await render( - (refresh.mock.calls.length === 1 ? 300_000 : 610_000)} - />, - { router: false }, - ); - using _timers = fakeTimers({ now: 310_000 }); - await act(() => complete()); - await act(() => vi.advanceTimersByTime(0)); - expect(refresh).toHaveBeenCalledTimes(2); - }); + it.each(["advances", "remains overdue", "remounted overdue"] as const)( + "rechecks an overdue server deadline after a wider read, then paces retries (%s)", + async (lifecycle) => { + let complete!: () => void; + const refresh = vi.fn().mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + const probe = () => ( + + refresh.mock.calls.length === 1 || lifecycle !== "advances" ? 300_000 : 610_000 + } + /> + ); + // This probe only observes the scheduler. + await using screen = await render(probe(), { router: false }); + using _timers = fakeTimers({ now: 310_000 }); + await act(() => complete()); + await act(() => vi.advanceTimersByTime(0)); + expect(refresh).toHaveBeenCalledTimes(2); + if (lifecycle === "remounted overdue") { + await screen.rerender(<>); + await screen.rerender(probe()); + } + await act(() => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("focus")); + vi.advanceTimersByTime(LIVE_REFRESH_MIN_INTERVAL_MS - 1); + }); + expect(refresh).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(lifecycle === "advances" ? 2 : 3); + await act(() => vi.advanceTimersByTime(LIVE_REFRESH_MIN_INTERVAL_MS)); + expect(refresh).toHaveBeenCalledTimes(lifecycle === "advances" ? 2 : 4); + }, + ); it("rechecks the shared deadline between simultaneous usage consumers", async () => { const reads: Array<{ promise: Promise; resolve: () => void }> = []; diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index da5a5d4b019b3..47fb5ffeaa163 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -43,6 +43,7 @@ function shouldRefreshOnInterval(input: { // Survive remounts so returning to a view does not make fresh reads look overdue. const lastRefreshedAtByView = new Map(); const pendingRefreshesByView = new Map>(); +const overdueRefreshesByView = new Set(); let lastInteractedAt = 0; const interactionWatchers = new Set<() => void>(); @@ -147,6 +148,12 @@ export function useLiveRefresh( .finally(() => { pendingRefreshesByView.delete(requestKey); const completedAt = Date.now(); + const overdue = deadline !== undefined && deadline <= completedAt; + if (overdue && overdueRefreshesByView.has(requestKey)) { + deadline = completedAt + LIVE_REFRESH_MIN_INTERVAL_MS; + } + if (overdue) overdueRefreshesByView.add(requestKey); + else overdueRefreshesByView.delete(requestKey); const observedAt = deadline !== undefined ? Math.min(completedAt, deadline - refreshFloorMs) From 43ddeafcb5969cdb77ec784b9914253cb9267822 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 09:00:22 -0500 Subject: [PATCH 88/95] fix(claude): invalidate quota freshness on local account changes --- .../src/provider/Drivers/ClaudeDriver.ts | 25 +++++- .../provider/ProviderInstanceRegistry.test.ts | 82 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 77a2b3b7e3b90..9a3f36dac90ff 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -7,6 +7,8 @@ import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Digest from "effect/crypto/Digest"; +import * as Hex from "effect/encoding/Hex"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -79,6 +81,7 @@ export type ClaudeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator | SecureRandom.SecureRandom + | Digest.Digest | FileSystem.FileSystem | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions @@ -105,6 +108,7 @@ export const ClaudeDriver: ProviderDriver + digest.digest(Digest.sha256, new TextEncoder().encode(identity)), + ), + Effect.map(Hex.encode), + Effect.orDie, + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + const checkProvider = catalogService.refreshInBackground.pipe( Effect.andThen( modelCatalog.pipe( @@ -301,7 +314,16 @@ export const ClaudeDriver: ProviderDriver + getUsageRefreshKey.pipe( + Effect.map((usageRefreshKey) => + stampIdentity({ + ...provider, + auth: { ...provider.auth, usageRefreshKey }, + }), + ), + ), + ), ), ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -321,6 +343,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index c18002912985b..ae563390919ad 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -5,6 +5,7 @@ import * as CodexInstallation from "./CodexInstallation.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -470,6 +471,87 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); + it.live("refreshes Claude quotas immediately after a local account switch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "claude-quota-account-" }); + const binaryPath = writeFakeCli({ + directory: path.join(home, "bin"), + name: "claude", + source: 'process.stdout.write("2.1.219");', + }); + let account = "a"; + const writeAccount = () => + fs.writeFileString( + path.join(home, ".claude.json"), + JSON.stringify({ + oauthAccount: { + organizationUuid: "shared-org", + accountUuid: account, + emailAddress: `${account}@example.com`, + }, + }), + ); + yield* writeAccount(); + let quotaReads = 0; + using _query = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: async () => ({ + commands: [], + account: { + email: `${account}@example.com`, + subscriptionType: "pro", + tokenSource: "oauth", + }, + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { + quotaReads++; + return { + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: account === "a" ? 100 : 25, + resets_at: "2026-07-18T14:39:00Z", + }, + }, + }; + }, + }) as unknown as ReturnType, + ); + const instanceId = ProviderInstanceId.make("claude-quota-account-switch"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ClaudeDriver.driverKind, + enabled: true, + config: makeClaudeConfig({ enabled: true, binaryPath, homePath: home }), + }, + }, + }); + const instance = (yield* registry.getInstance(instanceId))!; + yield* instance.snapshot.refresh; + yield* instance.snapshot.refreshUsage!; + const first = yield* instance.snapshot.getSnapshot; + expect(first.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(quotaReads).toBe(1); + yield* instance.snapshot.refreshUsage!; + expect(quotaReads).toBe(1); + account = "b"; + yield* writeAccount(); + yield* instance.snapshot.refreshUsage!; + const switched = yield* instance.snapshot.getSnapshot; + expect(switched.auth.email).toBe("b@example.com"); + expect(switched.usageLimits?.windows[0]?.usedPercent).toBe(25); + expect(switched.auth.usageRefreshKey).not.toBe(first.auth.usageRefreshKey); + expect(quotaReads).toBe(2); + yield* instance.snapshot.refreshUsage!; + expect(quotaReads).toBe(2); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("scopes Claude's one-probe fallback to the logged-in organization", () => Effect.gen(function* () { if (yield* HostProcess.isWindows) return; From 5b0afcd50b69ac590fbe30251e0cbee52bb2a11b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 09:38:19 -0500 Subject: [PATCH 89/95] fix(usage): retain expiry on synthesized cooldown responses --- packages/provider-core/src/server/usageHttpClient.test.ts | 7 ++++++- packages/provider-core/src/server/usageHttpClient.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/provider-core/src/server/usageHttpClient.test.ts b/packages/provider-core/src/server/usageHttpClient.test.ts index ca77e2e5367b4..f183e1c1b0963 100644 --- a/packages/provider-core/src/server/usageHttpClient.test.ts +++ b/packages/provider-core/src/server/usageHttpClient.test.ts @@ -10,6 +10,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { makeUsageHttpClient, usageResponseCheckedAt, + usageReadRefreshAfter, UsageAccountIdentity, } from "./usageHttpClient.ts"; @@ -90,7 +91,11 @@ it.effect.each(["seconds", "date", "missing"] as const)( yield* client.execute(request("b")); assert.strictEqual(calls, 2); yield* TestClock.adjust(kind === "missing" ? "4 minutes" : "9 minutes"); - yield* client.execute(request("a", "?manual=1")); + const synthesized = yield* client.execute(request("a", "?manual=1")); + assert.strictEqual( + usageReadRefreshAfter(synthesized), + now + (kind === "missing" ? 300_000 : 600_000), + ); assert.strictEqual(calls, 2); yield* TestClock.adjust("1 minute"); yield* client.execute(request("a", "?manual=2")); diff --git a/packages/provider-core/src/server/usageHttpClient.ts b/packages/provider-core/src/server/usageHttpClient.ts index 60ba6173afdeb..3c02265314059 100644 --- a/packages/provider-core/src/server/usageHttpClient.ts +++ b/packages/provider-core/src/server/usageHttpClient.ts @@ -130,13 +130,15 @@ export const makeUsageHttpClient = Effect.fn("makeUsageHttpClient")(function ( return response; } if (now < state.retryAt) { - return HttpClientResponse.fromWeb( + const response = HttpClientResponse.fromWeb( request, new Response(null, { status: 429, headers: { "retry-after": String(Math.ceil((state.retryAt - now) / 1000)) }, }), ); + samples.set(response, { refreshAfter: state.retryAt }); + return response; } const generation = state.generation; const result = yield* read.pipe( From e5a2d7d693f4341132a716ac7130eea5774c507b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 09:39:12 -0500 Subject: [PATCH 90/95] fix(codex): track native quota ownership before reusing freshness --- .../src/provider/Drivers/CodexDriver.ts | 57 ++++++++++- .../provider/ProviderInstanceRegistry.test.ts | 94 +++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 736591aec3565..104a13eb664c8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,6 +1,8 @@ import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; +import * as Hex from "effect/encoding/Hex"; +import * as Option from "effect/Option"; import * as SecureRandom from "effect/crypto/SecureRandom"; import type * as Signature from "effect/crypto/Signature"; import * as Effect from "effect/Effect"; @@ -59,8 +61,22 @@ import * as CodexManagedTokenBroker from "../CodexManagedTokenBroker.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { openAiUserId } from "../../telemetry/Identify.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); +const decodeNativeAccount = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + tokens: Schema.Struct({ + account_id: Schema.String, + id_token: Schema.optionalKey(Schema.String), + }), + }), + ), +); +const encodeUsageOwner = Schema.encodeSync( + Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.String])), +); const DRIVER_KIND = ProviderDriverKind.make("codex"); const CODEX_STANDALONE_HOME_PATTERN = /^(.*)\/packages\/standalone\//is; @@ -147,6 +163,7 @@ export const CodexDriver: ProviderDriver< config, }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const digest = yield* Digest.Digest; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -229,6 +246,26 @@ export const CodexDriver: ProviderDriver< ), ); + const usageAccount = Effect.gen(function* () { + const account = yield* fileSystem + .readFileString( + pathService.join(homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath, "auth.json"), + ) + .pipe(Effect.flatMap(decodeNativeAccount), Effect.option); + if (Option.isNone(account)) return undefined; + const workspaceId = account.value.tokens.account_id.trim(); + if (!workspaceId) return undefined; + const personId = Option.getOrUndefined(openAiUserId(account.value.tokens.id_token ?? "")); + const usageRefreshKey = yield* digest + .digest( + Digest.sha256, + new TextEncoder().encode(encodeUsageOwner(["chatgpt", workspaceId, personId ?? ""])), + ) + .pipe(Effect.map(Hex.encode), Effect.orDie); + return { workspaceId, usageRefreshKey }; + }); + const getUsageRefreshKey = usageAccount.pipe(Effect.map((account) => account?.usageRefreshKey)); + // Kick the TTL-gated manifest refresh in the background and classify with the in-memory // manifest, so a slow or hung fetch never delays the provider check. const checkProvider = modelCatalog.refreshInBackground.pipe( @@ -244,10 +281,27 @@ export const CodexDriver: ProviderDriver< ), ), currentCatalog, - (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), + (draft, catalog) => applyCodexModelCatalog(draft, catalog), { concurrent: true }, ), ), + Effect.flatMap((draft) => + usageAccount.pipe( + Effect.map((account) => + stampIdentity({ + ...draft, + auth: + draft.auth.type === "chatgpt" && account + ? { + ...draft.auth, + ...account, + workspaceId: draft.auth.workspaceId ?? account.workspaceId, + } + : draft.auth, + }), + ), + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), @@ -265,6 +319,7 @@ export const CodexDriver: ProviderDriver< (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), ), checkProvider, + getUsageRefreshKey, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index ae563390919ad..cf128217ead5c 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -471,6 +471,100 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); + it.live("tracks native Codex quota ownership before its cached deadline", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "codex-quota-owner-" }); + const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-owner-" }); + yield* fs.writeFileString( + path.join(sharedHome, "auth.json"), + JSON.stringify({ tokens: { account_id: "unrelated-shared-workspace" } }), + ); + const authPath = path.join(home, "auth.json"); + const readsPath = path.join(home, "quota-reads"); + const writeAccount = (workspace: string, issuedAt = 1) => + fs.writeFileString( + authPath, + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + account_id: workspace, + id_token: `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { user_id: "same-person" }, iat: issuedAt }))}.signature`, + }, + }), + ); + yield* writeAccount("workspace-a"); + const binaryPath = writeFakeCli({ + directory: path.join(home, "bin"), + name: "codex", + source: String.raw` + import * as fs from "node:fs"; + import * as readline from "node:readline"; + if (process.argv.includes("--version")) { + console.log("codex-cli 0.0.0"); process.exit(0); + } + readline.createInterface({ input: process.stdin }).on("line", (line) => { + const { id, method } = JSON.parse(line); + if (id === undefined) return; + const workspace = JSON.parse(fs.readFileSync(${JSON.stringify(authPath)}, "utf8")).tokens.account_id; + let result = {}; + if (method === "initialize") result = { userAgent: "fixture/0.0.0", codexHome: ${JSON.stringify(home)}, platformFamily: "unix", platformOs: "linux" }; + if (method === "account/read") result = { account: { type: "chatgpt", email: "same@example.com", planType: "plus" }, requiresOpenaiAuth: false }; + if (method === "model/list" || method === "skills/list") result = { data: [] }; + if (method === "account/rateLimits/read") { + fs.appendFileSync(${JSON.stringify(readsPath)}, "read\n"); + result = { accountId: workspace, rateLimits: { primary: { usedPercent: workspace === "workspace-a" ? 100 : 25, windowDurationMins: 300 } } }; + } + process.stdout.write(JSON.stringify({ id, result }) + "\n"); + }); + `, + }); + const quotaReads = () => + fs.readFileString(readsPath).pipe(Effect.map((text) => text.trim().split("\n").length)); + const instanceId = ProviderInstanceId.make("codex-quota-owner"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { + [instanceId]: { + driver: CodexDriver.driverKind, + enabled: true, + config: makeCodexConfig({ + enabled: true, + binaryPath, + homePath: sharedHome, + shadowHomePath: home, + }), + }, + }, + }); + const instance = (yield* registry.getInstance(instanceId))!; + yield* instance.snapshot.refresh; + yield* instance.snapshot.refreshUsage!; + const first = yield* instance.snapshot.getSnapshot; + expect(first.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(yield* quotaReads()).toBe(1); + yield* writeAccount("workspace-a", 2); + yield* instance.snapshot.refreshUsage!; + expect(yield* quotaReads()).toBe(1); + yield* writeAccount("workspace-b"); + yield* instance.snapshot.refreshUsage!; + const switched = yield* instance.snapshot.getSnapshot; + expect(switched.auth.workspaceId).toBe("workspace-b"); + expect(switched.auth.usageRefreshKey).not.toBe(first.auth.usageRefreshKey); + expect(switched.usageLimits?.windows[0]?.usedPercent).toBe(25); + expect(yield* quotaReads()).toBe(2); + yield* writeAccount("workspace-c"); + const health = yield* instance.snapshot.refresh; + expect(health.usageLimits?.windows ?? []).toEqual([]); + expect(health.auth.usageRefreshKey).not.toBe(switched.auth.usageRefreshKey); + expect(yield* quotaReads()).toBe(2); + yield* instance.snapshot.refreshUsage!; + expect((yield* instance.snapshot.getSnapshot).auth.workspaceId).toBe("workspace-c"); + expect(yield* quotaReads()).toBe(3); + }).pipe(Effect.provide(layerTest)), + ); + it.live("refreshes Claude quotas immediately after a local account switch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From a16e8b56fbf78d0d8f629d03405878e639be075f Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 09:55:27 -0500 Subject: [PATCH 91/95] refactor(usage): remove unused pending-refresh mode --- .../features/usage/usageLimitsRefresh.test.ts | 1 - .../src/features/usage/usageLimitsRefresh.ts | 2 - apps/web/src/components/usage/UsagePage.tsx | 3 +- .../client-runtime/src/state/usage.test.ts | 38 ++++++------------- packages/client-runtime/src/state/usage.ts | 11 +----- 5 files changed, 14 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts index 17f98b56cc5be..e8611626675cb 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.test.ts @@ -144,7 +144,6 @@ it.each(["limits", "thread", "thread-and-other-provider"] as const)( a, () => Promise.resolve(success), true, - false, instanceLimitsRefreshSelection( ProviderInstanceId.make("cursor-instance"), ProviderDriverKind.make("cursor"), diff --git a/apps/mobile/src/features/usage/usageLimitsRefresh.ts b/apps/mobile/src/features/usage/usageLimitsRefresh.ts index 6e456e88aba91..dade04e1a5945 100644 --- a/apps/mobile/src/features/usage/usageLimitsRefresh.ts +++ b/apps/mobile/src/features/usage/usageLimitsRefresh.ts @@ -94,7 +94,6 @@ export async function refreshEnvironmentLimits( environmentId: EnvironmentId, options: { readonly automatic?: boolean; - readonly afterPending?: boolean; readonly instanceId?: ProviderInstanceId; readonly driver?: ProviderDriverKind; } = {}, @@ -119,7 +118,6 @@ export async function refreshEnvironmentLimits( { reportFailure: false }, ), options.automatic, - options.afterPending, { ...(options.instanceId ? instanceLimitsRefreshSelection(options.instanceId, options.driver, providers, generation) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 80eec86064011..d65496ce5fea2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -257,7 +257,7 @@ export function UsagePage() { if (nextMetric === "limits") setLimitsNow(Date.now()); updatePreferences({ metric: nextMetric }); }; - const refreshLimits = (afterPending = false) => + const refreshLimits = () => // Settled so the timestamp still advances when a refresh throws. settlePromise(() => Promise.all( @@ -275,7 +275,6 @@ export function UsagePage() { }, }), false, - afterPending, JSON.stringify([ [...hiddenProviders].sort(), usageRefreshAccountKey( diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 6c35b4d8685d8..c87bf68fa3649 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -200,42 +200,30 @@ describe("manual usage refresh", () => { describe("limits refresh cooldown", () => { it.each(["legacy", "resources"] as const)( - "runs a fresh check after an in-flight check when settings change (%s)", + "coalesces an in-flight check while other providers refresh (%s)", async (kind) => { - const id = EnvironmentId.make(`limits-after-enable-${kind}`); + const id = EnvironmentId.make(`limits-coalesced-${kind}`); const cursor = kind === "legacy" ? "cursor" : { key: "cursor", resources: ["cursor", "instance-a"] }; const claude = kind === "legacy" ? "claude" : { key: "claude", resources: ["claude"] }; const oldCheck = Promise.withResolvers(); - const first = refreshUsageLimits(id, () => oldCheck.promise, true, false, cursor); - const otherProviders = refreshUsageLimits( - id, - async () => "claude limits", - false, - false, - claude, - ); + const first = refreshUsageLimits(id, () => oldCheck.promise, true, cursor); + const otherProviders = refreshUsageLimits(id, async () => "claude limits", false, claude); const automaticCheck = vi.fn(async () => "unexpected extra limits"); const coalescedAutomatic = refreshUsageLimits( id, automaticCheck, true, - false, typeof cursor === "string" ? cursor : { ...cursor, resources: [...cursor.resources].reverse().concat("new-instance") }, ); - const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true, cursor); - expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); expect(await coalescedAutomatic).toBe("old limits"); expect(automaticCheck).not.toHaveBeenCalled(); if (kind === "resources") expect(usageLimitsRefreshDelay(id, "new-instance")).toBe(0); expect(await otherProviders).toBe("claude limits"); - expect(await afterEnable).toBe("new limits"); - expect(newCheck).toHaveBeenCalledTimes(1); }, ); it("keeps a provider's deadline when a wider refresh runs before it is due", async () => { @@ -260,26 +248,26 @@ describe("limits refresh cooldown", () => { 310_000, ); expect(overdue).toBe(300_000); - await refreshUsageLimits(remote, async () => overdue, true, false, { + await refreshUsageLimits(remote, async () => overdue, true, { key: "overdue", resources: ["expired", "new"], refreshDeadline: (deadline) => deadline, }); expect(usageLimitsRefreshDelay(remote, "expired")).toBe(0); now.mockReturnValue(4 * 60_000); - await refreshUsageLimits(remote, async () => 300_000, true, false, { + await refreshUsageLimits(remote, async () => 300_000, true, { key: "", resources: ["claude"], refreshDeadline: (deadline) => deadline, }); expect(usageLimitsRefreshDelay(remote, "claude")).toBe(60_000); now.mockReturnValue(0); - await refreshUsageLimits(id, async () => "claude", true, false, { + await refreshUsageLimits(id, async () => "claude", true, { key: "thread", resources: ["claude"], }); now.mockReturnValue(4 * 60_000); - await refreshUsageLimits(id, async () => "all", true, false, { + await refreshUsageLimits(id, async () => "all", true, { key: "", resources: ["claude", "codex"], }); @@ -287,7 +275,7 @@ describe("limits refresh cooldown", () => { now.mockReturnValue(5 * 60_000); const due = vi.fn(async () => "claude due"); expect( - await refreshUsageLimits(id, due, true, false, { key: "", resources: ["claude", "codex"] }), + await refreshUsageLimits(id, due, true, { key: "", resources: ["claude", "codex"] }), ).toBe("claude due"); expect(usageLimitsRefreshDelay(id, ["claude"])).toBe(5 * 60_000); expect(usageLimitsRefreshDelay(id, ["claude", "codex"])).toBe(4 * 60_000); @@ -300,14 +288,12 @@ describe("limits refresh cooldown", () => { }; now.mockReturnValue(0); const oldKey = usageRefreshAccountKey([provider]); - await refreshUsageLimits(rebuilt, async () => "old quota", true, false, oldKey); + await refreshUsageLimits(rebuilt, async () => "old quota", true, oldKey); now.mockReturnValue(60_000); const currentKey = usageRefreshAccountKey([{ ...provider, instanceRevision: 2 }]); const readRebuilt = vi.fn(async () => "rebuilt quota"); - expect(await refreshUsageLimits(rebuilt, readRebuilt, true, false, currentKey)).toBe( - "rebuilt quota", - ); - expect(await refreshUsageLimits(rebuilt, readRebuilt, true, false, currentKey)).toBeUndefined(); + expect(await refreshUsageLimits(rebuilt, readRebuilt, true, currentKey)).toBe("rebuilt quota"); + expect(await refreshUsageLimits(rebuilt, readRebuilt, true, currentKey)).toBeUndefined(); expect(readRebuilt).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 68f046ae6ebf0..8d8c998fda06b 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -142,7 +142,6 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, - afterPending = false, selectionKey: | string | { @@ -162,15 +161,7 @@ export async function refreshUsageLimits( typeof selectionKey === "string" ? selectionKey : selectionKey.key, ]); const pending = limitsRefreshes.get(key); - if (pending !== undefined) { - if (afterPending) { - try { - await pending; - } catch {} - return refreshUsageLimits(environmentId, refresh, false, true, selectionKey); - } - return (await pending) as A; - } + if (pending !== undefined) return (await pending) as A; if (automatic && usageLimitsRefreshDelay(environmentId, selections) > 0) return; const refreshedSelections = typeof selectionKey === "string" From e0d829f311689d575ab42b9cf2c0c0f8139f3f68 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 09:56:13 -0500 Subject: [PATCH 92/95] fix(server): stop quota reads at the first usable fallback --- .../CodexTextGeneration.test.ts | 15 +++-- .../src/textGeneration/TextGeneration.ts | 60 +++++++++---------- packages/shared/src/serverSettings.ts | 2 +- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 1c95cc02ebb7a..187d43ebad0cd 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -563,6 +563,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "ready", "pending", "exhausted", + "fallback exhausted", "not installed", "signed out", "signed out, fallback failed", @@ -576,7 +577,8 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima const slow = primaryState.endsWith("slow"); const primaryBlocked = primaryState === "primary slow"; const fallbackBlocked = primaryState === "fallback slow" || primaryState === "both slow"; - const exhausted = primaryState === "exhausted" || slow; + const fallbackExhausted = primaryState === "fallback exhausted"; + const exhausted = primaryState === "exhausted" || fallbackExhausted || slow; let readStarted = yield* Deferred.make(); const fallbackFailed = primaryState === "signed out, fallback failed"; const needsFallback = @@ -624,7 +626,8 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ? { status: "error", auth: { status: "unauthenticated" } } : {}), ...(fallbackFailed && instanceId !== primary ? { status: "error" } : {}), - ...(exhausted && instanceId === primary + ...((exhausted && instanceId === primary) || + (fallbackExhausted && instanceId === unrelated) ? { usageLimits: { checkedAt: "1970-01-01T00:00:00Z", @@ -745,10 +748,12 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima expect(calls).toEqual([ needsFallback && !fallbackFailed ? { - instanceId: operation === "title" ? unrelated : fallback, + instanceId: operation === "title" && !fallbackExhausted ? unrelated : fallback, model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ - ProviderDriverKind.make(operation === "title" ? "antigravity" : "claudeAgent") + ProviderDriverKind.make( + operation === "title" && !fallbackExhausted ? "antigravity" : "claudeAgent", + ) ], } : selection, @@ -759,7 +764,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima (needsFallback && !fallbackFailed && !primaryBlocked ? [ [primary, fallback], - [primary, fallback, unrelated], + fallbackExhausted ? [primary, unrelated, fallback] : [primary, unrelated], ] : [[primary], [primary]] ).map((ids) => ids.sort()), diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 5ab6775161e60..3bc5238445ab4 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -10,10 +10,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { - defaultTextGenerationModel, - fallbackTextGenerationModelSelection, -} from "@t3tools/shared/serverSettings"; +import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -123,38 +120,35 @@ export const make = Effect.gen(function* () { isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) && (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(provider.driver)); - const remaining = usageDeadline - (yield* Clock.currentTimeMillis); - if (remaining > 0) - yield* Effect.forEach( - instances.filter( - (instance) => - instance.enabled && - fallbackTextGenerationModelSelection( - settings.providerInstances, - (instanceId) => instanceId === instance.instanceId, - { includeConfiguredInstances: true }, - ) !== null && - snapshots.some( - (provider) => provider.instanceId === instance.instanceId && canServe(provider), - ), - ), - (instance) => (instance.snapshot.refreshUsage ?? Effect.void).pipe(Effect.ignore), - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.timeoutOption(remaining)); - snapshots = yield* readSnapshots; - selection = - fallbackTextGenerationModelSelection( + const attempted = new Set(); + while (true) { + const candidate = fallbackTextGenerationModelSelection( settings.providerInstances, - (instanceId, driver) => { - const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); - return ( - provider !== undefined && - canServe(provider) && - !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) - ); + (instanceId) => { + const provider = snapshots.find((provider) => provider.instanceId === instanceId); + return !attempted.has(instanceId) && provider !== undefined && canServe(provider); }, { includeConfiguredInstances: true }, - ) ?? selection; + ); + if (candidate === null) break; + attempted.add(candidate.instanceId); + const remaining = usageDeadline - (yield* Clock.currentTimeMillis); + if (remaining > 0) + yield* ( + instances.find((instance) => instance.instanceId === candidate.instanceId)?.snapshot + .refreshUsage ?? Effect.void + ).pipe(Effect.ignore, Effect.timeoutOption(remaining)); + snapshots = yield* readSnapshots; + const provider = snapshots.find((provider) => provider.instanceId === candidate.instanceId); + if ( + provider && + canServe(provider) && + !exhaustedUsageWindow(provider, now, candidate.model) + ) { + selection = candidate; + break; + } + } } return yield* resolveInstance(registry, operation, selection.instanceId).pipe( Effect.flatMap((service) => invoke(service, selection)), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index ee1fc724ee3b8..7f9e99052884d 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -101,7 +101,7 @@ export function fallbackTextGenerationModelSelection( return { instanceId, model: defaultTextGenerationModel(driver) }; } -export function defaultTextGenerationModel(driver: ProviderDriverKind): string { +function defaultTextGenerationModel(driver: ProviderDriverKind): string { return ( DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? From a5ed779d686e85fd06ad7b4344b3b67319ad9abf Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 10:13:54 -0500 Subject: [PATCH 93/95] fix(codex): invalidate quotas when native ownership changes --- .../src/provider/Drivers/CodexDriver.ts | 110 ++++---- .../provider/ProviderInstanceRegistry.test.ts | 236 +++++++++++------- 2 files changed, 215 insertions(+), 131 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 104a13eb664c8..bbde79f8f946e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,4 +1,8 @@ -import { UsagePolling, UsageAfterReset } from "@t3tools/provider-core/server/usageLimits"; +import { + UsagePolling, + UsageAfterReset, + makeUnavailableUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as Hex from "effect/encoding/Hex"; @@ -66,10 +70,16 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings); const decodeNativeAccount = Schema.decodeEffect( Schema.fromJsonString( Schema.Struct({ - tokens: Schema.Struct({ - account_id: Schema.String, - id_token: Schema.optionalKey(Schema.String), - }), + auth_mode: Schema.optionalKey(Schema.NullOr(Schema.String)), + OPENAI_API_KEY: Schema.optionalKey(Schema.NullOr(Schema.String)), + tokens: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + account_id: Schema.String, + id_token: Schema.optionalKey(Schema.String), + }), + ), + ), }), ), ); @@ -251,15 +261,26 @@ export const CodexDriver: ProviderDriver< .readFileString( pathService.join(homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath, "auth.json"), ) - .pipe(Effect.flatMap(decodeNativeAccount), Effect.option); + .pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(decodeNativeAccount), + Effect.option, + ); if (Option.isNone(account)) return undefined; - const workspaceId = account.value.tokens.account_id.trim(); - if (!workspaceId) return undefined; - const personId = Option.getOrUndefined(openAiUserId(account.value.tokens.id_token ?? "")); + const mode = account.value.auth_mode ?? (account.value.OPENAI_API_KEY ? "apikey" : "chatgpt"); + const isChatGpt = mode === "chatgpt" || mode === "chatgptAuthTokens"; + const workspaceId = isChatGpt ? account.value.tokens?.account_id.trim() : undefined; + const personId = isChatGpt + ? Option.getOrUndefined(openAiUserId(account.value.tokens?.id_token ?? "")) + : account.value.OPENAI_API_KEY?.trim(); + const kind = workspaceId ? "chatgpt" : mode; const usageRefreshKey = yield* digest .digest( Digest.sha256, - new TextEncoder().encode(encodeUsageOwner(["chatgpt", workspaceId, personId ?? ""])), + new TextEncoder().encode(encodeUsageOwner([kind, workspaceId ?? "", personId ?? ""])), ) .pipe(Effect.map(Hex.encode), Effect.orDie); return { workspaceId, usageRefreshKey }; @@ -268,40 +289,41 @@ export const CodexDriver: ProviderDriver< // Kick the TTL-gated manifest refresh in the background and classify with the in-memory // manifest, so a slow or hung fetch never delays the provider check. - const checkProvider = modelCatalog.refreshInBackground.pipe( - Effect.andThen( - Effect.zipWith( - Effect.flatMap(UsagePolling, (includeUsage) => - checkCodexProviderStatus( - effectiveConfig, - undefined, - processEnv, - undefined, - !includeUsage, - ), - ), - currentCatalog, - (draft, catalog) => applyCodexModelCatalog(draft, catalog), - { concurrent: true }, - ), - ), - Effect.flatMap((draft) => - usageAccount.pipe( - Effect.map((account) => - stampIdentity({ - ...draft, - auth: - draft.auth.type === "chatgpt" && account - ? { - ...draft.auth, - ...account, - workspaceId: draft.auth.workspaceId ?? account.workspaceId, - } - : draft.auth, - }), - ), - ), - ), + const checkProvider = Effect.gen(function* () { + const before = yield* usageAccount; + yield* modelCatalog.refreshInBackground; + const includeUsage = yield* UsagePolling; + const draft = yield* Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv, undefined, !includeUsage), + currentCatalog, + (draft, catalog) => applyCodexModelCatalog(draft, catalog), + { concurrent: true }, + ); + const after = yield* usageAccount; + if (after && after.usageRefreshKey !== before?.usageRefreshKey) { + return stampIdentity({ + ...draft, + auth: { status: "unknown", ...after }, + usageLimits: { + ...makeUnavailableUsageLimits({ checkedAt: draft.checkedAt, reason: "probeFailed" }), + refreshAfter: 0, + }, + }); + } + return stampIdentity({ + ...draft, + auth: before + ? { + ...draft.auth, + ...before, + workspaceId: + draft.auth.type === "chatgpt" + ? (draft.auth.workspaceId ?? before.workspaceId) + : undefined, + } + : draft.auth, + }); + }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index cf128217ead5c..f5563dc119fda 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -2,6 +2,7 @@ import { UsagePolling, makeUsageHttpClient } from "@t3tools/provider-core/server import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; +import * as CodexProvider from "./CodexProvider.ts"; import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; @@ -471,98 +472,159 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); - it.live("tracks native Codex quota ownership before its cached deadline", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "codex-quota-owner-" }); - const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-owner-" }); - yield* fs.writeFileString( - path.join(sharedHome, "auth.json"), - JSON.stringify({ tokens: { account_id: "unrelated-shared-workspace" } }), - ); - const authPath = path.join(home, "auth.json"); - const readsPath = path.join(home, "quota-reads"); - const writeAccount = (workspace: string, issuedAt = 1) => - fs.writeFileString( - authPath, - JSON.stringify({ - auth_mode: "chatgpt", - tokens: { - account_id: workspace, - id_token: `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { user_id: "same-person" }, iat: issuedAt }))}.signature`, - }, - }), + it.effect.each(["workspace", "in-flight", "api-key", "sign-out"] as const)( + "tracks native Codex ownership across %s changes", + (transition) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const sharedHome = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + path.join(sharedHome, "auth.json"), + JSON.stringify({ tokens: { account_id: "unrelated-shared-workspace" } }), ); - yield* writeAccount("workspace-a"); - const binaryPath = writeFakeCli({ - directory: path.join(home, "bin"), - name: "codex", - source: String.raw` - import * as fs from "node:fs"; - import * as readline from "node:readline"; - if (process.argv.includes("--version")) { - console.log("codex-cli 0.0.0"); process.exit(0); - } - readline.createInterface({ input: process.stdin }).on("line", (line) => { - const { id, method } = JSON.parse(line); - if (id === undefined) return; - const workspace = JSON.parse(fs.readFileSync(${JSON.stringify(authPath)}, "utf8")).tokens.account_id; - let result = {}; - if (method === "initialize") result = { userAgent: "fixture/0.0.0", codexHome: ${JSON.stringify(home)}, platformFamily: "unix", platformOs: "linux" }; - if (method === "account/read") result = { account: { type: "chatgpt", email: "same@example.com", planType: "plus" }, requiresOpenaiAuth: false }; - if (method === "model/list" || method === "skills/list") result = { data: [] }; - if (method === "account/rateLimits/read") { - fs.appendFileSync(${JSON.stringify(readsPath)}, "read\n"); - result = { accountId: workspace, rateLimits: { primary: { usedPercent: workspace === "workspace-a" ? 100 : 25, windowDurationMins: 300 } } }; + const authPath = path.join(home, "auth.json"); + let workspace = "workspace-a"; + let mode: "chatgpt" | "apiKey" | "signed-out" = "chatgpt"; + const writeAccount = (next: string, issuedAt = 1) => { + workspace = next; + mode = "chatgpt"; + return fs.writeFileString( + authPath, + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: "unused-fixture-key", + tokens: { + account_id: workspace, + id_token: `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { user_id: "same-person" }, iat: issuedAt }))}.signature`, + }, + }), + ); + }; + yield* writeAccount(workspace); + let quotaReads = 0; + let blocked = false; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const probe: NonNullable[1]> = ({ + skipNativeUsage, + }) => + Effect.gen(function* () { + const captured = workspace; + const account = + mode === "chatgpt" + ? { type: "chatgpt" as const, email: "same@example.com", planType: "plus" } + : mode === "apiKey" + ? { type: "apiKey" as const } + : null; + if (!skipNativeUsage && mode === "chatgpt") { + quotaReads++; + if (blocked) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } } - process.stdout.write(JSON.stringify({ id, result }) + "\n"); + return { + account: { account, requiresOpenaiAuth: true }, + ...(!skipNativeUsage && account?.type === "chatgpt" + ? { + rateLimits: { + snapshot: { + primary: { + usedPercent: captured === "workspace-a" ? 100 : 25, + windowDurationMins: 300, + }, + }, + resetCredits: undefined, + accountId: captured, + }, + } + : {}), + version: "0.0.0", + models: [], + skills: [], + }; }); - `, - }); - const quotaReads = () => - fs.readFileString(readsPath).pipe(Effect.map((text) => text.trim().split("\n").length)); - const instanceId = ProviderInstanceId.make("codex-quota-owner"); - const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver], - configMap: { - [instanceId]: { - driver: CodexDriver.driverKind, - enabled: true, - config: makeCodexConfig({ + const checkStatus = CodexProvider.checkCodexProviderStatus; + using _status = vi + .spyOn(CodexProvider, "checkCodexProviderStatus") + .mockImplementation((settings, _probe, env, managedAuth, skipUsage) => + checkStatus(settings, probe, env, managedAuth, skipUsage), + ); + const instanceId = ProviderInstanceId.make("codex-quota-owner"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { + [instanceId]: { + driver: CodexDriver.driverKind, enabled: true, - binaryPath, - homePath: sharedHome, - shadowHomePath: home, - }), + config: makeCodexConfig({ + enabled: true, + binaryPath: "unused-codex", + homePath: sharedHome, + shadowHomePath: home, + }), + }, }, - }, - }); - const instance = (yield* registry.getInstance(instanceId))!; - yield* instance.snapshot.refresh; - yield* instance.snapshot.refreshUsage!; - const first = yield* instance.snapshot.getSnapshot; - expect(first.usageLimits?.windows[0]?.usedPercent).toBe(100); - expect(yield* quotaReads()).toBe(1); - yield* writeAccount("workspace-a", 2); - yield* instance.snapshot.refreshUsage!; - expect(yield* quotaReads()).toBe(1); - yield* writeAccount("workspace-b"); - yield* instance.snapshot.refreshUsage!; - const switched = yield* instance.snapshot.getSnapshot; - expect(switched.auth.workspaceId).toBe("workspace-b"); - expect(switched.auth.usageRefreshKey).not.toBe(first.auth.usageRefreshKey); - expect(switched.usageLimits?.windows[0]?.usedPercent).toBe(25); - expect(yield* quotaReads()).toBe(2); - yield* writeAccount("workspace-c"); - const health = yield* instance.snapshot.refresh; - expect(health.usageLimits?.windows ?? []).toEqual([]); - expect(health.auth.usageRefreshKey).not.toBe(switched.auth.usageRefreshKey); - expect(yield* quotaReads()).toBe(2); - yield* instance.snapshot.refreshUsage!; - expect((yield* instance.snapshot.getSnapshot).auth.workspaceId).toBe("workspace-c"); - expect(yield* quotaReads()).toBe(3); - }).pipe(Effect.provide(layerTest)), + }); + const instance = (yield* registry.getInstance(instanceId))!; + yield* instance.snapshot.refresh; + yield* instance.snapshot.refreshUsage!; + const first = yield* instance.snapshot.getSnapshot; + expect(first.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(quotaReads).toBe(1); + yield* writeAccount("workspace-a", 2); + yield* instance.snapshot.refreshUsage!; + expect(quotaReads).toBe(1); + yield* fs.writeFileString(authPath, "{"); + yield* instance.snapshot.refreshUsage!; + expect(quotaReads).toBe(1); + expect((yield* instance.snapshot.getSnapshot).usageLimits?.windows[0]?.usedPercent).toBe( + 100, + ); + yield* writeAccount("workspace-a", 2); + if (transition === "workspace" || transition === "in-flight") { + if (transition === "in-flight") { + blocked = true; + yield* TestClock.adjust("5 minutes"); + const pending = yield* instance.snapshot.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* writeAccount("workspace-b"); + blocked = false; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(pending); + const raced = yield* instance.snapshot.getSnapshot; + expect(raced.usageLimits?.windows ?? []).toEqual([]); + expect(raced.usageLimits?.refreshAfter).toBe(0); + } else yield* writeAccount("workspace-b"); + yield* instance.snapshot.refreshUsage!; + const switched = yield* instance.snapshot.getSnapshot; + expect(switched.auth.workspaceId).toBe("workspace-b"); + expect(switched.auth.usageRefreshKey).not.toBe(first.auth.usageRefreshKey); + expect(switched.usageLimits?.windows[0]?.usedPercent).toBe(25); + expect(quotaReads).toBe(transition === "in-flight" ? 3 : 2); + yield* writeAccount("workspace-c"); + const health = yield* instance.snapshot.refresh; + expect(health.usageLimits?.windows ?? []).toEqual([]); + yield* instance.snapshot.refreshUsage!; + expect((yield* instance.snapshot.getSnapshot).auth.workspaceId).toBe("workspace-c"); + } else { + mode = transition === "api-key" ? "apiKey" : "signed-out"; + yield* fs.writeFileString( + authPath, + transition === "api-key" ? '{"OPENAI_API_KEY":"fixture-key"}' : "{}", + ); + yield* instance.snapshot.refreshUsage!; + const changed = yield* instance.snapshot.getSnapshot; + expect(changed.usageLimits?.windows ?? []).toEqual([]); + expect(changed.auth.usageRefreshKey).not.toBe(first.auth.usageRefreshKey); + if (transition === "api-key") { + expect(changed.auth.type).toBe("apiKey"); + expect(changed.usageLimits?.unavailable?.reason).toBe("unsupported"); + } else expect(changed.auth.status).toBe("unauthenticated"); + } + }).pipe(Effect.provide(layerTest)), ); it.live("refreshes Claude quotas immediately after a local account switch", () => From 8eed9b45841710da545b528cfd14a4df8c73d56b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 10:21:22 -0500 Subject: [PATCH 94/95] fix(usage): preserve hub retry deadlines in the cache --- apps/server/src/usage/cliproxyApi.test.ts | 19 +++++++++++++-- apps/server/src/usage/cliproxyApi.ts | 28 +++++++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index eb7d89e9f1980..f7ba2874f5210 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -211,7 +211,7 @@ describe("CLIProxyAPI built-in management API", () => { accounts: accounts.map((account) => ({ ...account, provider: "claude" })), upstream: () => limited - ? { status, body: {}, headers: { "Retry-After": ["600"] } } + ? { status, body: {}, headers: { "Retry-After": ["420"] } } : { status: 200, body: { five_hour: { utilization: 25, resets_at: null } } }, }); const api = yield* test.api; @@ -238,11 +238,26 @@ describe("CLIProxyAPI built-in management API", () => { "probeFailed", "probeFailed", ]); + expect(stale.map((account) => account.usageLimits.refreshAfter)).toEqual([ + 720_000, 720_000, + ]); yield* TestClock.adjust("5 minutes"); - yield* api.readAccounts(config, ["claudeAgent"]); + const cooling = yield* api.readAccounts(config, ["claudeAgent"]); + expect(cooling.map((account) => account.usageLimits.refreshAfter)).toEqual([ + 720_000, 720_000, + ]); expect( test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), ).toHaveLength(4); + limited = false; + yield* TestClock.adjust("2 minutes"); + const recovered = yield* api.readAccounts(config, ["claudeAgent"]); + expect(recovered.map((account) => account.usageLimits.windows[0]?.usedPercent)).toEqual([ + 25, 25, + ]); + expect( + test.requests.filter((request) => request.body?.url?.endsWith("/api/oauth/usage")), + ).toHaveLength(6); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 08b0b1af7efb4..a6a1092498e91 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -13,6 +13,7 @@ import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Hex from "effect/encoding/Hex"; import * as Schema from "effect/Schema"; import * as MutableHashMap from "effect/MutableHashMap"; @@ -58,6 +59,12 @@ const ApiCallInput = Schema.Struct({ url: Schema.String, }); const decodeApiCallInput = Schema.decodeEffect(Schema.fromJsonString(ApiCallInput)); +const decodeCacheKey = Schema.decodeSync(Schema.fromJsonString(ApiCallInput)); +const encodeCooldownKey = Schema.encodeSync( + Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String, Schema.String, Schema.String])), +); +const cooldownKey = (hub: CliproxyHub, account: typeof AuthFile.Type) => + encodeCooldownKey([hub.url, hub.managementKey, account.auth_index, account.id]); const CodexWindow = Schema.Struct({ used_percent: Schema.Number, reset_at: Schema.optional(Schema.NullOr(Schema.Number)), @@ -194,6 +201,7 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; + const clock = yield* Clock.Clock; const digest = yield* Digest.Digest; const management = Effect.fn("CliproxyApi.management")(function* ( @@ -238,12 +246,7 @@ export const makeCliproxyApi = Effect.gen(function* () { url: string, data?: unknown, ) { - const accountKey = yield* encodeJson([ - hub.url, - hub.managementKey, - account.auth_index, - account.id, - ]); + const accountKey = cooldownKey(hub, account); const now = yield* Clock.currentTimeMillis; if (data === undefined && now < (cooldowns.get(accountKey) ?? 0)) return yield* new UsageLimitSourceError({ @@ -323,7 +326,18 @@ export const makeCliproxyApi = Effect.gen(function* () { Effect.map(DateTime.now, (now) => ({ body, checkedAt: DateTime.formatIso(now) })), ), ), - { capacity: 128, timeToLive: () => Duration.minutes(5) }, + { + capacity: 128, + timeToLive: (exit, key) => { + if (Exit.isFailure(exit)) { + const { hub, account } = decodeCacheKey(key); + const retryAt = cooldowns.get(cooldownKey(hub, account)); + const now = clock.currentTimeMillisUnsafe(); + if (retryAt !== undefined && retryAt > now) return Duration.millis(retryAt - now); + } + return Duration.minutes(5); + }, + }, ); const apiCall = Effect.fn("CliproxyApi.cachedApiCall")(function* ( hub: CliproxyHub, From 68c32c9eb1802ac24cef954475de4148044ffb15 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 11:00:41 -0500 Subject: [PATCH 95/95] fix(usage): invalidate removed Grok auth and skip ineligible fallbacks --- .../client-runtime/src/state/usage.test.ts | 66 +++++++++++++++++++ packages/client-runtime/src/state/usage.ts | 11 +++- .../provider-grok/src/server/driver.test.ts | 23 ++++++- .../provider-grok/src/server/usageLimits.ts | 2 +- packages/shared/src/usageLimits.ts | 28 ++++---- 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index c87bf68fa3649..3962916554b42 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,5 +1,8 @@ import { EnvironmentId, + ProjectId, + DEFAULT_SERVER_SETTINGS, + type ServerProvider, ProviderInstanceId, ProviderDriverKind, UsageDay, @@ -15,6 +18,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { refreshUsage, + refreshNewThreadUsage, refreshUsageLimits, usageLimitsRefreshDelay, usageRefreshDeadline, @@ -297,3 +301,65 @@ describe("limits refresh cooldown", () => { expect(readRebuilt).toHaveBeenCalledTimes(1); }); }); + +describe("new-thread quota preflight", () => { + it.each([true, false])( + "checks project fallback eligibility before reading (enabled: %s)", + async (enabled) => { + const primaryId = ProviderInstanceId.make("codex"); + const fallbackId = ProviderInstanceId.make("claudeAgent"); + const projectId = ProjectId.make("quota-preflight"); + const provider = (instanceId: ProviderInstanceId, driver: string): ServerProvider => ({ + instanceId, + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + }); + const providers = [provider(primaryId, "codex"), provider(fallbackId, "claudeAgent")]; + const registry = AtomRegistry.make(); + registries.push(registry); + const requested: ProviderInstanceId[] = []; + const server = { + refreshProviders: { + label: "test:preflight", + requiredScopes: () => [], + permissionAtom: () => Atom.make(true), + run: async (_registry, target) => { + requested.push(target.input.instanceId!); + return AsyncResult.success({ providers, usageLimitSources: [], serverTime: 0 }); + }, + }, + } satisfies Parameters[0]["server"]; + await refreshNewThreadUsage({ + registry, + server, + environmentId: EnvironmentId.make("preflight"), + input: { + defaultSelection: { instanceId: primaryId, model: "gpt" }, + fallbackSelection: { instanceId: fallbackId, model: "claude" }, + providers, + now: 0, + projectId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [fallbackId]: { enabled } } }, + }, + }, + }, + }); + expect(requested).toEqual(enabled ? [primaryId, fallbackId] : [primaryId]); + }, + ); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 8d8c998fda06b..9f7642a2c5ae4 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -9,7 +9,11 @@ import type { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; -import { exhaustedUsageWindow, resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { + exhaustedUsageWindow, + isNewThreadFallbackEligible, + resolveNewThreadModelSelection, +} from "@t3tools/shared/usageLimits"; import { AsyncResult } from "effect/reactivity"; import type { AtomRegistry } from "effect/reactivity"; @@ -62,6 +66,11 @@ export async function refreshNewThreadUsage({ providers.find((provider) => provider.instanceId === defaultSelection.instanceId), input.now, defaultSelection.model, + ) && + isNewThreadFallbackEligible( + providers.find((provider) => provider.instanceId === fallbackSelection.instanceId), + input.settings, + input.projectId, ) ) { providers = (await refresh(fallbackSelection.instanceId)) ?? providers; diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 70b7814d7f9af..5892d26c4db14 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -300,13 +300,34 @@ it.layer(layerTest)("GrokDriver", (it) => { expect((yield* instance.snapshot.getSnapshot).auth.usageRefreshKey).not.toBe( fresh.auth.usageRefreshKey, ); + let expectedRequests = requests; + for (const contents of [ + "{}", + JSON.stringify({ + "https://accounts.x.ai/sign-in": { auth_mode: "api_key", key: "api-key" }, + }), + ]) { + const previous = yield* instance.snapshot.getSnapshot; + yield* fs.writeFileString(`${home}/auth.json`, contents); + yield* instance.snapshot.refreshUsage!; + const removed = yield* instance.snapshot.getSnapshot; + expect(removed.auth.usageRefreshKey).toBeDefined(); + expect(removed.auth.usageRefreshKey).not.toBe(previous.auth.usageRefreshKey); + expect(removed.usageLimits?.windows).toEqual([]); + expect(removed.usageLimits?.unavailable?.reason).toBe("unsupported"); + expect(requests).toBe(expectedRequests); + yield* writeCredential("account-c"); + yield* instance.snapshot.refreshUsage!; + expectedRequests += 1; + expect(requests).toBe(expectedRequests); + } customAuth = true; const unsupported = yield* instance.snapshot.refresh; expect(unsupported.auth.type).toBe("cached_token"); expect(unsupported.supportsUsageLimits).toBe(false); expect(unsupported.usageLimits?.windows).toEqual([]); expect(unsupported.usageLimits?.unavailable?.reason).toBe("unsupported"); - expect(requests).toBe(3); + expect(requests).toBe(expectedRequests); } }), ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)), diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index fdfaf50558494..690ed6cbf29f8 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -192,7 +192,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), supportsUsageLimits: false, - usageRefreshKey: undefined, + usageRefreshKey: "grok:unsupported", }; } const digest = yield* Digest.Digest; diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index d7fe9b04d86c9..382a917c22ef2 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -64,6 +64,21 @@ export function exhaustedUsageWindow( } } +export function isNewThreadFallbackEligible( + provider: ServerProvider | undefined, + settings?: ServerSettings, + projectId?: ProjectId | null, +): boolean { + return ( + !!provider?.enabled && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + (!settings || + resolveProjectProviderInstanceEnabled(settings, projectId ?? null, provider.instanceId)) + ); +} + export function resolveNewThreadModelSelection(input: { readonly defaultSelection: ModelSelection | null; readonly explicitSelection?: ModelSelection | null; @@ -84,17 +99,8 @@ export function resolveNewThreadModelSelection(input: { if ( !provider || !window || - !fallback?.enabled || - !fallback.installed || - fallback.auth.status === "unauthenticated" || - !isProviderAvailable(fallback) || - exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || - (input.settings && - !resolveProjectProviderInstanceEnabled( - input.settings, - input.projectId ?? null, - fallback.instanceId, - )) + !isNewThreadFallbackEligible(fallback, input.settings, input.projectId) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) ) return unchanged; return {