diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..70a3f31eff49 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, + [WS_METHODS.serverConsumeProviderRateLimitReset]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..ef88a7f1944b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -239,6 +239,40 @@ const validationLayer = it.layer( ), ); +const rateLimitLayer = it.layer( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: validationRuntimeFactory.factory, + consumeRateLimitResetCredit: () => Effect.succeed("reset"), + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +rateLimitLayer("CodexAdapterLive rate-limit resets", (it) => { + it.effect("redeems a banked reset through the Codex account API", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const consume = adapter.consumeRateLimitResetCredit; + NodeAssert.ok(consume); + const outcome = yield* consume({ + creditId: "reset-1", + idempotencyKey: "attempt-1", + }); + + NodeAssert.equal(outcome, "reset"); + }), + ); +}); + validationLayer("CodexAdapterLive validation", (it) => { it.effect("returns validation error for non-codex provider on startSession", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..5dc83ade9770 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -14,6 +14,8 @@ import { ProviderDriverKind, type ProviderEvent, ProviderInstanceId, + type ProviderRateLimitResetOutcome, + type ProviderRateLimitResetRequest, type ProviderRuntimeEvent, type ProviderRequestKind, type ThreadTokenUsageSnapshot, @@ -64,6 +66,7 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { consumeCodexRateLimitResetCredit } from "./CodexProvider.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -85,6 +88,9 @@ export interface CodexAdapterLiveOptions { >; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly consumeRateLimitResetCredit?: ( + input: ProviderRateLimitResetRequest, + ) => Effect.Effect; } interface CodexAdapterSessionContext { @@ -1957,6 +1963,37 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const hasSession: CodexAdapterShape["hasSession"] = (threadId) => Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + const consumeRateLimitReset: NonNullable = ( + input, + ) => + (options?.consumeRateLimitResetCredit + ? options.consumeRateLimitResetCredit(input) + : consumeCodexRateLimitResetCredit( + { + binaryPath: codexConfig.binaryPath, + cwd: process.cwd(), + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), + ...(options?.environment ? { environment: options.environment } : {}), + }, + input, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.scoped, + ) + ).pipe( + Effect.timeout("30 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "account/rateLimitResetCredit/consume", + detail: cause.message, + cause, + }), + ), + ); + const stopAll: CodexAdapterShape["stopAll"] = () => Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { concurrency: 1, @@ -1981,6 +2018,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( interruptTurn, readThread, rollbackThread, + consumeRateLimitResetCredit: consumeRateLimitReset, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 7469818dcefd..eb8da0aa6555 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -3,9 +3,50 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, isLegacyCodexModel, + mapCodexRateLimits, mapCodexModelCapabilities, } from "./CodexProvider.ts"; +it("maps Codex windows and banked resets into the provider snapshot", () => { + assert.deepStrictEqual( + mapCodexRateLimits({ + rateLimits: { + primary: { usedPercent: 72, resetsAt: 1_777_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 46, resetsAt: null, windowDurationMins: null }, + }, + rateLimitResetCredits: { + availableCount: 2, + credits: [ + { + id: "reset-1", + resetType: "codexRateLimits", + status: "available", + grantedAt: 1_776_000_000, + expiresAt: 1_778_000_000, + title: "Referral reset", + }, + ], + }, + }), + { + primary: { usedPercent: 72, resetsAt: 1_777_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 46 }, + resetCredits: { + availableCount: 2, + credits: [ + { + id: "reset-1", + status: "available", + grantedAt: 1_776_000_000, + expiresAt: 1_778_000_000, + title: "Referral reset", + }, + ], + }, + }, + ); +}); + it("keeps current Codex models out of legacy models", () => { assert.deepStrictEqual( [ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1d58ca6b18d9..2a530d47819c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,6 +15,9 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import type { CodexSettings, + ProviderRateLimitResetRequest, + ProviderRateLimits, + ProviderRateLimitWindow, ServerProvider, ServerProviderState, ModelCapabilities, @@ -45,6 +48,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: ProviderRateLimits; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -294,6 +298,46 @@ function parseCodexSkillsListResponse( }); } +function mapCodexRateLimitWindow( + window: CodexSchema.V2GetAccountRateLimitsResponse["rateLimits"]["primary"], +): ProviderRateLimitWindow | undefined { + if (!window) return undefined; + return { + usedPercent: window.usedPercent, + ...(window.resetsAt != null ? { resetsAt: window.resetsAt } : {}), + ...(window.windowDurationMins != null ? { windowDurationMins: window.windowDurationMins } : {}), + }; +} + +export function mapCodexRateLimits( + response: CodexSchema.V2GetAccountRateLimitsResponse, +): ProviderRateLimits { + const credits = response.rateLimitResetCredits?.credits?.map((credit) => ({ + id: credit.id, + status: credit.status, + grantedAt: credit.grantedAt, + ...(credit.expiresAt != null ? { expiresAt: credit.expiresAt } : {}), + ...(credit.title ? { title: credit.title } : {}), + ...(credit.description ? { description: credit.description } : {}), + })); + + const primary = mapCodexRateLimitWindow(response.rateLimits.primary); + const secondary = mapCodexRateLimitWindow(response.rateLimits.secondary); + + return { + ...(primary ? { primary } : {}), + ...(secondary ? { secondary } : {}), + ...(response.rateLimitResetCredits + ? { + resetCredits: { + availableCount: response.rateLimitResetCredits.availableCount, + ...(credits ? { credits } : {}), + }, + } + : {}), + }; +} + const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( client: CodexClient.CodexAppServerClient["Service"], ) { @@ -325,18 +369,17 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { +interface CodexAppServerConnectionInput { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; -}) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". - // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. +} + +const connectCodexAppServer = Effect.fn("connectCodexAppServer")(function* ( + input: CodexAppServerConnectionInput, +) { const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { @@ -346,10 +389,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const spawnCommand = yield* resolveSpawnCommand( input.binaryPath, codexAppServerArgs(input.launchArgs), - { - env: environment, - extendEnv: true, - }, + { env: environment, extendEnv: true }, ); const child = yield* spawner .spawn( @@ -374,22 +414,25 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); - - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); + const initialize = yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); - // Extract the version string after the first '/' in userAgent, up to the next space or the end - const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); - const version = versionMatch ? versionMatch[1] : undefined; + const version = initialize.userAgent.match(/\/([^\s]+)/)?.[1]; + return { client, version }; +}); + +export const consumeCodexRateLimitResetCredit = Effect.fn("consumeCodexRateLimitResetCredit")( + function* (connection: CodexAppServerConnectionInput, input: ProviderRateLimitResetRequest) { + const { client } = yield* connectCodexAppServer(connection); + const response = yield* client.request("account/rateLimitResetCredit/consume", input); + return response.outcome; + }, +); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* ( + input: CodexAppServerConnectionInput & { readonly customModels?: ReadonlyArray }, +) { + const { client, version } = yield* connectCodexAppServer(input); const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { @@ -401,18 +444,20 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + client.request("account/rateLimits/read", undefined).pipe(Effect.option), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + ...(Option.isSome(rateLimits) ? { rateLimits: mapCodexRateLimits(rateLimits.value) } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -601,20 +646,23 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: codexSettings.enabled, - checkedAt, - models: snapshot.models, - skills: snapshot.skills, - probe: { - installed: true, - version: snapshot.version ?? null, - status: accountStatus.status, - auth: accountStatus.auth, - ...(accountStatus.message ? { message: accountStatus.message } : {}), - }, - }); + return { + ...buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: codexSettings.enabled, + checkedAt, + models: snapshot.models, + skills: snapshot.skills, + probe: { + installed: true, + version: snapshot.version ?? null, + status: accountStatus.status, + auth: accountStatus.auth, + ...(accountStatus.message ? { message: accountStatus.message } : {}), + }, + }), + ...(snapshot.rateLimits ? { rateLimits: snapshot.rateLimits } : {}), + }; }); // NOTE: the singleton `CodexProviderLive` Layer has been removed as part of diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..a9f23c3731ef 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1146,7 +1146,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("returns the cached provider list when a manual refresh fails", () => + it.effect("keeps a consumed reset successful when its refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); const codexInstanceId = ProviderInstanceId.make("codex"); @@ -1181,7 +1181,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te refresh: Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, }, - adapter: {} as ProviderInstance["adapter"], + adapter: { + ...({} as ProviderInstance["adapter"]), + consumeRateLimitResetCredit: () => Effect.succeed("reset" as const), + }, textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( @@ -1220,6 +1223,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ cachedProvider, ]); + + const consume = registry.consumeRateLimitResetCredit; + assert.isDefined(consume); + assert.strictEqual( + yield* consume(codexInstanceId, { idempotencyKey: "attempt-1" }), + "reset", + ); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..874ac57b8e05 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -26,6 +26,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, type ProviderInstanceId, + type ProviderRateLimitResetRequest, type ServerProvider, type ServerProviderUpdateState, } from "@t3tools/contracts"; @@ -41,6 +42,7 @@ import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; +import { ProviderInstanceNotFoundError, ProviderUnsupportedError } from "../Errors.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { @@ -496,6 +498,23 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const consumeRateLimitResetCredit = Effect.fn("consumeRateLimitResetCredit")(function* ( + instanceId: ProviderInstanceId, + input: ProviderRateLimitResetRequest, + ) { + const instance = yield* instanceRegistry.getInstance(instanceId); + if (!instance) { + return yield* new ProviderInstanceNotFoundError({ instanceId }); + } + const consume = instance.adapter.consumeRateLimitResetCredit; + if (!consume) { + return yield* new ProviderUnsupportedError({ provider: instance.driverKind }); + } + const outcome = yield* consume(input); + yield* refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)); + return outcome; + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { @@ -710,6 +729,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + consumeRateLimitResetCredit, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..83c14ed4b384 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -12,6 +12,8 @@ import type { ProviderApprovalDecision, ProviderDriverKind, ProviderUserInputAnswers, + ProviderRateLimitResetRequest, + ProviderRateLimitResetOutcome, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, @@ -114,6 +116,10 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + readonly consumeRateLimitResetCredit?: ( + input: ProviderRateLimitResetRequest, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381..064451fd8cf2 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -9,12 +9,19 @@ import type { ProviderInstanceId, ProviderDriverKind, + ProviderRateLimitResetOutcome, + ProviderRateLimitResetRequest, ServerProvider, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type { + ProviderAdapterError, + ProviderInstanceNotFoundError, + ProviderUnsupportedError, +} from "../Errors.ts"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; export type ProviderMaintenanceActionKind = "update"; @@ -48,6 +55,14 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + readonly consumeRateLimitResetCredit?: ( + instanceId: ProviderInstanceId, + input: ProviderRateLimitResetRequest, + ) => Effect.Effect< + ProviderRateLimitResetOutcome, + ProviderAdapterError | ProviderInstanceNotFoundError | ProviderUnsupportedError + >; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c5b7e50a8704..3feb6c4eae1b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -50,6 +50,7 @@ import { AssetWorkspaceContextResolutionError, RpcClientId, EnvironmentAuthorizationError, + ServerProviderRateLimitResetError, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -1474,6 +1475,30 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverConsumeProviderRateLimitReset]: (input) => + observeRpcEffect( + WS_METHODS.serverConsumeProviderRateLimitReset, + Effect.gen(function* () { + const consume = providerRegistry.consumeRateLimitResetCredit; + if (!consume) { + return yield* new ServerProviderRateLimitResetError({ + instanceId: input.instanceId, + reason: "This provider does not support banked resets.", + }); + } + const outcome = yield* consume(input.instanceId, input).pipe( + Effect.mapError( + (cause) => + new ServerProviderRateLimitResetError({ + instanceId: input.instanceId, + reason: cause.message, + }), + ), + ); + return { outcome }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/usage/CodexLimitsPanel.test.tsx b/apps/web/src/components/usage/CodexLimitsPanel.test.tsx new file mode 100644 index 000000000000..de857700e417 --- /dev/null +++ b/apps/web/src/components/usage/CodexLimitsPanel.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { CodexLimitsPanel } from "./CodexLimitsPanel"; + +describe("CodexLimitsPanel", () => { + it("shows both limit windows and the next banked reset expiry", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain("5-hour limit"); + expect(html).toContain("72% used"); + expect(html).toContain("Weekly limit"); + expect(html).toContain("2 banked resets"); + expect(html).toContain("Use reset"); + expect(html).toContain("Next expires"); + }); + + it("does not offer an unavailable reset", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain("No banked resets"); + expect(html).not.toContain("Use reset"); + }); +}); diff --git a/apps/web/src/components/usage/CodexLimitsPanel.tsx b/apps/web/src/components/usage/CodexLimitsPanel.tsx new file mode 100644 index 000000000000..75aee0671654 --- /dev/null +++ b/apps/web/src/components/usage/CodexLimitsPanel.tsx @@ -0,0 +1,104 @@ +import type { ProviderRateLimits } from "@t3tools/contracts"; +import { RotateCcwIcon } from "lucide-react"; + +import { Button } from "../ui/button"; + +interface CodexLimitsPanelProps { + readonly label: string; + readonly now?: number; + readonly rateLimits: ProviderRateLimits; + readonly onUseReset: () => void; +} + +function formatDate(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format( + timestamp * 1000, + ); +} + +function formatResetTime(timestamp: number | undefined, now: number): string | null { + if (timestamp === undefined) return null; + const minutes = Math.max(0, Math.round((timestamp * 1000 - now) / 60_000)); + if (minutes < 60) return `Resets in ${minutes}m`; + if (minutes < 24 * 60) return `Resets in ${Math.floor(minutes / 60)}h ${minutes % 60}m`; + return `Resets ${formatDate(timestamp)}`; +} + +function LimitWindow({ + label, + now, + window, +}: { + readonly label: string; + readonly now: number; + readonly window: NonNullable; +}) { + const usedPercent = Math.round(window.usedPercent); + const resetTime = formatResetTime(window.resetsAt, now); + + return ( +
+
+ {label} + {usedPercent}% used +
+
+
+
+ {resetTime ? {resetTime} : null} +
+ ); +} + +export function CodexLimitsPanel({ + label, + now = Date.now(), + rateLimits, + onUseReset, +}: CodexLimitsPanelProps) { + const resetCredits = rateLimits.resetCredits; + const expiresAt = resetCredits?.credits + ?.flatMap((credit) => + credit.status === "available" && credit.expiresAt !== undefined ? [credit.expiresAt] : [], + ) + .sort((left, right) => left - right)[0]; + const availableCount = resetCredits?.availableCount ?? 0; + + return ( +
+
+

{label} limits

+
+ {rateLimits.primary ? ( + + ) : null} + {rateLimits.secondary ? ( + + ) : null} +
+
+ +
+ +
+

+ {availableCount === 0 + ? "No banked resets" + : `${availableCount} banked reset${availableCount === 1 ? "" : "s"}`} +

+ {expiresAt ? ( +

Next expires {formatDate(expiresAt)}

+ ) : null} +
+ {availableCount > 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3c99271c1b2b..b7cd5a3de4f9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,11 +1,20 @@ -import type { UsageProviderKind } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentId, + ProviderInstanceId, + ProviderRateLimits, + UsageProviderKind, +} from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; -import { cn } from "../../lib/utils"; +import { cn, randomUUID } from "../../lib/utils"; +import { environmentPresentations } from "../../state/presentation"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { enumerateDays, @@ -19,10 +28,20 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; +import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { WorkspaceBreadcrumb, @@ -31,9 +50,25 @@ import { } from "../WorkspaceBreadcrumb"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { CodexLimitsPanel } from "./CodexLimitsPanel"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; +interface CodexLimitsTarget { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly label: string; + readonly creditId?: string; + readonly rateLimits: ProviderRateLimits; +} + +const RESET_OUTCOME_TOAST = { + reset: { type: "success", title: "Codex limits reset" }, + nothingToReset: { type: "warning", title: "Codex limits did not need a reset" }, + noCredit: { type: "warning", title: "No banked reset is available" }, + alreadyRedeemed: { type: "warning", title: "This banked reset was already used" }, +} as const; + const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, { days: 7, label: "7 days" }, @@ -48,6 +83,14 @@ export function UsagePage() { })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [resetTarget, setResetTarget] = useState< + (CodexLimitsTarget & { readonly idempotencyKey: string }) | null + >(null); + const [usingReset, setUsingReset] = useState(false); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const consumeReset = useAtomCommand(serverEnvironment.consumeProviderRateLimitReset, { + reportFailure: false, + }); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -75,6 +118,52 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); + const codexLimits = useMemo(() => { + const targets: CodexLimitsTarget[] = []; + for (const [environmentId, presentation] of presentations) { + for (const provider of presentation.serverConfig?.providers ?? []) { + if (provider.driver !== "codex" || !provider.rateLimits) continue; + const availableCredit = provider.rateLimits.resetCredits?.credits?.find( + (credit) => credit.status === "available", + ); + targets.push({ + environmentId, + instanceId: provider.instanceId, + label: (provider.displayName ?? "Codex") + " · " + presentation.entry.target.label, + ...(availableCredit ? { creditId: availableCredit.id } : {}), + rateLimits: provider.rateLimits, + }); + } + } + return targets; + }, [presentations]); + + const useBankedReset = useCallback(async () => { + if (!resetTarget || usingReset) return; + setUsingReset(true); + const result = await consumeReset({ + environmentId: resetTarget.environmentId, + input: { + instanceId: resetTarget.instanceId, + idempotencyKey: resetTarget.idempotencyKey, + ...(resetTarget.creditId ? { creditId: resetTarget.creditId } : {}), + }, + }); + setUsingReset(false); + + if (result._tag !== "Success") { + toastManager.add({ + type: "error", + title: "Could not use banked reset", + description: "Codex did not accept the reset. Try again.", + }); + return; + } + + setResetTarget(null); + toastManager.add(RESET_OUTCOME_TOAST[result.value.outcome]); + }, [consumeReset, resetTarget, usingReset]); + const selectWindow = (days: number) => { setWindowSelection({ days, @@ -210,6 +299,15 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> + {codexLimits.map((target) => ( + setResetTarget({ ...target, idempotencyKey: randomUUID() })} + /> + ))} +
@@ -431,6 +529,31 @@ export function UsagePage() {
+ + { + if (!open && !usingReset) setResetTarget(null); + }} + > + + + Use a banked reset? + + This uses one reset for {resetTarget?.label}. It resets both limits and moves your + weekly reset date. + + + + } disabled={usingReset}> + Cancel + + + + + ); } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..a0527732d23a 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -731,6 +731,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + consumeProviderRateLimitReset: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:consume-provider-rate-limit-reset", + tag: WS_METHODS.serverConsumeProviderRateLimitReset, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.instanceId}`, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..0061bde65683 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -154,6 +154,9 @@ import { import { ServerConfigStreamEvent, ServerConfig, + ServerProviderRateLimitResetError, + ServerProviderRateLimitResetInput, + ServerProviderRateLimitResetResult, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -255,6 +258,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverConsumeProviderRateLimitReset: "server.consumeProviderRateLimitReset", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -353,6 +357,15 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerConsumeProviderRateLimitResetRpc = Rpc.make( + WS_METHODS.serverConsumeProviderRateLimitReset, + { + payload: ServerProviderRateLimitResetInput, + success: ServerProviderRateLimitResetResult, + error: Schema.Union([ServerProviderRateLimitResetError, EnvironmentAuthorizationError]), + }, +); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -986,6 +999,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerConsumeProviderRateLimitResetRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..02a1083b52af 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -115,6 +115,31 @@ describe("ServerProvider", () => { expect(parsed.models[0]?.isLegacy).toBe(true); }); + + it("decodes Codex rate limits and banked resets", () => { + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + rateLimits: { + primary: { usedPercent: 72, resetsAt: 1_777_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 46, resetsAt: 1_777_604_800, windowDurationMins: 10_080 }, + resetCredits: { + availableCount: 1, + credits: [ + { + id: "reset-1", + status: "available", + grantedAt: 1_776_000_000, + expiresAt: 1_778_000_000, + title: "Referral reset", + }, + ], + }, + }, + }); + + expect(parsed.rateLimits?.resetCredits?.availableCount).toBe(1); + expect(parsed.rateLimits?.resetCredits?.credits?.[0]?.expiresAt).toBe(1_778_000_000); + }); }); describe("server config forward compatibility", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..17a58db3831c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -158,6 +158,73 @@ export const ServerProviderUpdateState = Schema.Struct({ }); export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; +export const ProviderRateLimitWindow = Schema.Struct({ + usedPercent: Schema.Number, + resetsAt: Schema.optional(Schema.Number), + windowDurationMins: Schema.optional(Schema.Number), +}); +export type ProviderRateLimitWindow = typeof ProviderRateLimitWindow.Type; + +export const ProviderRateLimitResetCredit = Schema.Struct({ + id: TrimmedNonEmptyString, + status: Schema.Literals(["available", "redeeming", "redeemed", "unknown"]), + grantedAt: Schema.Number, + expiresAt: Schema.optional(Schema.Number), + title: Schema.optional(TrimmedNonEmptyString), + description: Schema.optional(TrimmedNonEmptyString), +}); +export type ProviderRateLimitResetCredit = typeof ProviderRateLimitResetCredit.Type; + +export const ProviderRateLimits = Schema.Struct({ + primary: Schema.optional(ProviderRateLimitWindow), + secondary: Schema.optional(ProviderRateLimitWindow), + resetCredits: Schema.optional( + Schema.Struct({ + availableCount: NonNegativeInt, + credits: Schema.optional(Schema.Array(ProviderRateLimitResetCredit)), + }), + ), +}); +export type ProviderRateLimits = typeof ProviderRateLimits.Type; + +export const ProviderRateLimitResetOutcome = Schema.Literals([ + "reset", + "nothingToReset", + "noCredit", + "alreadyRedeemed", +]); +export type ProviderRateLimitResetOutcome = typeof ProviderRateLimitResetOutcome.Type; + +export const ProviderRateLimitResetRequest = Schema.Struct({ + creditId: Schema.optionalKey(TrimmedNonEmptyString), + idempotencyKey: TrimmedNonEmptyString, +}); +export type ProviderRateLimitResetRequest = typeof ProviderRateLimitResetRequest.Type; + +export const ServerProviderRateLimitResetInput = Schema.Struct({ + instanceId: ProviderInstanceId, + creditId: Schema.optionalKey(TrimmedNonEmptyString), + idempotencyKey: TrimmedNonEmptyString, +}); +export type ServerProviderRateLimitResetInput = typeof ServerProviderRateLimitResetInput.Type; + +export const ServerProviderRateLimitResetResult = Schema.Struct({ + outcome: ProviderRateLimitResetOutcome, +}); +export type ServerProviderRateLimitResetResult = typeof ServerProviderRateLimitResetResult.Type; + +export class ServerProviderRateLimitResetError extends Schema.TaggedErrorClass()( + "ServerProviderRateLimitResetError", + { + instanceId: ProviderInstanceId, + reason: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Could not use a banked reset for provider '${this.instanceId}': ${this.reason}`; + } +} + export const ServerProvider = Schema.Struct({ // Routing key for the configured instance this snapshot represents. This // is the only stable identity consumers may use for provider routing. @@ -194,6 +261,7 @@ export const ServerProvider = Schema.Struct({ skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), + rateLimits: Schema.optionalKey(ProviderRateLimits), }); export type ServerProvider = typeof ServerProvider.Type;