diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index a3cfec5ed33..0f102f5b02c 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -50,6 +50,7 @@ function makeSecretStorage(available: boolean): DesktopSecretStorage { const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + codexUsageIndicatorMode: "five-hour", confirmThreadArchive: true, confirmThreadDelete: false, diffIgnoreWhitespace: true, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ad5fb59bd1e..c7522908155 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -114,6 +114,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 09252571c37..a8e5c432655 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -304,6 +304,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 487d1a3aac7..c15ec6f6e57 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -113,6 +113,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 4df4fb5d32f..48efafe44cc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -23,6 +23,7 @@ import { it, vi } from "@effect/vitest"; import { Context, Effect, Exit, Fiber, Layer, Option, Queue, Schema, Scope, Stream } from "effect"; 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"; @@ -92,6 +93,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), @@ -130,6 +140,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)); } @@ -159,6 +171,7 @@ function makeRuntimeFactory() { return { factory, + runtimes, get lastRuntime(): FakeCodexRuntime | undefined { return runtimes.at(-1); }, @@ -348,6 +361,60 @@ 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("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.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 5186dc29627..d18393809c1 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, @@ -54,6 +55,7 @@ import { type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { normalizeCodexUsageSnapshot } from "../codexUsage.ts"; const PROVIDER = ProviderDriverKind.make("codex"); @@ -1350,6 +1352,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( @@ -1409,6 +1412,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", { @@ -1644,6 +1660,75 @@ 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) { + 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; + return snapshot; + }, + ); + const stopAll: CodexAdapterShape["stopAll"] = () => Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { concurrency: 1, @@ -1673,6 +1758,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 4f9011fba04..d66fd8c414f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -120,6 +120,10 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly readAccountRateLimits: Effect.Effect< + EffectCodexSchema.V2GetAccountRateLimitsResponse, + CodexSessionRuntimeError + >; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -1286,6 +1290,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 7e771251437..04d56ff5e6f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -8,6 +8,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + CodexUsageSnapshot, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -191,6 +192,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, @@ -207,6 +226,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + ...(provider === CODEX_DRIVER ? { readCodexUsage } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -243,6 +263,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { readThread, rollbackThread, stopAll, + readCodexUsage, }; } @@ -772,6 +793,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 05b4e72ec18..40bdb6b4b88 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 { Cause, Effect, Layer, Option, PubSub, Ref, Schema, SchemaIssue, Stream } from "effect"; @@ -922,6 +922,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) { @@ -1022,6 +1040,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 91e1a9aef97..612bb48ff97 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -159,6 +159,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 dd1be738721..5eed80b325f 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 { Effect } from "effect"; import type { Stream } from "effect"; @@ -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 17a64689b49..80407421ad8 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 { Context } from "effect"; import type { Effect, Stream } from "effect"; @@ -96,6 +97,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..d5070d99d1c --- /dev/null +++ b/apps/server/src/provider/codexUsage.ts @@ -0,0 +1,150 @@ +import { + type CodexUsageSnapshot, + type CodexUsageSnapshotSource, + type CodexUsageWindow, + type ProviderInstanceId, +} from "@t3tools/contracts"; + +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 new Date(value * 1000).toISOString(); +} + +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 ?? new Date().toISOString(), + windows, + rateLimitReachedType: bucket?.rateLimitReachedType ?? null, + source: input.source, + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3827d98bf6..ed1dcb46d54 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,7 @@ import { observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "./provider/Services/ProviderService.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; @@ -752,6 +753,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 48c1d89218b..55887d56e80 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 { @@ -186,6 +200,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + providerStatuses, + codexUsageIndicatorMode, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -196,6 +212,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 @@ -220,6 +239,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; @@ -257,6 +300,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 40cd1b42105..6491b4a97cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3456,6 +3456,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 8fc36d4a32b..695992ceb52 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -95,6 +95,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( @@ -392,6 +398,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"] : []), @@ -414,6 +423,7 @@ export function useSettingsRestore(onRestored?: () => void) { areProviderSettingsDirty, isGitWritingModelDirty, settings.autoOpenPlanSidebar, + settings.codexUsageIndicatorMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -972,6 +982,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 663d618b92e..08e88095eaf 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, @@ -7,6 +8,7 @@ import { import { queryOptions } from "@tanstack/react-query"; import { Option, Schema } from "effect"; import { ensureEnvironmentApi } from "../environmentApi"; +import { ensureLocalApi } from "../localApi"; interface CheckpointDiffQueryInput { environmentId: EnvironmentId | null; @@ -31,6 +33,8 @@ export const providerQueryKeys = { input.ignoreWhitespace, input.cacheScope ?? null, ] as const, + codexUsage: (instanceId: ProviderInstanceId | null) => + ["providers", "codexUsage", instanceId] as const, }; function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { @@ -133,3 +137,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 47a791806a0..c05ddfe1eee 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -535,6 +535,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, diffIgnoreWhitespace: true, @@ -596,6 +597,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); const clientSettings = { autoOpenPlanSidebar: false, + codexUsageIndicatorMode: "five-hour" as const, confirmThreadArchive: true, confirmThreadDelete: false, diffIgnoreWhitespace: true, diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c872a5f1030..1c043835a9a 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -112,6 +112,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { }, server: { getConfig: rpcClient.server.getConfig, + getCodexUsage: rpcClient.server.getCodexUsage, refreshProviders: rpcClient.server.refreshProviders, upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 39abcb2c09c..fa302f178ca 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -107,6 +107,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. @@ -220,6 +221,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 ?? {})), upsertKeybinding: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 35539a86e33..492229d991f 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, ServerProviderUpdatedPayload, @@ -217,6 +218,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 f5e4e7452a8..2d3308f9030 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -17,6 +17,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 f2da90e1907..6d33f2d7a7d 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, @@ -119,6 +120,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverGetCodexUsage: "server.getCodexUsage", serverRefreshProviders: "server.refreshProviders", serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", @@ -145,6 +147,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({ /** @@ -376,6 +385,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, + WsServerGetCodexUsageRpc, WsServerRefreshProvidersRpc, WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d2b73f567a7..8a713de4f0c 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 { Schema } from "effect"; 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 90b9099d177..90c9577ad5e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -28,8 +28,25 @@ export const SidebarProjectGroupingMode = Schema.Literals([ export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +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))), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -450,6 +467,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),