diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e69286..7cd98f3fa1 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/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b610c0abc2..5ce20960b4 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 571164fad9..1ab62e441a 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 3b2411cba2..7a539a0c00 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 c5eaa536c3..f708048f51 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"; @@ -33,6 +34,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 +105,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 +152,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)); } @@ -160,16 +173,20 @@ 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); }); return { factory, + runtimes, get lastRuntime(): FakeCodexRuntime | undefined { return runtimes.at(-1); }, @@ -359,6 +376,200 @@ 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("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 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, 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, + remainingPercent: 75, + resetsAt: null, + windowDurationMins: 300, + }); + }), + ); + + 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 28af1cda27..9ee520b89a 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( @@ -141,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; @@ -1114,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 [ @@ -1122,7 +1148,7 @@ function mapToRuntimeEvents( type: "account.rate-limits.updated", ...runtimeEventBase(event, canonicalThreadId), payload: { - rateLimits: event.payload ?? {}, + rateLimits: unwrapAccountRateLimitsUpdatedPayload(payload), }, }, ]; @@ -1361,6 +1387,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 +1447,17 @@ 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 = readAccountRateLimitsUpdatedPayload(event.payload); + if (payload) { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: boundInstanceId, + payload, + source: "notification", + }); + cachedCodexUsage = snapshot ?? cachedCodexUsage; + } + } const runtimeEvents = mapToRuntimeEvents(event, event.threadId); if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { @@ -1655,6 +1693,81 @@ 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 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(); + return cacheCodexUsageSnapshot(snapshot); + } + 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", + }); + return cacheCodexUsageSnapshot(snapshot); + }, + ); + const stopAll: CodexAdapterShape["stopAll"] = () => Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { concurrency: 1, @@ -1684,6 +1797,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 a2b54fac21..e035b0b6e2 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"; @@ -137,6 +138,10 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly readAccountRateLimits: Effect.Effect< + EffectCodexSchema.V2GetAccountRateLimitsResponse, + CodexSessionRuntimeError + >; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -707,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 @@ -779,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( @@ -1177,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); @@ -1307,6 +1323,9 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + 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); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fc0450b8b6..2822acdb83 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 2bce1f483b..6cbba64dca 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 add225e2ee..dd2f58dc39 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 01eeae7b7b..34a8c1aac2 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 4d4cb4fa01..9f328a2bb5 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 0000000000..45a77a15ac --- /dev/null +++ b/apps/server/src/provider/codexUsage.test.ts @@ -0,0 +1,310 @@ +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("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, + 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, + 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("maps named buckets when limit-id buckets are empty", () => { + const snapshot = normalizeCodexUsageSnapshot({ + providerInstanceId: instanceId, + source: "read", + payload: { + rateLimits: { + primary: { usedPercent: 90, windowDurationMins: 300 }, + }, + rateLimitsByLimitId: {}, + 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("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, + source: "read", + payload: { + rateLimits: {}, + rateLimitsByLimitId: { + FiveHourLimit: { + primary: { usedPercent: 100 }, + rateLimitReachedType: "primary", + }, + }, + }, + }); + + 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("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: {}, + }, + }); + + expect(snapshot?.rateLimitReachedType).toBeNull(); + }); + + 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 0000000000..4e2e639ffc --- /dev/null +++ b/apps/server/src/provider/codexUsage.ts @@ -0,0 +1,254 @@ +import { + type CodexUsageSnapshot, + type CodexUsageSnapshotSource, + 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"; + +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 limitName?: string | null; + readonly primary?: RateLimitWindow | null; + readonly secondary?: RateLimitWindow | null; + readonly rateLimitReachedType?: string | null; +}; + +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; + readonly rateLimitsByName?: Record | null; +}; + +const FIVE_HOUR_WINDOW_MINS = 300; +const WEEKLY_WINDOW_MINS = 10_080; + +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 { + 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 windowKindForLimitKey( + limitKey: string | null | undefined, +): CodexUsageWindow["kind"] | null { + const normalized = limitKey + ?.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 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; + } + return ( + payload.rateLimitsByLimitId?.codex ?? + payload.rateLimitsByName?.codex ?? + (hasBuckets(payload.rateLimitsByLimitId) || hasBuckets(payload.rateLimitsByName) + ? null + : payload.rateLimits) ?? + null + ); +} + +function windowsFromBuckets( + buckets: Record | null | undefined, +): CodexUsageWindow[] { + if (!buckets) { + return []; + } + const windows: CodexUsageWindow[] = []; + for (const [limitKey, bucket] of Object.entries(buckets)) { + const kind = windowKindForLimitKey(bucket.limitId ?? bucket.limitName ?? limitKey); + if (!kind) { + continue; + } + const window = normalizeWindow(bucket.primary ?? bucket.secondary, kind); + if (window) { + windows.push(window); + } + } + return sortCodexUsageWindowsForDisplay(windows); +} + +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 [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; +} + +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; + readonly source: CodexUsageSnapshotSource; + readonly checkedAt?: string; +}): CodexUsageSnapshot | null { + const bucket = selectCodexBucket(input.payload); + const bucketWindows = bucket + ? [ + 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) + : windowsFromBucketGroups(input.payload.rateLimitsByLimitId, input.payload.rateLimitsByName); + if (windows.length === 0) { + return null; + } + + return { + providerInstanceId: input.providerInstanceId, + checkedAt: input.checkedAt ?? DateTime.formatIso(DateTime.nowUnsafe()), + windows, + rateLimitReachedType: rateLimitReachedTypeFromSelectedBucket(bucket, () => + rateLimitReachedTypeFromBucketGroups( + input.payload.rateLimitsByLimitId, + input.payload.rateLimitsByName, + ), + ), + source: input.source, + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 001c9baff9..9306f49636 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -50,6 +50,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"; @@ -835,6 +836,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 27c5c311c6..e71d7dfb44 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,6 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentId, type ProviderInstanceId, type ThreadId } from "@t3tools/contracts"; +import type { CodexUsageIndicatorMode } from "@t3tools/contracts/settings"; import { ChevronDownIcon, CloudIcon, @@ -25,6 +26,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, @@ -51,6 +53,8 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + codexUsageIndicatorMode: CodexUsageIndicatorMode; + codexUsageInstanceId: ProviderInstanceId | null; } interface MobileRunContextSelectorProps { @@ -202,6 +206,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + codexUsageIndicatorMode, + codexUsageInstanceId, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -236,6 +242,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const showCodexUsage = codexUsageIndicatorMode !== "off" && codexUsageInstanceId !== null; const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -273,6 +280,15 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} /> + {showCodexUsage ? ( + <> + + + + ) : null} )} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca..45c55b1907 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -115,9 +115,15 @@ 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"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { @@ -1257,11 +1263,69 @@ export default function ChatView(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), - ); + const providerInstanceEntries = useMemo( + () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), + [providerStatuses], + ); + const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; + const unlockedSelectedProvider = + resolveProviderDriverKindForInstanceSelection( + providerInstanceEntries, + providerStatuses, + 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) return null; + const lockedInstanceId = activeThreadSessionProviderInstanceId ?? activeThreadModelInstanceId; + if (!lockedInstanceId) return null; + return ( + providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) + ?.continuationGroupKey ?? null + ); + }, [ + activeThreadModelInstanceId, + activeThreadSessionProviderInstanceId, + 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, + fallbackInstanceIds: [ + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + }); + 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 +3706,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,6 +3750,8 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + 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 2c4743de3c..02fe011f10 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,28 @@ 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, + fallbackInstanceIds: [ + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, 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 0000000000..7ae34f6957 --- /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 0000000000..e233c239d5 --- /dev/null +++ b/apps/web/src/components/chat/CodexUsageIndicator.tsx @@ -0,0 +1,130 @@ +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"; + +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 sortCodexUsageWindowsForDisplay( + 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 505be5c73f..759072186c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -99,6 +99,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( @@ -405,6 +411,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"] : []), @@ -429,6 +438,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ isGitWritingModelDirty, settings.autoOpenPlanSidebar, + settings.codexUsageIndicatorMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -460,6 +470,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, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -695,6 +706,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 69ba1faef4..5be76434d3 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 8bfb0e599a..4fcc9ada1d 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 cbb3427b00..f55b3e44aa 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/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 7104f365eb..073f8cb1f9 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,72 @@ 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"); + }); + + 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 6ff0bd1ab9..7d0b8477d6 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,48 @@ export function sortProviderInstanceEntries( return sorted; } +export function resolveSelectedProviderInstanceId(input: { + entries: ReadonlyArray; + candidates: ReadonlyArray; + selectedProvider: ProviderDriverKind; + lockedProvider?: ProviderDriverKind | null | undefined; + lockedContinuationGroupKey?: string | null | undefined; + fallbackInstanceIds?: ReadonlyArray; +}): ProviderInstanceId { + const lockedProvider = input.lockedProvider ?? null; + const lockedContinuationGroupKey = input.lockedContinuationGroupKey ?? 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; + } + + const fallbackProvider = lockedProvider ?? input.selectedProvider; + const byKind = input.entries.find( + (entry) => + entry.enabled && + entry.driverKind === fallbackProvider && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (byKind) return byKind.instanceId; + + const anyEnabled = input.entries.find((entry) => entry.enabled); + const fallbackSelection = input.fallbackInstanceIds?.find((instanceId) => instanceId != null); + return ( + anyEnabled?.instanceId ?? + input.entries[0]?.instanceId ?? + (fallbackSelection + ? ProviderInstanceId.make(fallbackSelection) + : ProviderInstanceId.make("codex")) + ); +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 2f1ca624d9..31af87970d 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. @@ -243,6 +244,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 58894adac1..70e5330432 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, @@ -459,6 +460,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 5032dc4eb4..92d6aef842 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 705621b5da..fdc16586ca 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({ /** @@ -463,6 +472,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 39695fe3b0..b63601370e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,12 +2,48 @@ 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); const encodeServerSettings = Schema.encodeSync(ServerSettings); +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", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98..cde45d624f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,8 +39,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( @@ -476,6 +493,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), diff --git a/packages/shared/package.json b/packages/shared/package.json index c499bf3c6e..80339b5f49 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -87,6 +87,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 0000000000..2c7abc9452 --- /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 0000000000..ee5f0b9836 --- /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; + +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); +}