From 0c08ac6b261ed0818b25a27a1a4fd4ee5787931f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20=C5=BDab=C4=8D=C3=ADk?= <35886301+dominikzabcik@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:01:25 +0100 Subject: [PATCH 1/2] fix(server): retry Claude probes with missing usage limits --- .../provider/ClaudeCapabilitiesProbe.test.ts | 26 +++++++++++++++++++ apps/server/src/provider/ClaudeProvider.ts | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877b..2605eeee1091 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -88,3 +88,29 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi assert.equal(abortSignal?.aborted, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("treats limits Claude could not fetch as a failed usage read", () => + Effect.gen(function* () { + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: async () => ({ + account: { subscriptionType: "max", tokenSource: "claude.ai" }, + commands: [], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: true, + rate_limits: null, + }), + }) as unknown as ReturnType, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + const capabilities = yield* probeClaudeCapabilities( + yield* decodeClaudeSettings({ binaryPath: "claude" }), + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, + ); + assert.strictEqual(capabilities.subscriptionType, "max"); + assert.strictEqual(capabilities.usage, undefined); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5ea745622bb3..369dd7ef293a 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -422,8 +422,11 @@ const probeClaudeCapabilities = ( catch: (cause) => new ClaudeCapabilitiesProbeSdkError({ operation: "usage", cause }), }).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) : undefined; + // Claude answers available-but-null while it holds back usage reads; retry it like a failure. const usage = - usageResult && Result.isSuccess(usageResult) + usageResult && + Result.isSuccess(usageResult) && + (usageResult.success.rate_limits !== null || !usageResult.success.rate_limits_available) ? { rate_limits_available: usageResult.success.rate_limits_available, rate_limits: usageResult.success.rate_limits, From 48112f540724a86baab134ebf3a83c05b8f42471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20=C5=BDab=C4=8D=C3=ADk?= <35886301+dominikzabcik@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:18:26 +0100 Subject: [PATCH 2/2] fix(server): retry missing Claude usage at the cache instead of the probe Dropping available-but-null usage in the probe kept the previous scoped limit name after an account switch. Keep the probe response as is, so the name resets and the snapshot shows the failed read, and skip the five-minute TTL for that response instead. Co-Authored-By: Claude Opus 5.5 --- .../provider/ClaudeCapabilitiesProbe.test.ts | 26 ------------------- apps/server/src/provider/ClaudeProvider.ts | 5 +--- .../src/provider/Drivers/ClaudeDriver.ts | 5 +++- .../provider/ProviderInstanceRegistry.test.ts | 9 ++++--- .../ProviderInstanceRegistryLive.fixture.mjs | 8 +++++- 5 files changed, 18 insertions(+), 35 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 2605eeee1091..bf3c9318877b 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -88,29 +88,3 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi assert.equal(abortSignal?.aborted, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - -it.effect("treats limits Claude could not fetch as a failed usage read", () => - Effect.gen(function* () { - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: async () => ({ - account: { subscriptionType: "max", tokenSource: "claude.ai" }, - commands: [], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: true, - rate_limits: null, - }), - }) as unknown as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); - const capabilities = yield* probeClaudeCapabilities( - yield* decodeClaudeSettings({ binaryPath: "claude" }), - { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, - ); - assert.strictEqual(capabilities.subscriptionType, "max"); - assert.strictEqual(capabilities.usage, undefined); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5afff7384a82..eafd128d610f 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -515,11 +515,8 @@ const probeClaudeCapabilities = ( ], { concurrency: "unbounded" }, ); - // Claude answers available-but-null while it holds back usage reads; retry it like a failure. const usage = - usageResult && - Result.isSuccess(usageResult) && - (usageResult.success.rate_limits !== null || !usageResult.success.rate_limits_available) + usageResult && Result.isSuccess(usageResult) ? { rate_limits_available: usageResult.success.rate_limits_available, rate_limits: usageResult.success.rate_limits, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 3ada12297649..aad47cd297b2 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -219,9 +219,12 @@ export const ClaudeDriver: ProviderDriver // Only fresh usage earns the TTL; failures, fallbacks and usage-less probes retry. + // Claude answers available-but-null while it holds back usage reads; retry that too. Exit.isSuccess(exit) && exit.value._tag === "fresh" && - exit.value.capabilities.usage !== undefined + exit.value.capabilities.usage !== undefined && + (exit.value.capabilities.usage.rate_limits !== null || + !exit.value.capabilities.usage.rate_limits_available) ? CAPABILITIES_PROBE_TTL : Duration.zero, }, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 4faac2c8592b..d785b0bc32a2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -783,7 +783,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ); const makeClaudeCacheFixture = Effect.fnUntraced(function* ( - state: "ready" | "signed-out" | "usage-failed", + state: "ready" | "signed-out" | "usage-failed" | "usage-null", ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -811,6 +811,9 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { sensitive: false, }, ]), + ...(state === "usage-null" + ? [{ name: "T3_CLAUDE_USAGE_FAILS_AS_NULL", value: "1", sensitive: false }] + : []), ], config: makeClaudeConfig({ enabled: true, @@ -829,11 +832,11 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }; }); - for (const state of ["signed-out", "usage-failed"] as const) { + for (const state of ["signed-out", "usage-failed", "usage-null"] 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", + : `retries Claude usage on the first refresh after a ${state === "usage-null" ? "missing" : "failed"} usage read`, () => Effect.gen(function* () { const fixture = yield* makeClaudeCacheFixture(state); diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index b996e420f85f..fc585d0da78a 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -41,7 +41,13 @@ lines.on("line", (line) => { process.stdout.write( JSON.stringify({ type: "control_response", - response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, + response: process.env.T3_CLAUDE_USAGE_FAILS_AS_NULL + ? { + subtype: "success", + request_id: message.request_id, + response: { session: {}, rate_limits_available: true, rate_limits: null }, + } + : { subtype: "error", request_id: message.request_id, error: "usage failed" }, }) + "\n", ); return;