From 1404b02f6e0931bdc174da490b972788a81dbb20 Mon Sep 17 00:00:00 2001 From: Tamim Bin Hakim <184807161+tamimbinhakim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:58:03 +0600 Subject: [PATCH 1/4] feat(providers): switch accounts mid-thread and track usage limits Two Claude providers with different config directories could not share a thread: the continuation key was tied to the config dir, so the model picker greyed out the other account and the server rejected the switch. Claude instances now share one continuation group. The resume cursor records the config dir it ran under, and the adapter copies the session transcript (plus its sidecar directory) into the target config dir before resuming, so the thread continues with full history on the other account. ProviderService also reuses a stopped thread's persisted cursor and cwd when a sibling instance shares the same continuation key. Rate-limit events the adapters already emitted were dropped on the floor. A new ProviderRateLimitReactor normalizes them (Claude and Codex) onto ServerProvider.rateLimit with status, reset time, window and utilization. With the new autoSwitchProviderOnRateLimit setting the reactor re-sends a turn that failed on a limited account using a sibling account and records the switch as a thread activity; the command reactor routes later turns the same way. With the setting off the composer shows a notice with the reset time and a one-click switch that also restores the failed message. Accounts with an accent colour now tint the provider logo itself instead of adding a corner badge, so two Claude accounts read as two colours. --- .../OrchestrationEngineHarness.integration.ts | 6 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProviderCommandReactor.ts | 96 +++++- .../Layers/ProviderRateLimitReactor.test.ts | 246 +++++++++++++++ .../Layers/ProviderRateLimitReactor.ts | 286 ++++++++++++++++++ .../Services/ProviderRateLimitReactor.ts | 27 ++ .../src/provider/Drivers/ClaudeDriver.ts | 7 +- .../src/provider/Drivers/ClaudeHome.test.ts | 101 ++++++- .../server/src/provider/Drivers/ClaudeHome.ts | 72 ++++- .../src/provider/Layers/ClaudeAdapter.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 54 +++- .../src/provider/Layers/ProviderRegistry.ts | 46 ++- .../provider/Layers/ProviderService.test.ts | 173 +++++++++++ .../src/provider/Layers/ProviderService.ts | 27 +- .../src/provider/Services/ProviderRegistry.ts | 11 + .../providerMaintenanceRunner.test.ts | 1 + .../src/provider/providerRateLimits.test.ts | 124 ++++++++ .../server/src/provider/providerRateLimits.ts | 163 ++++++++++ .../testUtils/providerRegistryMock.ts | 1 + apps/server/src/server.test.ts | 1 + apps/server/src/server.ts | 2 + apps/web/src/components/ChatView.tsx | 102 +++++++ .../components/chat/ModelPickerSidebar.tsx | 13 +- .../components/chat/ProviderInstanceIcon.tsx | 25 +- .../providerRateLimitBanner.logic.test.ts | 106 +++++++ .../chat/providerRateLimitBanner.logic.ts | 74 +++++ .../components/settings/SettingsPanels.tsx | 34 +++ .../src/components/settings/settingsSearch.ts | 4 + apps/web/src/providerInstances.ts | 3 + docs/user/providers-claude.md | 14 +- docs/user/providers-codex.md | 3 + packages/contracts/src/server.ts | 22 ++ packages/contracts/src/settings.ts | 10 + packages/shared/package.json | 4 + .../shared/src/providerRateLimits.test.ts | 107 +++++++ packages/shared/src/providerRateLimits.ts | 75 +++++ 37 files changed, 2008 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProviderRateLimitReactor.test.ts create mode 100644 apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts create mode 100644 apps/server/src/orchestration/Services/ProviderRateLimitReactor.ts create mode 100644 apps/server/src/provider/providerRateLimits.test.ts create mode 100644 apps/server/src/provider/providerRateLimits.ts create mode 100644 apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts create mode 100644 apps/web/src/components/chat/providerRateLimitBanner.logic.ts create mode 100644 packages/shared/src/providerRateLimits.test.ts create mode 100644 packages/shared/src/providerRateLimits.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ae217a3aabe7..ddbd04d255ad 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -58,6 +58,7 @@ import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts" import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; +import { ProviderRateLimitReactorLive } from "../src/orchestration/Layers/ProviderRateLimitReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts"; import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts"; @@ -347,6 +348,10 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); + const providerRateLimitReactorLayer = ProviderRateLimitReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(serverSettingsLayer), + ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( @@ -385,6 +390,7 @@ export const makeOrchestrationIntegrationHarness = ( const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(providerRateLimitReactorLayer), Layer.provideMerge(checkpointReactorLayer), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index dd76721defa0..35bb5fa035a9 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import { ProviderRateLimitReactor } from "../Services/ProviderRateLimitReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -48,6 +49,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ProviderRateLimitReactor, { + start: () => { + started.push("provider-rate-limit-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(CheckpointReactor, { start: () => { @@ -103,6 +113,7 @@ describe("OrchestrationReactor", () => { expect(started).toEqual([ "provider-runtime-ingestion", "provider-command-reactor", + "provider-rate-limit-reactor", "checkpoint-reactor", "thread-deletion-reactor", "thread-pull-request-reactor", diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index a86907b0d78b..f8e952d21b11 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,6 +7,7 @@ import { } from "../Services/OrchestrationReactor.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import { ProviderRateLimitReactor } from "../Services/ProviderRateLimitReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -16,6 +17,7 @@ import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; + const providerRateLimitReactor = yield* ProviderRateLimitReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; @@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); + yield* providerRateLimitReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df08bfadb9c..1bf929de4ca6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -7,12 +7,17 @@ import { ProviderDriverKind, type ProjectId, type OrchestrationSession, + type OrchestrationThread, ThreadId, type ProviderSession, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { + isProviderRateLimitActive, + selectRateLimitFallbackProvider, +} from "@t3tools/shared/providerRateLimits"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -42,6 +47,10 @@ import { ProviderAuthService } from "../../provider/Services/ProviderAuthService import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + formatRateLimitSwitchSummary, + PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, +} from "./ProviderRateLimitReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderCommandReactor, @@ -1180,6 +1189,82 @@ const make = Effect.gen(function* () { processThreadTitleRegenerationSafely, ); + /** + * When the account a turn would run on is out of usage and the user opted + * into auto-switching, route the turn to a sibling account that can resume + * the same provider session. The switch is recorded as a thread activity so + * it is visible in the transcript. + */ + const resolveRateLimitedModelSelection = Effect.fnUntraced(function* (input: { + readonly thread: OrchestrationThread; + readonly requestedModelSelection: ModelSelection | undefined; + readonly createdAt: string; + }) { + const baseSelection = + input.requestedModelSelection ?? + threadModelSelections.get(input.thread.id) ?? + input.thread.modelSelection; + const currentInstanceId = + input.requestedModelSelection?.instanceId ?? + input.thread.session?.providerInstanceId ?? + baseSelection.instanceId; + const nowMs = Date.parse(input.createdAt); + const providers = yield* providerRegistry.getProviders; + const current = providers.find((provider) => provider.instanceId === currentInstanceId); + if (!current || !isProviderRateLimitActive(current.rateLimit, nowMs)) { + return input.requestedModelSelection; + } + const autoSwitch = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.autoSwitchProviderOnRateLimit), + Effect.orElseSucceed(() => false), + ); + if (!autoSwitch) { + return input.requestedModelSelection; + } + const fallback = selectRateLimitFallbackProvider({ + providers, + instanceId: currentInstanceId, + nowMs, + }); + if (!fallback) { + return input.requestedModelSelection; + } + yield* Effect.logInfo("provider command reactor routing turn to fallback account", { + threadId: input.thread.id, + fromInstanceId: currentInstanceId, + toInstanceId: fallback.instanceId, + resetsAt: current.rateLimit?.resetsAt, + }); + yield* Effect.all({ + commandId: serverCommandId("provider-rate-limit-switch"), + eventId: serverEventId(), + }).pipe( + Effect.flatMap(({ commandId, eventId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.thread.id, + activity: { + id: eventId, + tone: "info", + kind: PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, + summary: formatRateLimitSwitchSummary({ from: current, to: fallback }), + payload: { + reason: "rate-limit", + fromInstanceId: currentInstanceId, + toInstanceId: fallback.instanceId, + ...(current.rateLimit?.resetsAt ? { resetsAt: current.rateLimit.resetsAt } : {}), + }, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + ); + return { ...baseSelection, instanceId: fallback.instanceId } satisfies ModelSelection; + }); + const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( event: Extract, ) { @@ -1422,13 +1507,18 @@ const make = Effect.gen(function* () { "Wait for context compaction to finish before sending another message.", ); } + + const modelSelection = yield* resolveRateLimitedModelSelection({ + thread, + requestedModelSelection: event.payload.modelSelection, + createdAt: event.payload.createdAt, + }); + const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.modelSelection !== undefined - ? { modelSelection: event.payload.modelSelection } - : {}), + ...(modelSelection !== undefined ? { modelSelection } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.test.ts new file mode 100644 index 000000000000..d988a374516f --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.test.ts @@ -0,0 +1,246 @@ +import { + EventId, + MessageId, + type OrchestrationCommand, + type OrchestrationThread, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ServerProvider, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { makeManualOnlyProviderMaintenanceCapabilities } from "../../provider/providerMaintenance.ts"; +import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ProviderRateLimitReactor } from "../Services/ProviderRateLimitReactor.ts"; +import { + PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, + ProviderRateLimitReactorLive, +} from "./ProviderRateLimitReactor.ts"; + +const THREAD_ID = ThreadId.make("thread-rate-limit"); +const TURN_ID = TurnId.make("turn-1"); +const WORK = ProviderInstanceId.make("claude_work"); +const PERSONAL = ProviderInstanceId.make("claude_personal"); +const AT = "2026-09-02T10:00:00.000Z"; + +function provider(instanceId: ProviderInstanceId): ServerProvider { + return { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + displayName: instanceId, + continuation: { groupKey: "claude:session-transcript" }, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: AT, + models: [], + slashCommands: [], + skills: [], + }; +} + +const thread: OrchestrationThread = { + id: THREAD_ID, + projectId: "project-1" as OrchestrationThread["projectId"], + title: "Thread", + modelSelection: { instanceId: WORK, model: "claude-sonnet-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: AT, + updatedAt: AT, + messages: [ + { + id: MessageId.make("message-1"), + role: "user", + text: "Fix the failing build", + turnId: TURN_ID, + streaming: false, + createdAt: AT, + updatedAt: AT, + }, + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: null, + latestTurn: null, +} as unknown as OrchestrationThread; + +function makeHarness(autoSwitch: boolean) { + return Effect.gen(function* () { + const runtimeEvents = yield* PubSub.unbounded(); + const providersRef = yield* Ref.make>([ + provider(WORK), + provider(PERSONAL), + ]); + const dispatched: OrchestrationCommand[] = []; + + const layer = ProviderRateLimitReactorLive.pipe( + Layer.provideMerge( + Layer.mock(ProviderService)({ + get streamEvents() { + return Stream.fromPubSub(runtimeEvents); + }, + }), + ), + Layer.provideMerge( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => { + dispatched.push(command as OrchestrationCommand); + return Effect.succeed(undefined as never); + }, + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadDetailById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? Option.some(thread) : Option.none()), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderRegistry, { + getProviders: Ref.get(providersRef), + refresh: () => Ref.get(providersRef), + refreshInstance: () => Ref.get(providersRef), + refreshWorkspaceSnapshot: () => Ref.get(providersRef), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, driver) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + setProviderMaintenanceActionState: () => Ref.get(providersRef), + setProviderRateLimit: (input) => + Ref.updateAndGet(providersRef, (providers) => + providers.map((candidate) => + candidate.instanceId === input.instanceId + ? { ...candidate, ...(input.rateLimit ? { rateLimit: input.rateLimit } : {}) } + : candidate, + ), + ), + streamChanges: Stream.empty, + }), + ), + Layer.provideMerge( + ServerSettingsService.layerTest({ autoSwitchProviderOnRateLimit: autoSwitch }), + ), + Layer.provideMerge(NodeServices.layer), + ); + return { layer, runtimeEvents, providersRef, dispatched }; + }); +} + +const rejectedEvent: ProviderRuntimeEvent = { + eventId: EventId.make("event-rate-limit"), + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: WORK, + threadId: THREAD_ID, + createdAt: AT, + type: "account.rate-limits.updated", + payload: { + rateLimits: { + rate_limit_info: { status: "rejected", resetsAt: 1_788_000_000, rateLimitType: "five_hour" }, + }, + }, +}; + +const failedTurnEvent: ProviderRuntimeEvent = { + eventId: EventId.make("event-turn-failed"), + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: WORK, + threadId: THREAD_ID, + turnId: TURN_ID, + createdAt: AT, + type: "turn.completed", + payload: { state: "failed", errorMessage: "You've hit your limit · resets 3pm" }, +}; + +describe("ProviderRateLimitReactor", () => { + it.effect("projects Claude rate limit events onto the provider snapshot", () => + Effect.gen(function* () { + const harness = yield* makeHarness(false); + yield* Effect.gen(function* () { + const reactor = yield* ProviderRateLimitReactor; + yield* reactor.start(); + // Let the forked subscriber attach before publishing. + yield* Effect.yieldNow; + yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); + yield* reactor.drain; + const providers = yield* Ref.get(harness.providersRef); + const work = providers.find((candidate) => candidate.instanceId === WORK); + assert.equal(work?.rateLimit?.status, "rejected"); + assert.equal(work?.rateLimit?.window, "five_hour"); + assert.equal(work?.rateLimit?.resetsAt, "2026-08-29T10:40:00.000Z"); + assert.equal(harness.dispatched.length, 0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("re-sends a limited turn on a sibling account when auto-switch is on", () => + Effect.gen(function* () { + const harness = yield* makeHarness(true); + yield* Effect.gen(function* () { + const reactor = yield* ProviderRateLimitReactor; + yield* reactor.start(); + // Let the forked subscriber attach before publishing. + yield* Effect.yieldNow; + yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); + yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); + yield* reactor.drain; + + assert.equal(harness.dispatched.length, 2); + const [activity, retry] = harness.dispatched; + assert.equal(activity?.type, "thread.activity.append"); + if (activity?.type === "thread.activity.append") { + assert.equal(activity.activity.kind, PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND); + } + assert.equal(retry?.type, "thread.turn.start"); + if (retry?.type === "thread.turn.start") { + assert.equal(retry.modelSelection?.instanceId, PERSONAL); + assert.equal(retry.modelSelection?.model, "claude-sonnet-5"); + assert.equal(retry.message.text, "Fix the failing build"); + } + + // A second failure for the same turn is never retried again. + yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); + yield* reactor.drain; + assert.equal(harness.dispatched.length, 2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("leaves a limited turn alone when auto-switch is off", () => + Effect.gen(function* () { + const harness = yield* makeHarness(false); + yield* Effect.gen(function* () { + const reactor = yield* ProviderRateLimitReactor; + yield* reactor.start(); + // Let the forked subscriber attach before publishing. + yield* Effect.yieldNow; + yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); + yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); + yield* reactor.drain; + assert.equal(harness.dispatched.length, 0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts b/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts new file mode 100644 index 000000000000..2e8825ff5985 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts @@ -0,0 +1,286 @@ +/** + * ProviderRateLimitReactor - Live usage-limit tracking and account fallback. + * + * Consumes provider runtime events in order: + * + * 1. `account.rate-limits.updated` is normalized per driver and projected onto + * `ServerProvider.rateLimit`, so every client can show which account is + * limited and when it resets. + * 2. A turn that fails while its account is limited is re-sent on a sibling + * account when `autoSwitchProviderOnRateLimit` is enabled. The sibling must + * share the driver and continuation group so the provider session resumes + * with full history. Each failed turn is retried at most once. + * + * Ordering matters: the runtime reports the rejection before the failing + * result, and both arrive on the same stream, so the retry decision never + * races the state update. + * + * @module ProviderRateLimitReactorLive + */ +import { + CommandId, + EventId, + MessageId, + type ModelSelection, + type OrchestrationThread, + type ProviderRuntimeEvent, + type ServerProvider, + type ThreadId, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + isProviderRateLimitActive, + selectRateLimitFallbackProvider, +} from "@t3tools/shared/providerRateLimits"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { + readProviderRateLimitFromPayload, + readProviderRateLimitFromTurnError, +} from "../../provider/providerRateLimits.ts"; +import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + ProviderRateLimitReactor, + type ProviderRateLimitReactorShape, +} from "../Services/ProviderRateLimitReactor.ts"; + +export const PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND = "provider.instance.switched"; + +const RETRIED_TURN_KEY_MAX = 512; + +export function providerDisplayName(provider: Pick) { + return provider.displayName ?? provider.instanceId; +} + +export function formatRateLimitSwitchSummary(input: { + readonly from: Pick; + readonly to: Pick; +}): string { + return `Switched to ${providerDisplayName(input.to)} because ${providerDisplayName(input.from)} hit its usage limit`; +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const providerService = yield* ProviderService; + const providerRegistry = yield* ProviderRegistry; + const serverSettingsService = yield* ServerSettingsService; + + const serverCommandId = (tag: string) => + crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); + + // Failed turns already retried once, keyed by `${threadId}:${turnId}`. + const retriedTurnKeys = new Set(); + const rememberRetriedTurn = (key: string) => { + if (retriedTurnKeys.size >= RETRIED_TURN_KEY_MAX) { + const oldest = retriedTurnKeys.values().next().value; + if (oldest !== undefined) retriedTurnKeys.delete(oldest); + } + retriedTurnKeys.add(key); + }; + + const autoSwitchEnabled = serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.autoSwitchProviderOnRateLimit), + Effect.catch((cause) => + Effect.logWarning("Could not read server settings; skipping provider auto-switch.", { + cause, + }).pipe(Effect.as(false)), + ), + ); + + const resolveThreadDetail = (threadId: ThreadId) => + projectionSnapshotQuery + .getThreadDetailById(threadId, { activityKinds: [] }) + .pipe(Effect.map(Option.getOrUndefined)); + + const appendSwitchActivity = (input: { + readonly threadId: ThreadId; + readonly from: ServerProvider; + readonly to: ServerProvider; + readonly turnId: ProviderRuntimeEvent["turnId"]; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const commandId = yield* serverCommandId("provider-rate-limit-switch"); + const eventId = yield* serverEventId(); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: eventId, + tone: "info", + kind: PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, + summary: formatRateLimitSwitchSummary(input), + payload: { + reason: "rate-limit", + fromInstanceId: input.from.instanceId, + toInstanceId: input.to.instanceId, + ...(input.from.rateLimit?.resetsAt ? { resetsAt: input.from.rateLimit.resetsAt } : {}), + }, + turnId: input.turnId ?? null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const retryTurnOnProvider = (input: { + readonly thread: OrchestrationThread; + readonly userMessage: OrchestrationThread["messages"][number]; + readonly to: ServerProvider; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const commandId = yield* serverCommandId("provider-rate-limit-retry"); + const messageId = MessageId.make(yield* crypto.randomUUIDv4); + const modelSelection: ModelSelection = { + ...input.thread.modelSelection, + instanceId: input.to.instanceId, + }; + yield* orchestrationEngine.dispatch({ + type: "thread.turn.start", + commandId, + threadId: input.thread.id, + message: { + messageId, + role: "user", + text: input.userMessage.text, + attachments: input.userMessage.attachments ?? [], + }, + modelSelection, + runtimeMode: input.thread.runtimeMode, + interactionMode: input.thread.interactionMode, + createdAt: input.createdAt, + }); + }); + + const recordRateLimit = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + if (event.providerInstanceId === undefined) return; + const rateLimit = + event.type === "account.rate-limits.updated" + ? readProviderRateLimitFromPayload({ + driver: event.provider, + payload: event.payload, + observedAt: event.createdAt, + }) + : event.type === "turn.completed" && event.payload.state === "failed" + ? readProviderRateLimitFromTurnError({ + errorMessage: event.payload.errorMessage, + observedAt: event.createdAt, + }) + : undefined; + if (!rateLimit) return; + // A structured update always wins; the error-text heuristic only fills + // in when nothing structured has marked this account as limited. + if (event.type === "turn.completed") { + const providers = yield* providerRegistry.getProviders; + const current = providers.find((p) => p.instanceId === event.providerInstanceId); + if (isProviderRateLimitActive(current?.rateLimit, Date.parse(event.createdAt))) return; + } + yield* providerRegistry.setProviderRateLimit({ + instanceId: event.providerInstanceId, + rateLimit, + }); + }); + + const maybeSwitchFailedTurn = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + if (event.type !== "turn.completed" || event.payload.state !== "failed") return; + if (event.providerInstanceId === undefined || event.turnId === undefined) return; + const retryKey = `${event.threadId}:${event.turnId}`; + if (retriedTurnKeys.has(retryKey)) return; + + const nowMs = Date.parse(event.createdAt); + const providers = yield* providerRegistry.getProviders; + const from = providers.find((p) => p.instanceId === event.providerInstanceId); + if (!from || !isProviderRateLimitActive(from.rateLimit, nowMs)) return; + if (!(yield* autoSwitchEnabled)) return; + + const to = selectRateLimitFallbackProvider({ + providers, + instanceId: from.instanceId, + nowMs, + }); + if (!to) { + yield* Effect.logInfo("provider rate limit reactor found no fallback account", { + threadId: event.threadId, + instanceId: from.instanceId, + }); + return; + } + + const thread = yield* resolveThreadDetail(event.threadId); + if (!thread) return; + const userMessages = thread.messages + .toReversed() + .filter((message) => message.role === "user" && message.text.trim().length > 0); + const userMessage = + userMessages.find((message) => message.turnId === event.turnId) ?? userMessages[0]; + if (!userMessage) return; + + rememberRetriedTurn(retryKey); + yield* Effect.logInfo("provider rate limit reactor switching thread to fallback account", { + threadId: thread.id, + turnId: event.turnId, + fromInstanceId: from.instanceId, + toInstanceId: to.instanceId, + resetsAt: from.rateLimit?.resetsAt, + }); + yield* appendSwitchActivity({ + threadId: thread.id, + from, + to, + turnId: event.turnId, + createdAt: event.createdAt, + }); + yield* retryTurnOnProvider({ thread, userMessage, to, createdAt: event.createdAt }); + }); + + const processEvent = (event: ProviderRuntimeEvent) => + recordRateLimit(event).pipe( + Effect.andThen(maybeSwitchFailedTurn(event)), + Effect.catchCause((cause) => + Effect.logWarning("provider rate limit reactor failed to process runtime event", { + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }), + ), + ); + + const worker = yield* makeDrainableWorker(processEvent); + + const start: ProviderRateLimitReactorShape["start"] = () => + forkParked( + Stream.runForEach(providerService.streamEvents, (event) => { + if ( + event.type !== "account.rate-limits.updated" && + !(event.type === "turn.completed" && event.payload.state === "failed") + ) { + return Effect.void; + } + return worker.enqueue(event); + }), + ); + + return { + start, + drain: worker.drain, + } satisfies ProviderRateLimitReactorShape; +}); + +export const ProviderRateLimitReactorLive = Layer.effect(ProviderRateLimitReactor, make); diff --git a/apps/server/src/orchestration/Services/ProviderRateLimitReactor.ts b/apps/server/src/orchestration/Services/ProviderRateLimitReactor.ts new file mode 100644 index 000000000000..3f69088cc422 --- /dev/null +++ b/apps/server/src/orchestration/Services/ProviderRateLimitReactor.ts @@ -0,0 +1,27 @@ +/** + * ProviderRateLimitReactor - Provider usage-limit reaction service interface. + * + * Tracks the usage-limit state each provider instance reports and, when the + * user opted in, moves limited threads to a sibling account. + * + * @module ProviderRateLimitReactor + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface ProviderRateLimitReactorShape { + /** + * Start consuming provider runtime events. The returned effect must be run + * in a scope so the worker fiber is finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** Resolves when every queued event has been processed. Test use only. */ + readonly drain: Effect.Effect; +} + +export class ProviderRateLimitReactor extends Context.Service< + ProviderRateLimitReactor, + ProviderRateLimitReactorShape +>()("t3/orchestration/Services/ProviderRateLimitReactor") {} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..954f71f6d817 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -59,7 +59,7 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { CLAUDE_CONTINUATION_GROUP_KEY, makeClaudeCapabilitiesCacheKey } from "./ClaudeHome.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -135,13 +135,12 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ), ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, displayName, accentColor, - continuationGroupKey, + continuationGroupKey: CLAUDE_CONTINUATION_GROUP_KEY, }); // One per instance: the status probe writes the model-scoped bucket @@ -248,7 +247,7 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, continuationIdentity: { ...fallbackContinuationIdentity, - continuationKey: continuationGroupKey, + continuationKey: CLAUDE_CONTINUATION_GROUP_KEY, }, displayName, accentColor, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 33b11237547e..d88596e7f964 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -5,10 +5,13 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; + import { claudeSignedOutMessage, + carryOverClaudeSessionTranscript, + claudeProjectDirectoryName, makeClaudeCapabilitiesCacheKey, - makeClaudeContinuationGroupKey, makeClaudeEnvironment, resolveClaudeHomePath, } from "./ClaudeHome.ts"; @@ -25,7 +28,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); - it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => + it.effect("resolves configured Claude HOME and stamps the cache key with it", () => Effect.gen(function* () { const path = yield* Path.Path; const homePath = "~/.claude-work"; @@ -33,7 +36,6 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); - expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( `claude\0${resolved}\0`, ); @@ -59,16 +61,99 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(first).not.toBe(second); }), ); + }); + + describe("session transcript carry-over", () => { + it.effect("derives the Claude project directory name from the cwd", () => + Effect.sync(() => { + expect(claudeProjectDirectoryName("/Users/me/dev/app")).toBe("-Users-me-dev-app"); + expect(claudeProjectDirectoryName("/home/me/.t3")).toBe("-home-me--t3"); + }), + ); - it.effect("keeps continuation compatible across instances with the same Claude HOME", () => + it.effect("copies the transcript and its sidecar into the target config dir", () => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const resolved = path.resolve(NodeOS.homedir()); + const root = yield* fileSystem.makeTempDirectoryScoped(); + const fromHomePath = path.join(root, "work"); + const toHomePath = path.join(root, "personal"); + const cwd = "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/repo/app"; + const sessionId = "550e8400-e29b-41d4-a716-446655440000"; + const project = claudeProjectDirectoryName(cwd); + const fromProject = path.join(fromHomePath, "projects", project); + yield* fileSystem.makeDirectory(path.join(fromProject, sessionId, "subagents"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(fromProject, `${sessionId}.jsonl`), "{}\n"); + yield* fileSystem.writeFileString( + path.join(fromProject, sessionId, "subagents", "agent-1.jsonl"), + "{}\n", + ); + + const carriedOver = yield* carryOverClaudeSessionTranscript({ + fromHomePath, + toHomePath, + sessionId, + cwd, + }); - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( - `claude:home:${resolved}`, + expect(carriedOver).toBe(true); + const toProject = path.join(toHomePath, "projects", project); + expect(yield* fileSystem.readFileString(path.join(toProject, `${sessionId}.jsonl`))).toBe( + "{}\n", ); - }), + expect( + yield* fileSystem.exists(path.join(toProject, sessionId, "subagents", "agent-1.jsonl")), + ).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("finds the transcript under another project directory when the cwd moved", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const fromHomePath = path.join(root, "work"); + const toHomePath = path.join(root, "personal"); + const sessionId = "550e8400-e29b-41d4-a716-446655440001"; + const fromProject = path.join(fromHomePath, "projects", "-repo-old"); + yield* fileSystem.makeDirectory(fromProject, { recursive: true }); + yield* fileSystem.writeFileString(path.join(fromProject, `${sessionId}.jsonl`), "{}\n"); + + const carriedOver = yield* carryOverClaudeSessionTranscript({ + fromHomePath, + toHomePath, + sessionId, + cwd: "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/repo/new", + }); + + expect(carriedOver).toBe(true); + expect( + yield* fileSystem.exists( + path.join(toHomePath, "projects", "-repo-old", `${sessionId}.jsonl`), + ), + ).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("reports a missing transcript without touching the target config dir", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const toHomePath = path.join(root, "personal"); + + const carriedOver = yield* carryOverClaudeSessionTranscript({ + fromHomePath: path.join(root, "missing"), + toHomePath, + sessionId: "550e8400-e29b-41d4-a716-446655440002", + cwd: "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/repo/app", + }); + + expect(carriedOver).toBe(false); + expect(yield* fileSystem.exists(toHomePath)).toBe(false); + }).pipe(Effect.scoped), ); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bbd005a1e00b..1dfe4821ea7e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -2,8 +2,10 @@ import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; 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 type * as PlatformError from "effect/PlatformError"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -37,10 +39,72 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; }); -export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( - function* (config: Pick): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); - return `claude:home:${resolvedHomePath}`; +/** + * Every Claude instance shares one continuation group. A thread can move + * between Claude config directories because the adapter carries the session + * transcript into the target directory before resuming + * (see `carryOverClaudeSessionTranscript`). + */ +export const CLAUDE_CONTINUATION_GROUP_KEY = "claude:session-transcript"; + +/** + * Claude Code keeps one transcript per session under + * `/projects//.jsonl`, where the project + * dir is the session cwd with every non-alphanumeric character replaced by `-`. + */ +export function claudeProjectDirectoryName(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, "-"); +} + +export interface ClaudeSessionTranscriptCarryOver { + readonly fromHomePath: string; + readonly toHomePath: string; + readonly sessionId: string; + readonly cwd: string | undefined; +} + +/** + * Copy a session transcript (and its sidecar directory holding subagent + * transcripts and tool results) from one Claude config directory into + * another so `--resume` works there. Returns `false` when the source has no + * transcript for the session, so the caller can fall through to the normal + * resume path and let Claude Code report the missing session. + */ +export const carryOverClaudeSessionTranscript = Effect.fn("carryOverClaudeSessionTranscript")( + function* ( + input: ClaudeSessionTranscriptCarryOver, + ): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (input.fromHomePath === input.toHomePath) return false; + + const fromProjects = path.join(input.fromHomePath, "projects"); + const transcriptName = `${input.sessionId}.jsonl`; + const preferredProject = input.cwd ? claudeProjectDirectoryName(input.cwd) : undefined; + const candidates = preferredProject ? [preferredProject] : []; + if (yield* fileSystem.exists(fromProjects)) { + for (const entry of yield* fileSystem.readDirectory(fromProjects)) { + if (entry !== preferredProject) candidates.push(entry); + } + } + + for (const project of candidates) { + const fromTranscript = path.join(fromProjects, project, transcriptName); + if (!(yield* fileSystem.exists(fromTranscript))) continue; + + const toProject = path.join(input.toHomePath, "projects", project); + yield* fileSystem.makeDirectory(toProject, { recursive: true }); + yield* fileSystem.copyFile(fromTranscript, path.join(toProject, transcriptName)); + + const fromSidecar = path.join(fromProjects, project, input.sessionId); + if (yield* fileSystem.exists(fromSidecar)) { + yield* fileSystem.copy(fromSidecar, path.join(toProject, input.sessionId), { + overwrite: true, + }); + } + return true; + } + return false; }, ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..1b596c4589ff 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -6099,6 +6099,7 @@ describe("ClaudeAdapterLive", () => { resume: "550e8400-e29b-41d4-a716-446655440000", resumeSessionAt: "assistant-99", turnCount: 3, + configDir: NodePath.resolve(NodeOS.homedir()), }); const createInput = harness.getLastCreateQueryInput(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81a4a197e9db..054efccbe010 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -83,7 +83,12 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { + carryOverClaudeSessionTranscript, + claudeSignedOutMessage, + makeClaudeEnvironment, + resolveClaudeHomePath, +} from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; @@ -139,6 +144,8 @@ interface ClaudeResumeState { readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; + /** Resolved Claude config dir that owns the transcript for `resume`. */ + readonly configDir?: string; } interface ClaudeTurnState { @@ -854,6 +861,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; + configDir?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; @@ -871,6 +879,10 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const resumeSessionAt = typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + const configDir = + typeof cursor.configDir === "string" && cursor.configDir.length > 0 + ? cursor.configDir + : undefined; return { ...(threadId ? { threadId } : {}), @@ -879,6 +891,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), + ...(configDir ? { configDir } : {}), }; } @@ -1930,6 +1943,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); + const claudeHomePath = yield* resolveClaudeHomePath(claudeSettings).pipe( + Effect.provideService(Path.Path, path), + ); const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( claudeSettings.binaryPath, claudeEnvironment, @@ -2044,6 +2060,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), turnCount: context.turns.length, + configDir: claudeHomePath, }; context.session = { @@ -4198,6 +4215,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const newSessionId = existingResumeSessionId === undefined ? yield* randomUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; + // The thread last ran under another Claude config dir (a different + // account). Bring its transcript here so `--resume` can find it. + if ( + existingResumeSessionId !== undefined && + resumeState?.configDir !== undefined && + resumeState.configDir !== claudeHomePath + ) { + const carriedOver = yield* carryOverClaudeSessionTranscript({ + fromHomePath: resumeState.configDir, + toHomePath: claudeHomePath, + sessionId: existingResumeSessionId, + cwd: input.cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catchCause((cause) => + Effect.logWarning("claude.session.transcript_carry_over_failed", { + threadId, + sessionId: existingResumeSessionId, + fromConfigDir: resumeState.configDir, + toConfigDir: claudeHomePath, + cause: Cause.pretty(cause), + }).pipe(Effect.as(false)), + ), + ); + yield* Effect.logInfo("claude.session.transcript_carry_over", { + threadId, + sessionId: existingResumeSessionId, + fromConfigDir: resumeState.configDir, + toConfigDir: claudeHomePath, + carriedOver, + }); + } + const runtimeContext = yield* Effect.context(); const runFork = Effect.runForkWith(runtimeContext); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -4768,6 +4819,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(sessionId ? { resume: sessionId } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), turnCount: resumeState?.turnCount ?? 0, + configDir: claudeHomePath, }, createdAt: startedAt, updatedAt: startedAt, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..7caa406b3fe8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -27,6 +27,7 @@ import { ProviderDriverKind, type ProviderInstanceId, type ServerProvider, + type ServerProviderRateLimit, type ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -401,18 +402,26 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const rateLimitStatesRef = yield* Ref.make>( + new Map(), + ); + const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( provider: ServerProvider, ) { const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); + const rateLimitStates = yield* Ref.get(rateLimitStatesRef); const updateState = maintenanceActionStates.get(provider.instanceId)?.update; - if (!updateState) { - const { updateState: _updateState, ...providerWithoutUpdateState } = provider; - return providerWithoutUpdateState; - } + const rateLimit = rateLimitStates.get(provider.instanceId); + const { + updateState: _updateState, + rateLimit: _rateLimit, + ...providerWithoutVolatileState + } = provider; return { - ...provider, - updateState, + ...providerWithoutVolatileState, + ...(updateState ? { updateState } : {}), + ...(rateLimit ? { rateLimit } : {}), }; }); @@ -521,6 +530,30 @@ export const ProviderRegistryLive = Layer.effect( }, ); + const setProviderRateLimit = Effect.fn("setProviderRateLimit")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly rateLimit: ServerProviderRateLimit | null; + }) { + yield* Ref.update(rateLimitStatesRef, (previous) => { + const next = new Map(previous); + if (input.rateLimit === null) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, input.rateLimit); + } + return next; + }); + + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + return yield* upsertProviders([matchingProvider], { persist: false }); + }); + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { @@ -875,6 +908,7 @@ export const ProviderRegistryLive = Layer.effect( refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, + setProviderRateLimit, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..47ce10ce644d 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -986,6 +986,179 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance }).pipe(Effect.provide(NodeServices.layer)), ); +function makeSiblingInstanceRegistry(input: { + readonly driverKind: ProviderDriverKind; + readonly instances: ReadonlyArray<{ + readonly instanceId: ProviderInstanceId; + readonly adapter: ProviderAdapterShape; + readonly continuationKey: string; + }>; +}): ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] { + const byId = new Map(input.instances.map((entry) => [entry.instanceId, entry] as const)); + const unsupported = () => new ProviderUnsupportedError({ provider: input.driverKind }); + return { + getByInstance: (instanceId) => { + const entry = byId.get(instanceId); + return entry ? Effect.succeed(entry.adapter) : Effect.fail(unsupported()); + }, + getInstanceInfo: (instanceId) => { + const entry = byId.get(instanceId); + return entry + ? Effect.succeed({ + instanceId, + driverKind: input.driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: input.driverKind, + continuationKey: entry.continuationKey, + }, + }) + : Effect.fail(unsupported()); + }, + listInstances: () => Effect.succeed(Array.from(byId.keys())), + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +} + +function makeProviderLayerForRegistry( + registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"], + dbPath: string, +) { + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(makeSqlitePersistenceLive(dbPath)), + ); + return makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer))), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); +} + +it.effect( + "ProviderServiceLive resumes a stopped thread on a sibling instance that shares its continuation key", + () => + Effect.gen(function* () { + const driverKind = ProviderDriverKind.make("claudeAgent"); + const workInstanceId = ProviderInstanceId.make("claude_work"); + const personalInstanceId = ProviderInstanceId.make("claude_personal"); + const work = makeFakeCodexAdapter(driverKind); + const personal = makeFakeCodexAdapter(driverKind); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-sibling-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); + const registry = makeSiblingInstanceRegistry({ + driverKind, + instances: [ + { instanceId: workInstanceId, adapter: work.adapter, continuationKey: "claude:shared" }, + { + instanceId: personalInstanceId, + adapter: personal.adapter, + continuationKey: "claude:shared", + }, + ], + }); + const threadId = asThreadId("thread-claude-sibling"); + + const initial = yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const session = yield* provider.startSession(threadId, { + provider: driverKind, + providerInstanceId: workInstanceId, + threadId, + cwd: "/tmp/project-claude-sibling", + runtimeMode: "full-access", + }); + yield* provider.stopSession({ threadId }); + return session; + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: driverKind, + providerInstanceId: personalInstanceId, + threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + + assert.equal(personal.startSession.mock.calls.length, 1); + const startPayload = personal.startSession.mock.calls[0]?.[0] as { + cwd?: string; + resumeCursor?: unknown; + }; + assert.equal(startPayload.cwd, "/tmp/project-claude-sibling"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "ProviderServiceLive starts fresh on a sibling instance with a different continuation key", + () => + Effect.gen(function* () { + const driverKind = ProviderDriverKind.make("codex"); + const workInstanceId = ProviderInstanceId.make("codex_work"); + const personalInstanceId = ProviderInstanceId.make("codex_personal"); + const work = makeFakeCodexAdapter(driverKind); + const personal = makeFakeCodexAdapter(driverKind); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-sibling-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); + const registry = makeSiblingInstanceRegistry({ + driverKind, + instances: [ + { instanceId: workInstanceId, adapter: work.adapter, continuationKey: "codex:home:a" }, + { + instanceId: personalInstanceId, + adapter: personal.adapter, + continuationKey: "codex:home:b", + }, + ], + }); + const threadId = asThreadId("thread-codex-sibling"); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: driverKind, + providerInstanceId: workInstanceId, + threadId, + cwd: "/tmp/project-codex-sibling", + runtimeMode: "full-access", + }); + yield* provider.stopSession({ threadId }); + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: driverKind, + providerInstanceId: personalInstanceId, + threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + + const startPayload = personal.startSession.mock.calls[0]?.[0] as { + cwd?: string; + resumeCursor?: unknown; + }; + assert.equal(startPayload.cwd, undefined); + assert.equal(startPayload.resumeCursor, undefined); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + const routing = makeProviderServiceLayer(); const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 016bcd5c4a28..05a4a1aeccd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1377,14 +1377,27 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } } + // A persisted binding is reusable when it belongs to this instance or + // to a sibling instance that can continue the same provider session + // (same driver, same continuation key). + const persistedBindingReusable = + persistedBinding?.providerInstanceId !== undefined && + (persistedBinding.providerInstanceId === resolvedInstanceId || + (yield* registry.getInstanceInfo(persistedBinding.providerInstanceId).pipe( + Effect.map( + (previousInfo) => + previousInfo.driverKind === instanceInfo.driverKind && + previousInfo.continuationIdentity.continuationKey === + instanceInfo.continuationIdentity.continuationKey, + ), + Effect.orElseSucceed(() => false), + ))); const effectiveResumeCursor = input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); + (persistedBindingReusable ? persistedBinding.resumeCursor : undefined); const effectiveCwd = input.cwd ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId + (persistedBindingReusable ? readPersistedCwd(persistedBinding.runtimePayload) : undefined); yield* Effect.annotateCurrentSpan({ @@ -1392,16 +1405,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.resume_cursor.source": input.resumeCursor !== undefined ? "request" - : effectiveResumeCursor !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId + : effectiveResumeCursor !== undefined && persistedBindingReusable ? "persisted" : "none", "provider.resume_cursor.present": effectiveResumeCursor !== undefined, "provider.cwd.source": input.cwd !== undefined ? "request" - : effectiveCwd !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId + : effectiveCwd !== undefined && persistedBindingReusable ? "persisted" : "none", "provider.cwd.effective": effectiveCwd ?? "", diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index bd917834e972..e5ba9c500fc3 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,6 +10,7 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderRateLimit, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -76,6 +77,16 @@ export interface ProviderRegistryShape { readonly state: ServerProviderUpdateState | null; }) => Effect.Effect>; + /** + * Record the latest usage-limit state a provider runtime reported for one + * instance. Volatile like maintenance-action state: projected onto + * `ServerProvider.rateLimit`, never persisted. `null` clears it. + */ + readonly setProviderRateLimit: (input: { + readonly instanceId: ProviderInstanceId; + readonly rateLimit: ServerProviderRateLimit | null; + }) => Effect.Effect>; + /** * Stream of provider snapshot updates — one emission per aggregated * change. The array contains the full current state. diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..3d7380a28221 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -197,6 +197,7 @@ function makeRegistry( getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, + setProviderRateLimit: () => Effect.succeed([]), streamChanges: Stream.empty, }; diff --git a/apps/server/src/provider/providerRateLimits.test.ts b/apps/server/src/provider/providerRateLimits.test.ts new file mode 100644 index 000000000000..74e8528c4e49 --- /dev/null +++ b/apps/server/src/provider/providerRateLimits.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + readProviderRateLimitFromPayload, + readProviderRateLimitFromTurnError, +} from "./providerRateLimits.ts"; + +const OBSERVED_AT = "2026-09-02T10:00:00.000Z"; + +describe("readProviderRateLimitFromPayload", () => { + it("normalizes a Claude rate_limit_event", () => { + expect( + readProviderRateLimitFromPayload({ + driver: "claudeAgent", + observedAt: OBSERVED_AT, + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + resetsAt: 1_788_000_000, + rateLimitType: "five_hour", + utilization: 100, + }, + }, + }, + }), + ).toEqual({ + status: "rejected", + resetsAt: "2026-08-29T10:40:00.000Z", + window: "five_hour", + utilization: 100, + observedAt: OBSERVED_AT, + }); + }); + + it("maps Claude allowed_warning to warning", () => { + expect( + readProviderRateLimitFromPayload({ + driver: "claudeAgent", + observedAt: OBSERVED_AT, + payload: { + rateLimits: { rate_limit_info: { status: "allowed_warning", utilization: 92 } }, + }, + }), + ).toEqual({ status: "warning", utilization: 92, observedAt: OBSERVED_AT }); + }); + + it("normalizes a Codex rate limit snapshot using the hottest window", () => { + expect( + readProviderRateLimitFromPayload({ + driver: "codex", + observedAt: OBSERVED_AT, + payload: { + rateLimits: { + rateLimits: { + primary: { usedPercent: 40, resetsAt: 1_788_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 95, resetsAt: 1_788_500_000, windowDurationMins: 10_080 }, + }, + }, + }, + }), + ).toEqual({ + status: "warning", + resetsAt: "2026-09-04T05:33:20.000Z", + window: "10080m", + utilization: 95, + observedAt: OBSERVED_AT, + }); + }); + + it("treats a Codex rateLimitReachedType as rejected", () => { + expect( + readProviderRateLimitFromPayload({ + driver: "codex", + observedAt: OBSERVED_AT, + payload: { + rateLimits: { + rateLimits: { + primary: { usedPercent: 100, resetsAt: 1_788_000_000, windowDurationMins: 300 }, + rateLimitReachedType: "rate_limit_reached", + }, + }, + }, + })?.status, + ).toBe("rejected"); + }); + + it("ignores payloads it does not understand", () => { + expect( + readProviderRateLimitFromPayload({ + driver: "claudeAgent", + observedAt: OBSERVED_AT, + payload: { rateLimits: { something: true } }, + }), + ).toBeUndefined(); + expect( + readProviderRateLimitFromPayload({ driver: "cursor", observedAt: OBSERVED_AT, payload: {} }), + ).toBeUndefined(); + }); +}); + +describe("readProviderRateLimitFromTurnError", () => { + it("recognizes usage limit error text", () => { + expect( + readProviderRateLimitFromTurnError({ + errorMessage: "You've hit your limit · resets 3pm (Asia/Dhaka)", + observedAt: OBSERVED_AT, + }), + ).toEqual({ status: "rejected", observedAt: OBSERVED_AT }); + }); + + it("ignores unrelated failures", () => { + expect( + readProviderRateLimitFromTurnError({ + errorMessage: "Claude turn failed.", + observedAt: OBSERVED_AT, + }), + ).toBeUndefined(); + expect( + readProviderRateLimitFromTurnError({ errorMessage: undefined, observedAt: OBSERVED_AT }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerRateLimits.ts b/apps/server/src/provider/providerRateLimits.ts new file mode 100644 index 000000000000..1ad594c5db3b --- /dev/null +++ b/apps/server/src/provider/providerRateLimits.ts @@ -0,0 +1,163 @@ +/** + * Normalize provider-native usage-limit notifications into + * `ServerProviderRateLimit`. Each adapter forwards its runtime's payload + * untouched on `account.rate-limits.updated`; this is the one place that knows + * the per-driver shapes. + * + * @module provider/providerRateLimits + */ +import type { ServerProviderRateLimit, ServerProviderRateLimitStatus } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +const USAGE_LIMIT_ERROR_PATTERN = + /hit your (usage )?limit|usage limit|rate[ -]?limit|limit reached|too many requests|resets? (at|in) /i; + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** Providers report reset times as unix seconds; a few already use millis. */ +function epochToIso(value: unknown): string | undefined { + const epoch = asFiniteNumber(value); + if (epoch === undefined || epoch <= 0) return undefined; + const ms = epoch < 1e12 ? epoch * 1000 : epoch; + const dateTime = DateTime.make(ms); + return Option.isNone(dateTime) ? undefined : DateTime.formatIso(dateTime.value); +} + +function clampUtilization(value: unknown): number | undefined { + const utilization = asFiniteNumber(value); + if (utilization === undefined) return undefined; + return Math.min(100, Math.max(0, utilization)); +} + +function build(input: { + readonly status: ServerProviderRateLimitStatus; + readonly resetsAt?: string | undefined; + readonly window?: string | undefined; + readonly utilization?: number | undefined; + readonly observedAt: string; +}): ServerProviderRateLimit { + return { + status: input.status, + ...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}), + ...(input.window !== undefined ? { window: input.window } : {}), + ...(input.utilization !== undefined ? { utilization: input.utilization } : {}), + observedAt: input.observedAt, + }; +} + +/** + * Claude Agent SDK `rate_limit_event`: + * `{ rate_limit_info: { status, resetsAt, rateLimitType, utilization } }`. + */ +function readClaudeRateLimit( + payload: Record, + observedAt: string, +): ServerProviderRateLimit | undefined { + const info = asRecord(payload.rate_limit_info); + if (!info) return undefined; + const status = + info.status === "rejected" + ? "rejected" + : info.status === "allowed_warning" + ? "warning" + : info.status === "allowed" + ? "allowed" + : undefined; + if (status === undefined) return undefined; + return build({ + status, + resetsAt: epochToIso(info.resetsAt), + window: asNonEmptyString(info.rateLimitType), + utilization: clampUtilization(info.utilization), + observedAt, + }); +} + +/** + * Codex app-server `account/rateLimits/updated`: + * `{ rateLimits: { primary?, secondary?, rateLimitReachedType? } }` where each + * window is `{ usedPercent, resetsAt, windowDurationMins }`. + */ +function readCodexRateLimit( + payload: Record, + observedAt: string, +): ServerProviderRateLimit | undefined { + const snapshot = asRecord(payload.rateLimits) ?? payload; + const windows = [asRecord(snapshot.primary), asRecord(snapshot.secondary)].filter( + (window): window is Record => window !== undefined, + ); + if (windows.length === 0 && snapshot.rateLimitReachedType === undefined) return undefined; + + const hottest = windows.reduce | undefined>((best, window) => { + const used = asFiniteNumber(window.usedPercent) ?? 0; + const bestUsed = best ? (asFiniteNumber(best.usedPercent) ?? 0) : -1; + return used > bestUsed ? window : best; + }, undefined); + const utilization = hottest ? clampUtilization(hottest.usedPercent) : undefined; + const reached = asNonEmptyString(snapshot.rateLimitReachedType) !== undefined; + const status: ServerProviderRateLimitStatus = + reached || (utilization !== undefined && utilization >= 100) + ? "rejected" + : utilization !== undefined && utilization >= 90 + ? "warning" + : "allowed"; + const windowMinutes = hottest ? asFiniteNumber(hottest.windowDurationMins) : undefined; + return build({ + status, + resetsAt: hottest ? epochToIso(hottest.resetsAt) : undefined, + window: windowMinutes !== undefined ? `${windowMinutes}m` : undefined, + utilization, + observedAt, + }); +} + +/** + * Read a rate-limit snapshot out of an `account.rate-limits.updated` payload. + * Returns `undefined` when the payload is not understood so callers leave the + * previous state untouched. + */ +export function readProviderRateLimitFromPayload(input: { + readonly driver: string; + readonly payload: unknown; + readonly observedAt: string; +}): ServerProviderRateLimit | undefined { + const outer = asRecord(input.payload); + const rateLimits = asRecord(outer?.rateLimits); + if (!rateLimits) return undefined; + switch (input.driver) { + case "claudeAgent": + return readClaudeRateLimit(rateLimits, input.observedAt); + case "codex": + return readCodexRateLimit(rateLimits, input.observedAt); + default: + return undefined; + } +} + +/** + * Some runtimes only surface a usage limit as the failing turn's error text. + * Treat that as a rejection with no known reset so the account is not retried + * blindly; the next structured update replaces it. + */ +export function readProviderRateLimitFromTurnError(input: { + readonly errorMessage: string | undefined; + readonly observedAt: string; +}): ServerProviderRateLimit | undefined { + if (!input.errorMessage || !USAGE_LIMIT_ERROR_PATTERN.test(input.errorMessage)) { + return undefined; + } + return build({ status: "rejected", observedAt: input.observedAt }); +} diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index b2195b1d890c..918b1f6953c1 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -15,6 +15,7 @@ const makeProviderRegistryMock = ( getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), + setProviderRateLimit: () => Effect.succeed(providers), streamChanges: Stream.empty, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32b6524d7b..0e1abad84678 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -776,6 +776,7 @@ const buildAppUnderTest = (options?: { makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), ), setProviderMaintenanceActionState: () => Effect.succeed([]), + setProviderRateLimit: () => Effect.succeed([]), streamChanges: Stream.empty, ...options?.layers?.providerRegistry, }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fd8ee4a4f699..14f9cc1cea23 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -68,6 +68,7 @@ import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationRe import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { ProviderRateLimitReactorLive } from "./orchestration/Layers/ProviderRateLimitReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; @@ -277,6 +278,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), + Layer.provideMerge(ProviderRateLimitReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..619c006e28d4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -216,6 +216,7 @@ import { ChevronDownIcon, GitBranchIcon, Minimize2Icon, + GaugeIcon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -352,6 +353,11 @@ import { ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + formatProviderRateLimitReset, + providerLabel, + resolveProviderRateLimitSuggestion, +} from "./chat/providerRateLimitBanner.logic"; import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableCompactionProvider, @@ -5865,6 +5871,98 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionPermanentlyDismissed, selectedProvider, ]); + // Usage-limit suggestion: the thread's account is out of usage and the + // server is not switching accounts on its own. Dismissal is per limit + // window, so a fresh limit later resurfaces the banner. + const [dismissedRateLimitKeys, setDismissedRateLimitKeys] = useState>( + new Set(), + ); + const rateLimitSuggestion = useMemo( + () => + activeThread + ? resolveProviderRateLimitSuggestion({ + providers: providerStatuses, + instanceId: activeThread.session?.providerInstanceId ?? activeProviderInstanceId, + autoSwitchEnabled: settings.autoSwitchProviderOnRateLimit, + nowMs: Date.parse(`${nowMinute}:00.000Z`), + }) + : null, + [ + activeProviderInstanceId, + activeThread, + nowMinute, + providerStatuses, + settings.autoSwitchProviderOnRateLimit, + ], + ); + const handleSwitchRateLimitedAccount = useCallback(() => { + if (!activeThread || !rateLimitSuggestion?.fallback) return; + const fallback = rateLimitSuggestion.fallback; + const currentModel = activeThread.modelSelection.model; + const resolvedModel = + resolveAppModelSelectionForInstance( + fallback.instanceId, + settings, + providerStatuses, + currentModel, + ) ?? currentModel; + const nextModelSelection: ModelSelection = { + instanceId: fallback.instanceId, + model: resolvedModel, + }; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + setComposerDraftModelSelection(threadRef, nextModelSelection, { explicit: true }); + setStickyComposerModelSelection(nextModelSelection); + // A turn that just failed on the limited account is worth re-sending + // as-is; put it back in the composer when nothing else is drafted. + if (activeThread.session?.status === "error" && !composerHasUnsentContent) { + const lastUserMessage = activeThread.messages + .toReversed() + .find((message) => message.role === "user" && message.text.trim().length > 0); + if (lastUserMessage) { + setComposerDraftPrompt(threadRef, lastUserMessage.text); + } + } + setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitSuggestion.key)); + scheduleComposerFocus(); + }, [ + activeThread, + composerHasUnsentContent, + providerStatuses, + rateLimitSuggestion, + scheduleComposerFocus, + setComposerDraftModelSelection, + setComposerDraftPrompt, + setStickyComposerModelSelection, + settings, + ]); + const rateLimitBannerItem = useMemo(() => { + if (!rateLimitSuggestion || dismissedRateLimitKeys.has(rateLimitSuggestion.key)) { + return null; + } + const reset = formatProviderRateLimitReset( + rateLimitSuggestion.limited.rateLimit?.resetsAt, + Date.parse(`${nowMinute}:00.000Z`), + ); + const fallback = rateLimitSuggestion.fallback; + return { + id: `provider-rate-limit:${rateLimitSuggestion.key}`, + variant: "warning", + icon: , + title: `${providerLabel(rateLimitSuggestion.limited)} hit its usage limit`, + description: fallback + ? `${reset ? `${reset[0]!.toUpperCase()}${reset.slice(1)}. ` : ""}${providerLabel(fallback)} can continue this thread.` + : (reset ?? undefined), + actions: fallback ? ( + + ) : undefined, + dismissLabel: "Dismiss usage limit notice", + onDismiss: () => + setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitSuggestion.key)), + }; + }, [dismissedRateLimitKeys, handleSwitchRateLimitedAccount, nowMinute, rateLimitSuggestion]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -5892,6 +5990,7 @@ export default function ChatView(props: ChatViewProps) { backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const resumeCompactionItems = resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; + const rateLimitItems = rateLimitBannerItem === null ? [] : [rateLimitBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. @@ -5902,6 +6001,7 @@ export default function ChatView(props: ChatViewProps) { ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, + ...rateLimitItems, ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, @@ -5912,6 +6012,7 @@ export default function ChatView(props: ChatViewProps) { ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, + ...rateLimitItems, ...resumeCompactionItems, ...wokeThreadItems, { @@ -5962,6 +6063,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + rateLimitBannerItem, resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index dd53270268ba..169e074780e9 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -2,6 +2,8 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import { formatProviderRateLimitReset } from "./providerRateLimitBanner.logic"; +import { isProviderRateLimitActive } from "@t3tools/shared/providerRateLimits"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { @@ -29,6 +31,12 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } +function describeRateLimitedInstance(entry: ProviderInstanceEntry): string | null { + if (!isProviderRateLimitActive(entry.rateLimit, Date.now())) return null; + const reset = formatProviderRateLimitReset(entry.rateLimit?.resetsAt, Date.now()); + return `${entry.displayName} — Usage limit reached${reset ? `, ${reset}` : ""}.`; +} + const SELECTED_INDICATOR_CLASS = "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; const BADGE_BASE_CLASS = @@ -150,9 +158,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ? describeUnavailableInstance(entry) : isContextDisabled ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName) - : showNewBadge - ? `${entry.displayName} — New` - : entry.displayName; + : (describeRateLimitedInstance(entry) ?? + (showNewBadge ? `${entry.displayName} — New` : entry.displayName)); const button = ( ) : undefined, dismissLabel: "Dismiss usage limit notice", onDismiss: () => setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitSuggestion.key)), }; - }, [dismissedRateLimitKeys, handleSwitchRateLimitedAccount, nowMinute, rateLimitSuggestion]); + }, [ + dismissedRateLimitKeys, + handleSwitchRateLimitedAccount, + nowMinute, + providerInstanceLabel, + rateLimitSuggestion, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..1374848ef7a1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -380,11 +380,13 @@ function SidebarThreadTooltip({ providerEntry?.displayName ?? thread.session?.providerName ?? modelInstanceId } accentColor={providerEntry?.accentColor} - // Initials would swallow a size-3 glyph: accent dot, name in label. - showBadge={showInstanceBadge && providerEntry?.accentColor !== undefined} - badgeContent="none" - badgeClassName="h-2 min-w-2 px-0" - iconClassName="size-3 shrink-0 grayscale opacity-60" + // The accent tints the glyph itself; a size-3 glyph has no + // room for initials, so accent-less siblings show nothing. + showBadge={false} + iconClassName={cn( + "size-3 shrink-0 opacity-60", + providerEntry?.accentColor === undefined && "grayscale", + )} />
{showInstanceBadge && providerEntry @@ -1918,7 +1920,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { modelInstanceId } accentColor={providerEntry?.accentColor} - showBadge={showInstanceBadge} + showBadge={showInstanceBadge && providerEntry?.accentColor === undefined} // Glyph dims, badge stays saturated; offset matches the composer trigger. iconClassName="size-3.5 opacity-60" badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]" diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 169e074780e9..1c692bab12dd 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -180,7 +180,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { disabled={isDisabled} type="button" aria-label={ - isUnavailable || isContextDisabled + isUnavailable || isContextDisabled || describeRateLimitedInstance(entry) !== null ? tooltip : showNewBadge ? `${entry.displayName}, new` @@ -191,7 +191,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { driverKind={entry.driverKind} displayName={entry.displayName} accentColor={entry.accentColor} - showBadge={showInstanceBadge} + // The tinted glyph is the account marker; initials only + // disambiguate siblings that have no colour. + showBadge={showInstanceBadge && entry.accentColor === undefined} className="size-6" iconClassName="size-5" indicatorBackground={ @@ -201,9 +203,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ? "var(--background)" : "color-mix(in oklab, var(--muted) 30%, transparent)" } - {...(entry.accentColor - ? { badgeClassName: "h-3 min-w-3 px-0.5 text-[7px]" } - : {})} /> {showNewBadge ? ( diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index c333b5cfcc69..378a1326e98a 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -11,14 +11,18 @@ export { providerInstanceInitials }; * Accounts with an accent colour paint the provider logo itself in that * colour, so two Claude accounts read as a blue Claude and a green Claude * instead of identical logos with a tiny corner badge. The inline style wins - * over the brand `fill-*` class and any `grayscale` dimming a caller applies. + * over the brand `fill-*` class; callers keep ownership of dimming and of + * whether a badge is shown at all. */ export function providerAccentIconStyle( accentColor: string | undefined, ): CSSProperties | undefined { - return accentColor ? { fill: accentColor, color: accentColor, filter: "none" } : undefined; + return accentColor ? { fill: accentColor, color: accentColor } : undefined; } +/** Logos with per-path fills (OpenCode) need the accent pushed onto the paths too. */ +const ACCENT_PATH_CLASS = "[&_path]:[fill:var(--provider-accent)]"; + export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; displayName: string; @@ -49,7 +53,11 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { > {Icon ? ( @@ -68,10 +76,13 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { aria-hidden /> ) : null} - {props.showBadge && !props.accentColor ? ( + {props.showBadge ? ( { candidate("claude_off", { enabled: false }), candidate("claude_missing", { availability: "unavailable" }), candidate("claude_broken", { status: "error" }), + candidate("claude_missing_cli", { installed: false }), candidate("codex_personal", { driver: ProviderDriverKind.make("codex") }), candidate("claude_other_home", { continuation: { groupKey: "claude:home:/other" } }), ]; @@ -95,6 +97,19 @@ describe("selectRateLimitFallbackProvider", () => { ).toBeNull(); }); + it("returns null when the current account has no continuation group", () => { + expect( + selectRateLimitFallbackProvider({ + providers: [ + candidate("claude_work", { continuation: undefined }), + candidate("claude_personal", { continuation: undefined }), + ], + instanceId: ProviderInstanceId.make("claude_work"), + nowMs: NOW, + }), + ).toBeNull(); + }); + it("returns null for an unknown instance", () => { expect( selectRateLimitFallbackProvider({ diff --git a/packages/shared/src/providerRateLimits.ts b/packages/shared/src/providerRateLimits.ts index e2f12207a288..6373169dc5ed 100644 --- a/packages/shared/src/providerRateLimits.ts +++ b/packages/shared/src/providerRateLimits.ts @@ -18,6 +18,7 @@ export type RateLimitFallbackCandidate = Pick< | "displayName" | "continuation" | "enabled" + | "installed" | "status" | "availability" | "rateLimit" @@ -53,6 +54,9 @@ export function selectRateLimitFallbackProvider< const current = input.providers.find((provider) => provider.instanceId === input.instanceId); if (!current) return null; const currentGroupKey = current.continuation?.groupKey; + // No continuation group means the snapshot predates instance identity; + // there is no way to know its session store is shared. + if (currentGroupKey === undefined) return null; let best: Candidate | null = null; for (const candidate of input.providers) { @@ -60,6 +64,7 @@ export function selectRateLimitFallbackProvider< if (candidate.driver !== current.driver) continue; if (candidate.continuation?.groupKey !== currentGroupKey) continue; if (!candidate.enabled) continue; + if (candidate.installed === false) continue; if (candidate.availability === "unavailable") continue; if (candidate.status === "error" || candidate.status === "disabled") continue; if (isProviderRateLimitActive(candidate.rateLimit, input.nowMs)) continue; From d986ca59894125bbc554e3ac7ceb83e50b94f660 Mon Sep 17 00:00:00 2001 From: Tamim Bin Hakim <184807161+tamimbinhakim@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:21:20 +0600 Subject: [PATCH 3/4] fix(providers): second review pass on account switching - Error-text detections carry a turn-error window so the Codex merge never mistakes them for a real window. - Turn-start records are bounded and a missing record is logged instead of silently skipping the retry. - Rate-limit banner dismissal is per thread and stable when no reset time is known; the composer trigger follows the tinted-glyph rule. - Namespace imports for the reactor module. --- .../Layers/OrchestrationReactor.test.ts | 4 +- .../Layers/OrchestrationReactor.ts | 4 +- .../ProviderRateLimitReactor.test.ts | 41 ++++++++++++------- .../orchestration/ProviderRateLimitReactor.ts | 27 +++++++++--- .../src/provider/providerRateLimits.test.ts | 1 + .../server/src/provider/providerRateLimits.ts | 9 +++- apps/web/src/components/ChatView.tsx | 17 ++++++-- .../components/chat/ProviderModelPicker.tsx | 3 +- .../providerRateLimitBanner.logic.test.ts | 11 +++++ .../chat/providerRateLimitBanner.logic.ts | 4 +- 10 files changed, 91 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 9109d6ddc568..5d4970a17587 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; -import { ProviderRateLimitReactor } from "../ProviderRateLimitReactor.ts"; +import * as ProviderRateLimitReactor from "../ProviderRateLimitReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -50,7 +50,7 @@ describe("OrchestrationReactor", () => { }), ), Layer.provideMerge( - Layer.succeed(ProviderRateLimitReactor, { + Layer.succeed(ProviderRateLimitReactor.ProviderRateLimitReactor, { start: () => { started.push("provider-rate-limit-reactor"); return Effect.void; diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index f5137140e6a1..d7598bc0af21 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,7 +7,7 @@ import { } from "../Services/OrchestrationReactor.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; -import { ProviderRateLimitReactor } from "../ProviderRateLimitReactor.ts"; +import * as ProviderRateLimitReactor from "../ProviderRateLimitReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -17,7 +17,7 @@ import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; - const providerRateLimitReactor = yield* ProviderRateLimitReactor; + const providerRateLimitReactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; diff --git a/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts b/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts index 9ed956309b43..a2d891b1f599 100644 --- a/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts +++ b/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts @@ -26,12 +26,7 @@ import { ProviderService } from "../provider/Services/ProviderService.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; -import { - layer as providerRateLimitReactorLayer, - mergeCodexRateLimit, - PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, - ProviderRateLimitReactor, -} from "./ProviderRateLimitReactor.ts"; +import * as ProviderRateLimitReactor from "./ProviderRateLimitReactor.ts"; const THREAD_ID = ThreadId.make("thread-rate-limit"); const TURN_ID = TurnId.make("turn-1"); @@ -96,7 +91,7 @@ function makeHarness(autoSwitch: boolean) { ]); const dispatched: OrchestrationCommand[] = []; - const layer = providerRateLimitReactorLayer.pipe( + const layer = ProviderRateLimitReactor.layer.pipe( Layer.provideMerge( Layer.mock(ProviderService)({ get streamEvents() { @@ -207,11 +202,26 @@ describe("mergeCodexRateLimit", () => { observedAt: AT, }; const sparse = { status: "allowed" as const, utilization: 40, observedAt: AT }; - assert.deepEqual(mergeCodexRateLimit(previous, sparse, Date.parse(AT)), previous); + assert.deepEqual( + ProviderRateLimitReactor.mergeCodexRateLimit(previous, sparse, Date.parse(AT)), + previous, + ); + const heuristic = { ...previous, window: "turn-error" }; + assert.deepEqual( + ProviderRateLimitReactor.mergeCodexRateLimit(heuristic, sparse, Date.parse(AT)), + sparse, + ); const sameWindow = { ...sparse, resetsAt: "2026-09-02T12:00:00.000Z" }; - assert.deepEqual(mergeCodexRateLimit(previous, sameWindow, Date.parse(AT)), sameWindow); assert.deepEqual( - mergeCodexRateLimit(previous, sparse, Date.parse("2026-09-02T12:00:01.000Z")), + ProviderRateLimitReactor.mergeCodexRateLimit(previous, sameWindow, Date.parse(AT)), + sameWindow, + ); + assert.deepEqual( + ProviderRateLimitReactor.mergeCodexRateLimit( + previous, + sparse, + Date.parse("2026-09-02T12:00:01.000Z"), + ), sparse, ); }), @@ -223,7 +233,7 @@ describe("ProviderRateLimitReactor", () => { Effect.gen(function* () { const harness = yield* makeHarness(false); yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor; + const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; yield* reactor.start(); // Let the forked subscriber attach before publishing. yield* Effect.yieldNow; @@ -243,7 +253,7 @@ describe("ProviderRateLimitReactor", () => { Effect.gen(function* () { const harness = yield* makeHarness(true); yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor; + const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; yield* reactor.start(); // Let the forked subscriber attach before publishing. yield* Effect.yieldNow; @@ -256,7 +266,10 @@ describe("ProviderRateLimitReactor", () => { const [activity, retry] = harness.dispatched; assert.equal(activity?.type, "thread.activity.append"); if (activity?.type === "thread.activity.append") { - assert.equal(activity.activity.kind, PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND); + assert.equal( + activity.activity.kind, + ProviderRateLimitReactor.PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, + ); } assert.equal(retry?.type, "thread.turn.start"); if (retry?.type === "thread.turn.start") { @@ -279,7 +292,7 @@ describe("ProviderRateLimitReactor", () => { Effect.gen(function* () { const harness = yield* makeHarness(false); yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor; + const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; yield* reactor.start(); // Let the forked subscriber attach before publishing. yield* Effect.yieldNow; diff --git a/apps/server/src/orchestration/ProviderRateLimitReactor.ts b/apps/server/src/orchestration/ProviderRateLimitReactor.ts index 04426cdb6f52..db27098fd942 100644 --- a/apps/server/src/orchestration/ProviderRateLimitReactor.ts +++ b/apps/server/src/orchestration/ProviderRateLimitReactor.ts @@ -48,6 +48,7 @@ import * as Stream from "effect/Stream"; import { readProviderRateLimitFromPayload, readProviderRateLimitFromTurnError, + TURN_ERROR_RATE_LIMIT_WINDOW, } from "../provider/providerRateLimits.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; import { ProviderService } from "../provider/Services/ProviderService.ts"; @@ -104,6 +105,8 @@ export function mergeCodexRateLimit( ): ServerProviderRateLimit { if ( previous !== undefined && + // An error-text detection is not a Codex window; a structured update replaces it. + previous.window !== TURN_ERROR_RATE_LIMIT_WINDOW && next.status !== "rejected" && isProviderRateLimitActive(previous, nowMs) && previous.resetsAt !== undefined && @@ -138,7 +141,18 @@ export const make = Effect.gen(function* () { // The user message that started each thread's most recent turn. Messages // are stored with a null turn id, so this is the only link back from a // failed turn to the prompt that should be retried. + // Bounded like the retry set: threads beyond the cap simply lose auto-retry + // for their oldest entries, they never leak. + const LATEST_TURN_MESSAGE_MAX = 512; const latestTurnMessageByThread = new Map(); + const rememberLatestTurnMessage = (threadId: ThreadId, messageId: MessageId) => { + latestTurnMessageByThread.delete(threadId); + if (latestTurnMessageByThread.size >= LATEST_TURN_MESSAGE_MAX) { + const oldest = latestTurnMessageByThread.keys().next().value; + if (oldest !== undefined) latestTurnMessageByThread.delete(oldest); + } + latestTurnMessageByThread.set(threadId, messageId); + }; const autoSwitchEnabled = serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.autoSwitchProviderOnRateLimit), @@ -286,7 +300,13 @@ export const make = Effect.gen(function* () { } const messageId = latestTurnMessageByThread.get(event.threadId); - if (messageId === undefined) return; + if (messageId === undefined) { + yield* Effect.logWarning( + "provider rate limit reactor has no turn-start record for the failed turn; not retrying", + { threadId: event.threadId, turnId: event.turnId }, + ); + return; + } const thread = yield* resolveThreadDetail(event.threadId); const userMessage = thread?.messages.find( (message) => message.id === messageId && message.role === "user", @@ -314,10 +334,7 @@ export const make = Effect.gen(function* () { const processInput = (input: ReactorInput) => (input.source === "domain" ? Effect.sync(() => { - latestTurnMessageByThread.set( - input.event.payload.threadId, - input.event.payload.messageId, - ); + rememberLatestTurnMessage(input.event.payload.threadId, input.event.payload.messageId); }) : recordRateLimit(input.event).pipe(Effect.andThen(maybeSwitchFailedTurn(input.event))) ).pipe( diff --git a/apps/server/src/provider/providerRateLimits.test.ts b/apps/server/src/provider/providerRateLimits.test.ts index 1cfadcdae341..aa4b6f50e039 100644 --- a/apps/server/src/provider/providerRateLimits.test.ts +++ b/apps/server/src/provider/providerRateLimits.test.ts @@ -111,6 +111,7 @@ describe("readProviderRateLimitFromTurnError", () => { ).toEqual({ status: "rejected", resetsAt: "2026-09-02T10:15:00.000Z", + window: "turn-error", observedAt: OBSERVED_AT, }); }); diff --git a/apps/server/src/provider/providerRateLimits.ts b/apps/server/src/provider/providerRateLimits.ts index ef4c6f261218..2d61a8a02c9e 100644 --- a/apps/server/src/provider/providerRateLimits.ts +++ b/apps/server/src/provider/providerRateLimits.ts @@ -149,6 +149,8 @@ export function readProviderRateLimitFromPayload(input: { /** How long an error-text detection stays active without a structured update. */ export const TURN_ERROR_RATE_LIMIT_TTL_MS = 15 * 60 * 1000; +/** Window name stamped on error-text detections so they are never mistaken for a provider window. */ +export const TURN_ERROR_RATE_LIMIT_WINDOW = "turn-error"; const TURN_ERROR_RATE_LIMIT_DRIVERS = new Set(["claudeAgent", "codex"]); @@ -173,5 +175,10 @@ export function readProviderRateLimitFromTurnError(input: { const resetsAt = Number.isFinite(observedMs) ? epochToIso(observedMs + TURN_ERROR_RATE_LIMIT_TTL_MS) : undefined; - return build({ status: "rejected", resetsAt, observedAt: input.observedAt }); + return build({ + status: "rejected", + resetsAt, + window: TURN_ERROR_RATE_LIMIT_WINDOW, + observedAt: input.observedAt, + }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76291da89909..d45cd54a2ee7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5932,7 +5932,9 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftPrompt(threadRef, lastUserMessage.text); } } - setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitSuggestion.key)); + setDismissedRateLimitKeys((keys) => + new Set(keys).add(`${activeThread.id}:${rateLimitSuggestion.key}`), + ); scheduleComposerFocus(); }, [ activeThread, @@ -5945,8 +5947,15 @@ export default function ChatView(props: ChatViewProps) { setStickyComposerModelSelection, settings, ]); + // Dismissal is per thread: the offered switch only changes this thread. + const rateLimitDismissKey = + activeThread && rateLimitSuggestion ? `${activeThread.id}:${rateLimitSuggestion.key}` : null; const rateLimitBannerItem = useMemo(() => { - if (!rateLimitSuggestion || dismissedRateLimitKeys.has(rateLimitSuggestion.key)) { + if ( + !rateLimitSuggestion || + rateLimitDismissKey === null || + dismissedRateLimitKeys.has(rateLimitDismissKey) + ) { return null; } const reset = formatProviderRateLimitReset( @@ -5968,14 +5977,14 @@ export default function ChatView(props: ChatViewProps) { ) : undefined, dismissLabel: "Dismiss usage limit notice", - onDismiss: () => - setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitSuggestion.key)), + onDismiss: () => setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitDismissKey)), }; }, [ dismissedRateLimitKeys, handleSwitchRateLimitedAccount, nowMinute, providerInstanceLabel, + rateLimitDismissKey, rateLimitSuggestion, ]); const handleRestoreThreadBranch = useCallback(() => { diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 932754db1eca..9462d4668072 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -187,7 +187,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={activeEntry.driverKind} displayName={activeEntry.displayName} accentColor={activeEntry.accentColor} - showBadge={showInstanceBadge} + // The tinted glyph is the account marker; initials only for accent-less siblings. + showBadge={showInstanceBadge && activeEntry.accentColor === undefined} className="size-4" iconClassName={cn("size-4", props.activeProviderIconClassName)} indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"} diff --git a/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts b/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts index 0a6f7cba42ff..70a91f5fb52e 100644 --- a/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts +++ b/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts @@ -48,6 +48,17 @@ describe("resolveProviderRateLimitSuggestion", () => { expect(suggestion?.key).toBe("claude_work:2026-09-02T12:00:00.000Z"); }); + it("keys an unknown reset stably across snapshots", () => { + const key = (observedAt: string) => + resolveProviderRateLimitSuggestion({ + providers: [provider("claude_work", { rateLimit: { status: "rejected", observedAt } })], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: NOW, + })?.key; + expect(key("2026-09-02T09:55:00.000Z")).toBe(key("2026-09-02T09:59:00.000Z")); + }); + it("still surfaces the limit when no sibling can take over", () => { const suggestion = resolveProviderRateLimitSuggestion({ providers: [limitedWork], diff --git a/apps/web/src/components/chat/providerRateLimitBanner.logic.ts b/apps/web/src/components/chat/providerRateLimitBanner.logic.ts index ca252c246373..7fd9e419b05e 100644 --- a/apps/web/src/components/chat/providerRateLimitBanner.logic.ts +++ b/apps/web/src/components/chat/providerRateLimitBanner.logic.ts @@ -35,7 +35,9 @@ export function resolveProviderRateLimitSuggestion(input: { return { limited, fallback, - key: `${limited.instanceId}:${limited.rateLimit?.resetsAt ?? limited.rateLimit?.observedAt ?? ""}`, + // One dismissal per limit window; an unknown reset counts as one window + // so a fresh observedAt on every snapshot cannot resurface the banner. + key: `${limited.instanceId}:${limited.rateLimit?.resetsAt ?? "unknown-reset"}`, }; } From 6daa0c4014a755484a1aca37e817513684e285d8 Mon Sep 17 00:00:00 2001 From: Tamim Bin Hakim <184807161+tamimbinhakim@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:42:56 +0600 Subject: [PATCH 4/4] refactor(providers): build account switching on the new usage limits Upstream now normalizes subscription usage itself: adapters emit typed windows, ProviderUsageLimitsIngestion merges them onto ServerProvider.usageLimits, and the clients already render them. Drop our parallel rate-limit layer and read that state instead. Removed: the ServerProviderRateLimit contract, the per-driver normalizer, the registry mutator that carried volatile state, and the reactor half that tracked limits. What is left is the part upstream does not do: continue a thread on another account of the same provider when the bound one runs out of usage. The reactor is renamed to ProviderAccountSwitchReactor to say what it now does, and reset wording comes from the shared usage-limit helpers so the banner, the picker and the limits view agree. --- .../OrchestrationEngineHarness.integration.ts | 6 +- .../Layers/OrchestrationReactor.test.ts | 8 +- .../Layers/OrchestrationReactor.ts | 7 +- .../Layers/ProviderCommandReactor.ts | 34 ++-- ...s => ProviderAccountSwitchReactor.test.ts} | 177 ++++++++--------- ...tor.ts => ProviderAccountSwitchReactor.ts} | 178 ++++++----------- .../src/provider/Layers/ProviderRegistry.ts | 62 +----- .../provider/Layers/ProviderService.test.ts | 92 +++++---- .../src/provider/Services/ProviderRegistry.ts | 11 -- .../providerMaintenanceRunner.test.ts | 1 - .../src/provider/providerRateLimits.test.ts | 142 -------------- .../server/src/provider/providerRateLimits.ts | 184 ------------------ .../testUtils/providerRegistryMock.ts | 1 - apps/server/src/server.test.ts | 1 - apps/server/src/server.ts | 4 +- apps/web/src/components/ChatView.tsx | 34 ++-- .../components/chat/ModelPickerSidebar.tsx | 18 +- .../providerAccountSwitchBanner.logic.test.ts | 129 ++++++++++++ .../chat/providerAccountSwitchBanner.logic.ts | 62 ++++++ .../providerRateLimitBanner.logic.test.ts | 117 ----------- .../chat/providerRateLimitBanner.logic.ts | 76 -------- .../components/settings/SettingsPanels.tsx | 1 + .../src/components/settings/settingsSearch.ts | 2 + apps/web/src/providerInstances.ts | 3 - packages/contracts/src/server.ts | 22 --- packages/shared/package.json | 6 +- .../src/providerAccountSwitching.test.ts | 149 ++++++++++++++ .../shared/src/providerAccountSwitching.ts | 106 ++++++++++ .../shared/src/providerRateLimits.test.ts | 122 ------------ packages/shared/src/providerRateLimits.ts | 80 -------- 30 files changed, 696 insertions(+), 1139 deletions(-) rename apps/server/src/orchestration/{ProviderRateLimitReactor.test.ts => ProviderAccountSwitchReactor.test.ts} (65%) rename apps/server/src/orchestration/{ProviderRateLimitReactor.ts => ProviderAccountSwitchReactor.ts} (60%) delete mode 100644 apps/server/src/provider/providerRateLimits.test.ts delete mode 100644 apps/server/src/provider/providerRateLimits.ts create mode 100644 apps/web/src/components/chat/providerAccountSwitchBanner.logic.test.ts create mode 100644 apps/web/src/components/chat/providerAccountSwitchBanner.logic.ts delete mode 100644 apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts delete mode 100644 apps/web/src/components/chat/providerRateLimitBanner.logic.ts create mode 100644 packages/shared/src/providerAccountSwitching.test.ts create mode 100644 packages/shared/src/providerAccountSwitching.ts delete mode 100644 packages/shared/src/providerRateLimits.test.ts delete mode 100644 packages/shared/src/providerRateLimits.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b951943a930c..d00098157631 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -58,7 +58,7 @@ import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts" import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; -import * as ProviderRateLimitReactor from "../src/orchestration/ProviderRateLimitReactor.ts"; +import * as ProviderAccountSwitchReactor from "../src/orchestration/ProviderAccountSwitchReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts"; import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts"; @@ -348,7 +348,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); - const providerRateLimitReactorLayer = ProviderRateLimitReactor.layer.pipe( + const providerAccountSwitchReactorLayer = ProviderAccountSwitchReactor.layer.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); @@ -390,7 +390,7 @@ export const makeOrchestrationIntegrationHarness = ( const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(providerRateLimitReactorLayer), + Layer.provideMerge(providerAccountSwitchReactorLayer), Layer.provideMerge(checkpointReactorLayer), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 5d4970a17587..257440ca0f64 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; -import * as ProviderRateLimitReactor from "../ProviderRateLimitReactor.ts"; +import * as ProviderAccountSwitchReactor from "../ProviderAccountSwitchReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -50,9 +50,9 @@ describe("OrchestrationReactor", () => { }), ), Layer.provideMerge( - Layer.succeed(ProviderRateLimitReactor.ProviderRateLimitReactor, { + Layer.succeed(ProviderAccountSwitchReactor.ProviderAccountSwitchReactor, { start: () => { - started.push("provider-rate-limit-reactor"); + started.push("provider-account-switch-reactor"); return Effect.void; }, drain: Effect.void, @@ -113,7 +113,7 @@ describe("OrchestrationReactor", () => { expect(started).toEqual([ "provider-runtime-ingestion", "provider-command-reactor", - "provider-rate-limit-reactor", + "provider-account-switch-reactor", "checkpoint-reactor", "thread-deletion-reactor", "thread-pull-request-reactor", diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index d7598bc0af21..f4a1e9404576 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,7 +7,7 @@ import { } from "../Services/OrchestrationReactor.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; -import * as ProviderRateLimitReactor from "../ProviderRateLimitReactor.ts"; +import * as ProviderAccountSwitchReactor from "../ProviderAccountSwitchReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; @@ -17,7 +17,8 @@ import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; - const providerRateLimitReactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; + const providerAccountSwitchReactor = + yield* ProviderAccountSwitchReactor.ProviderAccountSwitchReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; @@ -27,7 +28,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); - yield* providerRateLimitReactor.start(); + yield* providerAccountSwitchReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a2f3628d2b3d..1c37b7bdba92 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,9 +15,9 @@ import { } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { - isProviderRateLimitActive, - selectRateLimitFallbackProvider, -} from "@t3tools/shared/providerRateLimits"; + exhaustedUsageWindow, + selectAccountSwitchTarget, +} from "@t3tools/shared/providerAccountSwitching"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -48,9 +48,9 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { - formatRateLimitSwitchSummary, + formatAccountSwitchSummary, PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, -} from "../ProviderRateLimitReactor.ts"; +} from "../ProviderAccountSwitchReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderCommandReactor, @@ -1195,8 +1195,8 @@ const make = Effect.gen(function* () { * the same provider session. The switch is recorded as a thread activity so * it is visible in the transcript. */ - const resolveRateLimitedModelSelection = Effect.fnUntraced(function* (input: { - readonly thread: OrchestrationThread; + const resolveAccountSwitchModelSelection = Effect.fnUntraced(function* (input: { + readonly thread: Pick; readonly requestedModelSelection: ModelSelection | undefined; readonly createdAt: string; }) { @@ -1211,7 +1211,8 @@ const make = Effect.gen(function* () { const nowMs = Date.parse(input.createdAt); const providers = yield* providerRegistry.getProviders; const current = providers.find((provider) => provider.instanceId === currentInstanceId); - if (!current || !isProviderRateLimitActive(current.rateLimit, nowMs)) { + const spent = current ? exhaustedUsageWindow(current, nowMs) : null; + if (!current || !spent) { return input.requestedModelSelection; } const autoSwitch = yield* serverSettingsService.getSettings.pipe( @@ -1221,7 +1222,7 @@ const make = Effect.gen(function* () { if (!autoSwitch) { return input.requestedModelSelection; } - const fallback = selectRateLimitFallbackProvider({ + const fallback = selectAccountSwitchTarget({ providers, instanceId: currentInstanceId, nowMs, @@ -1229,14 +1230,15 @@ const make = Effect.gen(function* () { if (!fallback) { return input.requestedModelSelection; } - yield* Effect.logInfo("provider command reactor routing turn to fallback account", { + yield* Effect.logInfo("provider command reactor routing turn to another account", { threadId: input.thread.id, fromInstanceId: currentInstanceId, toInstanceId: fallback.instanceId, - resetsAt: current.rateLimit?.resetsAt, + window: spent.id, + resetsAt: spent.resetsAt, }); yield* Effect.all({ - commandId: serverCommandId("provider-rate-limit-switch"), + commandId: serverCommandId("provider-account-switch"), eventId: serverEventId(), }).pipe( Effect.flatMap(({ commandId, eventId }) => @@ -1248,12 +1250,12 @@ const make = Effect.gen(function* () { id: eventId, tone: "info", kind: PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, - summary: formatRateLimitSwitchSummary({ from: current, to: fallback }), + summary: formatAccountSwitchSummary({ from: current, to: fallback }), payload: { - reason: "rate-limit", + reason: "usage-limit", fromInstanceId: currentInstanceId, toInstanceId: fallback.instanceId, - ...(current.rateLimit?.resetsAt ? { resetsAt: current.rateLimit.resetsAt } : {}), + ...(spent.resetsAt ? { resetsAt: spent.resetsAt } : {}), }, turnId: null, createdAt: input.createdAt, @@ -1515,7 +1517,7 @@ const make = Effect.gen(function* () { ); } - const modelSelection = yield* resolveRateLimitedModelSelection({ + const modelSelection = yield* resolveAccountSwitchModelSelection({ thread, requestedModelSelection: event.payload.modelSelection, createdAt: event.payload.createdAt, diff --git a/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts b/apps/server/src/orchestration/ProviderAccountSwitchReactor.test.ts similarity index 65% rename from apps/server/src/orchestration/ProviderRateLimitReactor.test.ts rename to apps/server/src/orchestration/ProviderAccountSwitchReactor.test.ts index a2d891b1f599..1e2f91c5d938 100644 --- a/apps/server/src/orchestration/ProviderRateLimitReactor.test.ts +++ b/apps/server/src/orchestration/ProviderAccountSwitchReactor.test.ts @@ -26,15 +26,33 @@ import { ProviderService } from "../provider/Services/ProviderService.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; -import * as ProviderRateLimitReactor from "./ProviderRateLimitReactor.ts"; +import * as ProviderAccountSwitchReactor from "./ProviderAccountSwitchReactor.ts"; -const THREAD_ID = ThreadId.make("thread-rate-limit"); +const THREAD_ID = ThreadId.make("thread-account-switch"); const TURN_ID = TurnId.make("turn-1"); const WORK = ProviderInstanceId.make("claude_work"); const PERSONAL = ProviderInstanceId.make("claude_personal"); const AT = "2026-09-02T10:00:00.000Z"; -function provider(instanceId: ProviderInstanceId): ServerProvider { +function usageLimits(usedPercent: number, resetsAt?: string): ServerProvider["usageLimits"] { + return { + checkedAt: AT, + windows: [ + { + id: "five_hour", + kind: "session", + label: "5-hour", + usedPercent, + ...(resetsAt !== undefined ? { resetsAt } : {}), + }, + ], + }; +} + +function provider( + instanceId: ProviderInstanceId, + overrides: Partial = {}, +): ServerProvider { return { instanceId, driver: ProviderDriverKind.make("claudeAgent"), @@ -49,6 +67,7 @@ function provider(instanceId: ProviderInstanceId): ServerProvider { models: [], slashCommands: [], skills: [], + ...overrides, }; } @@ -81,17 +100,23 @@ const thread: OrchestrationThread = { latestTurn: null, } as unknown as OrchestrationThread; -function makeHarness(autoSwitch: boolean) { +function makeHarness(input: { + readonly autoSwitch: boolean; + readonly workUsedPercent: number; + readonly personalUsedPercent?: number; +}) { return Effect.gen(function* () { const runtimeEvents = yield* PubSub.unbounded(); const domainEvents = yield* PubSub.unbounded(); const providersRef = yield* Ref.make>([ - provider(WORK), - provider(PERSONAL), + provider(WORK, { + usageLimits: usageLimits(input.workUsedPercent, "2026-09-02T12:00:00.000Z"), + }), + provider(PERSONAL, { usageLimits: usageLimits(input.personalUsedPercent ?? 10) }), ]); const dispatched: OrchestrationCommand[] = []; - const layer = ProviderRateLimitReactor.layer.pipe( + const layer = ProviderAccountSwitchReactor.layer.pipe( Layer.provideMerge( Layer.mock(ProviderService)({ get streamEvents() { @@ -130,40 +155,18 @@ function makeHarness(autoSwitch: boolean) { }), ), setProviderMaintenanceActionState: () => Ref.get(providersRef), - setProviderRateLimit: (input) => - Ref.updateAndGet(providersRef, (providers) => - providers.map((candidate) => - candidate.instanceId === input.instanceId - ? { ...candidate, ...(input.rateLimit ? { rateLimit: input.rateLimit } : {}) } - : candidate, - ), - ), streamChanges: Stream.empty, }), ), Layer.provideMerge( - ServerSettingsService.layerTest({ autoSwitchProviderOnRateLimit: autoSwitch }), + ServerSettingsService.layerTest({ autoSwitchProviderOnRateLimit: input.autoSwitch }), ), Layer.provideMerge(NodeServices.layer), ); - return { layer, runtimeEvents, domainEvents, providersRef, dispatched }; + return { layer, runtimeEvents, domainEvents, dispatched }; }); } -const rejectedEvent: ProviderRuntimeEvent = { - eventId: EventId.make("event-rate-limit"), - provider: ProviderDriverKind.make("claudeAgent"), - providerInstanceId: WORK, - threadId: THREAD_ID, - createdAt: AT, - type: "account.rate-limits.updated", - payload: { - rateLimits: { - rate_limit_info: { status: "rejected", resetsAt: 1_788_000_000, rateLimitType: "five_hour" }, - }, - }, -}; - const turnStartRequestedEvent = { eventId: EventId.make("event-turn-start"), sequence: 1, @@ -190,75 +193,19 @@ const failedTurnEvent: ProviderRuntimeEvent = { turnId: TURN_ID, createdAt: AT, type: "turn.completed", - payload: { state: "failed", errorMessage: "You've hit your limit · resets 3pm" }, + payload: { state: "failed", errorMessage: "You've hit your limit" }, }; -describe("mergeCodexRateLimit", () => { - it.effect("keeps an active rejection when a sparse update omits its window", () => - Effect.sync(() => { - const previous = { - status: "rejected" as const, - resetsAt: "2026-09-02T12:00:00.000Z", - observedAt: AT, - }; - const sparse = { status: "allowed" as const, utilization: 40, observedAt: AT }; - assert.deepEqual( - ProviderRateLimitReactor.mergeCodexRateLimit(previous, sparse, Date.parse(AT)), - previous, - ); - const heuristic = { ...previous, window: "turn-error" }; - assert.deepEqual( - ProviderRateLimitReactor.mergeCodexRateLimit(heuristic, sparse, Date.parse(AT)), - sparse, - ); - const sameWindow = { ...sparse, resetsAt: "2026-09-02T12:00:00.000Z" }; - assert.deepEqual( - ProviderRateLimitReactor.mergeCodexRateLimit(previous, sameWindow, Date.parse(AT)), - sameWindow, - ); - assert.deepEqual( - ProviderRateLimitReactor.mergeCodexRateLimit( - previous, - sparse, - Date.parse("2026-09-02T12:00:01.000Z"), - ), - sparse, - ); - }), - ); -}); - -describe("ProviderRateLimitReactor", () => { - it.effect("projects Claude rate limit events onto the provider snapshot", () => +describe("ProviderAccountSwitchReactor", () => { + it.effect("re-sends a failed turn on a sibling account with usage left", () => Effect.gen(function* () { - const harness = yield* makeHarness(false); + const harness = yield* makeHarness({ autoSwitch: true, workUsedPercent: 100 }); yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; + const reactor = yield* ProviderAccountSwitchReactor.ProviderAccountSwitchReactor; yield* reactor.start(); - // Let the forked subscriber attach before publishing. - yield* Effect.yieldNow; - yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); - yield* reactor.drain; - const providers = yield* Ref.get(harness.providersRef); - const work = providers.find((candidate) => candidate.instanceId === WORK); - assert.equal(work?.rateLimit?.status, "rejected"); - assert.equal(work?.rateLimit?.window, "five_hour"); - assert.equal(work?.rateLimit?.resetsAt, "2026-08-29T10:40:00.000Z"); - assert.equal(harness.dispatched.length, 0); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }), - ); - - it.effect("re-sends a limited turn on a sibling account when auto-switch is on", () => - Effect.gen(function* () { - const harness = yield* makeHarness(true); - yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; - yield* reactor.start(); - // Let the forked subscriber attach before publishing. + // Let the forked subscribers attach before publishing. yield* Effect.yieldNow; yield* PubSub.publish(harness.domainEvents, turnStartRequestedEvent); - yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); yield* reactor.drain; @@ -268,7 +215,7 @@ describe("ProviderRateLimitReactor", () => { if (activity?.type === "thread.activity.append") { assert.equal( activity.activity.kind, - ProviderRateLimitReactor.PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, + ProviderAccountSwitchReactor.PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, ); } assert.equal(retry?.type, "thread.turn.start"); @@ -288,16 +235,48 @@ describe("ProviderRateLimitReactor", () => { }), ); - it.effect("leaves a limited turn alone when auto-switch is off", () => + it.effect("leaves the turn alone when the account still has usage", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ autoSwitch: true, workUsedPercent: 80 }); + yield* Effect.gen(function* () { + const reactor = yield* ProviderAccountSwitchReactor.ProviderAccountSwitchReactor; + yield* reactor.start(); + yield* Effect.yieldNow; + yield* PubSub.publish(harness.domainEvents, turnStartRequestedEvent); + yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); + yield* reactor.drain; + assert.equal(harness.dispatched.length, 0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("leaves the turn alone when auto-switch is off", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ autoSwitch: false, workUsedPercent: 100 }); + yield* Effect.gen(function* () { + const reactor = yield* ProviderAccountSwitchReactor.ProviderAccountSwitchReactor; + yield* reactor.start(); + yield* Effect.yieldNow; + yield* PubSub.publish(harness.domainEvents, turnStartRequestedEvent); + yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); + yield* reactor.drain; + assert.equal(harness.dispatched.length, 0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("does not retry when every sibling is also out of usage", () => Effect.gen(function* () { - const harness = yield* makeHarness(false); + const harness = yield* makeHarness({ + autoSwitch: true, + workUsedPercent: 100, + personalUsedPercent: 100, + }); yield* Effect.gen(function* () { - const reactor = yield* ProviderRateLimitReactor.ProviderRateLimitReactor; + const reactor = yield* ProviderAccountSwitchReactor.ProviderAccountSwitchReactor; yield* reactor.start(); - // Let the forked subscriber attach before publishing. yield* Effect.yieldNow; yield* PubSub.publish(harness.domainEvents, turnStartRequestedEvent); - yield* PubSub.publish(harness.runtimeEvents, rejectedEvent); yield* PubSub.publish(harness.runtimeEvents, failedTurnEvent); yield* reactor.drain; assert.equal(harness.dispatched.length, 0); diff --git a/apps/server/src/orchestration/ProviderRateLimitReactor.ts b/apps/server/src/orchestration/ProviderAccountSwitchReactor.ts similarity index 60% rename from apps/server/src/orchestration/ProviderRateLimitReactor.ts rename to apps/server/src/orchestration/ProviderAccountSwitchReactor.ts index db27098fd942..6322c574d312 100644 --- a/apps/server/src/orchestration/ProviderRateLimitReactor.ts +++ b/apps/server/src/orchestration/ProviderAccountSwitchReactor.ts @@ -1,23 +1,23 @@ /** - * ProviderRateLimitReactor - Live usage-limit tracking and account fallback. + * ProviderAccountSwitchReactor - continue a thread on another account when + * the one it is bound to runs out of subscription usage. * - * Consumes provider runtime events in order: + * Usage windows themselves are owned by the provider snapshot: adapters + * normalise their native payloads and `ProviderUsageLimitsIngestion` merges + * them onto `ServerProvider.usageLimits`. This reactor only reads that state + * and reacts to it. * - * 1. `account.rate-limits.updated` is normalized per driver and projected onto - * `ServerProvider.rateLimit`, so every client can show which account is - * limited and when it resets. - * 2. A turn that fails while its account is limited is re-sent on a sibling - * account when `autoSwitchProviderOnRateLimit` is enabled. The sibling must - * share the driver and continuation group so the provider session resumes - * with full history. The retry reuses the original user message, so the - * thread shows one bubble, and each failed turn is retried at most once. + * When a turn fails while its account has a spent window, and the user has + * turned on `autoSwitchProviderOnRateLimit`, the turn is re-sent on a sibling + * account that shares the driver and continuation group, so the provider + * session resumes with full history. The retry reuses the original user + * message id, so the thread keeps one bubble, and each failed turn is retried + * at most once. * - * Ordering matters: the runtime reports the rejection before the failing - * result, and both arrive on the same stream, so the retry decision never - * races the state update. Turn-start domain events are also observed so the - * reactor knows which user message started the turn that failed. + * Turn-start domain events are observed so a failed turn can be traced back + * to the message that started it: messages are stored with a null turn id. * - * @module orchestration/ProviderRateLimitReactor + * @module orchestration/ProviderAccountSwitchReactor */ import { CommandId, @@ -28,14 +28,13 @@ import { type OrchestrationThread, type ProviderRuntimeEvent, type ServerProvider, - type ServerProviderRateLimit, type ThreadId, } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { - isProviderRateLimitActive, - selectRateLimitFallbackProvider, -} from "@t3tools/shared/providerRateLimits"; + exhaustedUsageWindow, + selectAccountSwitchTarget, +} from "@t3tools/shared/providerAccountSwitching"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -45,11 +44,6 @@ import * as Option from "effect/Option"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { - readProviderRateLimitFromPayload, - readProviderRateLimitFromTurnError, - TURN_ERROR_RATE_LIMIT_WINDOW, -} from "../provider/providerRateLimits.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; import { ProviderService } from "../provider/Services/ProviderService.ts"; import { forkParked } from "../serverActivation.ts"; @@ -57,23 +51,23 @@ import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; -export class ProviderRateLimitReactor extends Context.Service< - ProviderRateLimitReactor, +export class ProviderAccountSwitchReactor extends Context.Service< + ProviderAccountSwitchReactor, { /** - * Start consuming provider runtime and turn-start events. The returned - * effect must be run in a scope so the worker fiber is finalized on - * shutdown. + * Start consuming failed turns and turn starts. The returned effect must + * be run in a scope so the worker fiber is finalized on shutdown. */ readonly start: () => Effect.Effect; /** Resolves when every queued event has been processed. Test use only. */ readonly drain: Effect.Effect; } ->()("t3/orchestration/ProviderRateLimitReactor") {} +>()("t3/orchestration/ProviderAccountSwitchReactor") {} export const PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND = "provider.instance.switched"; const RETRIED_TURN_KEY_MAX = 512; +const LATEST_TURN_MESSAGE_MAX = 512; type ReactorInput = | { readonly source: "runtime"; readonly event: ProviderRuntimeEvent } @@ -86,35 +80,11 @@ export function providerDisplayName(provider: Pick; readonly to: Pick; }): string { - return `Switched to ${providerDisplayName(input.to)} because ${providerDisplayName(input.from)} hit its usage limit`; -} - -/** - * Codex reports windows sparsely: an update carrying only the 5-hour window - * says nothing about a still-rejected weekly window. Keep the previous - * rejection until its own reset passes or an update names that window. - */ -export function mergeCodexRateLimit( - previous: ServerProviderRateLimit | undefined, - next: ServerProviderRateLimit, - nowMs: number, -): ServerProviderRateLimit { - if ( - previous !== undefined && - // An error-text detection is not a Codex window; a structured update replaces it. - previous.window !== TURN_ERROR_RATE_LIMIT_WINDOW && - next.status !== "rejected" && - isProviderRateLimitActive(previous, nowMs) && - previous.resetsAt !== undefined && - next.resetsAt !== previous.resetsAt - ) { - return previous; - } - return next; + return `Switched to ${providerDisplayName(input.to)} because ${providerDisplayName(input.from)} ran out of usage`; } export const make = Effect.gen(function* () { @@ -138,12 +108,9 @@ export const make = Effect.gen(function* () { } retriedTurnKeys.add(key); }; - // The user message that started each thread's most recent turn. Messages - // are stored with a null turn id, so this is the only link back from a - // failed turn to the prompt that should be retried. - // Bounded like the retry set: threads beyond the cap simply lose auto-retry - // for their oldest entries, they never leak. - const LATEST_TURN_MESSAGE_MAX = 512; + + // The user message that started each thread's most recent turn. Bounded: + // threads past the cap lose auto-retry rather than leaking memory. const latestTurnMessageByThread = new Map(); const rememberLatestTurnMessage = (threadId: ThreadId, messageId: MessageId) => { latestTurnMessageByThread.delete(threadId); @@ -172,11 +139,12 @@ export const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly from: ServerProvider; readonly to: ServerProvider; + readonly resetsAt: string | undefined; readonly turnId: ProviderRuntimeEvent["turnId"]; readonly createdAt: string; }) => Effect.gen(function* () { - const commandId = yield* serverCommandId("provider-rate-limit-switch"); + const commandId = yield* serverCommandId("provider-account-switch"); const eventId = yield* serverEventId(); yield* orchestrationEngine.dispatch({ type: "thread.activity.append", @@ -186,12 +154,12 @@ export const make = Effect.gen(function* () { id: eventId, tone: "info", kind: PROVIDER_INSTANCE_SWITCHED_ACTIVITY_KIND, - summary: formatRateLimitSwitchSummary(input), + summary: formatAccountSwitchSummary(input), payload: { - reason: "rate-limit", + reason: "usage-limit", fromInstanceId: input.from.instanceId, toInstanceId: input.to.instanceId, - ...(input.from.rateLimit?.resetsAt ? { resetsAt: input.from.rateLimit.resetsAt } : {}), + ...(input.resetsAt ? { resetsAt: input.resetsAt } : {}), }, turnId: input.turnId ?? null, createdAt: input.createdAt, @@ -201,7 +169,7 @@ export const make = Effect.gen(function* () { }).pipe( // The switch note is informational; never let it block the retry. Effect.catchCause((cause) => - Effect.logWarning("provider rate limit reactor could not record the account switch", { + Effect.logWarning("provider account switch reactor could not record the switch", { threadId: input.threadId, cause: Cause.pretty(cause), }), @@ -215,7 +183,7 @@ export const make = Effect.gen(function* () { readonly createdAt: string; }) => Effect.gen(function* () { - const commandId = yield* serverCommandId("provider-rate-limit-retry"); + const commandId = yield* serverCommandId("provider-account-switch-retry"); const modelSelection: ModelSelection = { ...input.thread.modelSelection, instanceId: input.to.instanceId, @@ -239,40 +207,6 @@ export const make = Effect.gen(function* () { }); }); - const recordRateLimit = (event: ProviderRuntimeEvent) => - Effect.gen(function* () { - if (event.providerInstanceId === undefined) return; - const derived = - event.type === "account.rate-limits.updated" - ? readProviderRateLimitFromPayload({ - driver: event.provider, - payload: event.payload, - observedAt: event.createdAt, - }) - : event.type === "turn.completed" && event.payload.state === "failed" - ? readProviderRateLimitFromTurnError({ - driver: event.provider, - errorMessage: event.payload.errorMessage, - observedAt: event.createdAt, - }) - : undefined; - if (!derived) return; - const nowMs = Date.parse(event.createdAt); - const providers = yield* providerRegistry.getProviders; - const previous = providers.find((p) => p.instanceId === event.providerInstanceId)?.rateLimit; - // A structured update always wins; the error-text heuristic only fills - // in when nothing structured has marked this account as limited. - if (event.type === "turn.completed" && isProviderRateLimitActive(previous, nowMs)) return; - const rateLimit = - event.provider === "codex" && event.type === "account.rate-limits.updated" - ? mergeCodexRateLimit(previous, derived, nowMs) - : derived; - yield* providerRegistry.setProviderRateLimit({ - instanceId: event.providerInstanceId, - rateLimit, - }); - }); - const maybeSwitchFailedTurn = (event: ProviderRuntimeEvent) => Effect.gen(function* () { if (event.type !== "turn.completed" || event.payload.state !== "failed") return; @@ -283,16 +217,18 @@ export const make = Effect.gen(function* () { const nowMs = Date.parse(event.createdAt); const providers = yield* providerRegistry.getProviders; const from = providers.find((p) => p.instanceId === event.providerInstanceId); - if (!from || !isProviderRateLimitActive(from.rateLimit, nowMs)) return; + if (!from) return; + const spent = exhaustedUsageWindow(from, nowMs); + if (!spent) return; if (!(yield* autoSwitchEnabled)) return; - const to = selectRateLimitFallbackProvider({ + const to = selectAccountSwitchTarget({ providers, instanceId: from.instanceId, nowMs, }); if (!to) { - yield* Effect.logInfo("provider rate limit reactor found no fallback account", { + yield* Effect.logInfo("provider account switch reactor found no account with usage left", { threadId: event.threadId, instanceId: from.instanceId, }); @@ -302,7 +238,7 @@ export const make = Effect.gen(function* () { const messageId = latestTurnMessageByThread.get(event.threadId); if (messageId === undefined) { yield* Effect.logWarning( - "provider rate limit reactor has no turn-start record for the failed turn; not retrying", + "provider account switch reactor has no turn-start record for the failed turn; not retrying", { threadId: event.threadId, turnId: event.turnId }, ); return; @@ -314,17 +250,19 @@ export const make = Effect.gen(function* () { if (!thread || !userMessage) return; rememberRetriedTurn(retryKey); - yield* Effect.logInfo("provider rate limit reactor switching thread to fallback account", { + yield* Effect.logInfo("provider account switch reactor moving thread to another account", { threadId: thread.id, turnId: event.turnId, fromInstanceId: from.instanceId, toInstanceId: to.instanceId, - resetsAt: from.rateLimit?.resetsAt, + window: spent.id, + resetsAt: spent.resetsAt, }); yield* appendSwitchActivity({ threadId: thread.id, from, to, + resetsAt: spent.resetsAt, turnId: event.turnId, createdAt: event.createdAt, }); @@ -336,10 +274,10 @@ export const make = Effect.gen(function* () { ? Effect.sync(() => { rememberLatestTurnMessage(input.event.payload.threadId, input.event.payload.messageId); }) - : recordRateLimit(input.event).pipe(Effect.andThen(maybeSwitchFailedTurn(input.event))) + : maybeSwitchFailedTurn(input.event) ).pipe( Effect.catchCause((cause) => - Effect.logWarning("provider rate limit reactor failed to process event", { + Effect.logWarning("provider account switch reactor failed to process event", { eventType: input.event.type, cause: Cause.pretty(cause), }), @@ -348,18 +286,14 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processInput); - const start: ProviderRateLimitReactor["Service"]["start"] = () => + const start: ProviderAccountSwitchReactor["Service"]["start"] = () => Effect.gen(function* () { yield* forkParked( - Stream.runForEach(providerService.streamEvents, (event) => { - if ( - event.type !== "account.rate-limits.updated" && - !(event.type === "turn.completed" && event.payload.state === "failed") - ) { - return Effect.void; - } - return worker.enqueue({ source: "runtime", event }); - }), + Stream.runForEach(providerService.streamEvents, (event) => + event.type === "turn.completed" && event.payload.state === "failed" + ? worker.enqueue({ source: "runtime", event }) + : Effect.void, + ), ); // Subscribe before returning so no turn start is missed while event // handling waits for server activation. @@ -376,7 +310,7 @@ export const make = Effect.gen(function* () { return { start, drain: worker.drain, - } satisfies ProviderRateLimitReactor["Service"]; + } satisfies ProviderAccountSwitchReactor["Service"]; }); -export const layer = Layer.effect(ProviderRateLimitReactor, make); +export const layer = Layer.effect(ProviderAccountSwitchReactor, make); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a90aa71395fa..a8e6caf95aa7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -27,7 +27,6 @@ import { ProviderDriverKind, type ProviderInstanceId, type ServerProvider, - type ServerProviderRateLimit, type ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -393,12 +392,7 @@ export const ProviderRegistryLive = Layer.effect( cacheDir: config.providerStatusCacheDir, instanceId: key, }).pipe(Effect.provideService(Path.Path, path)); - // Usage-limit state is volatile; never let it survive a restart. - const { - workspaceSnapshots: _workspaceSnapshots, - rateLimit: _rateLimit, - ...machineProvider - } = provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -407,26 +401,18 @@ export const ProviderRegistryLive = Layer.effect( ); }); - const rateLimitStatesRef = yield* Ref.make>( - new Map(), - ); - const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( provider: ServerProvider, ) { const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); - const rateLimitStates = yield* Ref.get(rateLimitStatesRef); const updateState = maintenanceActionStates.get(provider.instanceId)?.update; - const rateLimit = rateLimitStates.get(provider.instanceId); - const { - updateState: _updateState, - rateLimit: _rateLimit, - ...providerWithoutVolatileState - } = provider; + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = provider; + return providerWithoutUpdateState; + } return { - ...providerWithoutVolatileState, - ...(updateState ? { updateState } : {}), - ...(rateLimit ? { rateLimit } : {}), + ...provider, + updateState, }; }); @@ -535,30 +521,6 @@ export const ProviderRegistryLive = Layer.effect( }, ); - const setProviderRateLimit = Effect.fn("setProviderRateLimit")(function* (input: { - readonly instanceId: ProviderInstanceId; - readonly rateLimit: ServerProviderRateLimit | null; - }) { - yield* Ref.update(rateLimitStatesRef, (previous) => { - const next = new Map(previous); - if (input.rateLimit === null) { - next.delete(input.instanceId); - } else { - next.set(input.instanceId, input.rateLimit); - } - return next; - }); - - const existingProviders = yield* Ref.get(providersRef); - const matchingProvider = existingProviders.find( - (candidate) => candidate.instanceId === input.instanceId, - ); - if (!matchingProvider) { - return existingProviders; - } - return yield* upsertProviders([matchingProvider], { persist: false }); - }); - const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { @@ -764,15 +726,6 @@ export const ProviderRegistryLive = Layer.effect( } return next; }); - yield* Ref.update(rateLimitStatesRef, (previous) => { - const next = new Map(previous); - for (const instanceId of previous.keys()) { - if (!knownInstanceIds.has(instanceId)) { - next.delete(instanceId); - } - } - return next; - }); }), ); const syncLiveSourcesAndContinue = syncLiveSources.pipe( @@ -922,7 +875,6 @@ export const ProviderRegistryLive = Layer.effect( refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, - setProviderRateLimit, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 47ce10ce644d..3b0bf08bc9ed 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1075,7 +1075,7 @@ it.effect( provider: driverKind, providerInstanceId: workInstanceId, threadId, - cwd: "/tmp/project-claude-sibling", + cwd: tempDir, runtimeMode: "full-access", }); yield* provider.stopSession({ threadId }); @@ -1097,66 +1097,64 @@ it.effect( cwd?: string; resumeCursor?: unknown; }; - assert.equal(startPayload.cwd, "/tmp/project-claude-sibling"); + assert.equal(startPayload.cwd, tempDir); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect( - "ProviderServiceLive starts fresh on a sibling instance with a different continuation key", - () => - Effect.gen(function* () { - const driverKind = ProviderDriverKind.make("codex"); - const workInstanceId = ProviderInstanceId.make("codex_work"); - const personalInstanceId = ProviderInstanceId.make("codex_personal"); - const work = makeFakeCodexAdapter(driverKind); - const personal = makeFakeCodexAdapter(driverKind); - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-sibling-")); - const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); - const registry = makeSiblingInstanceRegistry({ - driverKind, - instances: [ - { instanceId: workInstanceId, adapter: work.adapter, continuationKey: "codex:home:a" }, - { - instanceId: personalInstanceId, - adapter: personal.adapter, - continuationKey: "codex:home:b", - }, - ], - }); - const threadId = asThreadId("thread-codex-sibling"); +it.effect("ProviderServiceLive refuses a sibling instance with a different continuation key", () => + Effect.gen(function* () { + const driverKind = ProviderDriverKind.make("codex"); + const workInstanceId = ProviderInstanceId.make("codex_work"); + const personalInstanceId = ProviderInstanceId.make("codex_personal"); + const work = makeFakeCodexAdapter(driverKind); + const personal = makeFakeCodexAdapter(driverKind); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-sibling-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); + const registry = makeSiblingInstanceRegistry({ + driverKind, + instances: [ + { instanceId: workInstanceId, adapter: work.adapter, continuationKey: "codex:home:a" }, + { + instanceId: personalInstanceId, + adapter: personal.adapter, + continuationKey: "codex:home:b", + }, + ], + }); + const threadId = asThreadId("thread-codex-sibling"); - yield* Effect.gen(function* () { - const provider = yield* ProviderService.ProviderService; - yield* provider.startSession(threadId, { - provider: driverKind, - providerInstanceId: workInstanceId, - threadId, - cwd: "/tmp/project-codex-sibling", - runtimeMode: "full-access", - }); - yield* provider.stopSession({ threadId }); - }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: driverKind, + providerInstanceId: workInstanceId, + threadId, + cwd: tempDir, + runtimeMode: "full-access", + }); + yield* provider.stopSession({ threadId }); + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); - yield* Effect.gen(function* () { + const failure = yield* Effect.flip( + Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; - yield* provider.startSession(threadId, { + return yield* provider.startSession(threadId, { provider: driverKind, providerInstanceId: personalInstanceId, threadId, runtimeMode: "full-access", }); - }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))); + }).pipe(Effect.provide(makeProviderLayerForRegistry(registry, dbPath))), + ); - const startPayload = personal.startSession.mock.calls[0]?.[0] as { - cwd?: string; - resumeCursor?: unknown; - }; - assert.equal(startPayload.cwd, undefined); - assert.equal(startPayload.resumeCursor, undefined); - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), + // Sibling reuse never weakens the incompatible-resume guard. + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "provider resume state is incompatible"); + assert.equal(personal.startSession.mock.calls.length, 0); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), ); const routing = makeProviderServiceLayer(); diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index e5ba9c500fc3..bd917834e972 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,7 +10,6 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, - ServerProviderRateLimit, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -77,16 +76,6 @@ export interface ProviderRegistryShape { readonly state: ServerProviderUpdateState | null; }) => Effect.Effect>; - /** - * Record the latest usage-limit state a provider runtime reported for one - * instance. Volatile like maintenance-action state: projected onto - * `ServerProvider.rateLimit`, never persisted. `null` clears it. - */ - readonly setProviderRateLimit: (input: { - readonly instanceId: ProviderInstanceId; - readonly rateLimit: ServerProviderRateLimit | null; - }) => Effect.Effect>; - /** * Stream of provider snapshot updates — one emission per aggregated * change. The array contains the full current state. diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 3d7380a28221..19af22c882ae 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -197,7 +197,6 @@ function makeRegistry( getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, - setProviderRateLimit: () => Effect.succeed([]), streamChanges: Stream.empty, }; diff --git a/apps/server/src/provider/providerRateLimits.test.ts b/apps/server/src/provider/providerRateLimits.test.ts deleted file mode 100644 index aa4b6f50e039..000000000000 --- a/apps/server/src/provider/providerRateLimits.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - readProviderRateLimitFromPayload, - readProviderRateLimitFromTurnError, -} from "./providerRateLimits.ts"; - -const OBSERVED_AT = "2026-09-02T10:00:00.000Z"; - -describe("readProviderRateLimitFromPayload", () => { - it("normalizes a Claude rate_limit_event", () => { - expect( - readProviderRateLimitFromPayload({ - driver: "claudeAgent", - observedAt: OBSERVED_AT, - payload: { - rateLimits: { - type: "rate_limit_event", - rate_limit_info: { - status: "rejected", - resetsAt: 1_788_000_000, - rateLimitType: "five_hour", - utilization: 100, - }, - }, - }, - }), - ).toEqual({ - status: "rejected", - resetsAt: "2026-08-29T10:40:00.000Z", - window: "five_hour", - utilization: 100, - observedAt: OBSERVED_AT, - }); - }); - - it("maps Claude allowed_warning to warning", () => { - expect( - readProviderRateLimitFromPayload({ - driver: "claudeAgent", - observedAt: OBSERVED_AT, - payload: { - rateLimits: { rate_limit_info: { status: "allowed_warning", utilization: 92 } }, - }, - }), - ).toEqual({ status: "warning", utilization: 92, observedAt: OBSERVED_AT }); - }); - - it("normalizes a Codex rate limit snapshot using the hottest window", () => { - expect( - readProviderRateLimitFromPayload({ - driver: "codex", - observedAt: OBSERVED_AT, - payload: { - rateLimits: { - rateLimits: { - primary: { usedPercent: 40, resetsAt: 1_788_000_000, windowDurationMins: 300 }, - secondary: { usedPercent: 95, resetsAt: 1_788_500_000, windowDurationMins: 10_080 }, - }, - }, - }, - }), - ).toEqual({ - status: "warning", - resetsAt: "2026-09-04T05:33:20.000Z", - window: "10080m", - utilization: 95, - observedAt: OBSERVED_AT, - }); - }); - - it("treats a Codex rateLimitReachedType as rejected", () => { - expect( - readProviderRateLimitFromPayload({ - driver: "codex", - observedAt: OBSERVED_AT, - payload: { - rateLimits: { - rateLimits: { - primary: { usedPercent: 100, resetsAt: 1_788_000_000, windowDurationMins: 300 }, - rateLimitReachedType: "rate_limit_reached", - }, - }, - }, - })?.status, - ).toBe("rejected"); - }); - - it("ignores payloads it does not understand", () => { - expect( - readProviderRateLimitFromPayload({ - driver: "claudeAgent", - observedAt: OBSERVED_AT, - payload: { rateLimits: { something: true } }, - }), - ).toBeUndefined(); - expect( - readProviderRateLimitFromPayload({ driver: "cursor", observedAt: OBSERVED_AT, payload: {} }), - ).toBeUndefined(); - }); -}); - -describe("readProviderRateLimitFromTurnError", () => { - it("recognizes usage limit error text with a short expiry", () => { - expect( - readProviderRateLimitFromTurnError({ - driver: "claudeAgent", - errorMessage: "You've hit your limit · resets 3pm (Asia/Dhaka)", - observedAt: OBSERVED_AT, - }), - ).toEqual({ - status: "rejected", - resetsAt: "2026-09-02T10:15:00.000Z", - window: "turn-error", - observedAt: OBSERVED_AT, - }); - }); - - it("ignores unrelated failures and drivers without structured updates", () => { - expect( - readProviderRateLimitFromTurnError({ - driver: "claudeAgent", - errorMessage: "Claude turn failed.", - observedAt: OBSERVED_AT, - }), - ).toBeUndefined(); - expect( - readProviderRateLimitFromTurnError({ - driver: "grok", - errorMessage: "Grok usage limit reached. Try again later.", - observedAt: OBSERVED_AT, - }), - ).toBeUndefined(); - expect( - readProviderRateLimitFromTurnError({ - driver: "codex", - errorMessage: undefined, - observedAt: OBSERVED_AT, - }), - ).toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/providerRateLimits.ts b/apps/server/src/provider/providerRateLimits.ts deleted file mode 100644 index 2d61a8a02c9e..000000000000 --- a/apps/server/src/provider/providerRateLimits.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Normalize provider-native usage-limit notifications into - * `ServerProviderRateLimit`. Each adapter forwards its runtime's payload - * untouched on `account.rate-limits.updated`; this is the one place that knows - * the per-driver shapes. - * - * @module provider/providerRateLimits - */ -import type { ServerProviderRateLimit, ServerProviderRateLimitStatus } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; - -const USAGE_LIMIT_ERROR_PATTERN = - /hit your (usage )?limit|usage limit|rate[ -]?limit|limit reached|too many requests|resets? (at|in) /i; - -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -/** Providers report reset times as unix seconds; a few already use millis. */ -function epochToIso(value: unknown): string | undefined { - const epoch = asFiniteNumber(value); - if (epoch === undefined || epoch <= 0) return undefined; - const ms = epoch < 1e12 ? epoch * 1000 : epoch; - const dateTime = DateTime.make(ms); - return Option.isNone(dateTime) ? undefined : DateTime.formatIso(dateTime.value); -} - -function clampUtilization(value: unknown): number | undefined { - const utilization = asFiniteNumber(value); - if (utilization === undefined) return undefined; - return Math.min(100, Math.max(0, utilization)); -} - -function build(input: { - readonly status: ServerProviderRateLimitStatus; - readonly resetsAt?: string | undefined; - readonly window?: string | undefined; - readonly utilization?: number | undefined; - readonly observedAt: string; -}): ServerProviderRateLimit { - return { - status: input.status, - ...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}), - ...(input.window !== undefined ? { window: input.window } : {}), - ...(input.utilization !== undefined ? { utilization: input.utilization } : {}), - observedAt: input.observedAt, - }; -} - -/** - * Claude Agent SDK `rate_limit_event`: - * `{ rate_limit_info: { status, resetsAt, rateLimitType, utilization } }`. - */ -function readClaudeRateLimit( - payload: Record, - observedAt: string, -): ServerProviderRateLimit | undefined { - const info = asRecord(payload.rate_limit_info); - if (!info) return undefined; - const status = - info.status === "rejected" - ? "rejected" - : info.status === "allowed_warning" - ? "warning" - : info.status === "allowed" - ? "allowed" - : undefined; - if (status === undefined) return undefined; - return build({ - status, - resetsAt: epochToIso(info.resetsAt), - window: asNonEmptyString(info.rateLimitType), - utilization: clampUtilization(info.utilization), - observedAt, - }); -} - -/** - * Codex app-server `account/rateLimits/updated`: - * `{ rateLimits: { primary?, secondary?, rateLimitReachedType? } }` where each - * window is `{ usedPercent, resetsAt, windowDurationMins }`. - */ -function readCodexRateLimit( - payload: Record, - observedAt: string, -): ServerProviderRateLimit | undefined { - const snapshot = asRecord(payload.rateLimits) ?? payload; - const windows = [asRecord(snapshot.primary), asRecord(snapshot.secondary)].filter( - (window): window is Record => window !== undefined, - ); - if (windows.length === 0 && snapshot.rateLimitReachedType === undefined) return undefined; - - const hottest = windows.reduce | undefined>((best, window) => { - const used = asFiniteNumber(window.usedPercent) ?? 0; - const bestUsed = best ? (asFiniteNumber(best.usedPercent) ?? 0) : -1; - return used > bestUsed ? window : best; - }, undefined); - const utilization = hottest ? clampUtilization(hottest.usedPercent) : undefined; - const reached = asNonEmptyString(snapshot.rateLimitReachedType) !== undefined; - const status: ServerProviderRateLimitStatus = - reached || (utilization !== undefined && utilization >= 100) - ? "rejected" - : utilization !== undefined && utilization >= 90 - ? "warning" - : "allowed"; - const windowMinutes = hottest ? asFiniteNumber(hottest.windowDurationMins) : undefined; - return build({ - status, - resetsAt: hottest ? epochToIso(hottest.resetsAt) : undefined, - window: windowMinutes !== undefined ? `${windowMinutes}m` : undefined, - utilization, - observedAt, - }); -} - -/** - * Read a rate-limit snapshot out of an `account.rate-limits.updated` payload. - * Returns `undefined` when the payload is not understood so callers leave the - * previous state untouched. - */ -export function readProviderRateLimitFromPayload(input: { - readonly driver: string; - readonly payload: unknown; - readonly observedAt: string; -}): ServerProviderRateLimit | undefined { - const outer = asRecord(input.payload); - const rateLimits = asRecord(outer?.rateLimits); - if (!rateLimits) return undefined; - switch (input.driver) { - case "claudeAgent": - return readClaudeRateLimit(rateLimits, input.observedAt); - case "codex": - return readCodexRateLimit(rateLimits, input.observedAt); - default: - return undefined; - } -} - -/** How long an error-text detection stays active without a structured update. */ -export const TURN_ERROR_RATE_LIMIT_TTL_MS = 15 * 60 * 1000; -/** Window name stamped on error-text detections so they are never mistaken for a provider window. */ -export const TURN_ERROR_RATE_LIMIT_WINDOW = "turn-error"; - -const TURN_ERROR_RATE_LIMIT_DRIVERS = new Set(["claudeAgent", "codex"]); - -/** - * Claude and Codex sometimes surface a usage limit only as the failing turn's - * error text, ahead of or instead of a structured update. Treat that as a - * short-lived rejection so the account is not retried blindly; a structured - * update replaces it and the TTL clears it otherwise. Other drivers emit no - * structured updates at all, so their error text is left alone rather than - * marking the account limited indefinitely. - */ -export function readProviderRateLimitFromTurnError(input: { - readonly driver: string; - readonly errorMessage: string | undefined; - readonly observedAt: string; -}): ServerProviderRateLimit | undefined { - if (!TURN_ERROR_RATE_LIMIT_DRIVERS.has(input.driver)) return undefined; - if (!input.errorMessage || !USAGE_LIMIT_ERROR_PATTERN.test(input.errorMessage)) { - return undefined; - } - const observedMs = Date.parse(input.observedAt); - const resetsAt = Number.isFinite(observedMs) - ? epochToIso(observedMs + TURN_ERROR_RATE_LIMIT_TTL_MS) - : undefined; - return build({ - status: "rejected", - resetsAt, - window: TURN_ERROR_RATE_LIMIT_WINDOW, - observedAt: input.observedAt, - }); -} diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 918b1f6953c1..b2195b1d890c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -15,7 +15,6 @@ const makeProviderRegistryMock = ( getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), - setProviderRateLimit: () => Effect.succeed(providers), streamChanges: Stream.empty, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0e1abad84678..2f32b6524d7b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -776,7 +776,6 @@ const buildAppUnderTest = (options?: { makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), ), setProviderMaintenanceActionState: () => Effect.succeed([]), - setProviderRateLimit: () => Effect.succeed([]), streamChanges: Stream.empty, ...options?.layers?.providerRegistry, }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 71a8fad0913f..f274aacd8352 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -68,7 +68,7 @@ import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationRe import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; -import * as ProviderRateLimitReactor from "./orchestration/ProviderRateLimitReactor.ts"; +import * as ProviderAccountSwitchReactor from "./orchestration/ProviderAccountSwitchReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; @@ -278,7 +278,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), - Layer.provideMerge(ProviderRateLimitReactor.layer), + Layer.provideMerge(ProviderAccountSwitchReactor.layer), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d45cd54a2ee7..18c5e411d743 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -354,10 +354,10 @@ import { } from "./chat/ThreadErrorBanner"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - formatProviderRateLimitReset, + describeExhaustedWindow, providerLabel, - resolveProviderRateLimitSuggestion, -} from "./chat/providerRateLimitBanner.logic"; + resolveAccountSwitchSuggestion, +} from "./chat/providerAccountSwitchBanner.logic"; import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableCompactionProvider, @@ -5887,7 +5887,7 @@ export default function ChatView(props: ChatViewProps) { const rateLimitSuggestion = useMemo( () => activeThread - ? resolveProviderRateLimitSuggestion({ + ? resolveAccountSwitchSuggestion({ providers: providerStatuses, instanceId: activeThread.session?.providerInstanceId ?? activeProviderInstanceId, autoSwitchEnabled: settings.autoSwitchProviderOnRateLimit, @@ -5903,8 +5903,8 @@ export default function ChatView(props: ChatViewProps) { ], ); const handleSwitchRateLimitedAccount = useCallback(() => { - if (!activeThread || !rateLimitSuggestion?.fallback) return; - const fallback = rateLimitSuggestion.fallback; + if (!activeThread || !rateLimitSuggestion?.target) return; + const fallback = rateLimitSuggestion.target; const currentModel = activeThread.modelSelection.model; const resolvedModel = resolveAppModelSelectionForInstance( @@ -5958,25 +5958,25 @@ export default function ChatView(props: ChatViewProps) { ) { return null; } - const reset = formatProviderRateLimitReset( - rateLimitSuggestion.limited.rateLimit?.resetsAt, + const reset = describeExhaustedWindow( + rateLimitSuggestion.window, Date.parse(`${nowMinute}:00.000Z`), ); - const fallback = rateLimitSuggestion.fallback; + const target = rateLimitSuggestion.target; return { - id: `provider-rate-limit:${rateLimitSuggestion.key}`, + id: `provider-account-switch:${rateLimitSuggestion.key}`, variant: "warning", icon: , - title: `${providerInstanceLabel(rateLimitSuggestion.limited)} hit its usage limit`, - description: fallback - ? `${reset ? `${reset[0]!.toUpperCase()}${reset.slice(1)}. ` : ""}${providerInstanceLabel(fallback)} can continue this thread.` - : (reset ?? undefined), - actions: fallback ? ( + title: `${providerInstanceLabel(rateLimitSuggestion.limited)} is out of usage`, + description: target + ? `${reset} ${providerInstanceLabel(target)} can continue this thread.` + : reset, + actions: target ? ( ) : undefined, - dismissLabel: "Dismiss usage limit notice", + dismissLabel: "Dismiss usage notice", onDismiss: () => setDismissedRateLimitKeys((keys) => new Set(keys).add(rateLimitDismissKey)), }; }, [ diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 1c692bab12dd..f8eb441790cd 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -2,8 +2,8 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { formatProviderRateLimitReset } from "./providerRateLimitBanner.logic"; -import { isProviderRateLimitActive } from "@t3tools/shared/providerRateLimits"; +import { describeExhaustedWindow } from "./providerAccountSwitchBanner.logic"; +import { exhaustedUsageWindow } from "@t3tools/shared/providerAccountSwitching"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { @@ -31,10 +31,12 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } -function describeRateLimitedInstance(entry: ProviderInstanceEntry): string | null { - if (!isProviderRateLimitActive(entry.rateLimit, Date.now())) return null; - const reset = formatProviderRateLimitReset(entry.rateLimit?.resetsAt, Date.now()); - return `${entry.displayName} — Usage limit reached${reset ? `, ${reset}` : ""}.`; +function describeOutOfUsageInstance(entry: ProviderInstanceEntry): string | null { + const now = Date.now(); + const window = exhaustedUsageWindow(entry.snapshot, now); + return window + ? `${entry.displayName} — Out of usage. ${describeExhaustedWindow(window, now)}` + : null; } const SELECTED_INDICATOR_CLASS = @@ -158,7 +160,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ? describeUnavailableInstance(entry) : isContextDisabled ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName) - : (describeRateLimitedInstance(entry) ?? + : (describeOutOfUsageInstance(entry) ?? (showNewBadge ? `${entry.displayName} — New` : entry.displayName)); const button = ( @@ -180,7 +182,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { disabled={isDisabled} type="button" aria-label={ - isUnavailable || isContextDisabled || describeRateLimitedInstance(entry) !== null + isUnavailable || isContextDisabled || describeOutOfUsageInstance(entry) !== null ? tooltip : showNewBadge ? `${entry.displayName}, new` diff --git a/apps/web/src/components/chat/providerAccountSwitchBanner.logic.test.ts b/apps/web/src/components/chat/providerAccountSwitchBanner.logic.test.ts new file mode 100644 index 000000000000..5ba291c02c99 --- /dev/null +++ b/apps/web/src/components/chat/providerAccountSwitchBanner.logic.test.ts @@ -0,0 +1,129 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + describeExhaustedWindow, + resolveAccountSwitchSuggestion, +} from "./providerAccountSwitchBanner.logic"; + +const NOW = Date.parse("2026-09-02T10:00:00.000Z"); + +function usageLimits(usedPercent: number, resetsAt?: string): ServerProvider["usageLimits"] { + return { + checkedAt: "2026-09-02T09:55:00.000Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "5-hour", + usedPercent, + ...(resetsAt !== undefined ? { resetsAt } : {}), + }, + ], + }; +} + +function provider(instanceId: string, overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: instanceId, + continuation: { groupKey: "claude:session-transcript" }, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-02T09:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +const spentWork = provider("claude_work", { + usageLimits: usageLimits(100, "2026-09-02T12:00:00.000Z"), +}); + +describe("resolveAccountSwitchSuggestion", () => { + it("names the spent window and the account that can take over", () => { + const suggestion = resolveAccountSwitchSuggestion({ + providers: [spentWork, provider("claude_personal", { usageLimits: usageLimits(10) })], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: NOW, + }); + expect(suggestion?.limited.instanceId).toBe("claude_work"); + expect(suggestion?.window.id).toBe("five_hour"); + expect(suggestion?.target?.instanceId).toBe("claude_personal"); + expect(suggestion?.key).toBe("claude_work:five_hour:2026-09-02T12:00:00.000Z"); + }); + + it("still surfaces the limit when no sibling can take over", () => { + expect( + resolveAccountSwitchSuggestion({ + providers: [spentWork], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: NOW, + })?.target, + ).toBeNull(); + }); + + it("keys an unknown reset stably across snapshots", () => { + const key = (checkedAt: string) => + resolveAccountSwitchSuggestion({ + providers: [ + provider("claude_work", { + usageLimits: { checkedAt, windows: usageLimits(100)!.windows }, + }), + ], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: NOW, + })?.key; + expect(key("2026-09-02T09:55:00.000Z")).toBe(key("2026-09-02T09:59:00.000Z")); + }); + + it("stays quiet when auto-switch is on, quota remains, or the window reset", () => { + expect( + resolveAccountSwitchSuggestion({ + providers: [spentWork], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: true, + nowMs: NOW, + }), + ).toBeNull(); + expect( + resolveAccountSwitchSuggestion({ + providers: [provider("claude_work", { usageLimits: usageLimits(80) })], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: NOW, + }), + ).toBeNull(); + expect( + resolveAccountSwitchSuggestion({ + providers: [spentWork], + instanceId: ProviderInstanceId.make("claude_work"), + autoSwitchEnabled: false, + nowMs: Date.parse("2026-09-02T12:00:01.000Z"), + }), + ).toBeNull(); + }); +}); + +describe("describeExhaustedWindow", () => { + it("phrases the reset the way the limits view does", () => { + expect( + describeExhaustedWindow(usageLimits(100, "2026-09-02T12:13:00.000Z")!.windows[0]!, NOW), + ).toBe("Its 5-hour limit resets in 2h 13m."); + }); + + it("falls back when the provider named no reset", () => { + expect(describeExhaustedWindow(usageLimits(100)!.windows[0]!, NOW)).toBe( + "Its 5-hour limit is used up.", + ); + }); +}); diff --git a/apps/web/src/components/chat/providerAccountSwitchBanner.logic.ts b/apps/web/src/components/chat/providerAccountSwitchBanner.logic.ts new file mode 100644 index 000000000000..a893d687d25d --- /dev/null +++ b/apps/web/src/components/chat/providerAccountSwitchBanner.logic.ts @@ -0,0 +1,62 @@ +import type { + ProviderInstanceId, + ServerProvider, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import { + exhaustedUsageWindow, + selectAccountSwitchTarget, +} from "@t3tools/shared/providerAccountSwitching"; +import { formatResetsIn } from "@t3tools/shared/usageLimits"; + +export interface AccountSwitchSuggestion { + /** The account the thread is on, with no usage left in `window`. */ + readonly limited: ServerProvider; + /** The spent window that decides when this thread can continue. */ + readonly window: ServerProviderUsageWindow; + /** Another account of the same provider that can continue the thread, if any. */ + readonly target: ServerProvider | null; + /** Stable key so a dismissal only covers this particular window. */ + readonly key: string; +} + +/** + * Decide whether the composer should point out that the thread's account has + * run out of usage. Returns null when the account still has quota or when the + * server already switches accounts on its own. + */ +export function resolveAccountSwitchSuggestion(input: { + readonly providers: ReadonlyArray; + readonly instanceId: ProviderInstanceId | null; + readonly autoSwitchEnabled: boolean; + readonly nowMs: number; +}): AccountSwitchSuggestion | null { + if (input.instanceId === null || input.autoSwitchEnabled) return null; + const limited = input.providers.find((provider) => provider.instanceId === input.instanceId); + if (!limited) return null; + const window = exhaustedUsageWindow(limited, input.nowMs); + if (!window) return null; + const target = selectAccountSwitchTarget({ + providers: input.providers, + instanceId: limited.instanceId, + nowMs: input.nowMs, + }); + return { + limited, + window, + target, + // One dismissal per window; an unknown reset counts as one window so a + // fresh snapshot cannot resurface a banner the user already dismissed. + key: `${limited.instanceId}:${window.id}:${window.resetsAt ?? "unknown-reset"}`, + }; +} + +/** `Its 5-hour limit resets in 2h 13m.`, or just the window when no reset is known. */ +export function describeExhaustedWindow(window: ServerProviderUsageWindow, nowMs: number): string { + const resets = formatResetsIn(window, nowMs); + return resets ? `Its ${window.label} limit ${resets}.` : `Its ${window.label} limit is used up.`; +} + +export function providerLabel(provider: Pick) { + return provider.displayName ?? provider.instanceId; +} diff --git a/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts b/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts deleted file mode 100644 index 70a91f5fb52e..000000000000 --- a/apps/web/src/components/chat/providerRateLimitBanner.logic.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - formatProviderRateLimitReset, - resolveProviderRateLimitSuggestion, -} from "./providerRateLimitBanner.logic"; - -const NOW = Date.parse("2026-09-02T10:00:00.000Z"); - -function provider(instanceId: string, overrides: Partial = {}): ServerProvider { - return { - instanceId: ProviderInstanceId.make(instanceId), - driver: ProviderDriverKind.make("claudeAgent"), - displayName: instanceId, - continuation: { groupKey: "claude:session-transcript" }, - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-02T09:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - ...overrides, - }; -} - -const limitedWork = provider("claude_work", { - rateLimit: { - status: "rejected", - resetsAt: "2026-09-02T12:00:00.000Z", - observedAt: "2026-09-02T09:55:00.000Z", - }, -}); - -describe("resolveProviderRateLimitSuggestion", () => { - it("suggests the sibling account when the thread's account is limited", () => { - const suggestion = resolveProviderRateLimitSuggestion({ - providers: [limitedWork, provider("claude_personal")], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: false, - nowMs: NOW, - }); - expect(suggestion?.limited.instanceId).toBe("claude_work"); - expect(suggestion?.fallback?.instanceId).toBe("claude_personal"); - expect(suggestion?.key).toBe("claude_work:2026-09-02T12:00:00.000Z"); - }); - - it("keys an unknown reset stably across snapshots", () => { - const key = (observedAt: string) => - resolveProviderRateLimitSuggestion({ - providers: [provider("claude_work", { rateLimit: { status: "rejected", observedAt } })], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: false, - nowMs: NOW, - })?.key; - expect(key("2026-09-02T09:55:00.000Z")).toBe(key("2026-09-02T09:59:00.000Z")); - }); - - it("still surfaces the limit when no sibling can take over", () => { - const suggestion = resolveProviderRateLimitSuggestion({ - providers: [limitedWork], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: false, - nowMs: NOW, - }); - expect(suggestion?.fallback).toBeNull(); - }); - - it("stays quiet when auto-switch is on, nothing is limited, or the limit expired", () => { - expect( - resolveProviderRateLimitSuggestion({ - providers: [limitedWork, provider("claude_personal")], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: true, - nowMs: NOW, - }), - ).toBeNull(); - expect( - resolveProviderRateLimitSuggestion({ - providers: [provider("claude_work"), provider("claude_personal")], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: false, - nowMs: NOW, - }), - ).toBeNull(); - expect( - resolveProviderRateLimitSuggestion({ - providers: [limitedWork, provider("claude_personal")], - instanceId: ProviderInstanceId.make("claude_work"), - autoSwitchEnabled: false, - nowMs: Date.parse("2026-09-02T12:00:01.000Z"), - }), - ).toBeNull(); - }); -}); - -describe("formatProviderRateLimitReset", () => { - it("formats same-day, next-day, and later resets", () => { - const now = new Date(2026, 8, 2, 10, 0).getTime(); - const later = (hours: number) => new Date(now + hours * 60 * 60 * 1000).toISOString(); - expect(formatProviderRateLimitReset(later(2), now, "en-US")).toBe("resets at 12:00 PM"); - expect(formatProviderRateLimitReset(later(20), now, "en-US")).toMatch( - /^resets tomorrow at 6:00 AM$/, - ); - expect(formatProviderRateLimitReset(later(72), now, "en-US")).toMatch( - /^resets .* at 10:00 AM$/, - ); - }); - - it("returns null without a usable reset time", () => { - expect(formatProviderRateLimitReset(undefined, NOW)).toBeNull(); - expect(formatProviderRateLimitReset("not a date", NOW)).toBeNull(); - }); -}); diff --git a/apps/web/src/components/chat/providerRateLimitBanner.logic.ts b/apps/web/src/components/chat/providerRateLimitBanner.logic.ts deleted file mode 100644 index 7fd9e419b05e..000000000000 --- a/apps/web/src/components/chat/providerRateLimitBanner.logic.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import { - isProviderRateLimitActive, - selectRateLimitFallbackProvider, -} from "@t3tools/shared/providerRateLimits"; - -export interface ProviderRateLimitSuggestion { - /** The account the thread is on and that is out of usage. */ - readonly limited: ServerProvider; - /** Another account of the same provider that can continue the thread, if any. */ - readonly fallback: ServerProvider | null; - /** Stable key so a dismissal only covers this particular limit window. */ - readonly key: string; -} - -/** - * Decide whether the composer should point out that the thread's account hit - * its usage limit. Returns null when nothing is limited or when the server - * already switches accounts on its own. - */ -export function resolveProviderRateLimitSuggestion(input: { - readonly providers: ReadonlyArray; - readonly instanceId: ProviderInstanceId | null; - readonly autoSwitchEnabled: boolean; - readonly nowMs: number; -}): ProviderRateLimitSuggestion | null { - if (input.instanceId === null || input.autoSwitchEnabled) return null; - const limited = input.providers.find((provider) => provider.instanceId === input.instanceId); - if (!limited || !isProviderRateLimitActive(limited.rateLimit, input.nowMs)) return null; - const fallback = selectRateLimitFallbackProvider({ - providers: input.providers, - instanceId: limited.instanceId, - nowMs: input.nowMs, - }); - return { - limited, - fallback, - // One dismissal per limit window; an unknown reset counts as one window - // so a fresh observedAt on every snapshot cannot resurface the banner. - key: `${limited.instanceId}:${limited.rateLimit?.resetsAt ?? "unknown-reset"}`, - }; -} - -/** - * "resets at 3:00 PM" for today, "resets tomorrow at 3:00 PM" otherwise, or - * nothing when the provider did not say. - */ -export function formatProviderRateLimitReset( - resetsAt: string | undefined, - nowMs: number, - locale?: string, -): string | null { - if (resetsAt === undefined) return null; - const resetMs = Date.parse(resetsAt); - if (!Number.isFinite(resetMs)) return null; - const reset = new Date(resetMs); - const now = new Date(nowMs); - const time = reset.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }); - const sameDay = - reset.getFullYear() === now.getFullYear() && - reset.getMonth() === now.getMonth() && - reset.getDate() === now.getDate(); - if (sameDay) return `resets at ${time}`; - const dayMs = 24 * 60 * 60 * 1000; - if (resetMs - nowMs < dayMs) return `resets tomorrow at ${time}`; - const day = reset.toLocaleDateString(locale, { - weekday: "short", - month: "short", - day: "numeric", - }); - return `resets ${day} at ${time}`; -} - -export function providerLabel(provider: Pick) { - return provider.displayName ?? provider.instanceId; -} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a2f10f874946..688bdc8e43ee 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -559,6 +559,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.continueThreadsAfterServerUpdate !== DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? ["Continue threads after restarts"] + : []), ...(settings.autoSwitchProviderOnRateLimit !== DEFAULT_UNIFIED_SETTINGS.autoSwitchProviderOnRateLimit ? ["Switch accounts on usage limit"] diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index a8be29d42895..01b7bad6358f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -233,6 +233,8 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: [ "resume running active interrupted work restart reboot machine crash desktop update automatically", ], + }, + { id: "provider-rate-limit-auto-switch", title: "Switch accounts on usage limit", to: "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/settings/general", diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 0c44d6396a06..9230ab0a489e 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -67,8 +67,6 @@ export interface ProviderInstanceEntry { readonly isAvailable: boolean; readonly snapshot: ServerProvider; readonly models: ReadonlyArray; - /** Latest usage-limit state the server observed for this account. */ - readonly rateLimit?: ServerProvider["rateLimit"]; } /** @@ -114,7 +112,6 @@ export function deriveProviderInstanceEntries( isAvailable: snapshot.availability !== "unavailable", snapshot, models: snapshot.models, - ...(snapshot.rateLimit !== undefined ? { rateLimit: snapshot.rateLimit } : {}), } satisfies ProviderInstanceEntry; }); } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 114f7bf385ff..c2fc8524ece2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -185,27 +185,6 @@ export const ServerProviderUpdateState = Schema.Struct({ }); export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; -export const ServerProviderRateLimitStatus = Schema.Literals(["allowed", "warning", "rejected"]); -export type ServerProviderRateLimitStatus = typeof ServerProviderRateLimitStatus.Type; - -/** - * Latest usage-limit state reported by a provider instance's runtime. - * Volatile: it is never persisted and only reflects what the provider last - * told us. `rejected` with a future `resetsAt` means the account cannot run - * turns until then. - */ -export const ServerProviderRateLimit = Schema.Struct({ - status: ServerProviderRateLimitStatus, - /** When the limiting window resets, if the provider reported it. */ - resetsAt: Schema.optionalKey(IsoDateTime), - /** Provider-specific window name such as `five_hour` or `seven_day`. */ - window: Schema.optionalKey(TrimmedNonEmptyString), - /** Percentage of the window already used, 0-100, when reported. */ - utilization: Schema.optionalKey(Schema.Number), - observedAt: IsoDateTime, -}); -export type ServerProviderRateLimit = typeof ServerProviderRateLimit.Type; - export const ServerProvider = Schema.Struct({ // Routing key for the configured instance this snapshot represents. This // is the only stable identity consumers may use for provider routing. @@ -256,7 +235,6 @@ export const ServerProvider = Schema.Struct({ usageLimits: Schema.optional(ServerProviderUsageLimits), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), - rateLimit: Schema.optionalKey(ServerProviderRateLimit), }); export type ServerProvider = typeof ServerProvider.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index b80c7f228492..6f9443dd09b4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -279,9 +279,9 @@ "types": "./src/dateTime.ts", "import": "./src/dateTime.ts" }, - "./providerRateLimits": { - "types": "./src/providerRateLimits.ts", - "import": "./src/providerRateLimits.ts" + "./providerAccountSwitching": { + "types": "./src/providerAccountSwitching.ts", + "import": "./src/providerAccountSwitching.ts" } }, "scripts": { diff --git a/packages/shared/src/providerAccountSwitching.test.ts b/packages/shared/src/providerAccountSwitching.test.ts new file mode 100644 index 000000000000..2e4bde8cefe7 --- /dev/null +++ b/packages/shared/src/providerAccountSwitching.test.ts @@ -0,0 +1,149 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + type AccountSwitchCandidate, + exhaustedUsageWindow, + isProviderOutOfUsage, + selectAccountSwitchTarget, +} from "./providerAccountSwitching.ts"; + +const NOW = Date.parse("2026-09-02T10:00:00.000Z"); + +function candidate( + instanceId: string, + overrides: Partial = {}, +): AccountSwitchCandidate { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("claudeAgent"), + continuation: { groupKey: "claude:session-transcript" }, + enabled: true, + installed: true, + status: "ready", + ...overrides, + }; +} + +function limits( + windows: ReadonlyArray<{ id: string; usedPercent: number; resetsAt?: string }>, +): AccountSwitchCandidate["usageLimits"] { + return { + checkedAt: "2026-09-02T09:55:00.000Z", + windows: windows.map((entry) => ({ + id: entry.id, + kind: "session" as const, + label: entry.id, + usedPercent: entry.usedPercent, + ...(entry.resetsAt !== undefined ? { resetsAt: entry.resetsAt } : {}), + })), + }; +} + +describe("exhaustedUsageWindow", () => { + it("returns the spent window that reopens soonest", () => { + const spent = exhaustedUsageWindow( + { + usageLimits: limits([ + { id: "seven_day", usedPercent: 100, resetsAt: "2026-09-05T10:00:00.000Z" }, + { id: "five_hour", usedPercent: 100, resetsAt: "2026-09-02T12:00:00.000Z" }, + { id: "opus", usedPercent: 40, resetsAt: "2026-09-02T11:00:00.000Z" }, + ]), + }, + NOW, + ); + expect(spent?.id).toBe("five_hour"); + }); + + it("ignores windows with quota left or a reset already passed", () => { + expect( + exhaustedUsageWindow({ usageLimits: limits([{ id: "five_hour", usedPercent: 99 }]) }, NOW), + ).toBeNull(); + expect( + exhaustedUsageWindow( + { + usageLimits: limits([ + { id: "five_hour", usedPercent: 100, resetsAt: "2026-09-02T09:59:00.000Z" }, + ]), + }, + NOW, + ), + ).toBeNull(); + expect(exhaustedUsageWindow({ usageLimits: undefined }, NOW)).toBeNull(); + }); + + it("treats a spent window with no reset as still blocking", () => { + expect( + isProviderOutOfUsage({ usageLimits: limits([{ id: "five_hour", usedPercent: 100 }]) }, NOW), + ).toBe(true); + }); +}); + +describe("selectAccountSwitchTarget", () => { + const spentWork = candidate("claude_work", { + usageLimits: limits([ + { id: "five_hour", usedPercent: 100, resetsAt: "2026-09-02T12:00:00.000Z" }, + ]), + }); + + it("prefers the sibling with the most headroom", () => { + expect( + selectAccountSwitchTarget({ + providers: [ + spentWork, + candidate("claude_personal", { + usageLimits: limits([{ id: "five_hour", usedPercent: 60 }]), + }), + candidate("claude_spare", { + usageLimits: limits([{ id: "five_hour", usedPercent: 10 }]), + }), + ], + instanceId: ProviderInstanceId.make("claude_work"), + nowMs: NOW, + })?.instanceId, + ).toBe("claude_spare"); + }); + + it("skips siblings that are spent, disabled, uninstalled, unavailable, broken, or from another group", () => { + expect( + selectAccountSwitchTarget({ + providers: [ + spentWork, + candidate("claude_spent", { + usageLimits: limits([ + { id: "five_hour", usedPercent: 100, resetsAt: "2026-09-02T13:00:00.000Z" }, + ]), + }), + candidate("claude_off", { enabled: false }), + candidate("claude_no_cli", { installed: false }), + candidate("claude_missing", { availability: "unavailable" }), + candidate("claude_broken", { status: "error" }), + candidate("codex_personal", { driver: ProviderDriverKind.make("codex") }), + candidate("claude_other_home", { continuation: { groupKey: "claude:home:/other" } }), + ], + instanceId: ProviderInstanceId.make("claude_work"), + nowMs: NOW, + }), + ).toBeNull(); + }); + + it("returns null without a continuation group or for an unknown instance", () => { + expect( + selectAccountSwitchTarget({ + providers: [ + candidate("claude_work", { continuation: undefined }), + candidate("claude_personal", { continuation: undefined }), + ], + instanceId: ProviderInstanceId.make("claude_work"), + nowMs: NOW, + }), + ).toBeNull(); + expect( + selectAccountSwitchTarget({ + providers: [spentWork], + instanceId: ProviderInstanceId.make("nope"), + nowMs: NOW, + }), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/providerAccountSwitching.ts b/packages/shared/src/providerAccountSwitching.ts new file mode 100644 index 000000000000..3fb9ffb71fd9 --- /dev/null +++ b/packages/shared/src/providerAccountSwitching.ts @@ -0,0 +1,106 @@ +/** + * Choosing another account to continue a thread on when the one it is bound + * to has run out of subscription usage. + * + * Reads the usage windows the provider already publishes on its snapshot + * (`ServerProvider.usageLimits`), so there is no second notion of "limited" + * anywhere: the server auto-switch and the client's switch offer agree by + * construction. + * + * @module providerAccountSwitching + */ +import type { ServerProvider, ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { ProviderInstanceId } from "@t3tools/contracts"; + +export type AccountSwitchCandidate = Pick< + ServerProvider, + | "instanceId" + | "driver" + | "displayName" + | "continuation" + | "enabled" + | "installed" + | "status" + | "availability" + | "usageLimits" +>; + +function resetMillis(window: ServerProviderUsageWindow): number | null { + if (window.resetsAt === undefined) return null; + const at = Date.parse(window.resetsAt); + return Number.isFinite(at) ? at : null; +} + +/** + * The window that is out of quota and has not reopened yet, or null when the + * account can still run turns. When several are spent, the one reopening + * soonest is the one that decides when work can continue; a window whose + * reset the provider never named sorts last because it promises nothing. + */ +export function exhaustedUsageWindow( + provider: Pick, + nowMs: number, +): ServerProviderUsageWindow | null { + const spent = (provider.usageLimits?.windows ?? []).filter((window) => { + if (window.usedPercent < 100) return false; + const resetsAt = resetMillis(window); + return resetsAt === null || resetsAt > nowMs; + }); + if (spent.length === 0) return null; + return spent.reduce((soonest, window) => { + const a = resetMillis(soonest); + const b = resetMillis(window); + if (b === null) return soonest; + if (a === null) return window; + return b < a ? window : soonest; + }); +} + +export function isProviderOutOfUsage( + provider: Pick, + nowMs: number, +): boolean { + return exhaustedUsageWindow(provider, nowMs) !== null; +} + +/** Highest share of any window this account has spent, for ranking siblings. */ +function peakUsedPercent(provider: Pick): number { + return (provider.usageLimits?.windows ?? []).reduce( + (peak, window) => Math.max(peak, window.usedPercent), + 0, + ); +} + +/** + * Pick another configured account that can continue a thread bound to + * `instanceId`: same driver, same continuation group, usable, and with usage + * left. Ties break toward the account with the most headroom. + */ +export function selectAccountSwitchTarget(input: { + readonly providers: ReadonlyArray; + readonly instanceId: ProviderInstanceId; + readonly nowMs: number; +}): Candidate | null { + const current = input.providers.find((provider) => provider.instanceId === input.instanceId); + if (!current) return null; + const currentGroupKey = current.continuation?.groupKey; + // No continuation group means the snapshot predates instance identity; + // there is no way to know its session store is shared. + if (currentGroupKey === undefined) return null; + + let best: Candidate | null = null; + for (const candidate of input.providers) { + if (candidate.instanceId === current.instanceId) continue; + if (candidate.driver !== current.driver) continue; + if (candidate.continuation?.groupKey !== currentGroupKey) continue; + if (!candidate.enabled) continue; + if (candidate.installed === false) continue; + if (candidate.availability === "unavailable") continue; + if (candidate.status === "error" || candidate.status === "disabled") continue; + if (isProviderOutOfUsage(candidate, input.nowMs)) continue; + if (best === null || peakUsedPercent(candidate) < peakUsedPercent(best)) { + best = candidate; + } + } + return best; +} diff --git a/packages/shared/src/providerRateLimits.test.ts b/packages/shared/src/providerRateLimits.test.ts deleted file mode 100644 index 88d09973f1c0..000000000000 --- a/packages/shared/src/providerRateLimits.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - isProviderRateLimitActive, - selectRateLimitFallbackProvider, - type RateLimitFallbackCandidate, -} from "./providerRateLimits.ts"; - -const NOW = Date.parse("2026-09-02T10:00:00.000Z"); - -function candidate( - instanceId: string, - overrides: Partial = {}, -): RateLimitFallbackCandidate { - return { - instanceId: ProviderInstanceId.make(instanceId), - driver: ProviderDriverKind.make("claudeAgent"), - continuation: { groupKey: "claude:session-transcript" }, - enabled: true, - installed: true, - status: "ready", - ...overrides, - }; -} - -describe("isProviderRateLimitActive", () => { - it("is active while rejected and the reset is ahead", () => { - expect( - isProviderRateLimitActive( - { status: "rejected", resetsAt: "2026-09-02T11:00:00.000Z", observedAt: "x" }, - NOW, - ), - ).toBe(true); - }); - - it("expires once the reset time passes", () => { - expect( - isProviderRateLimitActive( - { status: "rejected", resetsAt: "2026-09-02T09:59:00.000Z", observedAt: "x" }, - NOW, - ), - ).toBe(false); - }); - - it("treats a rejection without a reset time as active", () => { - expect(isProviderRateLimitActive({ status: "rejected", observedAt: "x" }, NOW)).toBe(true); - }); - - it("ignores warnings and missing state", () => { - expect(isProviderRateLimitActive({ status: "warning", observedAt: "x" }, NOW)).toBe(false); - expect(isProviderRateLimitActive(undefined, NOW)).toBe(false); - }); -}); - -describe("selectRateLimitFallbackProvider", () => { - it("prefers the sibling with the most headroom", () => { - const providers = [ - candidate("claude_work", { - rateLimit: { status: "rejected", resetsAt: "2026-09-02T11:00:00.000Z", observedAt: "x" }, - }), - candidate("claude_personal", { - rateLimit: { status: "allowed", utilization: 60, observedAt: "x" }, - }), - candidate("claude_spare", { - rateLimit: { status: "allowed", utilization: 10, observedAt: "x" }, - }), - ]; - expect( - selectRateLimitFallbackProvider({ - providers, - instanceId: ProviderInstanceId.make("claude_work"), - nowMs: NOW, - })?.instanceId, - ).toBe("claude_spare"); - }); - - it("skips siblings that are limited, disabled, unavailable, or from another group", () => { - const providers = [ - candidate("claude_work"), - candidate("claude_limited", { - rateLimit: { status: "rejected", resetsAt: "2026-09-02T11:00:00.000Z", observedAt: "x" }, - }), - candidate("claude_off", { enabled: false }), - candidate("claude_missing", { availability: "unavailable" }), - candidate("claude_broken", { status: "error" }), - candidate("claude_missing_cli", { installed: false }), - candidate("codex_personal", { driver: ProviderDriverKind.make("codex") }), - candidate("claude_other_home", { continuation: { groupKey: "claude:home:/other" } }), - ]; - expect( - selectRateLimitFallbackProvider({ - providers, - instanceId: ProviderInstanceId.make("claude_work"), - nowMs: NOW, - }), - ).toBeNull(); - }); - - it("returns null when the current account has no continuation group", () => { - expect( - selectRateLimitFallbackProvider({ - providers: [ - candidate("claude_work", { continuation: undefined }), - candidate("claude_personal", { continuation: undefined }), - ], - instanceId: ProviderInstanceId.make("claude_work"), - nowMs: NOW, - }), - ).toBeNull(); - }); - - it("returns null for an unknown instance", () => { - expect( - selectRateLimitFallbackProvider({ - providers: [candidate("claude_work")], - instanceId: ProviderInstanceId.make("nope"), - nowMs: NOW, - }), - ).toBeNull(); - }); -}); diff --git a/packages/shared/src/providerRateLimits.ts b/packages/shared/src/providerRateLimits.ts deleted file mode 100644 index 6373169dc5ed..000000000000 --- a/packages/shared/src/providerRateLimits.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Provider usage-limit helpers shared by the server (auto-switch) and the - * clients (switch suggestion). Both sides must agree on what "limited right - * now" means and which sibling account can take over a thread. - * - * @module providerRateLimits - */ -import type { - ProviderInstanceId, - ServerProvider, - ServerProviderRateLimit, -} from "@t3tools/contracts"; - -export type RateLimitFallbackCandidate = Pick< - ServerProvider, - | "instanceId" - | "driver" - | "displayName" - | "continuation" - | "enabled" - | "installed" - | "status" - | "availability" - | "rateLimit" ->; - -/** - * A limit is active while the provider reported `rejected` and the reset time - * is unknown or still ahead. Once `resetsAt` passes the stale state no longer - * blocks anything, so the client can rely on this without a server round trip. - */ -export function isProviderRateLimitActive( - rateLimit: ServerProviderRateLimit | undefined, - nowMs: number, -): boolean { - if (!rateLimit || rateLimit.status !== "rejected") return false; - if (rateLimit.resetsAt === undefined) return true; - const resetsAtMs = Date.parse(rateLimit.resetsAt); - return !Number.isFinite(resetsAtMs) || resetsAtMs > nowMs; -} - -/** - * Pick another configured account that can continue a thread bound to - * `instanceId`: same driver, same continuation group, usable, and not limited - * itself. Ties break toward the account with the most headroom. - */ -export function selectRateLimitFallbackProvider< - Candidate extends RateLimitFallbackCandidate, ->(input: { - readonly providers: ReadonlyArray; - readonly instanceId: ProviderInstanceId; - readonly nowMs: number; -}): Candidate | null { - const current = input.providers.find((provider) => provider.instanceId === input.instanceId); - if (!current) return null; - const currentGroupKey = current.continuation?.groupKey; - // No continuation group means the snapshot predates instance identity; - // there is no way to know its session store is shared. - if (currentGroupKey === undefined) return null; - - let best: Candidate | null = null; - for (const candidate of input.providers) { - if (candidate.instanceId === current.instanceId) continue; - if (candidate.driver !== current.driver) continue; - if (candidate.continuation?.groupKey !== currentGroupKey) continue; - if (!candidate.enabled) continue; - if (candidate.installed === false) continue; - if (candidate.availability === "unavailable") continue; - if (candidate.status === "error" || candidate.status === "disabled") continue; - if (isProviderRateLimitActive(candidate.rateLimit, input.nowMs)) continue; - if (best === null || utilizationOf(candidate) < utilizationOf(best)) { - best = candidate; - } - } - return best; -} - -function utilizationOf(candidate: RateLimitFallbackCandidate): number { - return candidate.rateLimit?.utilization ?? 0; -}