From 3b5855641915417e93a266815c263ba50738c1fd Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Mon, 4 May 2026 03:38:40 +0300 Subject: [PATCH 01/17] Add Codex usage indicator --- .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../src/provider/Layers/CodexAdapter.test.ts | 102 +++++++++++ .../src/provider/Layers/CodexAdapter.ts | 102 +++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 5 + .../provider/Layers/ProviderService.test.ts | 35 ++++ .../src/provider/Layers/ProviderService.ts | 21 ++- .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 6 + .../src/provider/Services/ProviderService.ts | 5 + apps/server/src/provider/codexUsage.test.ts | 161 ++++++++++++++++++ apps/server/src/provider/codexUsage.ts | 157 +++++++++++++++++ apps/server/src/ws.ts | 19 +++ apps/web/src/components/BranchToolbar.tsx | 54 +++++- apps/web/src/components/ChatView.tsx | 2 + .../chat/CodexUsageIndicator.browser.tsx | 126 ++++++++++++++ .../components/chat/CodexUsageIndicator.tsx | 129 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 56 ++++++ apps/web/src/lib/providerReactQuery.test.ts | 49 +++++- apps/web/src/lib/providerReactQuery.ts | 21 +++ apps/web/src/localApi.test.ts | 2 + apps/web/src/localApi.ts | 10 +- apps/web/src/rpc/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/providerRuntime.ts | 24 +++ packages/contracts/src/rpc.ts | 10 ++ packages/contracts/src/settings.test.ts | 39 ++++- packages/contracts/src/settings.ts | 18 ++ 29 files changed, 1157 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/provider/codexUsage.test.ts create mode 100644 apps/server/src/provider/codexUsage.ts create mode 100644 apps/web/src/components/chat/CodexUsageIndicator.browser.tsx create mode 100644 apps/web/src/components/chat/CodexUsageIndicator.tsx diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b610c0abc28..5ce20960b4e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -123,6 +123,7 @@ function createProviderServiceHarness( continuationKey: `${providerName}:instance:${instanceId}`, }, }), + getCodexUsage: () => Effect.succeed(null), rollbackConversation, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 571164fad93..1ab62e441a9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -313,6 +313,7 @@ describe("ProviderCommandReactor", () => { }, }); }, + getCodexUsage: () => Effect.succeed(null), rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3b2411cba2a..7a539a0c005 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -119,6 +119,7 @@ function createProviderServiceHarness() { }, }); }, + getCodexUsage: () => Effect.succeed(null), rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c5eaa536c3f..3095e27c0d1 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -33,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as CodexErrors from "effect-codex-app-server/errors"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -103,6 +104,15 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly readAccountRateLimitsImpl = vi.fn( + (): Promise => + Promise.resolve({ + rateLimits: { + primary: { usedPercent: 25, windowDurationMins: 300 }, + }, + }), + ); + public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => Promise.resolve(undefined), @@ -141,6 +151,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } + readAccountRateLimits = Effect.promise(() => this.readAccountRateLimitsImpl()); + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); } @@ -170,6 +182,7 @@ function makeRuntimeFactory() { return { factory, + runtimes, get lastRuntime(): FakeCodexRuntime | undefined { return runtimes.at(-1); }, @@ -359,6 +372,95 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("reads and normalizes account rate limits through the active runtime", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("usage-thread"), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: { + primary: { usedPercent: 30, windowDurationMins: 300 }, + secondary: { usedPercent: 80, windowDurationMins: 10_080 }, + }, + }); + + const snapshot = yield* adapter.readCodexUsage!(); + + assert.equal(runtime.readAccountRateLimitsImpl.mock.calls.length, 1); + assert.deepStrictEqual( + snapshot?.windows.map((window) => ({ + kind: window.kind, + remainingPercent: window.remainingPercent, + })), + [ + { kind: "five-hour", remainingPercent: 70 }, + { kind: "weekly", remainingPercent: 20 }, + ], + ); + }), + ); + + it.effect("keeps cached account rate limits when an active read has no displayable windows", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("usage-cache-thread"), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: { + primary: { usedPercent: 45, windowDurationMins: 300 }, + }, + }); + yield* adapter.readCodexUsage!(); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: {}, + }); + + const snapshot = yield* adapter.readCodexUsage!(); + + assert.equal(runtime.readAccountRateLimitsImpl.mock.calls.length, 2); + assert.equal(snapshot?.source, "cache"); + assert.deepStrictEqual(snapshot?.windows[0], { + kind: "five-hour", + usedPercent: 45, + remainingPercent: 55, + resetsAt: null, + windowDurationMins: 300, + }); + }), + ); + + it.effect("reads account rate limits even before a Codex thread session exists", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.stopAll(); + const snapshot = yield* adapter.readCodexUsage!(); + const runtime = sessionRuntimeFactory.lastRuntime; + + assert.ok(runtime); + assert.equal(runtime.options.threadId, asThreadId("codex-usage")); + assert.equal(runtime.startImpl.mock.calls.length, 1); + assert.equal(runtime.readAccountRateLimitsImpl.mock.calls.length, 1); + assert.equal(runtime.closeImpl.mock.calls.length, 1); + assert.deepStrictEqual(snapshot?.windows[0], { + kind: "five-hour", + usedPercent: 25, + remainingPercent: 75, + resetsAt: null, + windowDurationMins: 300, + }); + }), + ); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 28af1cda27b..1d75c25b476 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -17,6 +17,7 @@ import { type ProviderRuntimeEvent, type ProviderRequestKind, type ThreadTokenUsageSnapshot, + type CodexUsageSnapshot, type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, @@ -61,6 +62,8 @@ import { type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { normalizeCodexUsageSnapshot } from "../codexUsage.ts"; + const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -1361,6 +1364,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); + let cachedCodexUsage: CodexUsageSnapshot | null = null; const startSession: CodexAdapterShape["startSession"] = (input) => Effect.scoped( @@ -1420,6 +1424,19 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); + if (event.method === "account/rateLimits/updated") { + const payload = readPayload( + EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, + event.payload, + ); + if (payload) { + cachedCodexUsage = normalizeCodexUsageSnapshot({ + providerInstanceId: boundInstanceId, + payload, + source: "notification", + }); + } + } const runtimeEvents = mapToRuntimeEvents(event, event.threadId); if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { @@ -1655,6 +1672,90 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const hasSession: CodexAdapterShape["hasSession"] = (threadId) => Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + const readCodexUsageWithoutSession = Effect.fn("readCodexUsageWithoutSession")(function* () { + const usageThreadId = ThreadId.make("codex-usage"); + const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; + return yield* Effect.acquireUseRelease( + Scope.make("sequential"), + (usageScope) => + Effect.gen(function* () { + const runtime = yield* createRuntime({ + threadId: usageThreadId, + providerInstanceId: boundInstanceId, + cwd: process.cwd(), + binaryPath: codexConfig.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + runtimeMode: "full-access", + }).pipe( + Effect.provideService(Scope.Scope, usageScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: usageThreadId, + detail: cause.message, + cause, + }), + ), + ); + const payload = yield* runtime.start().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: usageThreadId, + detail: cause.message, + cause, + }), + ), + Effect.andThen( + runtime.readAccountRateLimits.pipe( + Effect.mapError((cause) => + mapCodexRuntimeError(usageThreadId, "account/rateLimits/read", cause), + ), + ), + ), + Effect.ensuring(runtime.close), + ); + return normalizeCodexUsageSnapshot({ + providerInstanceId: boundInstanceId, + payload, + source: "read", + }); + }), + (usageScope) => Scope.close(usageScope, Exit.void), + ); + }); + + const readCodexUsage: CodexAdapterShape["readCodexUsage"] = Effect.fn("readCodexUsage")( + function* () { + const session = Array.from(sessions.values()).findLast((candidate) => !candidate.stopped); + if (!session) { + const snapshot = yield* readCodexUsageWithoutSession(); + cachedCodexUsage = snapshot ?? cachedCodexUsage; + return ( + snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) + ); + } + const payload = yield* session.runtime.readAccountRateLimits.pipe( + Effect.mapError((cause) => + mapCodexRuntimeError(session.threadId, "account/rateLimits/read", cause), + ), + ); + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: boundInstanceId, + payload, + source: "read", + }); + cachedCodexUsage = snapshot ?? cachedCodexUsage; + return ( + snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) + ); + }, + ); + const stopAll: CodexAdapterShape["stopAll"] = () => Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { concurrency: 1, @@ -1684,6 +1785,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( stopSession, listSessions, hasSession, + readCodexUsage, stopAll, get streamEvents() { return Stream.fromQueue(runtimeEventQueue); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a2b54fac21b..a0bf621c7b3 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -137,6 +137,10 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly readAccountRateLimits: Effect.Effect< + EffectCodexSchema.V2GetAccountRateLimitsResponse, + CodexSessionRuntimeError + >; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -1307,6 +1311,7 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + readAccountRateLimits: client.request("account/rateLimits/read", undefined), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fc0450b8b69..2822acdb839 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + CodexUsageSnapshot, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -202,6 +203,24 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { sessions.clear(); }), ); + const readCodexUsage = vi.fn( + (): Effect.Effect => + Effect.succeed({ + providerInstanceId: codexInstanceId, + checkedAt: "2026-05-04T00:00:00.000Z", + windows: [ + { + kind: "five-hour", + usedPercent: 25, + remainingPercent: 75, + resetsAt: null, + windowDurationMins: 300, + }, + ], + rateLimitReachedType: null, + source: "read", + }), + ); const adapter: ProviderAdapterShape = { provider, @@ -218,6 +237,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + ...(provider === CODEX_DRIVER ? { readCodexUsage } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -254,6 +274,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { readThread, rollbackThread, stopAll, + readCodexUsage, }; } @@ -783,6 +804,20 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect("returns usage for Codex instances and null for non-Codex instances", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const codexUsage = yield* provider.getCodexUsage(codexInstanceId); + const claudeUsage = yield* provider.getCodexUsage(claudeAgentInstanceId); + + assert.equal(codexUsage?.windows[0]?.remainingPercent, 75); + assert.equal(routing.codex.readCodexUsage.mock.calls.length, 1); + assert.equal(claudeUsage, null); + assert.equal(routing.claude.readCodexUsage.mock.calls.length, 0); + }), + ); + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2bce1f483b7..6cbba64dcac 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -20,9 +20,9 @@ import { ProviderSessionStartInput, ProviderStopSessionInput, type ProviderInstanceId, - type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, + ProviderDriverKind, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -935,6 +935,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) => registry.getInstanceInfo(instanceId); + const getCodexUsage: ProviderServiceShape["getCodexUsage"] = Effect.fn( + "ProviderService.getCodexUsage", + )(function* (instanceId) { + const info = yield* registry + .getInstanceInfo(instanceId) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!info || info.driverKind !== ProviderDriverKind.make("codex")) { + return null; + } + const adapter = yield* registry + .getByInstance(instanceId) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!adapter?.readCodexUsage) { + return null; + } + return yield* adapter.readCodexUsage().pipe(Effect.catch(() => Effect.succeed(null))); + }); + const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( "rollbackConversation", )(function* (rawInput) { @@ -1042,6 +1060,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( listSessions, getCapabilities, getInstanceInfo, + getCodexUsage, rollbackConversation, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 57903e3776c..d0413ee94a0 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -172,6 +172,7 @@ describe("ProviderSessionReaper", () => { }, }); }, + getCodexUsage: () => Effect.succeed(null), rollbackConversation: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..34a8c1aac2c 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -19,6 +19,7 @@ import type { ThreadId, ProviderTurnStartResult, TurnId, + CodexUsageSnapshot, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -114,6 +115,11 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** + * Read the provider account usage snapshot when the adapter supports it. + */ + readonly readCodexUsage?: () => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a..9f328a2bb54 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -23,6 +23,7 @@ import type { ProviderStopSessionInput, ThreadId, ProviderTurnStartResult, + CodexUsageSnapshot, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -97,6 +98,10 @@ export interface ProviderServiceShape { instanceId: ProviderInstanceId, ) => Effect.Effect; + readonly getCodexUsage: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts new file mode 100644 index 00000000000..b0cdd0c21a9 --- /dev/null +++ b/apps/server/src/provider/codexUsage.test.ts @@ -0,0 +1,161 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { normalizeCodexUsageSnapshot } from "./codexUsage.ts"; + +const instanceId = ProviderInstanceId.make("codex"); + +describe("normalizeCodexUsageSnapshot", () => { + it("prefers the codex multi-bucket over the legacy bucket", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + checkedAt: "2026-05-04T00:00:00.000Z", + payload: { + rateLimits: { + primary: { usedPercent: 90, windowDurationMins: 300 }, + }, + rateLimitsByLimitId: { + codex: { + primary: { usedPercent: 25, windowDurationMins: 300 }, + }, + }, + }, + }); + + expect(snapshot?.windows[0]).toMatchObject({ + kind: "five-hour", + usedPercent: 25, + remainingPercent: 75, + }); + }); + + it("falls back to the top-level rateLimits bucket", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 60, windowDurationMins: 300 }, + }, + }, + }); + + expect(snapshot?.windows).toEqual([ + { + kind: "five-hour", + usedPercent: 60, + remainingPercent: 40, + resetsAt: null, + windowDurationMins: 300, + }, + ]); + }); + + it("maps the 5h and weekly windows", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 10, windowDurationMins: 300 }, + secondary: { usedPercent: 55, windowDurationMins: 10_080 }, + }, + }, + }); + + expect(snapshot?.windows.map((window) => window.kind)).toEqual(["five-hour", "weekly"]); + }); + + it("maps Codex limit-id buckets when duration metadata is absent", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByLimitId: { + FiveHourLimit: { + primary: { usedPercent: 12 }, + }, + WeeklyLimit: { + primary: { usedPercent: 34 }, + }, + }, + }, + }); + + expect(snapshot?.windows).toEqual([ + { + kind: "five-hour", + usedPercent: 12, + remainingPercent: 88, + resetsAt: null, + windowDurationMins: null, + }, + { + kind: "weekly", + usedPercent: 34, + remainingPercent: 66, + resetsAt: null, + windowDurationMins: null, + }, + ]); + }); + + it("uses Codex primary and secondary semantics when durations are unknown", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 10, windowDurationMins: 15 }, + secondary: null, + }, + }, + }); + + expect(snapshot?.windows).toEqual([ + { + kind: "five-hour", + usedPercent: 10, + remainingPercent: 90, + resetsAt: null, + windowDurationMins: 15, + }, + ]); + }); + + it("accepts the long duration field name", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 21, windowDurationMinutes: 300 }, + }, + }, + }); + + expect(snapshot?.windows[0]).toMatchObject({ + kind: "five-hour", + usedPercent: 21, + remainingPercent: 79, + windowDurationMins: 300, + }); + }); + + it("clamps remaining percent at zero", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 120, windowDurationMins: 300 }, + }, + }, + }); + + expect(snapshot?.windows[0]?.usedPercent).toBe(100); + expect(snapshot?.windows[0]?.remainingPercent).toBe(0); + }); +}); diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts new file mode 100644 index 00000000000..397105e6c23 --- /dev/null +++ b/apps/server/src/provider/codexUsage.ts @@ -0,0 +1,157 @@ +import { + type CodexUsageSnapshot, + type CodexUsageSnapshotSource, + type CodexUsageWindow, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +type RateLimitWindow = { + readonly usedPercent?: number | null; + readonly resetsAt?: number | null; + readonly windowDurationMins?: number | null; + readonly windowDurationMinutes?: number | null; +}; + +type RateLimitBucket = { + readonly limitId?: string | null; + readonly primary?: RateLimitWindow | null; + readonly secondary?: RateLimitWindow | null; + readonly rateLimitReachedType?: string | null; +}; + +type RateLimitPayload = { + readonly rateLimits?: RateLimitBucket | null; + readonly rateLimitsByLimitId?: Record | null; +}; + +const FIVE_HOUR_WINDOW_MINS = 300; +const WEEKLY_WINDOW_MINS = 10_080; + +const FIVE_HOUR_LIMIT_IDS = new Set(["fivehourlimit", "five_hour_limit", "5hourlimit", "5h"]); +const WEEKLY_LIMIT_IDS = new Set(["weeklylimit", "weekly_limit", "week", "weekly"]); + +function unixSecondsToIso(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return DateTime.make(value * 1000).pipe( + Option.match({ + onNone: () => null, + onSome: DateTime.formatIso, + }), + ); +} + +function normalizePercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(100, Math.round(value))); +} + +function windowKindForDuration( + windowDurationMins: number | null | undefined, +): CodexUsageWindow["kind"] | null { + if (windowDurationMins === FIVE_HOUR_WINDOW_MINS) { + return "five-hour"; + } + if (windowDurationMins === WEEKLY_WINDOW_MINS) { + return "weekly"; + } + return null; +} + +function windowKindForLimitId(limitId: string | null | undefined): CodexUsageWindow["kind"] | null { + const normalized = limitId + ?.trim() + .toLowerCase() + .replace(/[\s-]+/g, ""); + if (!normalized) { + return null; + } + if (FIVE_HOUR_LIMIT_IDS.has(normalized)) { + return "five-hour"; + } + if (WEEKLY_LIMIT_IDS.has(normalized)) { + return "weekly"; + } + return null; +} + +function normalizeWindow( + window: RateLimitWindow | null | undefined, + fallbackKind?: CodexUsageWindow["kind"] | null, +): CodexUsageWindow | null { + if (!window || typeof window.usedPercent !== "number") { + return null; + } + const windowDurationMins = window.windowDurationMins ?? window.windowDurationMinutes; + const kind = windowKindForDuration(windowDurationMins) ?? fallbackKind; + if (!kind) { + return null; + } + const usedPercent = normalizePercent(window.usedPercent); + return { + kind, + usedPercent, + remainingPercent: Math.max(0, 100 - usedPercent), + resetsAt: unixSecondsToIso(window.resetsAt), + windowDurationMins: typeof windowDurationMins === "number" ? windowDurationMins : null, + }; +} + +function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { + return payload.rateLimitsByLimitId?.codex ?? payload.rateLimits ?? null; +} + +function windowsFromLimitIdBuckets( + buckets: Record | null | undefined, +): CodexUsageWindow[] { + if (!buckets) { + return []; + } + const windows: CodexUsageWindow[] = []; + for (const [limitId, bucket] of Object.entries(buckets)) { + const kind = windowKindForLimitId(limitId); + if (!kind) { + continue; + } + const window = normalizeWindow(bucket.primary ?? bucket.secondary, kind); + if (window) { + windows.push(window); + } + } + return windows; +} + +export function normalizeCodexUsageSnapshot(input: { + readonly providerInstanceId: ProviderInstanceId; + readonly payload: RateLimitPayload; + readonly source: CodexUsageSnapshotSource; + readonly checkedAt?: string; +}): CodexUsageSnapshot | null { + const bucket = selectCodexBucket(input.payload); + const bucketWindows = bucket + ? [ + normalizeWindow(bucket.primary, windowKindForLimitId(bucket.limitId) ?? "five-hour"), + normalizeWindow(bucket.secondary, "weekly"), + ].filter((window): window is CodexUsageWindow => window !== null) + : []; + const windows = + bucketWindows.length > 0 + ? bucketWindows + : windowsFromLimitIdBuckets(input.payload.rateLimitsByLimitId); + if (windows.length === 0) { + return null; + } + + return { + providerInstanceId: input.providerInstanceId, + checkedAt: input.checkedAt ?? DateTime.formatIso(DateTime.nowUnsafe()), + windows, + rateLimitReachedType: bucket?.rateLimitReachedType ?? null, + source: input.source, + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 80f5b1f8e3c..42a3470cfbf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -797,6 +798,24 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetCodexUsage]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.serverGetCodexUsage, + Effect.serviceOption(ProviderService).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(null), + onSome: (providerService) => + providerService + .getCodexUsage(instanceId) + .pipe(Effect.catch(() => Effect.succeed(null))), + }), + ), + ), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27c5c311c60..51379cbd8e0 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,11 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type EnvironmentId, + type ServerProvider, + type ThreadId, +} from "@t3tools/contracts"; +import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; import { ChevronDownIcon, CloudIcon, @@ -25,6 +31,7 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { CodexUsageIndicator } from "./chat/CodexUsageIndicator"; import { Button } from "./ui/button"; import { Menu, @@ -37,6 +44,11 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { + deriveProviderInstanceEntries, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, +} from "../providerInstances"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -51,6 +63,8 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + providerStatuses: readonly ServerProvider[]; + codexUsageIndicatorMode: CodexUsageIndicatorMode; } interface MobileRunContextSelectorProps { @@ -202,6 +216,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + providerStatuses, + codexUsageIndicatorMode, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -212,6 +228,9 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const composerDraft = useComposerDraftStore((store) => + store.getComposerDraft(draftId ?? threadRef), + ); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -236,6 +255,30 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const providerInstanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), + [providerStatuses], + ); + const selectedInstanceId = + composerDraft?.activeProvider ?? + serverThread?.session?.providerInstanceId ?? + serverThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; + const selectedProvider = + resolveProviderDriverKindForInstanceSelection( + providerInstanceEntries, + providerStatuses, + selectedInstanceId, + ) ?? ProviderDriverKind.make("codex"); + const selectedEntry = selectedInstanceId + ? providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId) + : providerInstanceEntries.find( + (entry) => entry.driverKind === selectedProvider && entry.enabled, + ); + const showCodexUsage = + codexUsageIndicatorMode !== "off" && + selectedEntry?.driverKind === ProviderDriverKind.make("codex"); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -273,6 +316,15 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} /> + {showCodexUsage && selectedEntry ? ( + <> + + + + ) : null} )} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..1a37b58cf28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3685,6 +3685,8 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + providerStatuses={providerStatuses as ServerProvider[]} + codexUsageIndicatorMode={settings.codexUsageIndicatorMode} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode ? { diff --git a/apps/web/src/components/chat/CodexUsageIndicator.browser.tsx b/apps/web/src/components/chat/CodexUsageIndicator.browser.tsx new file mode 100644 index 00000000000..7ae34f69570 --- /dev/null +++ b/apps/web/src/components/chat/CodexUsageIndicator.browser.tsx @@ -0,0 +1,126 @@ +import "../../index.css"; + +import { + type CodexUsageSnapshot, + type CodexUsageWindow, + type LocalApi, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { __resetLocalApiForTests } from "../../localApi"; +import { CodexUsageIndicator } from "./CodexUsageIndicator"; + +const codexInstanceId = ProviderInstanceId.make("codex"); + +function usageWindow(kind: CodexUsageWindow["kind"], remainingPercent: number): CodexUsageWindow { + return { + kind, + usedPercent: 100 - remainingPercent, + remainingPercent, + resetsAt: "2026-05-04T07:00:00.000Z", + windowDurationMins: kind === "five-hour" ? 300 : 10_080, + }; +} + +function usageSnapshot(input: { + windows: readonly CodexUsageWindow[]; + rateLimitReachedType?: string | null; +}): CodexUsageSnapshot { + return { + providerInstanceId: codexInstanceId, + checkedAt: "2026-05-04T02:00:00.000Z", + windows: [...input.windows], + rateLimitReachedType: input.rateLimitReachedType ?? null, + source: "read", + }; +} + +async function renderIndicator(mode: CodexUsageIndicatorMode, snapshot: CodexUsageSnapshot | null) { + await __resetLocalApiForTests(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const getCodexUsage = vi.fn().mockResolvedValue(snapshot); + window.nativeApi = { + server: { + getCodexUsage, + }, + } as unknown as LocalApi; + + const mounted = await render( + + + , + ); + + return { + getCodexUsage, + async cleanup() { + await mounted.unmount(); + queryClient.clear(); + }, + }; +} + +afterEach(async () => { + Reflect.deleteProperty(window, "nativeApi"); + await __resetLocalApiForTests(); + document.body.innerHTML = ""; +}); + +describe("CodexUsageIndicator", () => { + it("renders the five-hour window", async () => { + const mounted = await renderIndicator( + "five-hour", + usageSnapshot({ + windows: [usageWindow("five-hour", 73), usageWindow("weekly", 41)], + }), + ); + + await expect.element(page.getByText("Usage 5h 73% left")).toBeVisible(); + expect(mounted.getCodexUsage).toHaveBeenCalledWith({ instanceId: codexInstanceId }); + await mounted.cleanup(); + }); + + it("renders both configured windows", async () => { + const mounted = await renderIndicator( + "both", + usageSnapshot({ + windows: [usageWindow("five-hour", 73), usageWindow("weekly", 41)], + }), + ); + + await expect.element(page.getByText("Usage 5h 73% left | Weekly 41% left")).toBeVisible(); + await mounted.cleanup(); + }); + + it("renders an unavailable state when Codex usage is missing", async () => { + const mounted = await renderIndicator("five-hour", null); + + await expect.element(page.getByText("Usage 5h --")).toBeVisible(); + await mounted.cleanup(); + }); + + it("marks the indicator when a rate limit is reached", async () => { + const mounted = await renderIndicator( + "five-hour", + usageSnapshot({ + windows: [usageWindow("five-hour", 0)], + rateLimitReachedType: "primary", + }), + ); + + await expect.element(page.getByText("Usage 5h 0% left")).toBeVisible(); + expect(document.querySelector(".text-amber-600")).not.toBeNull(); + await mounted.cleanup(); + }); +}); diff --git a/apps/web/src/components/chat/CodexUsageIndicator.tsx b/apps/web/src/components/chat/CodexUsageIndicator.tsx new file mode 100644 index 00000000000..f5e0ed3f48f --- /dev/null +++ b/apps/web/src/components/chat/CodexUsageIndicator.tsx @@ -0,0 +1,129 @@ +import type { CodexUsageSnapshot, CodexUsageWindow, ProviderInstanceId } from "@t3tools/contracts"; +import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; +import { useQuery } from "@tanstack/react-query"; +import { GaugeIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import { codexUsageQueryOptions } from "../../lib/providerReactQuery"; +import { cn } from "../../lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const sameDayResetFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); +const laterResetFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +function labelForWindow(kind: CodexUsageWindow["kind"]): string { + return kind === "five-hour" ? "5h" : "Weekly"; +} + +function selectedWindows( + snapshot: CodexUsageSnapshot | null | undefined, + mode: CodexUsageIndicatorMode, +): CodexUsageWindow[] { + if (!snapshot || mode === "off") { + return []; + } + if (mode === "both") { + return snapshot.windows.filter( + (window) => window.kind === "five-hour" || window.kind === "weekly", + ); + } + return snapshot.windows.filter((window) => window.kind === "five-hour"); +} + +function unavailableLabel(mode: CodexUsageIndicatorMode): string { + if (mode === "both") { + return "Usage 5h -- | Weekly --"; + } + return "Usage 5h --"; +} + +function labelForDisplayWindow(window: CodexUsageWindow): string { + return `${labelForWindow(window.kind)} ${window.remainingPercent}% left`; +} + +function formatUsageTimestamp(isoDate: string, capturedAt: Date = new Date()): string | null { + const date = new Date(isoDate); + if (!Number.isFinite(date.getTime())) { + return null; + } + const isSameDay = + date.getFullYear() === capturedAt.getFullYear() && + date.getMonth() === capturedAt.getMonth() && + date.getDate() === capturedAt.getDate(); + return isSameDay ? sameDayResetFormatter.format(date) : laterResetFormatter.format(date); +} + +function tooltipForSnapshot(snapshot: CodexUsageSnapshot, windows: readonly CodexUsageWindow[]) { + const lines = windows.map((window) => { + const resetAt = window.resetsAt ? formatUsageTimestamp(window.resetsAt) : null; + const resetLabel = resetAt ? `resets ${resetAt}` : ""; + return `${labelForWindow(window.kind)}: ${window.remainingPercent}% left${resetLabel ? `, ${resetLabel}` : ""}`; + }); + const checkedAt = formatUsageTimestamp(snapshot.checkedAt); + if (checkedAt) { + lines.push(`Checked ${checkedAt}`); + } + if (snapshot.rateLimitReachedType) { + lines.push(`Limit state: ${snapshot.rateLimitReachedType}`); + } + return lines.join("\n"); +} + +export const CodexUsageIndicator = memo(function CodexUsageIndicator({ + instanceId, + mode, +}: { + readonly instanceId: ProviderInstanceId; + readonly mode: CodexUsageIndicatorMode; +}) { + const usageQuery = useQuery( + codexUsageQueryOptions({ + instanceId, + enabled: mode !== "off", + }), + ); + const windows = useMemo(() => selectedWindows(usageQuery.data, mode), [mode, usageQuery.data]); + + if (mode === "off") { + return null; + } + + const isUnavailable = !usageQuery.data || windows.length === 0; + const hasReachedLimit = Boolean(usageQuery.data?.rateLimitReachedType); + const snapshot = usageQuery.data; + const label = isUnavailable + ? unavailableLabel(mode) + : `Usage ${windows.map((window) => labelForDisplayWindow(window)).join(" | ")}`; + const tooltip = + isUnavailable || !snapshot + ? "Codex usage is unavailable for this account or session. The selected Codex account did not return displayable 5h or weekly limits." + : tooltipForSnapshot(snapshot, windows); + + return ( + + + + {label} + + } + /> + + {tooltip} + + + ); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 20d3b1040a8..6464ccfb140 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -101,6 +101,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const CODEX_USAGE_INDICATOR_LABELS = { + "five-hour": "5h window", + both: "5h + weekly", + off: "Off", +} as const; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -376,6 +382,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.codexUsageIndicatorMode !== DEFAULT_UNIFIED_SETTINGS.codexUsageIndicatorMode + ? ["Codex usage remaining"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -396,6 +405,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ isGitWritingModelDirty, settings.autoOpenPlanSidebar, + settings.codexUsageIndicatorMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -426,6 +436,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + codexUsageIndicatorMode: DEFAULT_UNIFIED_SETTINGS.codexUsageIndicatorMode, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, @@ -660,6 +671,51 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + codexUsageIndicatorMode: DEFAULT_UNIFIED_SETTINGS.codexUsageIndicatorMode, + }) + } + /> + ) : null + } + control={ + + } + /> + ; @@ -19,6 +31,14 @@ function mockNativeApi(input: { } as unknown as EnvironmentApi); } +function mockLocalApi(input: { getCodexUsage: ReturnType }) { + vi.spyOn(localApi, "ensureLocalApi").mockReturnValue({ + server: { + getCodexUsage: input.getCodexUsage, + }, + } as unknown as LocalApi); +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -222,3 +242,28 @@ describe("checkpointDiffQueryOptions", () => { expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true); }); }); + +describe("codexUsageQueryOptions", () => { + it("loads usage for the selected Codex instance", async () => { + const getCodexUsage = vi.fn().mockResolvedValue({ + providerInstanceId: codexInstanceId, + checkedAt: "2026-05-04T00:00:00.000Z", + windows: [], + rateLimitReachedType: null, + source: "read", + }); + mockLocalApi({ getCodexUsage }); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(codexUsageQueryOptions({ instanceId: codexInstanceId })); + + expect(getCodexUsage).toHaveBeenCalledWith({ instanceId: codexInstanceId }); + }); + + it("is disabled when the caller disables it or no instance is selected", () => { + expect(codexUsageQueryOptions({ instanceId: codexInstanceId, enabled: false }).enabled).toBe( + false, + ); + expect(codexUsageQueryOptions({ instanceId: null }).enabled).toBe(false); + }); +}); diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 69ba1faef45..5be76434d36 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + type ProviderInstanceId, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, ThreadId, @@ -8,6 +9,7 @@ import { queryOptions } from "@tanstack/react-query"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ensureEnvironmentApi } from "../environmentApi"; +import { ensureLocalApi } from "../localApi"; const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); @@ -35,6 +37,8 @@ export const providerQueryKeys = { input.ignoreWhitespace, input.cacheScope ?? null, ] as const, + codexUsage: (instanceId: ProviderInstanceId | null) => + ["providers", "codexUsage", instanceId] as const, }; function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { @@ -137,3 +141,20 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { : Math.min(1_000, 100 * 2 ** (attempt - 1)), }); } + +export function codexUsageQueryOptions(input: { + instanceId: ProviderInstanceId | null; + enabled?: boolean; +}) { + return queryOptions({ + queryKey: providerQueryKeys.codexUsage(input.instanceId), + queryFn: async () => { + if (!input.instanceId) { + return null; + } + return ensureLocalApi().server.getCodexUsage({ instanceId: input.instanceId }); + }, + enabled: (input.enabled ?? true) && input.instanceId !== null, + refetchInterval: 60_000, + }); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8bfb0e599ad..4fcc9ada1d4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -600,6 +600,7 @@ describe("wsApi", () => { it("reads and writes persistence through the desktop bridge when available", async () => { const clientSettings = { autoOpenPlanSidebar: false, + codexUsageIndicatorMode: "five-hour" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -663,6 +664,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); const clientSettings = { autoOpenPlanSidebar: false, + codexUsageIndicatorMode: "five-hour" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cbb3427b004..f55b3e44aa1 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -121,9 +121,15 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { server: { getConfig: () => rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => + getCodexUsage: (input) => rpcClient - ? rpcClient.server.refreshProviders() + ? rpcClient.server.getCodexUsage(input) + : Promise.reject(unavailableLocalBackendError()), + refreshProviders: (input) => + rpcClient + ? input === undefined + ? rpcClient.server.refreshProviders() + : rpcClient.server.refreshProviders(input) : Promise.reject(unavailableLocalBackendError()), updateProvider: (input) => rpcClient diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b36ffb0ab2e..ef1cf88afa4 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -113,6 +113,7 @@ export interface WsRpcClient { }; readonly server: { readonly getConfig: RpcUnaryNoArgMethod; + readonly getCodexUsage: RpcUnaryMethod; /** * Refresh provider snapshots. Pass `{ instanceId }` to refresh a single * configured instance; pass no argument (or `{}`) to refresh all. @@ -240,6 +241,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { }, server: { getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), + getCodexUsage: (input) => + transport.request((client) => client[WS_METHODS.serverGetCodexUsage](input)), refreshProviders: (input) => transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), updateProvider: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index abe8af5022f..37e814a5df2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -26,6 +26,7 @@ import type { ProjectWriteFileResult, } from "./project.ts"; import type { ProviderInstanceId } from "./providerInstance.ts"; +import type { CodexUsageSnapshot } from "./providerRuntime.ts"; import type { ServerConfig, ServerProcessDiagnosticsResult, @@ -458,6 +459,9 @@ export interface LocalApi { }; server: { getConfig: () => Promise; + getCodexUsage: (input: { + readonly instanceId: ProviderInstanceId; + }) => Promise; /** * Refresh provider snapshots. When `input.instanceId` is supplied only that * configured instance is probed; otherwise every configured instance is diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..92d6aef842a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -18,6 +18,30 @@ import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); +export const CodexUsageWindowKind = Schema.Literals(["five-hour", "weekly"]); +export type CodexUsageWindowKind = typeof CodexUsageWindowKind.Type; + +export const CodexUsageWindow = Schema.Struct({ + kind: CodexUsageWindowKind, + usedPercent: Schema.Number, + remainingPercent: Schema.Number, + resetsAt: Schema.NullOr(IsoDateTime), + windowDurationMins: Schema.NullOr(Schema.Number), +}); +export type CodexUsageWindow = typeof CodexUsageWindow.Type; + +export const CodexUsageSnapshotSource = Schema.Literals(["read", "notification", "cache"]); +export type CodexUsageSnapshotSource = typeof CodexUsageSnapshotSource.Type; + +export const CodexUsageSnapshot = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + checkedAt: IsoDateTime, + windows: Schema.Array(CodexUsageWindow), + rateLimitReachedType: Schema.NullOr(Schema.String), + source: CodexUsageSnapshotSource, +}); +export type CodexUsageSnapshot = typeof CodexUsageSnapshot.Type; + const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.app-server.notification"), Schema.Literal("codex.app-server.request"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index eb72b14f9e5..c7723964df9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -49,6 +49,7 @@ import { OrchestrationRpcSchemas, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { CodexUsageSnapshot } from "./providerRuntime.ts"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -136,6 +137,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverGetCodexUsage: "server.getCodexUsage", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverUpsertKeybinding: "server.upsertKeybinding", @@ -178,6 +180,13 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), }); +export const WsServerGetCodexUsageRpc = Rpc.make(WS_METHODS.serverGetCodexUsage, { + payload: Schema.Struct({ + instanceId: ProviderInstanceId, + }), + success: Schema.NullOr(CodexUsageSnapshot), +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -454,6 +463,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, + WsServerGetCodexUsageRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 8c292827927..5de43cafb7d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,10 +2,47 @@ import { describe, expect, it } from "vitest"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsPatch, + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); + +describe("ClientSettings.codexUsageIndicatorMode", () => { + it("defaults to the five-hour usage window", () => { + expect(DEFAULT_CLIENT_SETTINGS.codexUsageIndicatorMode).toBe("five-hour"); + expect(decodeClientSettings({}).codexUsageIndicatorMode).toBe("five-hour"); + }); + + it("decodes valid patch values", () => { + expect( + decodeClientSettingsPatch({ codexUsageIndicatorMode: "five-hour" }).codexUsageIndicatorMode, + ).toBe("five-hour"); + expect( + decodeClientSettingsPatch({ codexUsageIndicatorMode: "both" }).codexUsageIndicatorMode, + ).toBe("both"); + expect( + decodeClientSettingsPatch({ codexUsageIndicatorMode: "off" }).codexUsageIndicatorMode, + ).toBe("off"); + }); + + it("migrates the old weekly-only mode to both windows", () => { + expect( + decodeClientSettings({ codexUsageIndicatorMode: "weekly" }).codexUsageIndicatorMode, + ).toBe("both"); + expect( + decodeClientSettingsPatch({ codexUsageIndicatorMode: "weekly" }).codexUsageIndicatorMode, + ).toBe("both"); + }); +}); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 26a1a171cfd..8c4eed884dd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,8 +38,25 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +const CodexUsageIndicatorModeInput = Schema.Literals(["five-hour", "weekly", "both", "off"]); +const CodexUsageIndicatorModeOutput = Schema.Literals(["five-hour", "both", "off"]); +export const CodexUsageIndicatorMode = CodexUsageIndicatorModeInput.pipe( + Schema.decodeTo( + CodexUsageIndicatorModeOutput, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value === "weekly" ? "both" : value), + encode: (value) => Effect.succeed(value), + }), + ), +); +export type CodexUsageIndicatorMode = typeof CodexUsageIndicatorMode.Type; +export const DEFAULT_CODEX_USAGE_INDICATOR_MODE: CodexUsageIndicatorMode = "five-hour"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + codexUsageIndicatorMode: CodexUsageIndicatorMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODEX_USAGE_INDICATOR_MODE)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -467,6 +484,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + codexUsageIndicatorMode: Schema.optionalKey(CodexUsageIndicatorMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From bdedbd751a7e54665d87173bbc8d23318020f1db Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Mon, 4 May 2026 21:35:44 +0300 Subject: [PATCH 02/17] Preserve cached Codex usage on empty updates --- apps/server/src/provider/Layers/CodexAdapter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 1d75c25b476..5e6067a6702 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1430,11 +1430,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( event.payload, ); if (payload) { - cachedCodexUsage = normalizeCodexUsageSnapshot({ + const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: boundInstanceId, payload, source: "notification", }); + cachedCodexUsage = snapshot ?? cachedCodexUsage; } } const runtimeEvents = mapToRuntimeEvents(event, event.threadId); From 880235afcbea256ed447a30906d767b23b89fb60 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Mon, 4 May 2026 23:42:03 +0300 Subject: [PATCH 03/17] Preserve fallback Codex rate limit state --- apps/server/src/provider/codexUsage.test.ts | 18 ++++++++++++++++++ apps/server/src/provider/codexUsage.ts | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index b0cdd0c21a9..b49a59cd5d4 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -102,6 +102,24 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); + it("carries rate limit reached type from limit-id fallback buckets", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByLimitId: { + FiveHourLimit: { + primary: { usedPercent: 100 }, + rateLimitReachedType: "primary", + }, + }, + }, + }); + + expect(snapshot?.rateLimitReachedType).toBe("primary"); + }); + it("uses Codex primary and secondary semantics when durations are unknown", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index 397105e6c23..f8c95ddb760 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -126,6 +126,20 @@ function windowsFromLimitIdBuckets( return windows; } +function rateLimitReachedTypeFromLimitIdBuckets( + buckets: Record | null | undefined, +): string | null { + if (!buckets) { + return null; + } + for (const [limitId, bucket] of Object.entries(buckets)) { + if (windowKindForLimitId(limitId) && bucket.rateLimitReachedType) { + return bucket.rateLimitReachedType; + } + } + return null; +} + export function normalizeCodexUsageSnapshot(input: { readonly providerInstanceId: ProviderInstanceId; readonly payload: RateLimitPayload; @@ -151,7 +165,9 @@ export function normalizeCodexUsageSnapshot(input: { providerInstanceId: input.providerInstanceId, checkedAt: input.checkedAt ?? DateTime.formatIso(DateTime.nowUnsafe()), windows, - rateLimitReachedType: bucket?.rateLimitReachedType ?? null, + rateLimitReachedType: + bucket?.rateLimitReachedType ?? + rateLimitReachedTypeFromLimitIdBuckets(input.payload.rateLimitsByLimitId), source: input.source, }; } From c9a9be5628d97a5f730cd654840fe8ceee9fda20 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Tue, 5 May 2026 14:42:13 +0300 Subject: [PATCH 04/17] Sort Codex usage windows for display --- apps/server/src/provider/codexUsage.test.ts | 20 ++++++++++++++ apps/server/src/provider/codexUsage.ts | 5 ++-- .../components/chat/CodexUsageIndicator.tsx | 5 ++-- packages/shared/package.json | 4 +++ packages/shared/src/codexUsage.test.ts | 26 +++++++++++++++++++ packages/shared/src/codexUsage.ts | 19 ++++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/codexUsage.test.ts create mode 100644 packages/shared/src/codexUsage.ts diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index b49a59cd5d4..fe692348782 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -102,6 +102,26 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); + it("sorts fallback limit-id buckets by display priority", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByLimitId: { + WeeklyLimit: { + primary: { usedPercent: 34 }, + }, + FiveHourLimit: { + primary: { usedPercent: 12 }, + }, + }, + }, + }); + + expect(snapshot?.windows.map((window) => window.kind)).toEqual(["five-hour", "weekly"]); + }); + it("carries rate limit reached type from limit-id fallback buckets", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index f8c95ddb760..16c374ecdcc 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -4,6 +4,7 @@ import { type CodexUsageWindow, type ProviderInstanceId, } from "@t3tools/contracts"; +import { sortCodexUsageWindowsForDisplay } from "@t3tools/shared/codexUsage"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -123,7 +124,7 @@ function windowsFromLimitIdBuckets( windows.push(window); } } - return windows; + return sortCodexUsageWindowsForDisplay(windows); } function rateLimitReachedTypeFromLimitIdBuckets( @@ -155,7 +156,7 @@ export function normalizeCodexUsageSnapshot(input: { : []; const windows = bucketWindows.length > 0 - ? bucketWindows + ? sortCodexUsageWindowsForDisplay(bucketWindows) : windowsFromLimitIdBuckets(input.payload.rateLimitsByLimitId); if (windows.length === 0) { return null; diff --git a/apps/web/src/components/chat/CodexUsageIndicator.tsx b/apps/web/src/components/chat/CodexUsageIndicator.tsx index f5e0ed3f48f..e233c239d59 100644 --- a/apps/web/src/components/chat/CodexUsageIndicator.tsx +++ b/apps/web/src/components/chat/CodexUsageIndicator.tsx @@ -1,5 +1,6 @@ import type { CodexUsageSnapshot, CodexUsageWindow, ProviderInstanceId } from "@t3tools/contracts"; import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; +import { sortCodexUsageWindowsForDisplay } from "@t3tools/shared/codexUsage"; import { useQuery } from "@tanstack/react-query"; import { GaugeIcon } from "lucide-react"; import { memo, useMemo } from "react"; @@ -29,8 +30,8 @@ function selectedWindows( return []; } if (mode === "both") { - return snapshot.windows.filter( - (window) => window.kind === "five-hour" || window.kind === "weekly", + return sortCodexUsageWindowsForDisplay( + snapshot.windows.filter((window) => window.kind === "five-hour" || window.kind === "weekly"), ); } return snapshot.windows.filter((window) => window.kind === "five-hour"); diff --git a/packages/shared/package.json b/packages/shared/package.json index f410a98b381..d86247ba9a4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -83,6 +83,10 @@ "./keybindings": { "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" + }, + "./codexUsage": { + "types": "./src/codexUsage.ts", + "import": "./src/codexUsage.ts" } }, "scripts": { diff --git a/packages/shared/src/codexUsage.test.ts b/packages/shared/src/codexUsage.test.ts new file mode 100644 index 00000000000..2c7abc94522 --- /dev/null +++ b/packages/shared/src/codexUsage.test.ts @@ -0,0 +1,26 @@ +import type { CodexUsageWindow } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { sortCodexUsageWindowsForDisplay } from "./codexUsage.ts"; + +function usageWindow(kind: CodexUsageWindow["kind"], remainingPercent: number): CodexUsageWindow { + return { + kind, + usedPercent: 100 - remainingPercent, + remainingPercent, + resetsAt: null, + windowDurationMins: null, + }; +} + +describe("sortCodexUsageWindowsForDisplay", () => { + it("places the five-hour window before weekly usage", () => { + const windows = [usageWindow("weekly", 80), usageWindow("five-hour", 40)]; + + expect(sortCodexUsageWindowsForDisplay(windows).map((window) => window.kind)).toEqual([ + "five-hour", + "weekly", + ]); + expect(windows.map((window) => window.kind)).toEqual(["weekly", "five-hour"]); + }); +}); diff --git a/packages/shared/src/codexUsage.ts b/packages/shared/src/codexUsage.ts new file mode 100644 index 00000000000..715005fd9e6 --- /dev/null +++ b/packages/shared/src/codexUsage.ts @@ -0,0 +1,19 @@ +import type { CodexUsageWindow } from "@t3tools/contracts"; + +const CODEX_USAGE_WINDOW_DISPLAY_ORDER = { + "five-hour": 0, + weekly: 1, +} satisfies Record; + +export function compareCodexUsageWindowDisplayOrder( + left: Pick, + right: Pick, +): number { + return CODEX_USAGE_WINDOW_DISPLAY_ORDER[left.kind] - CODEX_USAGE_WINDOW_DISPLAY_ORDER[right.kind]; +} + +export function sortCodexUsageWindowsForDisplay( + windows: ReadonlyArray, +): CodexUsageWindow[] { + return windows.toSorted(compareCodexUsageWindowDisplayOrder); +} From 36c3043ea35f00c9a42f38ada84cc79075cb1b2f Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Wed, 6 May 2026 21:31:53 +0300 Subject: [PATCH 05/17] Handle direct Codex rate limit notifications --- .../src/provider/Layers/CodexAdapter.test.ts | 43 +++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 32 +++++++++++--- apps/server/src/provider/codexUsage.test.ts | 14 ++++++ apps/server/src/provider/codexUsage.ts | 22 ++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3095e27c0d1..f9916b4d2e1 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -23,6 +23,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -439,6 +440,48 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("caches direct account rate-limit notification snapshots", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("usage-notification-thread"), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + assert.ok(runtime); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-rate-limit-direct"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: DateTime.formatIso(yield* DateTime.now), + method: "account/rateLimits/updated", + threadId: asThreadId("usage-notification-thread"), + payload: { + primary: { usedPercent: 33, windowDurationMins: 300 }, + }, + } satisfies ProviderEvent); + const firstEvent = yield* Fiber.join(firstEventFiber); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: {}, + }); + + const snapshot = yield* adapter.readCodexUsage!(); + + assert.equal(firstEvent._tag, "Some"); + assert.equal(snapshot?.source, "cache"); + assert.deepStrictEqual(snapshot?.windows[0], { + kind: "five-hour", + usedPercent: 33, + remainingPercent: 67, + resetsAt: null, + windowDurationMins: 300, + }); + }), + ); + it.effect("reads account rate limits even before a Codex thread session exists", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5e6067a6702..c9240fcf9e3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -144,6 +144,28 @@ function readPayload( return isPayload(payload) ? payload : undefined; } +type AccountRateLimitsUpdatedPayload = + | EffectCodexSchema.V2AccountRateLimitsUpdatedNotification + | EffectCodexSchema.V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot; + +function readAccountRateLimitsUpdatedPayload( + payload: ProviderEvent["payload"], +): AccountRateLimitsUpdatedPayload | undefined { + return ( + readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, payload) ?? + readPayload( + EffectCodexSchema.V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot, + payload, + ) + ); +} + +function unwrapAccountRateLimitsUpdatedPayload( + payload: AccountRateLimitsUpdatedPayload, +): EffectCodexSchema.V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot { + return "rateLimits" in payload ? payload.rateLimits : payload; +} + function trimText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -1117,7 +1139,8 @@ function mapToRuntimeEvents( } if (event.method === "account/rateLimits/updated") { - if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) { + const payload = readAccountRateLimitsUpdatedPayload(event.payload); + if (!payload) { return []; } return [ @@ -1125,7 +1148,7 @@ function mapToRuntimeEvents( type: "account.rate-limits.updated", ...runtimeEventBase(event, canonicalThreadId), payload: { - rateLimits: event.payload ?? {}, + rateLimits: unwrapAccountRateLimitsUpdatedPayload(payload), }, }, ]; @@ -1425,10 +1448,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( Effect.gen(function* () { yield* writeNativeEvent(event); if (event.method === "account/rateLimits/updated") { - const payload = readPayload( - EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, - event.payload, - ); + const payload = readAccountRateLimitsUpdatedPayload(event.payload); if (payload) { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: boundInstanceId, diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index fe692348782..f66bb73b9e6 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -52,6 +52,20 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); + it("accepts a direct rate-limit snapshot payload", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "notification", + payload: { + primary: { usedPercent: 42, windowDurationMins: 300 }, + secondary: { usedPercent: 70, windowDurationMins: 10_080 }, + }, + }); + + expect(snapshot?.windows.map((window) => window.usedPercent)).toEqual([42, 70]); + expect(snapshot?.source).toBe("notification"); + }); + it("maps the 5h and weekly windows", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index 16c374ecdcc..9ba7e8be9b3 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -23,6 +23,13 @@ type RateLimitBucket = { }; type RateLimitPayload = { + readonly credits?: unknown; + readonly limitId?: string | null; + readonly limitName?: string | null; + readonly planType?: string | null; + readonly primary?: RateLimitWindow | null; + readonly secondary?: RateLimitWindow | null; + readonly rateLimitReachedType?: string | null; readonly rateLimits?: RateLimitBucket | null; readonly rateLimitsByLimitId?: Record | null; }; @@ -103,7 +110,22 @@ function normalizeWindow( }; } +function isRateLimitBucketPayload(payload: RateLimitPayload): boolean { + return ( + "primary" in payload || + "secondary" in payload || + "limitId" in payload || + "limitName" in payload || + "credits" in payload || + "planType" in payload || + "rateLimitReachedType" in payload + ); +} + function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { + if (isRateLimitBucketPayload(payload)) { + return payload; + } return payload.rateLimitsByLimitId?.codex ?? payload.rateLimits ?? null; } From ee6911719cd1da9fc8418f2ce8f67ef9cde77c6f Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Wed, 6 May 2026 21:50:49 +0300 Subject: [PATCH 06/17] Avoid Codex usage polling threads --- .../src/provider/Layers/CodexAdapter.test.ts | 54 +++++++++++++--- .../src/provider/Layers/CodexAdapter.ts | 63 +------------------ 2 files changed, 48 insertions(+), 69 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index f9916b4d2e1..2bbc7b91637 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -482,18 +482,58 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - it.effect("reads account rate limits even before a Codex thread session exists", () => - Effect.gen(function* () { + it.effect("returns no account rate limits before a Codex thread session exists", () => { + const isolatedRuntimeFactory = makeRuntimeFactory(); + const isolatedLayer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: isolatedRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { const adapter = yield* CodexAdapter; yield* adapter.stopAll(); + const runtimeCount = isolatedRuntimeFactory.runtimes.length; + const snapshot = yield* adapter.readCodexUsage!(); - const runtime = sessionRuntimeFactory.lastRuntime; + assert.equal(snapshot, null); + assert.equal(isolatedRuntimeFactory.runtimes.length, runtimeCount); + }).pipe(Effect.provide(isolatedLayer)); + }); + + it.effect("returns cached account rate limits without a Codex thread session", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("usage-stopped-cache-thread"), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; assert.ok(runtime); - assert.equal(runtime.options.threadId, asThreadId("codex-usage")); - assert.equal(runtime.startImpl.mock.calls.length, 1); - assert.equal(runtime.readAccountRateLimitsImpl.mock.calls.length, 1); - assert.equal(runtime.closeImpl.mock.calls.length, 1); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: { + primary: { usedPercent: 25, windowDurationMins: 300 }, + }, + }); + yield* adapter.readCodexUsage!(); + yield* adapter.stopAll(); + const runtimeCount = sessionRuntimeFactory.runtimes.length; + + const snapshot = yield* adapter.readCodexUsage!(); + + assert.equal(sessionRuntimeFactory.runtimes.length, runtimeCount); + assert.equal(snapshot?.source, "cache"); assert.deepStrictEqual(snapshot?.windows[0], { kind: "five-hour", usedPercent: 25, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index c9240fcf9e3..0e3a4f8ce5b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1693,72 +1693,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const hasSession: CodexAdapterShape["hasSession"] = (threadId) => Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); - const readCodexUsageWithoutSession = Effect.fn("readCodexUsageWithoutSession")(function* () { - const usageThreadId = ThreadId.make("codex-usage"); - const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; - return yield* Effect.acquireUseRelease( - Scope.make("sequential"), - (usageScope) => - Effect.gen(function* () { - const runtime = yield* createRuntime({ - threadId: usageThreadId, - providerInstanceId: boundInstanceId, - cwd: process.cwd(), - binaryPath: codexConfig.binaryPath, - ...(options?.environment ? { environment: options.environment } : {}), - ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), - runtimeMode: "full-access", - }).pipe( - Effect.provideService(Scope.Scope, usageScope), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: usageThreadId, - detail: cause.message, - cause, - }), - ), - ); - const payload = yield* runtime.start().pipe( - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: usageThreadId, - detail: cause.message, - cause, - }), - ), - Effect.andThen( - runtime.readAccountRateLimits.pipe( - Effect.mapError((cause) => - mapCodexRuntimeError(usageThreadId, "account/rateLimits/read", cause), - ), - ), - ), - Effect.ensuring(runtime.close), - ); - return normalizeCodexUsageSnapshot({ - providerInstanceId: boundInstanceId, - payload, - source: "read", - }); - }), - (usageScope) => Scope.close(usageScope, Exit.void), - ); - }); - const readCodexUsage: CodexAdapterShape["readCodexUsage"] = Effect.fn("readCodexUsage")( function* () { const session = Array.from(sessions.values()).findLast((candidate) => !candidate.stopped); if (!session) { - const snapshot = yield* readCodexUsageWithoutSession(); - cachedCodexUsage = snapshot ?? cachedCodexUsage; - return ( - snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) - ); + return cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null; } const payload = yield* session.runtime.readAccountRateLimits.pipe( Effect.mapError((cause) => From 0f373fecbdd9d96d4767da89841fc1ec56e7274b Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Wed, 6 May 2026 22:14:27 +0300 Subject: [PATCH 07/17] Read Codex usage without opening threads --- .../src/provider/Layers/CodexAdapter.test.ts | 122 +++++++++++------- .../src/provider/Layers/CodexAdapter.ts | 50 ++++++- .../provider/Layers/CodexSessionRuntime.ts | 20 ++- 3 files changed, 140 insertions(+), 52 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2bbc7b91637..f708048f51a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -173,10 +173,13 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { } } -function makeRuntimeFactory() { +function makeRuntimeFactory(factoryOptions?: { + readonly configureRuntime?: (runtime: FakeCodexRuntime) => void; +}) { const runtimes: Array = []; const factory = vi.fn((options: CodexSessionRuntimeOptions) => { const runtime = new FakeCodexRuntime(options); + factoryOptions?.configureRuntime?.(runtime); runtimes.push(runtime); return Effect.succeed(runtime); }); @@ -482,58 +485,18 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - it.effect("returns no account rate limits before a Codex thread session exists", () => { - const isolatedRuntimeFactory = makeRuntimeFactory(); - const isolatedLayer = Layer.effect( - CodexAdapter, - Effect.gen(function* () { - const codexConfig = Schema.decodeSync(CodexSettings)({}); - return yield* makeCodexAdapter(codexConfig, { - makeRuntime: isolatedRuntimeFactory.factory, - }); - }), - ).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* CodexAdapter; - yield* adapter.stopAll(); - const runtimeCount = isolatedRuntimeFactory.runtimes.length; - - const snapshot = yield* adapter.readCodexUsage!(); - - assert.equal(snapshot, null); - assert.equal(isolatedRuntimeFactory.runtimes.length, runtimeCount); - }).pipe(Effect.provide(isolatedLayer)); - }); - - it.effect("returns cached account rate limits without a Codex thread session", () => + it.effect("reads account rate limits before a Codex thread session exists", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("codex"), - threadId: asThreadId("usage-stopped-cache-thread"), - runtimeMode: "full-access", - }); - const runtime = sessionRuntimeFactory.lastRuntime; - assert.ok(runtime); - runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ - rateLimits: { - primary: { usedPercent: 25, windowDurationMins: 300 }, - }, - }); - yield* adapter.readCodexUsage!(); yield* adapter.stopAll(); - const runtimeCount = sessionRuntimeFactory.runtimes.length; - const snapshot = yield* adapter.readCodexUsage!(); + const runtime = sessionRuntimeFactory.lastRuntime; - assert.equal(sessionRuntimeFactory.runtimes.length, runtimeCount); - assert.equal(snapshot?.source, "cache"); + assert.ok(runtime); + assert.equal(runtime.options.threadId, asThreadId("codex-usage")); + assert.equal(runtime.startImpl.mock.calls.length, 0); + assert.equal(runtime.readAccountRateLimitsImpl.mock.calls.length, 1); + assert.equal(runtime.closeImpl.mock.calls.length, 1); assert.deepStrictEqual(snapshot?.windows[0], { kind: "five-hour", usedPercent: 25, @@ -544,6 +507,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect( + "keeps cached account rate limits when a no-session read has no displayable windows", + () => { + const isolatedRuntimeFactory = makeRuntimeFactory({ + configureRuntime: (runtime) => { + if (runtime.options.threadId === asThreadId("codex-usage")) { + runtime.readAccountRateLimitsImpl.mockResolvedValue({ + rateLimits: {}, + }); + } + }, + }); + const isolatedLayer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = Schema.decodeSync(CodexSettings)({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: isolatedRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("usage-stopped-cache-thread"), + runtimeMode: "full-access", + }); + const runtime = isolatedRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.readAccountRateLimitsImpl.mockResolvedValueOnce({ + rateLimits: { + primary: { usedPercent: 25, windowDurationMins: 300 }, + }, + }); + yield* adapter.readCodexUsage!(); + yield* adapter.stopAll(); + + const snapshot = yield* adapter.readCodexUsage!(); + const usageRuntime = isolatedRuntimeFactory.lastRuntime; + + assert.ok(usageRuntime); + assert.equal(usageRuntime.options.threadId, asThreadId("codex-usage")); + assert.equal(usageRuntime.startImpl.mock.calls.length, 0); + assert.equal(usageRuntime.readAccountRateLimitsImpl.mock.calls.length, 1); + assert.equal(snapshot?.source, "cache"); + assert.deepStrictEqual(snapshot?.windows[0], { + kind: "five-hour", + usedPercent: 25, + remainingPercent: 75, + resetsAt: null, + windowDurationMins: 300, + }); + }).pipe(Effect.provide(isolatedLayer)); + }, + ); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0e3a4f8ce5b..a474230ce02 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1693,11 +1693,59 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const hasSession: CodexAdapterShape["hasSession"] = (threadId) => Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + const readCodexUsageWithoutSession = Effect.fn("readCodexUsageWithoutSession")(function* () { + const usageThreadId = ThreadId.make("codex-usage"); + const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; + return yield* Effect.acquireUseRelease( + Scope.make("sequential"), + (usageScope) => + Effect.gen(function* () { + const runtime = yield* createRuntime({ + threadId: usageThreadId, + providerInstanceId: boundInstanceId, + cwd: process.cwd(), + binaryPath: codexConfig.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + runtimeMode: "full-access", + }).pipe( + Effect.provideService(Scope.Scope, usageScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: usageThreadId, + detail: cause.message, + cause, + }), + ), + ); + const payload = yield* runtime.readAccountRateLimits.pipe( + Effect.mapError((cause) => + mapCodexRuntimeError(usageThreadId, "account/rateLimits/read", cause), + ), + Effect.ensuring(runtime.close), + ); + return normalizeCodexUsageSnapshot({ + providerInstanceId: boundInstanceId, + payload, + source: "read", + }); + }), + (usageScope) => Scope.close(usageScope, Exit.void), + ); + }); + const readCodexUsage: CodexAdapterShape["readCodexUsage"] = Effect.fn("readCodexUsage")( function* () { const session = Array.from(sessions.values()).findLast((candidate) => !candidate.stopped); if (!session) { - return cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null; + const snapshot = yield* readCodexUsageWithoutSession(); + cachedCodexUsage = snapshot ?? cachedCodexUsage; + return ( + snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) + ); } const payload = yield* session.runtime.readAccountRateLimits.pipe( Effect.mapError((cause) => diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a0bf621c7b3..e035b0b6e2a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -29,6 +29,7 @@ import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SchemaIssue from "effect/SchemaIssue"; +import * as Semaphore from "effect/Semaphore"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -711,6 +712,8 @@ export const makeCodexSessionRuntime = ( const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); + const initializedRef = yield* Ref.make(false); + const initializeSemaphore = yield* Semaphore.make(1); // `~` is not shell-expanded when env vars are set via // `child_process.spawn`; `expandHomePath` lets a configured @@ -783,6 +786,16 @@ export const makeCodexSessionRuntime = ( method, message, }); + const ensureInitialized = initializeSemaphore.withPermits(1)( + Effect.gen(function* () { + if (yield* Ref.get(initializedRef)) { + return; + } + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + yield* Ref.set(initializedRef, true); + }), + ); const settlePendingApprovals = (decision: ProviderApprovalDecision) => Ref.get(pendingApprovalsRef).pipe( @@ -1181,8 +1194,7 @@ export const makeCodexSessionRuntime = ( const start = Effect.fn("CodexSessionRuntime.start")(function* () { yield* emitSessionEvent("session/connecting", "Starting Codex App Server session."); - yield* client.request("initialize", buildCodexInitializeParams()); - yield* client.notify("initialized", undefined); + yield* ensureInitialized; const requestedModel = normalizeCodexModelSlug(options.model); @@ -1311,7 +1323,9 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), - readAccountRateLimits: client.request("account/rateLimits/read", undefined), + readAccountRateLimits: ensureInitialized.pipe( + Effect.andThen(client.request("account/rateLimits/read", undefined)), + ), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); From 56e1f8c5a1bcc2b72005d3e1f5064857fa68a2bb Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Wed, 6 May 2026 22:36:02 +0300 Subject: [PATCH 08/17] Support named Codex rate limit buckets --- apps/server/src/provider/codexUsage.test.ts | 98 +++++++++++++++++++++ apps/server/src/provider/codexUsage.ts | 76 +++++++++++++--- 2 files changed, 161 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index f66bb73b9e6..9c8a96e2880 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -30,6 +30,30 @@ describe("normalizeCodexUsageSnapshot", () => { }); }); + it("prefers the codex named multi-bucket over the legacy bucket", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + checkedAt: "2026-05-04T00:00:00.000Z", + payload: { + rateLimits: { + primary: { usedPercent: 90, windowDurationMins: 300 }, + }, + rateLimitsByName: { + codex: { + primary: { usedPercent: 25, windowDurationMins: 300 }, + }, + }, + }, + }); + + expect(snapshot?.windows[0]).toMatchObject({ + kind: "five-hour", + usedPercent: 25, + remainingPercent: 75, + }); + }); + it("falls back to the top-level rateLimits bucket", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, @@ -116,6 +140,62 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); + it("maps Codex named buckets when duration metadata is absent", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByName: { + "5-hour limit": { + primary: { usedPercent: 12 }, + }, + "weekly limit": { + primary: { usedPercent: 34 }, + }, + }, + }, + }); + + expect(snapshot?.windows).toEqual([ + { + kind: "five-hour", + usedPercent: 12, + remainingPercent: 88, + resetsAt: null, + windowDurationMins: null, + }, + { + kind: "weekly", + usedPercent: 34, + remainingPercent: 66, + resetsAt: null, + windowDurationMins: null, + }, + ]); + }); + + it("falls through empty limit-id buckets to named buckets", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByLimitId: {}, + rateLimitsByName: { + "5-hour limit": { + primary: { usedPercent: 12 }, + }, + }, + }, + }); + + expect(snapshot?.windows[0]).toMatchObject({ + kind: "five-hour", + usedPercent: 12, + }); + }); + it("sorts fallback limit-id buckets by display priority", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, @@ -154,6 +234,24 @@ describe("normalizeCodexUsageSnapshot", () => { expect(snapshot?.rateLimitReachedType).toBe("primary"); }); + it("carries rate limit reached type from named fallback buckets", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByName: { + "5-hour limit": { + primary: { usedPercent: 100 }, + rateLimitReachedType: "primary", + }, + }, + }, + }); + + expect(snapshot?.rateLimitReachedType).toBe("primary"); + }); + it("uses Codex primary and secondary semantics when durations are unknown", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index 9ba7e8be9b3..7b318f0098a 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -17,6 +17,7 @@ type RateLimitWindow = { type RateLimitBucket = { readonly limitId?: string | null; + readonly limitName?: string | null; readonly primary?: RateLimitWindow | null; readonly secondary?: RateLimitWindow | null; readonly rateLimitReachedType?: string | null; @@ -32,12 +33,21 @@ type RateLimitPayload = { readonly rateLimitReachedType?: string | null; readonly rateLimits?: RateLimitBucket | null; readonly rateLimitsByLimitId?: Record | null; + readonly rateLimitsByName?: Record | null; }; const FIVE_HOUR_WINDOW_MINS = 300; const WEEKLY_WINDOW_MINS = 10_080; -const FIVE_HOUR_LIMIT_IDS = new Set(["fivehourlimit", "five_hour_limit", "5hourlimit", "5h"]); +const FIVE_HOUR_LIMIT_IDS = new Set([ + "fivehour", + "fivehourlimit", + "five_hour_limit", + "5hour", + "5hourlimit", + "5h", + "5hlimit", +]); const WEEKLY_LIMIT_IDS = new Set(["weeklylimit", "weekly_limit", "week", "weekly"]); function unixSecondsToIso(value: number | null | undefined): string | null { @@ -71,8 +81,10 @@ function windowKindForDuration( return null; } -function windowKindForLimitId(limitId: string | null | undefined): CodexUsageWindow["kind"] | null { - const normalized = limitId +function windowKindForLimitKey( + limitKey: string | null | undefined, +): CodexUsageWindow["kind"] | null { + const normalized = limitKey ?.trim() .toLowerCase() .replace(/[\s-]+/g, ""); @@ -126,18 +138,23 @@ function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { if (isRateLimitBucketPayload(payload)) { return payload; } - return payload.rateLimitsByLimitId?.codex ?? payload.rateLimits ?? null; + return ( + payload.rateLimitsByLimitId?.codex ?? + payload.rateLimitsByName?.codex ?? + payload.rateLimits ?? + null + ); } -function windowsFromLimitIdBuckets( +function windowsFromBuckets( buckets: Record | null | undefined, ): CodexUsageWindow[] { if (!buckets) { return []; } const windows: CodexUsageWindow[] = []; - for (const [limitId, bucket] of Object.entries(buckets)) { - const kind = windowKindForLimitId(limitId); + for (const [limitKey, bucket] of Object.entries(buckets)) { + const kind = windowKindForLimitKey(bucket.limitId ?? bucket.limitName ?? limitKey); if (!kind) { continue; } @@ -149,20 +166,47 @@ function windowsFromLimitIdBuckets( return sortCodexUsageWindowsForDisplay(windows); } -function rateLimitReachedTypeFromLimitIdBuckets( +function windowsFromBucketGroups( + ...groups: ReadonlyArray | null | undefined> +): CodexUsageWindow[] { + for (const group of groups) { + const windows = windowsFromBuckets(group); + if (windows.length > 0) { + return windows; + } + } + return []; +} + +function rateLimitReachedTypeFromBuckets( buckets: Record | null | undefined, ): string | null { if (!buckets) { return null; } - for (const [limitId, bucket] of Object.entries(buckets)) { - if (windowKindForLimitId(limitId) && bucket.rateLimitReachedType) { + for (const [limitKey, bucket] of Object.entries(buckets)) { + if ( + windowKindForLimitKey(bucket.limitId ?? bucket.limitName ?? limitKey) && + bucket.rateLimitReachedType + ) { return bucket.rateLimitReachedType; } } return null; } +function rateLimitReachedTypeFromBucketGroups( + ...groups: ReadonlyArray | null | undefined> +): string | null { + for (const group of groups) { + const rateLimitReachedType = rateLimitReachedTypeFromBuckets(group); + if (rateLimitReachedType) { + return rateLimitReachedType; + } + } + return null; +} + export function normalizeCodexUsageSnapshot(input: { readonly providerInstanceId: ProviderInstanceId; readonly payload: RateLimitPayload; @@ -172,14 +216,17 @@ export function normalizeCodexUsageSnapshot(input: { const bucket = selectCodexBucket(input.payload); const bucketWindows = bucket ? [ - normalizeWindow(bucket.primary, windowKindForLimitId(bucket.limitId) ?? "five-hour"), + normalizeWindow( + bucket.primary, + windowKindForLimitKey(bucket.limitId ?? bucket.limitName) ?? "five-hour", + ), normalizeWindow(bucket.secondary, "weekly"), ].filter((window): window is CodexUsageWindow => window !== null) : []; const windows = bucketWindows.length > 0 ? sortCodexUsageWindowsForDisplay(bucketWindows) - : windowsFromLimitIdBuckets(input.payload.rateLimitsByLimitId); + : windowsFromBucketGroups(input.payload.rateLimitsByLimitId, input.payload.rateLimitsByName); if (windows.length === 0) { return null; } @@ -190,7 +237,10 @@ export function normalizeCodexUsageSnapshot(input: { windows, rateLimitReachedType: bucket?.rateLimitReachedType ?? - rateLimitReachedTypeFromLimitIdBuckets(input.payload.rateLimitsByLimitId), + rateLimitReachedTypeFromBucketGroups( + input.payload.rateLimitsByLimitId, + input.payload.rateLimitsByName, + ), source: input.source, }; } From ca86c1500fdadb907a349c8abf36120a5442dc0f Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Thu, 7 May 2026 00:37:08 +0300 Subject: [PATCH 09/17] Tighten Codex usage payload detection --- apps/server/src/provider/codexUsage.test.ts | 25 +++++++++++++++++++++ apps/server/src/provider/codexUsage.ts | 10 +-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index 9c8a96e2880..077eb262d91 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -76,6 +76,31 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); + it("does not treat wrapped payload metadata as a direct bucket", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + planType: "plus", + rateLimitReachedType: "secondary", + rateLimits: { + primary: { usedPercent: 60, windowDurationMins: 300 }, + }, + }, + }); + + expect(snapshot?.windows).toEqual([ + { + kind: "five-hour", + usedPercent: 60, + remainingPercent: 40, + resetsAt: null, + windowDurationMins: 300, + }, + ]); + expect(snapshot?.rateLimitReachedType).toBeNull(); + }); + it("accepts a direct rate-limit snapshot payload", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index 7b318f0098a..a70ca47da3a 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -123,15 +123,7 @@ function normalizeWindow( } function isRateLimitBucketPayload(payload: RateLimitPayload): boolean { - return ( - "primary" in payload || - "secondary" in payload || - "limitId" in payload || - "limitName" in payload || - "credits" in payload || - "planType" in payload || - "rateLimitReachedType" in payload - ); + return "primary" in payload || "secondary" in payload; } function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { From 9a872945f6f341a1a706e0703f3cedc14f2cce25 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Thu, 7 May 2026 04:34:04 +0300 Subject: [PATCH 10/17] Fix codex usage rate limit fallback --- apps/server/src/provider/codexUsage.test.ts | 21 +++++++++++++++++++++ apps/server/src/provider/codexUsage.ts | 14 ++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index 077eb262d91..0041c64dff9 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -277,6 +277,27 @@ describe("normalizeCodexUsageSnapshot", () => { expect(snapshot?.rateLimitReachedType).toBe("primary"); }); + it("does not fall back when the selected bucket explicitly has no rate limit reached", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 50, windowDurationMins: 300 }, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: { + FiveHourLimit: { + primary: { usedPercent: 100 }, + rateLimitReachedType: "primary", + }, + }, + }, + }); + + expect(snapshot?.rateLimitReachedType).toBeNull(); + }); + it("uses Codex primary and secondary semantics when durations are unknown", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index a70ca47da3a..8a0f410b7d4 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -199,6 +199,16 @@ function rateLimitReachedTypeFromBucketGroups( return null; } +function rateLimitReachedTypeFromSelectedBucket( + bucket: RateLimitBucket | null, + fallback: () => string | null, +): string | null { + if (bucket && "rateLimitReachedType" in bucket) { + return bucket.rateLimitReachedType ?? null; + } + return fallback(); +} + export function normalizeCodexUsageSnapshot(input: { readonly providerInstanceId: ProviderInstanceId; readonly payload: RateLimitPayload; @@ -227,12 +237,12 @@ export function normalizeCodexUsageSnapshot(input: { providerInstanceId: input.providerInstanceId, checkedAt: input.checkedAt ?? DateTime.formatIso(DateTime.nowUnsafe()), windows, - rateLimitReachedType: - bucket?.rateLimitReachedType ?? + rateLimitReachedType: rateLimitReachedTypeFromSelectedBucket(bucket, () => rateLimitReachedTypeFromBucketGroups( input.payload.rateLimitsByLimitId, input.payload.rateLimitsByName, ), + ), source: input.source, }; } From 4fa463ff83e41502c7e94539a8ad9478ef8b4bda Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Thu, 7 May 2026 04:51:36 +0300 Subject: [PATCH 11/17] Simplify Codex usage cache handling --- .../src/provider/Layers/CodexAdapter.ts | 20 ++++--- apps/server/src/provider/codexUsage.test.ts | 59 ++----------------- apps/server/src/provider/codexUsage.ts | 8 ++- 3 files changed, 25 insertions(+), 62 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index a474230ce02..9ee520b89aa 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1737,15 +1737,22 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }); + const cacheCodexUsageSnapshot = ( + snapshot: CodexUsageSnapshot | null, + ): CodexUsageSnapshot | null => { + if (snapshot) { + cachedCodexUsage = snapshot; + return snapshot; + } + return cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null; + }; + const readCodexUsage: CodexAdapterShape["readCodexUsage"] = Effect.fn("readCodexUsage")( function* () { const session = Array.from(sessions.values()).findLast((candidate) => !candidate.stopped); if (!session) { const snapshot = yield* readCodexUsageWithoutSession(); - cachedCodexUsage = snapshot ?? cachedCodexUsage; - return ( - snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) - ); + return cacheCodexUsageSnapshot(snapshot); } const payload = yield* session.runtime.readAccountRateLimits.pipe( Effect.mapError((cause) => @@ -1757,10 +1764,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( payload, source: "read", }); - cachedCodexUsage = snapshot ?? cachedCodexUsage; - return ( - snapshot ?? (cachedCodexUsage ? { ...cachedCodexUsage, source: "cache" as const } : null) - ); + return cacheCodexUsageSnapshot(snapshot); }, ); diff --git a/apps/server/src/provider/codexUsage.test.ts b/apps/server/src/provider/codexUsage.test.ts index 0041c64dff9..45a77a15acd 100644 --- a/apps/server/src/provider/codexUsage.test.ts +++ b/apps/server/src/provider/codexUsage.test.ts @@ -30,30 +30,6 @@ describe("normalizeCodexUsageSnapshot", () => { }); }); - it("prefers the codex named multi-bucket over the legacy bucket", () => { - const snapshot = normalizeCodexUsageSnapshot({ - providerInstanceId: instanceId, - source: "read", - checkedAt: "2026-05-04T00:00:00.000Z", - payload: { - rateLimits: { - primary: { usedPercent: 90, windowDurationMins: 300 }, - }, - rateLimitsByName: { - codex: { - primary: { usedPercent: 25, windowDurationMins: 300 }, - }, - }, - }, - }); - - expect(snapshot?.windows[0]).toMatchObject({ - kind: "five-hour", - usedPercent: 25, - remainingPercent: 75, - }); - }); - it("falls back to the top-level rateLimits bucket", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, @@ -165,12 +141,15 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); - it("maps Codex named buckets when duration metadata is absent", () => { + it("maps named buckets when limit-id buckets are empty", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, source: "read", payload: { - rateLimits: {}, + rateLimits: { + primary: { usedPercent: 90, windowDurationMins: 300 }, + }, + rateLimitsByLimitId: {}, rateLimitsByName: { "5-hour limit": { primary: { usedPercent: 12 }, @@ -200,27 +179,6 @@ describe("normalizeCodexUsageSnapshot", () => { ]); }); - it("falls through empty limit-id buckets to named buckets", () => { - const snapshot = normalizeCodexUsageSnapshot({ - providerInstanceId: instanceId, - source: "read", - payload: { - rateLimits: {}, - rateLimitsByLimitId: {}, - rateLimitsByName: { - "5-hour limit": { - primary: { usedPercent: 12 }, - }, - }, - }, - }); - - expect(snapshot?.windows[0]).toMatchObject({ - kind: "five-hour", - usedPercent: 12, - }); - }); - it("sorts fallback limit-id buckets by display priority", () => { const snapshot = normalizeCodexUsageSnapshot({ providerInstanceId: instanceId, @@ -286,12 +244,7 @@ describe("normalizeCodexUsageSnapshot", () => { primary: { usedPercent: 50, windowDurationMins: 300 }, rateLimitReachedType: null, }, - rateLimitsByLimitId: { - FiveHourLimit: { - primary: { usedPercent: 100 }, - rateLimitReachedType: "primary", - }, - }, + rateLimitsByLimitId: {}, }, }); diff --git a/apps/server/src/provider/codexUsage.ts b/apps/server/src/provider/codexUsage.ts index 8a0f410b7d4..4e2e639ffc9 100644 --- a/apps/server/src/provider/codexUsage.ts +++ b/apps/server/src/provider/codexUsage.ts @@ -126,6 +126,10 @@ function isRateLimitBucketPayload(payload: RateLimitPayload): boolean { return "primary" in payload || "secondary" in payload; } +function hasBuckets(buckets: Record | null | undefined): boolean { + return Boolean(buckets && Object.keys(buckets).length > 0); +} + function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { if (isRateLimitBucketPayload(payload)) { return payload; @@ -133,7 +137,9 @@ function selectCodexBucket(payload: RateLimitPayload): RateLimitBucket | null { return ( payload.rateLimitsByLimitId?.codex ?? payload.rateLimitsByName?.codex ?? - payload.rateLimits ?? + (hasBuckets(payload.rateLimitsByLimitId) || hasBuckets(payload.rateLimitsByName) + ? null + : payload.rateLimits) ?? null ); } From 0d1e852c25fa248e7e0fac92c65d0c4acc0ae51c Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 03:04:40 +0300 Subject: [PATCH 12/17] Hide internal Codex usage comparator --- packages/shared/src/codexUsage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/codexUsage.ts b/packages/shared/src/codexUsage.ts index 715005fd9e6..ee5f0b9836e 100644 --- a/packages/shared/src/codexUsage.ts +++ b/packages/shared/src/codexUsage.ts @@ -5,7 +5,7 @@ const CODEX_USAGE_WINDOW_DISPLAY_ORDER = { weekly: 1, } satisfies Record; -export function compareCodexUsageWindowDisplayOrder( +function compareCodexUsageWindowDisplayOrder( left: Pick, right: Pick, ): number { From c0b08c385fb0d37b1550865eeffce244a924792d Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 03:44:22 +0300 Subject: [PATCH 13/17] Share Codex usage provider resolution --- apps/web/src/components/BranchToolbar.tsx | 48 ++---------- apps/web/src/components/ChatView.tsx | 53 ++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 78 ++++--------------- apps/web/src/providerInstances.ts | 40 +++++++++- 4 files changed, 112 insertions(+), 107 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 51379cbd8e0..e71d7dfb447 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,10 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import { - ProviderDriverKind, - type EnvironmentId, - type ServerProvider, - type ThreadId, -} from "@t3tools/contracts"; +import { type EnvironmentId, type ProviderInstanceId, type ThreadId } from "@t3tools/contracts"; import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; import { ChevronDownIcon, @@ -44,11 +39,6 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; -import { - deriveProviderInstanceEntries, - resolveProviderDriverKindForInstanceSelection, - sortProviderInstanceEntries, -} from "../providerInstances"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -63,8 +53,8 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; - providerStatuses: readonly ServerProvider[]; codexUsageIndicatorMode: CodexUsageIndicatorMode; + codexUsageInstanceId: ProviderInstanceId | null; } interface MobileRunContextSelectorProps { @@ -216,8 +206,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, - providerStatuses, codexUsageIndicatorMode, + codexUsageInstanceId, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -228,9 +218,6 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const composerDraft = useComposerDraftStore((store) => - store.getComposerDraft(draftId ?? threadRef), - ); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -255,30 +242,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); - const providerInstanceEntries = useMemo( - () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - [providerStatuses], - ); - const selectedInstanceId = - composerDraft?.activeProvider ?? - serverThread?.session?.providerInstanceId ?? - serverThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; - const selectedProvider = - resolveProviderDriverKindForInstanceSelection( - providerInstanceEntries, - providerStatuses, - selectedInstanceId, - ) ?? ProviderDriverKind.make("codex"); - const selectedEntry = selectedInstanceId - ? providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId) - : providerInstanceEntries.find( - (entry) => entry.driverKind === selectedProvider && entry.enabled, - ); - const showCodexUsage = - codexUsageIndicatorMode !== "off" && - selectedEntry?.driverKind === ProviderDriverKind.make("codex"); + const showCodexUsage = codexUsageIndicatorMode !== "off" && codexUsageInstanceId !== null; const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -316,11 +280,11 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} /> - {showCodexUsage && selectedEntry ? ( + {showCodexUsage ? ( <> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1a37b58cf28..4c3602f7acf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -118,6 +118,11 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; +import { + deriveProviderInstanceEntries, + resolveSelectedProviderInstanceId, + sortProviderInstanceEntries, +} from "../providerInstances"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { @@ -1257,11 +1262,56 @@ export default function ChatView(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + const providerInstanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), + [providerStatuses], + ); const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const lockedContinuationGroupKey = useMemo((): string | null => { + if (!lockedProvider || !activeThread) return null; + const lockedInstanceId = + activeThread.session?.providerInstanceId ?? activeThread.modelSelection.instanceId; + if (!lockedInstanceId) return null; + return ( + providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) + ?.continuationGroupKey ?? null + ); + }, [activeThread, lockedProvider, providerInstanceEntries]); + const codexUsageInstanceId = useMemo(() => { + if (settings.codexUsageIndicatorMode === "off") return null; + const selectedInstanceId = resolveSelectedProviderInstanceId({ + entries: providerInstanceEntries, + candidates: [ + composerActiveProvider, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + selectedProvider, + lockedProvider, + lockedContinuationGroupKey, + }); + const selectedEntry = providerInstanceEntries.find( + (entry) => entry.instanceId === selectedInstanceId, + ); + return selectedEntry?.driverKind === ProviderDriverKind.make("codex") + ? selectedEntry.instanceId + : null; + }, [ + activeProject?.defaultModelSelection?.instanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + composerActiveProvider, + lockedContinuationGroupKey, + lockedProvider, + providerInstanceEntries, + selectedProvider, + settings.codexUsageIndicatorMode, + ]); const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -3642,6 +3692,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + providerInstanceEntries={providerInstanceEntries} activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeThreadActivities={activeThread?.activities} @@ -3685,8 +3736,8 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} - providerStatuses={providerStatuses as ServerProvider[]} codexUsageIndicatorMode={settings.codexUsageIndicatorMode} + codexUsageInstanceId={codexUsageInstanceId} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode ? { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c6..4d0401c2ca0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -98,9 +98,8 @@ import { import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { - deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, - sortProviderInstanceEntries, + resolveSelectedProviderInstanceId, type ProviderInstanceEntry, } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -432,6 +431,7 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + providerInstanceEntries: ReadonlyArray; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -526,6 +526,7 @@ export const ChatComposer = memo( interactionMode, lockedProvider, providerStatuses, + providerInstanceEntries, activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, @@ -591,13 +592,6 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ // Model state // ------------------------------------------------------------------ - // Instance-aware projection of the wire provider list. One entry per - // configured instance (default built-in + any custom `providerInstances.*`), - // sorted default-first per driver kind for a stable picker order. - const providerInstanceEntries = useMemo>( - () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - [providerStatuses], - ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? @@ -629,66 +623,24 @@ export const ChatComposer = memo( providerInstanceEntries, ]); - // Resolve which configured instance the composer is currently targeting. - // Priority: - // 1. The composer draft's `activeProvider` — the user's unsaved pick - // from the model picker (must win, otherwise the UI appears to - // ignore picker selections). - // 2. Thread's persisted instance id (server-side saved selection). - // 3. Project default's instance id. - // 4. First enabled entry matching the current driver kind. - // 5. First enabled entry overall / default instance for the kind. - // const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } - const byKind = providerInstanceEntries.find( - (entry) => - entry.enabled && - entry.driverKind === selectedProvider && - (!lockedContinuationGroupKey || - entry.continuationGroupKey === lockedContinuationGroupKey), - ); - if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); - return ( - anyEnabled?.instanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") - ); + return resolveSelectedProviderInstanceId({ + entries: providerInstanceEntries, + candidates: [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + selectedProvider, + lockedProvider, + lockedContinuationGroupKey, + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 6ff0bd1ab98..15df37fb205 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -16,7 +16,7 @@ import { defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, type ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderModel, type ServerProviderState, @@ -184,6 +184,44 @@ export function sortProviderInstanceEntries( return sorted; } +export function resolveSelectedProviderInstanceId(input: { + entries: ReadonlyArray; + candidates: ReadonlyArray; + selectedProvider: ProviderDriverKind; + lockedProvider?: ProviderDriverKind | null | undefined; + lockedContinuationGroupKey?: string | null | undefined; +}): ProviderInstanceId { + const lockedProvider = input.lockedProvider ?? null; + const lockedContinuationGroupKey = input.lockedContinuationGroupKey ?? null; + const explicitSelection = input.candidates.find((candidate) => candidate != null); + + for (const candidate of input.candidates) { + if (!candidate) continue; + const match = input.entries.find((entry) => entry.instanceId === candidate && entry.enabled); + if (!match) continue; + if (lockedProvider && match.driverKind !== lockedProvider) continue; + if (lockedContinuationGroupKey && match.continuationGroupKey !== lockedContinuationGroupKey) { + continue; + } + return match.instanceId; + } + + if (explicitSelection) { + return ProviderInstanceId.make(explicitSelection); + } + + const byKind = input.entries.find( + (entry) => + entry.enabled && + entry.driverKind === input.selectedProvider && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (byKind) return byKind.instanceId; + + const anyEnabled = input.entries.find((entry) => entry.enabled); + return anyEnabled?.instanceId ?? input.entries[0]?.instanceId ?? ProviderInstanceId.make("codex"); +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. From 06df77aa7b5a82bee4dc7a8bcc2de82fd648b009 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 04:03:32 +0300 Subject: [PATCH 14/17] Respect locked provider fallback --- apps/web/src/providerInstances.test.ts | 20 ++++++++++++++++++++ apps/web/src/providerInstances.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 7104f365ebf..037fc32ff0f 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -4,6 +4,7 @@ import { deriveProviderInstanceEntries, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, + resolveSelectedProviderInstanceId, } from "./providerInstances"; function provider(input: { @@ -130,3 +131,22 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { ).toBeUndefined(); }); }); + +describe("resolveSelectedProviderInstanceId", () => { + it("falls back to the locked provider before the selected provider", () => { + const providers = [ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + ]; + const entries = deriveProviderInstanceEntries(providers); + + expect( + resolveSelectedProviderInstanceId({ + entries, + candidates: [null], + selectedProvider: ProviderDriverKind.make("claudeAgent"), + lockedProvider: ProviderDriverKind.make("codex"), + }), + ).toBe("codex"); + }); +}); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 15df37fb205..1876af8cd02 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -210,10 +210,11 @@ export function resolveSelectedProviderInstanceId(input: { return ProviderInstanceId.make(explicitSelection); } + const fallbackProvider = lockedProvider ?? input.selectedProvider; const byKind = input.entries.find( (entry) => entry.enabled && - entry.driverKind === input.selectedProvider && + entry.driverKind === fallbackProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; From 5af6aa1dec71c161fdff97b839e3fd230235689c Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 04:23:00 +0300 Subject: [PATCH 15/17] Preserve provider instance fallback ids --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 4 ++ apps/web/src/components/chat/ChatComposer.tsx | 4 ++ apps/web/src/providerInstances.test.ts | 50 +++++++++++++++++++ apps/web/src/providerInstances.ts | 15 +++--- 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..7cd98f3fa1d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + codexUsageIndicatorMode: "five-hour", confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4c3602f7acf..df35a40bc87 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1294,6 +1294,10 @@ export default function ChatView(props: ChatViewProps) { selectedProvider, lockedProvider, lockedContinuationGroupKey, + fallbackInstanceIds: [ + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], }); const selectedEntry = providerInstanceEntries.find( (entry) => entry.instanceId === selectedInstanceId, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4d0401c2ca0..02fe011f10d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -635,6 +635,10 @@ export const ChatComposer = memo( selectedProvider, lockedProvider, lockedContinuationGroupKey, + fallbackInstanceIds: [ + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], }); }, [ activeProjectDefaultModelSelection?.instanceId, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 037fc32ff0f..073f8cb1f9b 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -149,4 +149,54 @@ describe("resolveSelectedProviderInstanceId", () => { }), ).toBe("codex"); }); + + it("keeps persisted model selections as the last fallback before codex", () => { + const persistedInstanceId = ProviderInstanceId.make("codex_saved"); + + expect( + resolveSelectedProviderInstanceId({ + entries: [], + candidates: [null], + selectedProvider: ProviderDriverKind.make("codex"), + fallbackInstanceIds: [persistedInstanceId], + }), + ).toBe(persistedInstanceId); + }); + + it("falls back instead of returning a disabled explicit selection", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex_disabled", + enabled: false, + }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]; + const entries = deriveProviderInstanceEntries(providers); + + expect( + resolveSelectedProviderInstanceId({ + entries, + candidates: [ProviderInstanceId.make("codex_disabled")], + selectedProvider: ProviderDriverKind.make("codex"), + }), + ).toBe("codex"); + }); + + it("falls back instead of returning an explicit selection outside a lock", () => { + const providers = [ + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]; + const entries = deriveProviderInstanceEntries(providers); + + expect( + resolveSelectedProviderInstanceId({ + entries, + candidates: [ProviderInstanceId.make("claudeAgent")], + selectedProvider: ProviderDriverKind.make("claudeAgent"), + lockedProvider: ProviderDriverKind.make("codex"), + }), + ).toBe("codex"); + }); }); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 1876af8cd02..7d0b8477d6c 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -190,10 +190,10 @@ export function resolveSelectedProviderInstanceId(input: { selectedProvider: ProviderDriverKind; lockedProvider?: ProviderDriverKind | null | undefined; lockedContinuationGroupKey?: string | null | undefined; + fallbackInstanceIds?: ReadonlyArray; }): ProviderInstanceId { const lockedProvider = input.lockedProvider ?? null; const lockedContinuationGroupKey = input.lockedContinuationGroupKey ?? null; - const explicitSelection = input.candidates.find((candidate) => candidate != null); for (const candidate of input.candidates) { if (!candidate) continue; @@ -206,10 +206,6 @@ export function resolveSelectedProviderInstanceId(input: { return match.instanceId; } - if (explicitSelection) { - return ProviderInstanceId.make(explicitSelection); - } - const fallbackProvider = lockedProvider ?? input.selectedProvider; const byKind = input.entries.find( (entry) => @@ -220,7 +216,14 @@ export function resolveSelectedProviderInstanceId(input: { if (byKind) return byKind.instanceId; const anyEnabled = input.entries.find((entry) => entry.enabled); - return anyEnabled?.instanceId ?? input.entries[0]?.instanceId ?? ProviderInstanceId.make("codex"); + const fallbackSelection = input.fallbackInstanceIds?.find((instanceId) => instanceId != null); + return ( + anyEnabled?.instanceId ?? + input.entries[0]?.instanceId ?? + (fallbackSelection + ? ProviderInstanceId.make(fallbackSelection) + : ProviderInstanceId.make("codex")) + ); } /** From 3d4194f32b22be512282256a859abe1b01444255 Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Fri, 8 May 2026 23:20:16 +0300 Subject: [PATCH 16/17] Align usage provider resolution --- apps/web/src/components/ChatView.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index df35a40bc87..cf9ce45b224 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -115,11 +115,12 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { getProviderModelCapabilities } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { deriveProviderInstanceEntries, + resolveProviderDriverKindForInstanceSelection, resolveSelectedProviderInstanceId, sortProviderInstanceEntries, } from "../providerInstances"; @@ -1266,10 +1267,13 @@ export default function ChatView(props: ChatViewProps) { () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), [providerStatuses], ); - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), - ); + const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; + const unlockedSelectedProvider = + resolveProviderDriverKindForInstanceSelection( + providerInstanceEntries, + providerStatuses, + explicitSelectedInstanceId, + ) ?? ProviderDriverKind.make("codex"); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; From f6c19190d82b0ccf8cf0616c4fb40b445d8ec2ee Mon Sep 17 00:00:00 2001 From: lintowe <96458554+lintowe@users.noreply.github.com> Date: Sat, 9 May 2026 00:13:49 +0300 Subject: [PATCH 17/17] Refresh usage lock memo inputs --- apps/web/src/components/ChatView.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf9ce45b224..45c55b1907f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1275,16 +1275,22 @@ export default function ChatView(props: ChatViewProps) { explicitSelectedInstanceId, ) ?? ProviderDriverKind.make("codex"); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const activeThreadSessionProviderInstanceId = activeThread?.session?.providerInstanceId; + const activeThreadModelInstanceId = activeThread?.modelSelection.instanceId; const lockedContinuationGroupKey = useMemo((): string | null => { - if (!lockedProvider || !activeThread) return null; - const lockedInstanceId = - activeThread.session?.providerInstanceId ?? activeThread.modelSelection.instanceId; + if (!lockedProvider) return null; + const lockedInstanceId = activeThreadSessionProviderInstanceId ?? activeThreadModelInstanceId; if (!lockedInstanceId) return null; return ( providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) ?.continuationGroupKey ?? null ); - }, [activeThread, lockedProvider, providerInstanceEntries]); + }, [ + activeThreadModelInstanceId, + activeThreadSessionProviderInstanceId, + lockedProvider, + providerInstanceEntries, + ]); const codexUsageInstanceId = useMemo(() => { if (settings.codexUsageIndicatorMode === "off") return null; const selectedInstanceId = resolveSelectedProviderInstanceId({