authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 19:14:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 19:14:29-07:00
log1641b4aba58ab495cad7a9800b173af3416e16dd
treee782ad752e986dddc53215a69601514d7ced2016
parent232de5e8aac61e6f3de4bc61d10746167db9e905
signature Signed by PGP key B5690EEEBB952194

feat(usage): redeem Codex reset credits from the Limits tab (#9534)

Follow-up to #9507, carrying over the reset-credit redemption from #9421. Codex grants a reset credit when it has rate-limited an account unfairly (`"Thanks for using Codex! You've been granted one free rate limit reset."`). Redeeming one clears the current 5h/weekly windows. The Limits tab now shows how many are banked and when the next expires, with a confirmed **Use a reset credit** action. ## How it works - `ServerProviderUsageLimits.resetCredits` carries the count and soonest expiry; the Codex probe reads it from the same `account/rateLimits/read` it already makes. - `ProviderInstance.consumeResetCredit` is a new optional hook — account-level, so it sits beside `refreshModels` rather than on the thread-routed adapter. The Codex driver implements it over a short-lived app-server (via `withCodexAppServerClient`, factored out of the status and skills probes which duplicated the setup), then re-probes. - Single-flight per instance with one idempotency key kept until Codex reports an outcome, so a retry after a timeout does not open a second attempt. - New `provider.consumeResetCredit` RPC under the operate scope; the outcome (`reset` / `nothingToReset` / `noCredit` / `alreadyRedeemed`) is shown inline. Only Codex reports credits today. A provider without the hook gets a clear "does not bank reset credits" error; one without credits shows nothing. ## Screenshots The local Codex row with one banked credit (the same account via the CLIProxyAPI hub above it shows no credit, as expected — the hub does not relay them): ![Limits tab with the Codex row showing "1 reset credit banked · next expires in 16d 22h" and a "Use a reset credit" button](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/2897db5e357f12b3/credits-tab.png) Close-up of the row: ![Codex row: Weekly 25% used bar, then "1 reset credit banked · next expires in 16d 22h" with the "Use a reset credit" button](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/0e80f9114cab8f36/credits-row.png) Clicking it opens the confirmation; nothing is sent until **Use credit**: ![Confirm dialog: "Use a reset credit? This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone." with Cancel and Use credit](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/dc728580889ef1fe/credits-confirm.png) ## Verification - Mapper tests for the credit summary; provider, contract, and Usage page suites pass; typecheck clean. - Verified against a real Codex Pro account holding one credit: summary and expiry render, the confirm dialog opens. **Not redeemed** — that would spend the credit. Written by Claude Fable 5 via Claude Code; design and single-flight approach from @StiensWout's #9421. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Redemption spends real account credits over a new RPC; correctness depends on per-account locking and idempotency, though disabled instances and non-Codex providers are rejected explicitly. > > **Overview** > Adds **end-to-end redemption of banked Codex rate-limit reset credits** from the Limits UI on web and mobile, backed by a new operate-scoped `provider.consumeResetCredit` RPC. > > **Contracts and server:** `ServerProviderUsageLimits` can include `resetCredits` (count + next expiry). Codex probes attach that from `account/rateLimits/read`. Optional `ProviderInstance.consumeResetCredit` is implemented for Codex via a scoped app-server call to `account/rateLimitResetCredit/consume`, then a limits refresh. `CodexResetCreditCoordinator` serializes redemptions per Codex account directory, reuses one idempotency key until Codex returns an outcome, and times out hung requests. `withCodexAppServerClient` is extracted so status, skills, and redemption share the same short-lived app-server setup. > > **Clients:** Limits rows show banked credits and a confirmed **Use a reset credit** action that calls `serverEnvironment.consumeResetCredit` and surfaces outcomes (`reset`, `nothingToReset`, etc.) or errors. > > **Web usage sources (same PR):** Adding/removing CLIProxyAPI hubs and the add dialog target a **selected connected environment** (with picker when several are connected), gated by operate access—not only the primary environment. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 98f32e60852dca1d38c94335b3ed7aac8c4b895e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com>

18 files changed, 876 insertions(+), 115 deletions(-)

apps/mobile/src/features/usage/UsageLimitsSection.tsx+110-4
......@@ -1,6 +1,10 @@
11import { useAtomValue } from "@effect/atom-react";
22import type {
3 EnvironmentId,
4 ProviderConsumeResetCreditOutcome,
5 ProviderInstanceId,
36 ServerProvider,
7 ServerProviderResetCredits,
48 ServerProviderUsageWindow,
59 UsageLimitSourceAccount,
610} from "@t3tools/contracts";
......@@ -8,16 +12,19 @@ import {
812 collectLimitSources,
913 collectLimitsGroups,
1014 elapsedShare,
15 formatDuration,
1116 formatResetsIn,
1217 limitsNotice,
1318 paceOf,
1419 providerLimitsLabel,
1520} from "@t3tools/shared/usageLimits";
16import { useState } from "react";
17import { View } from "react-native";
21import { type ReactNode, useState } from "react";
22import { Alert, Pressable, View } from "react-native";
1823
1924import { AppText as Text } from "../../components/AppText";
2025import { environmentPresentations } from "../../state/presentation";
26import { serverEnvironment } from "../../state/server";
27import { useAtomCommand } from "../../state/use-atom-command";
2128import { SettingsSection } from "../settings/components/SettingsSection";
2229
2330const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
......@@ -71,6 +78,7 @@ function AccountLimits(props: {
7178 readonly limits: ServerProvider["usageLimits"];
7279 readonly now: number;
7380 readonly first: boolean;
81 readonly footer?: ReactNode;
7482}) {
7583 const { limits, now } = props;
7684 if (!limits) return null;
......@@ -88,23 +96,120 @@ function AccountLimits(props: {
8896 ) : (
8997 limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />)
9098 )}
99 {props.footer}
100 </View>
101 );
102}
103
104const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
105 reset: "Reset applied. Your windows have cleared.",
106 nothingToReset: "Nothing to reset right now.",
107 noCredit: "No reset credit left.",
108 alreadyRedeemed: "That credit was already redeemed.",
109};
110
111/**
112 * Banked reset credits with a confirmed redeem action. Redeeming spends a
113 * credit the provider granted the user, so it goes through the native
114 * confirm alert rather than firing on a bare tap.
115 */
116function ResetCredits(props: {
117 readonly environmentId: EnvironmentId;
118 readonly instanceId: ProviderInstanceId;
119 readonly credits: ServerProviderResetCredits;
120 readonly now: number;
121}) {
122 const { environmentId, instanceId, credits, now } = props;
123 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
124 reportFailure: false,
125 });
126 const [busy, setBusy] = useState(false);
127 const [status, setStatus] = useState<string | null>(null);
128 if (credits.availableCount === 0 && status === null) return null;
129
130 const expiresIn = credits.nextExpiresAt
131 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
132 : null;
133 const summary =
134 credits.availableCount === 0
135 ? "No reset credits banked"
136 : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
137 expiresIn ? ` · next expires in ${expiresIn}` : ""
138 }`;
139
140 const redeem = async () => {
141 setBusy(true);
142 setStatus(null);
143 const result = await consume({ environmentId, input: { instanceId } });
144 setBusy(false);
145 if (result._tag === "Success") {
146 setStatus(OUTCOME_TEXT[result.value.outcome]);
147 return;
148 }
149 setStatus(
150 "error" in result.cause && result.cause.error instanceof Error
151 ? result.cause.error.message
152 : "Could not use the reset credit.",
153 );
154 };
155
156 const confirm = () => {
157 Alert.alert(
158 "Use a reset credit?",
159 "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
160 [
161 { text: "Cancel", style: "cancel" },
162 { text: "Use credit", onPress: () => void redeem() },
163 ],
164 );
165 };
166
167 return (
168 <View className="gap-2">
169 <Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text>
170 {credits.availableCount > 0 ? (
171 <Pressable
172 accessibilityRole="button"
173 accessibilityState={{ disabled: busy }}
174 disabled={busy}
175 onPress={confirm}
176 className="self-start rounded-full bg-subtle-strong px-3 py-1.5"
177 >
178 <Text className="text-sm font-t3-medium text-foreground">
179 {busy ? "Using credit…" : "Use a reset credit"}
180 </Text>
181 </Pressable>
182 ) : null}
183 {status ? <Text className="text-sm text-foreground">{status}</Text> : null}
91184 </View>
92185 );
93186}
94187
95188function ProviderLimits(props: {
96189 readonly provider: ServerProvider;
190 readonly environmentId: EnvironmentId;
97191 readonly now: number;
98192 readonly first: boolean;
99193}) {
100 const { provider } = props;
194 const { provider, environmentId, now } = props;
195 const credits = provider.usageLimits?.resetCredits;
101196 return (
102197 <AccountLimits
103198 label={providerLimitsLabel(provider, () => undefined)}
104199 detail={provider.auth.label}
105200 limits={provider.usageLimits}
106 now={props.now}
201 now={now}
107202 first={props.first}
203 footer={
204 credits ? (
205 <ResetCredits
206 environmentId={environmentId}
207 instanceId={provider.instanceId}
208 credits={credits}
209 now={now}
210 />
211 ) : undefined
212 }
108213 />
109214 );
110215}
......@@ -172,6 +277,7 @@ export function UsageLimitsSection() {
172277 <ProviderLimits
173278 key={provider.instanceId}
174279 provider={provider}
280 environmentId={group.environmentId}
175281 now={now}
176282 first={index === 0}
177283 />
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -34,6 +34,7 @@ export const RPC_REQUIRED_SCOPES = {
3434 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
3535 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
3636 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
37 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
3738 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
3839 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
3940 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
apps/server/src/provider/Drivers/CodexDriver.ts+70
......@@ -36,10 +36,15 @@ import { ServerConfig } from "../../config.ts";
3636import { ServerSettingsService } from "../../serverSettings.ts";
3737import { ProviderDriverError } from "../Errors.ts";
3838import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
39import {
40 CODEX_RESET_CREDIT_TIMEOUT,
41 CodexResetCreditCoordinator,
42} from "../Layers/codexResetCredit.ts";
3943import {
4044 checkCodexProviderStatus,
4145 makePendingCodexProvider,
4246 probeCodexSkillsForCwd,
47 withCodexAppServerClient,
4348} from "../Layers/CodexProvider.ts";
4449import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
4550import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
......@@ -81,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
8186export type CodexDriverEnv =
8287 | BackgroundPolicy.BackgroundPolicy
8388 | ChildProcessSpawner.ChildProcessSpawner
89 | CodexResetCreditCoordinator
8490 | Crypto.Crypto
8591 | FileSystem.FileSystem
8692 | HttpClient.HttpClient
......@@ -101,6 +107,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
101107 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
102108 Effect.gen(function* () {
103109 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
110 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;
104111 const httpClient = yield* HttpClient.HttpClient;
105112 const serverSettings = yield* ServerSettingsService;
106113 const eventLoggers = yield* ProviderEventLoggers;
......@@ -229,6 +236,68 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
229236 ),
230237 );
231238
239 // Redemption spends something on the user's account. It serialises on
240 // the account (instances sharing a Codex home share the credit), keeps
241 // one idempotency key until Codex reports an outcome, and is bounded so
242 // a hung app-server cannot hold the account lock.
243 // Keyed on the directory holding auth.json: an auth-overlay instance has
244 // its own account under `effectiveHomePath`, while plain instances share
245 // the common home. The continuation key would conflate the two.
246 const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath;
247 const consumeResetCredit: NonNullable<ProviderInstance["consumeResetCredit"]> = () =>
248 resetCreditCoordinator
249 .redeem(accountKey, (idempotencyKey) =>
250 Effect.gen(function* () {
251 const { client } = yield* withCodexAppServerClient({
252 binaryPath: effectiveConfig.binaryPath,
253 homePath: effectiveConfig.homePath,
254 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
255 // Account-level request; any directory serves, same as the status probe.
256 cwd: process.cwd(),
257 environment: processEnv,
258 });
259 const response = yield* client.request("account/rateLimitResetCredit/consume", {
260 idempotencyKey,
261 });
262 return response.outcome;
263 }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)),
264 )
265 .pipe(
266 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
267 Effect.mapError(
268 (cause) =>
269 new ProviderDriverError({
270 driver: DRIVER_KIND,
271 instanceId,
272 detail: "Codex could not redeem the reset credit.",
273 cause,
274 }),
275 ),
276 // The windows just changed; re-probe so the snapshot says so. A
277 // failed probe republishes the pre-redemption limits rather than
278 // marking them failed, so "confirmed" means `checkedAt` moved
279 // past what was published before the redemption started.
280 Effect.tap(() =>
281 Effect.gen(function* () {
282 const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt;
283 const refreshed = yield* snapshot.refresh;
284 const after = refreshed.usageLimits?.checkedAt;
285 if (
286 after === undefined ||
287 after === before ||
288 refreshed.usageLimits?.unavailable?.reason === "probeFailed"
289 ) {
290 return yield* new ProviderDriverError({
291 driver: DRIVER_KIND,
292 instanceId,
293 detail:
294 "The reset was applied, but Codex could not confirm the new limits. Refresh to check.",
295 });
296 }
297 }),
298 ),
299 );
300
232301 return {
233302 instanceId,
234303 driverKind: DRIVER_KIND,
......@@ -238,6 +307,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
238307 enabled,
239308 snapshot,
240309 snapshotForCwd,
310 consumeResetCredit,
241311 adapter,
242312 textGeneration,
243313 } satisfies ProviderInstance;
apps/server/src/provider/Layers/CodexProvider.ts+39-59
......@@ -39,13 +39,17 @@ import {
3939 codexRateLimitsFailureMessage,
4040 codexRateLimitsToLimits,
4141 type CodexRateLimitSnapshot,
42 type CodexResetCreditsSummary,
4243} from "./codexUsageLimits.ts";
4344import packageJson from "../../../package.json" with { type: "json" };
4445const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
4546const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
4647
4748type CodexRateLimitsProbe =
48 | { readonly snapshot: CodexRateLimitSnapshot }
49 | {
50 readonly snapshot: CodexRateLimitSnapshot;
51 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
52 }
4953 | { readonly failure: string };
5054
5155const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
......@@ -334,13 +338,18 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
334338 };
335339}
336340
337const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
341/**
342 * Spawns a short-lived `codex app-server`, runs the initialize handshake, and
343 * hands the caller a connected client. Scoped: the process is killed when
344 * the caller's scope closes. Shared by the status probe, the skills probe,
345 * and account-level requests such as reset-credit redemption.
346 */
347export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
338348 readonly binaryPath: string;
339 readonly homePath?: string;
340 readonly launchArgs?: string;
349 readonly homePath?: string | undefined;
350 readonly launchArgs?: string | undefined;
341351 readonly cwd: string;
342 readonly customModels?: ReadonlyArray<string>;
343 readonly environment?: NodeJS.ProcessEnv;
352 readonly environment?: NodeJS.ProcessEnv | undefined;
344353}) {
345354 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,
346355 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
......@@ -355,10 +364,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
355364 const spawnCommand = yield* resolveSpawnCommand(
356365 input.binaryPath,
357366 codexAppServerArgs(input.launchArgs),
358 {
359 env: environment,
360 extendEnv: true,
361 },
367 { env: environment, extendEnv: true },
362368 );
363369 const child = yield* spawner
364370 .spawn(
......@@ -383,18 +389,20 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
383389 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
384390 Effect.provide(clientContext),
385391 );
386
387 const initialize = yield* client.request("initialize", {
388 clientInfo: {
389 name: "t3code_desktop",
390 title: "T3 Code Desktop",
391 version: "0.1.0",
392 },
393 capabilities: {
394 experimentalApi: true,
395 },
396 });
392 const initialize = yield* client.request("initialize", buildCodexInitializeParams());
397393 yield* client.notify("initialized", undefined);
394 return { client, initialize };
395});
396
397const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
398 readonly binaryPath: string;
399 readonly homePath?: string;
400 readonly launchArgs?: string;
401 readonly cwd: string;
402 readonly customModels?: ReadonlyArray<string>;
403 readonly environment?: NodeJS.ProcessEnv;
404}) {
405 const { client, initialize } = yield* withCodexAppServerClient(input);
398406
399407 // Extract the version string after the first '/' in userAgent, up to the next space or the end
400408 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
......@@ -419,7 +427,10 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
419427 // Usage is an enrichment: a failure or a slow answer degrades to "no
420428 // usage this probe" rather than costing the account and models.
421429 client.request("account/rateLimits/read", undefined).pipe(
422 Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits })),
430 Effect.map((response): CodexRateLimitsProbe => ({
431 snapshot: response.rateLimits,
432 resetCredits: response.rateLimitResetCredits,
433 })),
423434 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
424435 Effect.map(
425436 Option.getOrElse((): CodexRateLimitsProbe => ({
......@@ -454,42 +465,7 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
454465 readonly cwd: string;
455466 readonly environment?: NodeJS.ProcessEnv;
456467}) {
457 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
458 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
459 const environment = {
460 ...input.environment,
461 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
462 };
463 const spawnCommand = yield* resolveSpawnCommand(
464 input.binaryPath,
465 codexAppServerArgs(input.launchArgs),
466 { env: environment, extendEnv: true },
467 );
468 const child = yield* spawner
469 .spawn(
470 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
471 cwd: input.cwd,
472 env: environment,
473 extendEnv: true,
474 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
475 shell: spawnCommand.shell,
476 }),
477 )
478 .pipe(
479 Effect.mapError(
480 (cause) =>
481 new CodexErrors.CodexAppServerSpawnError({
482 command: `${input.binaryPath} app-server`,
483 cause,
484 }),
485 ),
486 );
487 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
488 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
489 Effect.provide(clientContext),
490 );
491 yield* client.request("initialize", buildCodexInitializeParams());
492 yield* client.notify("initialized", undefined);
468 const { client } = yield* withCodexAppServerClient(input);
493469 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
494470 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
495471});
......@@ -682,7 +658,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
682658 reason: "probeFailed",
683659 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
684660 })
685 : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, checkedAt });
661 : codexRateLimitsToLimits({
662 snapshot: snapshot.rateLimits.snapshot,
663 resetCredits: snapshot.rateLimits.resetCredits,
664 checkedAt,
665 });
686666
687667 return buildServerProvider({
688668 presentation: CODEX_PRESENTATION,
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+3
......@@ -52,6 +52,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";
5252import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
5353import * as ModelManifest from "../ModelManifest.ts";
5454import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
55import * as CodexResetCredit from "./codexResetCredit.ts";
5556import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
5657import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
5758
......@@ -152,6 +153,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
152153 Layer.provideMerge(TestHttpClientLive),
153154 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
154155 Layer.provideMerge(ModelManifest.layerTest),
156 Layer.provideMerge(CodexResetCredit.layerTest),
155157 );
156158
157159 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
......@@ -321,6 +323,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
321323 Layer.provideMerge(TestHttpClientLive),
322324 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
323325 Layer.provideMerge(ModelManifest.layerTest),
326 Layer.provideMerge(CodexResetCredit.layerTest),
324327 );
325328
326329 it.live("boots one instance of every shipped driver from a single config map", () =>
apps/server/src/provider/Layers/ProviderRegistry.test.ts+6
......@@ -37,6 +37,7 @@ import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
3737import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3838import { AntigravityInstallation } from "../AntigravityInstallation.ts";
3939import * as ModelManifest from "../ModelManifest.ts";
40import * as CodexResetCredit from "./codexResetCredit.ts";
4041import * as OpenCodeRuntime from "../opencodeRuntime.ts";
4142import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
4243import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
......@@ -2007,6 +2008,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20072008 ),
20082009 ),
20092010 Layer.provideMerge(ModelManifest.layerTest),
2011 Layer.provideMerge(CodexResetCredit.layerTest),
20102012 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
20112013 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
20122014 // NO spawner mock — `ChildProcessSpawner` is supplied by the
......@@ -2107,6 +2109,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
21072109 ),
21082110 ),
21092111 Layer.provideMerge(ModelManifest.layerTest),
2112 Layer.provideMerge(CodexResetCredit.layerTest),
21102113 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
21112114 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
21122115 ChildProcessSpawner.make((command) => {
......@@ -2222,6 +2225,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
22222225 ),
22232226 ),
22242227 Layer.provideMerge(ModelManifest.layerTest),
2228 Layer.provideMerge(CodexResetCredit.layerTest),
22252229 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
22262230 Layer.provideMerge(NodeServices.layer),
22272231 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
......@@ -2283,6 +2287,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
22832287 ),
22842288 ),
22852289 Layer.provideMerge(ModelManifest.layerTest),
2290 Layer.provideMerge(CodexResetCredit.layerTest),
2291 Layer.provideMerge(CodexResetCredit.layerTest),
22862292 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
22872293 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
22882294 Layer.provideMerge(
apps/server/src/provider/Layers/codexResetCredit.test.ts created+86
......@@ -0,0 +1,86 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Ref from "effect/Ref";
6
7import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts";
8
9describe("CodexResetCreditCoordinator", () => {
10 it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () =>
11 Effect.gen(function* () {
12 const { redeem } = yield* CodexResetCreditCoordinator;
13 const keys = yield* Ref.make<ReadonlyArray<string>>([]);
14 const attempts = yield* Ref.make(0);
15 const consume = (key: string) =>
16 Effect.gen(function* () {
17 yield* Ref.update(keys, (seen) => [...seen, key]);
18 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1);
19 if (attempt === 1) return yield* Effect.fail("timed out" as const);
20 return "reset" as const;
21 });
22
23 const first = yield* redeem("acct", consume).pipe(Effect.result);
24 assert.isTrue(first._tag === "Failure");
25 const second = yield* redeem("acct", consume);
26 assert.strictEqual(second, "reset");
27 // A fresh redemption after success must be a fresh attempt.
28 yield* redeem("acct", consume);
29
30 const seen = yield* Ref.get(keys);
31 assert.strictEqual(seen.length, 3);
32 assert.strictEqual(seen[0], seen[1]);
33 assert.notStrictEqual(seen[1], seen[2]);
34 }).pipe(Effect.provide(layerTest)),
35 );
36
37 it.effect("serialises concurrent redemptions on the same account, not per caller", () =>
38 Effect.gen(function* () {
39 const { redeem } = yield* CodexResetCreditCoordinator;
40 const release = yield* Deferred.make<void>();
41 const inFlight = yield* Ref.make(0);
42 const peak = yield* Ref.make(0);
43 const consume = () =>
44 Effect.gen(function* () {
45 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
46 yield* Ref.update(peak, (p) => Math.max(p, now));
47 yield* Deferred.await(release);
48 yield* Ref.update(inFlight, (n) => n - 1);
49 return "reset" as const;
50 });
51
52 // Two instances of the same account redeem at once.
53 const a = yield* redeem("acct", consume).pipe(Effect.forkChild);
54 const b = yield* redeem("acct", consume).pipe(Effect.forkChild);
55 yield* Effect.yieldNow;
56 yield* Deferred.succeed(release, undefined);
57 yield* Fiber.join(a);
58 yield* Fiber.join(b);
59
60 assert.strictEqual(yield* Ref.get(peak), 1);
61 }).pipe(Effect.provide(layerTest)),
62 );
63
64 it.effect("keeps different accounts independent", () =>
65 Effect.gen(function* () {
66 const { redeem } = yield* CodexResetCreditCoordinator;
67 const release = yield* Deferred.make<void>();
68 const peak = yield* Ref.make(0);
69 const inFlight = yield* Ref.make(0);
70 const consume = () =>
71 Effect.gen(function* () {
72 const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1);
73 yield* Ref.update(peak, (p) => Math.max(p, now));
74 yield* Deferred.await(release);
75 return "reset" as const;
76 });
77 const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild);
78 const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild);
79 yield* Effect.yieldNow;
80 yield* Deferred.succeed(release, undefined);
81 yield* Fiber.join(a);
82 yield* Fiber.join(b);
83 assert.strictEqual(yield* Ref.get(peak), 2);
84 }).pipe(Effect.provide(layerTest)),
85 );
86});
apps/server/src/provider/Layers/codexResetCredit.ts created+113
......@@ -0,0 +1,113 @@
1/**
2 * Redeeming a Codex reset credit is an account-level action: instances that
3 * share the directory holding `auth.json` share the credit, so their
4 * redemptions must serialise on that directory, not the instance. This
5 * service keeps one lock and one pending idempotency key per account key so
6 * overlapping confirmations from any instance queue rather than spending two
7 * credits, and a retry after a timeout re-sends the same attempt.
8 *
9 * @module provider/Layers/codexResetCredit
10 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import type * as PlatformError from "effect/PlatformError";
18import * as Ref from "effect/Ref";
19import * as Semaphore from "effect/Semaphore";
20
21/**
22 * Bounded so a hung app-server cannot hold the account lock forever; the
23 * timeout interrupts the scoped request, which kills the process, and the
24 * kept idempotency key makes the user's retry safe.
25 */
26export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20);
27
28interface AccountRedemptionState {
29 readonly lock: Semaphore.Semaphore;
30 readonly pendingKey: Ref.Ref<string | null>;
31}
32
33export class CodexResetCreditCoordinator extends Context.Service<
34 CodexResetCreditCoordinator,
35 {
36 /**
37 * Run `consume` under the account's lock with a stable idempotency key.
38 * The key is cleared only when Codex reports an outcome; a failure
39 * (timeout included) keeps it so the next attempt is the same attempt.
40 */
41 readonly redeem: <E, R>(
42 accountKey: string,
43 consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>,
44 ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>;
45 }
46>()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {}
47
48export const make = Effect.gen(function* () {
49 const crypto = yield* Crypto.Crypto;
50 const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map());
51
52 // Get-or-create through one Ref.modify so two first redemptions for the
53 // same account cannot each install their own lock.
54 const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* (
55 accountKey: string,
56 ) {
57 const existing = (yield* Ref.get(statesRef)).get(accountKey);
58 if (existing) return existing;
59 const candidate = {
60 lock: yield* Semaphore.make(1),
61 pendingKey: yield* Ref.make<string | null>(null),
62 };
63 return yield* Ref.modify(statesRef, (states) => {
64 const current = states.get(accountKey);
65 if (current) return [current, states] as const;
66 const next = new Map(states);
67 next.set(accountKey, candidate);
68 return [candidate, next] as const;
69 });
70 });
71
72 const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) =>
73 Effect.gen(function* () {
74 const state = yield* stateFor(accountKey);
75 return yield* state.lock.withPermits(1)(
76 Effect.gen(function* () {
77 const existing = yield* Ref.get(state.pendingKey);
78 const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4);
79 yield* Ref.set(state.pendingKey, idempotencyKey);
80 const outcome = yield* consume(idempotencyKey);
81 yield* Ref.set(state.pendingKey, null);
82 return outcome;
83 }),
84 );
85 });
86
87 return { redeem } satisfies CodexResetCreditCoordinator["Service"];
88});
89
90export const layer = Layer.effect(CodexResetCreditCoordinator, make);
91
92/**
93 * Self-contained for tests: a counter-backed Crypto so keys are deterministic
94 * and distinct without the platform layer.
95 */
96export const layerTest = Layer.effect(
97 CodexResetCreditCoordinator,
98 Effect.gen(function* () {
99 let counter = 0;
100 return yield* make.pipe(
101 Effect.provideService(
102 Crypto.Crypto,
103 Crypto.make({
104 randomBytes: (size) => {
105 counter += 1;
106 return new Uint8Array(size).fill(counter);
107 },
108 digest: (_algorithm, data) => Effect.succeed(data),
109 }),
110 ),
111 );
112 }),
113);
apps/server/src/provider/Layers/codexUsageLimits.test.ts+28
......@@ -5,6 +5,7 @@ import {
55 codexRateLimitsFailureMessage,
66 codexRateLimitsToLimits,
77 codexRateLimitsToUpdate,
8 codexResetCreditsToContract,
89} from "./codexUsageLimits.ts";
910
1011const checkedAt = "2026-07-18T10:00:00.000Z";
......@@ -101,3 +102,30 @@ describe("codexRateLimitsFailureMessage", () => {
101102 ).toBe("Codex exited before it could report usage.");
102103 });
103104});
105
106describe("codexResetCreditsToContract", () => {
107 it("counts available credits and reports the soonest expiry", () => {
108 expect(
109 codexResetCreditsToContract({
110 availableCount: 2,
111 credits: [
112 { status: "available", expiresAt: 1_784_500_000 },
113 { status: "redeemed", expiresAt: 1_700_000_000 },
114 { status: "available", expiresAt: 1_784_000_000 },
115 ],
116 }),
117 ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" });
118 expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 });
119 expect(codexResetCreditsToContract(null)).toBeUndefined();
120 });
121
122 it("rides along on the probe's limits", () => {
123 expect(
124 codexRateLimitsToLimits({
125 checkedAt,
126 snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } },
127 resetCredits: { availableCount: 1 },
128 }).resetCredits,
129 ).toEqual({ availableCount: 1 });
130 });
131});
apps/server/src/provider/Layers/codexUsageLimits.ts+35-4
......@@ -8,6 +8,7 @@
88 */
99import type {
1010 ProviderUsageLimitsUpdate,
11 ServerProviderResetCredits,
1112 ServerProviderUsageLimits,
1213 ServerProviderUsageWindow,
1314} from "@t3tools/contracts";
......@@ -30,6 +31,15 @@ export interface CodexRateLimitSnapshot {
3031 readonly secondary?: CodexRateLimitWindow | null;
3132}
3233
34/** Structural view of the read response's `rateLimitResetCredits`. */
35export interface CodexResetCreditsSummary {
36 readonly availableCount: number;
37 readonly credits?: ReadonlyArray<{
38 readonly status: string;
39 readonly expiresAt?: number | null;
40 }> | null;
41}
42
3343const SESSION_MINS = 5 * 60;
3444const WEEK_MINS = 7 * 24 * 60;
3545const MONTH_MINS = 30 * 24 * 60;
......@@ -82,14 +92,35 @@ export function codexRateLimitsToWindows(
8292 return windows;
8393}
8494
95export function codexResetCreditsToContract(
96 summary: CodexResetCreditsSummary | null | undefined,
97): ServerProviderResetCredits | undefined {
98 if (!summary) return undefined;
99 const expiries = (summary.credits ?? [])
100 .filter((credit) => credit.status === "available")
101 .map((credit) => credit.expiresAt)
102 .filter((value): value is number => typeof value === "number");
103 const nextExpiresAt =
104 expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined;
105 return {
106 availableCount: Math.max(0, summary.availableCount),
107 ...(nextExpiresAt ? { nextExpiresAt } : {}),
108 };
109}
110
85111export function codexRateLimitsToLimits(input: {
86112 readonly snapshot: CodexRateLimitSnapshot;
113 readonly resetCredits?: CodexResetCreditsSummary | null | undefined;
87114 readonly checkedAt: string;
88115}): ServerProviderUsageLimits {
89 return makeUsageLimits({
90 checkedAt: input.checkedAt,
91 windows: codexRateLimitsToWindows(input.snapshot),
92 });
116 const resetCredits = codexResetCreditsToContract(input.resetCredits);
117 return {
118 ...makeUsageLimits({
119 checkedAt: input.checkedAt,
120 windows: codexRateLimitsToWindows(input.snapshot),
121 }),
122 ...(resetCredits ? { resetCredits } : {}),
123 };
93124}
94125
95126export function codexRateLimitsToUpdate(
apps/server/src/provider/ProviderDriver.ts+10
......@@ -22,6 +22,7 @@
2222 * @module provider/ProviderDriver
2323 */
2424import type {
25 ProviderConsumeResetCreditOutcome,
2526 ProviderDriverKind,
2627 ProviderInstanceEnvironment,
2728 ProviderInstanceId,
......@@ -73,6 +74,15 @@ export interface ProviderInstance {
7374 readonly snapshot: ServerProviderShape;
7475 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
7576 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
77 /**
78 * Redeem one banked rate-limit reset credit on the signed-in account, then
79 * re-probe so the snapshot reflects the cleared windows. Account-level,
80 * not thread-level, which is why it lives here rather than on the adapter.
81 */
82 readonly consumeResetCredit?: () => Effect.Effect<
83 ProviderConsumeResetCreditOutcome,
84 ProviderDriverError
85 >;
7686 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
7787 readonly textGeneration: TextGeneration.TextGeneration["Service"];
7888 readonly auth?: ProviderAuthController;
apps/server/src/server.ts+4-1
......@@ -34,6 +34,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
3434import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
3535import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
3636import * as ModelManifest from "./provider/ModelManifest.ts";
37import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts";
3738import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
3839import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
3940import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
......@@ -479,7 +480,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
479480 // `ModelManifest.layer` is the legacy-model classification data, refreshed
480481 // from the repo's `model-manifest.json` on `main` and applied by the
481482 // Codex/Claude drivers.
482 Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)),
483 Layer.provideMerge(
484 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, CodexResetCredit.layer),
485 ),
483486 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
484487 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
485488 // the rewritten registry reads snapshots off the instance registry and
apps/server/src/ws.ts+35
......@@ -1746,6 +1746,41 @@ const makeWsRpcLayer = (
17461746 "rpc.aggregate": "server",
17471747 },
17481748 ),
1749 [WS_METHODS.providerConsumeResetCredit]: (input) =>
1750 observeRpcEffect(
1751 WS_METHODS.providerConsumeResetCredit,
1752 Effect.gen(function* () {
1753 const instance = yield* providerInstances.getInstance(input.instanceId);
1754 // A disabled instance must not spend anything on its account.
1755 if (instance === undefined || !instance.enabled) {
1756 return yield* new ProviderSetupError({
1757 instanceId: input.instanceId,
1758 operation: "consume-reset-credit",
1759 detail: instance ? "This provider is disabled." : "Provider instance not found.",
1760 });
1761 }
1762 if (instance.consumeResetCredit === undefined) {
1763 return yield* new ProviderSetupError({
1764 instanceId: input.instanceId,
1765 operation: "consume-reset-credit",
1766 detail: "This provider does not bank reset credits.",
1767 });
1768 }
1769 const outcome = yield* instance.consumeResetCredit().pipe(
1770 Effect.mapError(
1771 (error) =>
1772 new ProviderSetupError({
1773 instanceId: input.instanceId,
1774 operation: "consume-reset-credit",
1775 detail: error.detail,
1776 cause: error,
1777 }),
1778 ),
1779 );
1780 return { outcome };
1781 }),
1782 { "rpc.aggregate": "provider" },
1783 ),
17491784 [WS_METHODS.providerAuthStart]: (input) =>
17501785 observeRpcEffect(
17511786 WS_METHODS.providerAuthStart,
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx+11-7
......@@ -1,7 +1,7 @@
1import { UsageLimitSourceId } from "@t3tools/contracts";
1import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts";
22import { useState } from "react";
33
4import { useUpdatePrimarySettings } from "../../hooks/useSettings";
4import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
55import { Button } from "../ui/button";
66import {
77 Dialog,
......@@ -35,18 +35,22 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId {
3535}
3636
3737/**
38 * Adds a CLIProxyAPI hub as a usage-limit source on the primary environment.
39 * The management key is sent once and kept in the server's secret store;
38 * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The
39 * management key is sent once and kept in that server's secret store;
4040 * settings only ever carry a redaction marker for it afterwards.
4141 */
4242export function AddUsageLimitSourceDialog({
4343 open,
4444 onOpenChange,
45 environmentId,
46 environmentLabel,
4547}: {
4648 readonly open: boolean;
4749 readonly onOpenChange: (open: boolean) => void;
50 readonly environmentId: EnvironmentId;
51 readonly environmentLabel: string;
4852}) {
49 const updateSettings = useUpdatePrimarySettings();
53 const updateSettings = useUpdateEnvironmentSettings(environmentId);
5054 const [label, setLabel] = useState("");
5155 const [url, setUrl] = useState("");
5256 const [managementKey, setManagementKey] = useState("");
......@@ -90,8 +94,8 @@ export function AddUsageLimitSourceDialog({
9094 <DialogHeader>
9195 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>
9296 <DialogDescription>
93 Show the quota of every account the hub pools, next to the providers on this machine.
94 The key stays on the server.
97 Show the quota of every account the hub pools, next to the providers on{" "}
98 {environmentLabel}. The key stays on that server.
9599 </DialogDescription>
96100 </DialogHeader>
97101 <DialogPanel>
apps/web/src/components/usage/UsageLimits.tsx+272-39
......@@ -1,8 +1,11 @@
1import type {
1import {
2 EnvironmentId,
3 type ProviderConsumeResetCreditOutcome,
4 ProviderInstanceId,
25 ServerProvider,
6 ServerProviderResetCredits,
37 ServerProviderUsageWindow,
48 UsageLimitSourceAccount,
5 UsageLimitSourceId,
69 UsageLimitSourceSnapshot,
710 UsageProviderKind,
811} from "@t3tools/contracts";
......@@ -11,6 +14,7 @@ import {
1114 collectLimitSources,
1215 collectLimitsGroups,
1316 elapsedShare,
17 formatDuration,
1418 formatResetsIn,
1519 limitsNotice,
1620 type LimitPace,
......@@ -20,16 +24,25 @@ import {
2024import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
2125import { Fragment, useState } from "react";
2226
27import { isElectron } from "../../env";
28import { usePrimarySessionState } from "../../environments/primary";
29import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
2330import {
24 usePrimarySettings,
25 usePrimarySettingsAvailable,
26 useUpdatePrimarySettings,
27} from "../../hooks/useSettings";
28import { usePrimaryEnvironmentId } from "../../state/environments";
31 type EnvironmentPresentation,
32 useEnvironments,
33 usePrimaryEnvironmentId,
34} from "../../state/environments";
35import { useEnvironmentSessionState } from "../../state/session";
2936import { environmentPresentations } from "../../state/presentation";
37import { serverEnvironment } from "../../state/server";
38import { useAtomCommand } from "../../state/use-atom-command";
3039import { formatUpcomingTimestamp } from "../../timestampFormat";
3140import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
3241import { getDriverOption } from "../settings/providerDriverMeta";
42import {
43 resolvePrimaryOperateAccess,
44 resolveRemoteOperateAccess,
45} from "../settings/ProviderSettingsPanel.logic";
3346import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
3447import {
3548 AlertDialog,
......@@ -42,6 +55,7 @@ import {
4255} from "../ui/alert-dialog";
4356import { Badge } from "../ui/badge";
4457import { Button } from "../ui/button";
58import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
4559import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4660import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
4761import { PROVIDER_PRESENTATION } from "./usageProviders";
......@@ -245,9 +259,11 @@ function AccountHeading({
245259
246260function ProviderLimits({
247261 provider,
262 environmentId,
248263 now,
249264}: {
250265 readonly provider: ServerProvider;
266 readonly environmentId: EnvironmentId;
251267 readonly now: number;
252268}) {
253269 const limits = provider.usageLimits;
......@@ -267,10 +283,101 @@ function ProviderLimits({
267283 ) : (
268284 <LimitWindows driver={provider.driver} windows={limits.windows} now={now} />
269285 )}
286 {limits.resetCredits ? (
287 <ResetCredits
288 environmentId={environmentId}
289 instanceId={provider.instanceId}
290 credits={limits.resetCredits}
291 now={now}
292 />
293 ) : null}
270294 </section>
271295 );
272296}
273297
298const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
299 reset: "Reset applied. Your windows have cleared.",
300 nothingToReset: "Nothing to reset right now.",
301 noCredit: "No reset credit left.",
302 alreadyRedeemed: "That credit was already redeemed.",
303};
304
305/**
306 * Banked reset credits with a confirmed redeem action. Redeeming spends a
307 * credit the provider granted the user, so it never fires on a bare click.
308 */
309function ResetCredits({
310 environmentId,
311 instanceId,
312 credits,
313 now,
314}: {
315 readonly environmentId: EnvironmentId;
316 readonly instanceId: ProviderInstanceId;
317 readonly credits: ServerProviderResetCredits;
318 readonly now: number;
319}) {
320 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
321 const [confirming, setConfirming] = useState(false);
322 const [busy, setBusy] = useState(false);
323 const [status, setStatus] = useState<string | null>(null);
324 if (credits.availableCount === 0 && status === null) return null;
325
326 const expiresIn = credits.nextExpiresAt
327 ? formatDuration(Date.parse(credits.nextExpiresAt) - now)
328 : null;
329 const summary =
330 credits.availableCount === 0
331 ? "No reset credits banked"
332 : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
333 expiresIn ? ` · next expires in ${expiresIn}` : ""
334 }`;
335
336 const redeem = async () => {
337 setConfirming(false);
338 setBusy(true);
339 setStatus(null);
340 const result = await consume({ environmentId, input: { instanceId } });
341 setBusy(false);
342 if (result._tag === "Success") {
343 setStatus(OUTCOME_TEXT[result.value.outcome]);
344 return;
345 }
346 setStatus(
347 "error" in result.cause && result.cause.error instanceof Error
348 ? result.cause.error.message
349 : "Could not use the reset credit.",
350 );
351 };
352
353 return (
354 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
355 <span className="tabular-nums">{summary}</span>
356 {credits.availableCount > 0 ? (
357 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>
358 {busy ? "Using credit…" : "Use a reset credit"}
359 </Button>
360 ) : null}
361 {status ? <span className="text-foreground">{status}</span> : null}
362 <AlertDialog open={confirming} onOpenChange={setConfirming}>
363 <AlertDialogPopup>
364 <AlertDialogHeader>
365 <AlertDialogTitle>Use a reset credit?</AlertDialogTitle>
366 <AlertDialogDescription>
367 This redeems one credit on your account and clears the current rate-limit windows. It
368 cannot be undone.
369 </AlertDialogDescription>
370 </AlertDialogHeader>
371 <AlertDialogFooter>
372 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
373 <Button onClick={() => void redeem()}>Use credit</Button>
374 </AlertDialogFooter>
375 </AlertDialogPopup>
376 </AlertDialog>
377 </div>
378 );
379}
380
274381/** One account pooled by a usage-limit source, drawn like a provider row. */
275382function SourceAccountLimits({
276383 account,
......@@ -386,6 +493,58 @@ function SourceLimits({
386493 );
387494}
388495
496/**
497 * Whether this client's credential may write settings on an environment,
498 * resolved the way Settings → Providers does: the desktop app owns its
499 * primary outright; a browser session checks the scopes it was granted;
500 * a remote environment reports scopes over its own session endpoint.
501 */
502function useCanOperateEnvironment(environment: EnvironmentPresentation | null): boolean {
503 const isPrimary = environment?.entry.target._tag === "PrimaryConnectionTarget";
504 const primarySession = usePrimarySessionState();
505 const remoteSession = useEnvironmentSessionState(
506 environment?.environmentId ?? EnvironmentId.make("none"),
507 );
508 if (environment === null || environment.connection.phase !== "connected") return false;
509 if (isPrimary && isElectron) return true;
510 const access = isPrimary
511 ? resolvePrimaryOperateAccess({
512 isPrimary: true,
513 hasDesktopBridge: false,
514 session: primarySession.data,
515 isPending: primarySession.isPending,
516 hasError: primarySession.error !== null,
517 })
518 : resolveRemoteOperateAccess({
519 session: remoteSession.data,
520 isPending: remoteSession.isPending,
521 hasError: remoteSession.hasError,
522 });
523 return access === "granted";
524}
525
526/** One source with a remove control bound to the environment it lives in. */
527function SourceLimitsRow({
528 source,
529 now,
530}: {
531 readonly source: UsageLimitSourceSnapshot & {
532 readonly key: string;
533 readonly environmentId: EnvironmentId;
534 };
535 readonly now: number;
536}) {
537 const updateSettings = useUpdateEnvironmentSettings(source.environmentId);
538 const { environments } = useEnvironments();
539 const environment =
540 environments.find((entry) => entry.environmentId === source.environmentId) ?? null;
541 const canOperate = useCanOperateEnvironment(environment);
542 // The patch names only this entry, so two edits in flight cannot clobber
543 // each other's map.
544 const remove = () => updateSettings({ usageLimitSources: { [source.id]: null } });
545 return <SourceLimits source={source} now={now} onRemove={canOperate ? remove : null} />;
546}
547
389548/**
390549 * Subscription quota windows from every connected environment's providers.
391550 * Countdowns anchor to render time rather than ticking: a live clock would
......@@ -395,49 +554,108 @@ export function UsageLimitsSection() {
395554 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
396555 const groups = collectLimitsGroups(presentations);
397556 const sources = collectLimitSources(presentations);
398 const configuredSources = usePrimarySettings((settings) => settings.usageLimitSources);
399 const canEditSources = usePrimarySettingsAvailable();
400557 const primaryEnvironmentId = usePrimaryEnvironmentId();
401 const updateSettings = useUpdatePrimarySettings();
558 const { environments } = useEnvironments();
402559 const [adding, setAdding] = useState(false);
403560 // Anchored once per mount on purpose: countdowns must not tick (see below).
404561 const [now] = useState(() => Date.now());
405562
406 // The patch names only this entry, so two edits in flight cannot clobber
407 // each other's map.
408 const removeSource = (id: UsageLimitSourceId) => {
409 updateSettings({ usageLimitSources: { [id]: null } });
410 };
411
412 const addHubButton = canEditSources ? (
413 <Button size="xs" variant="outline" onClick={() => setAdding(true)}>
414 <PlusIcon className="size-3" aria-hidden />
415 Add CLIProxyAPI hub
416 </Button>
417 ) : null;
563 // Sources live in one environment's settings. Writing them needs only the
564 // operate scope, like any provider control, so a T3 Connect client can add
565 // a hub to whichever environment it is connected to: the primary when there
566 // is one, else the first connected environment, with a picker for more.
567 const connected = environments.filter(
568 (environment) => environment.connection.phase === "connected",
569 );
570 const [pickedEnvironmentId, setPickedEnvironmentId] = useState<EnvironmentId | null>(null);
571 const targetEnvironment =
572 (pickedEnvironmentId !== null
573 ? connected.find((environment) => environment.environmentId === pickedEnvironmentId)
574 : undefined) ??
575 (primaryEnvironmentId !== null
576 ? connected.find((environment) => environment.environmentId === primaryEnvironmentId)
577 : undefined) ??
578 connected[0] ??
579 null;
580 const canOperateTarget = useCanOperateEnvironment(targetEnvironment);
418581
419582 return (
420583 <div className="flex flex-col gap-8">
584 {/* Sources first: they are the thing a user configures here, so the
585 control to add one sits at the top rather than after every row. */}
586 <div className="flex items-center justify-between gap-3">
587 <div className="flex flex-col gap-1">
588 <h2 className="text-sm font-medium text-foreground">Usage sources</h2>
589 <p className="text-xs text-muted-foreground">
590 Quota from a CLIProxyAPI hub shows beside the providers signed in on this machine.
591 </p>
592 </div>
593 {/* The picker stays whenever several environments are connected, so
594 a read-only default target does not hide the way to an operable
595 one; only the button follows the picked target's access. */}
596 {targetEnvironment ? (
597 <div className="flex items-center gap-2">
598 {connected.length > 1 ? (
599 <Select
600 value={targetEnvironment.environmentId}
601 onValueChange={(value) => {
602 if (value !== null) setPickedEnvironmentId(EnvironmentId.make(value));
603 }}
604 >
605 <SelectTrigger
606 aria-label="Environment to add the hub to"
607 size="compact"
608 variant="ghost"
609 className="w-auto min-w-0"
610 >
611 <SelectValue>{targetEnvironment.label}</SelectValue>
612 </SelectTrigger>
613 <SelectPopup align="end" alignItemWithTrigger={false}>
614 {connected.map((environment) => (
615 <SelectItem key={environment.environmentId} value={environment.environmentId}>
616 {environment.label}
617 </SelectItem>
618 ))}
619 </SelectPopup>
620 </Select>
621 ) : null}
622 {canOperateTarget ? (
623 <Button size="xs" variant="outline" onClick={() => setAdding(true)}>
624 <PlusIcon className="size-3" aria-hidden />
625 Add hub
626 </Button>
627 ) : (
628 <Tooltip>
629 <TooltipTrigger
630 render={
631 <span
632 tabIndex={0}
633 className="rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
634 />
635 }
636 >
637 <span className="inline-flex" inert>
638 <Button size="xs" variant="outline" disabled>
639 <PlusIcon className="size-3" aria-hidden />
640 Add hub
641 </Button>
642 </span>
643 </TooltipTrigger>
644 <TooltipPopup side="top" className="max-w-72">
645 Your session cannot change settings on {targetEnvironment.label}.
646 </TooltipPopup>
647 </Tooltip>
648 )}
649 </div>
650 ) : null}
651 </div>
421652 {groups.length === 0 && sources.length === 0 ? (
422653 <p className="text-sm text-muted-foreground">
423654 No provider on a connected environment reports subscription limits.
424655 </p>
425656 ) : null}
426657 {sources.map((source) => (
427 <SourceLimits
428 key={source.key}
429 source={source}
430 now={now}
431 // Only the primary environment's own sources can be edited from
432 // here; a remote environment's row with the same id is read-only.
433 onRemove={
434 canEditSources &&
435 source.environmentId === primaryEnvironmentId &&
436 source.id in configuredSources
437 ? () => removeSource(source.id)
438 : null
439 }
440 />
658 <SourceLimitsRow key={source.key} source={source} now={now} />
441659 ))}
442660 {groups.map((group) => (
443661 <div key={group.environmentId} className="flex flex-col gap-6">
......@@ -447,12 +665,27 @@ export function UsageLimitsSection() {
447665 </h2>
448666 ) : null}
449667 {group.providers.map((provider) => (
450 <ProviderLimits key={provider.instanceId} provider={provider} now={now} />
668 <ProviderLimits
669 key={provider.instanceId}
670 provider={provider}
671 environmentId={group.environmentId}
672 now={now}
673 />
451674 ))}
452675 </div>
453676 ))}
454 {addHubButton ? <div>{addHubButton}</div> : null}
455 <AddUsageLimitSourceDialog open={adding} onOpenChange={setAdding} />
677 {targetEnvironment && canOperateTarget ? (
678 // Keyed on the target: if it disconnects or the primary changes while
679 // the dialog is open, a fresh dialog mounts empty rather than carrying
680 // a typed key over to a different environment.
681 <AddUsageLimitSourceDialog
682 key={targetEnvironment.environmentId}
683 open={adding}
684 onOpenChange={setAdding}
685 environmentId={targetEnvironment.environmentId}
686 environmentLabel={targetEnvironment.label}
687 />
688 ) : null}
456689 </div>
457690 );
458691}
packages/client-runtime/src/state/server.ts+9
......@@ -902,6 +902,15 @@ export function createServerEnvironmentAtoms<R, E>(
902902 Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),
903903 ),
904904 }),
905 consumeResetCredit: createEnvironmentRpcCommand(runtime, {
906 label: "environment-data:server:consume-reset-credit",
907 tag: WS_METHODS.providerConsumeResetCredit,
908 concurrency: {
909 mode: "singleFlight",
910 // Both ids are free-form strings; a delimiter could collide.
911 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]),
912 },
913 }),
905914 refreshProviders: createEnvironmentRpcCommand(runtime, {
906915 label: "environment-data:server:refresh-providers",
907916 tag: WS_METHODS.serverRefreshProviders,
packages/contracts/src/providerUsageLimits.ts+32-1
......@@ -6,7 +6,7 @@ import {
66 NonNegativeInt,
77 TrimmedNonEmptyString,
88} from "./baseSchemas.ts";
9import { ProviderDriverKind } from "./providerInstance.ts";
9import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
1010import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
1111
1212/**
......@@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({
2727});
2828export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;
2929
30/**
31 * Reset credits a provider banks on the account. Codex grants these when it
32 * has rate-limited the user unfairly; redeeming one clears the current
33 * windows. Only present when the provider reports them at all.
34 */
35export const ServerProviderResetCredits = Schema.Struct({
36 availableCount: NonNegativeInt,
37 nextExpiresAt: Schema.optional(IsoDateTime),
38});
39export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;
40
3041/**
3142 * Subscription usage the provider knows about the signed-in account.
3243 *
......@@ -37,6 +48,7 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;
3748export const ServerProviderUsageLimits = Schema.Struct({
3849 checkedAt: IsoDateTime,
3950 windows: ForwardCompatibleArray(ServerProviderUsageWindow),
51 resetCredits: Schema.optional(ServerProviderResetCredits),
4052 unavailable: Schema.optional(
4153 Schema.Struct({
4254 reason: Schema.Literals(["unsupported", "probeFailed"]),
......@@ -90,3 +102,22 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;
90102
91103export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);
92104export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;
105
106export const ProviderConsumeResetCreditInput = Schema.Struct({
107 instanceId: ProviderInstanceId,
108});
109export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type;
110
111/** Mirrors Codex's own outcome set; other providers map onto it. */
112export const ProviderConsumeResetCreditOutcome = Schema.Literals([
113 "reset",
114 "nothingToReset",
115 "noCredit",
116 "alreadyRedeemed",
117]);
118export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCreditOutcome.Type;
119
120export const ProviderConsumeResetCreditResult = Schema.Struct({
121 outcome: ProviderConsumeResetCreditOutcome,
122});
123export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;
packages/contracts/src/rpc.ts+12
......@@ -206,6 +206,10 @@ import {
206206 ResourceTelemetryRetryResult,
207207 ResourceTelemetrySnapshot,
208208} from "./resourceTelemetry.ts";
209import {
210 ProviderConsumeResetCreditInput,
211 ProviderConsumeResetCreditResult,
212} from "./providerUsageLimits.ts";
209213import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
210214import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
211215import {
......@@ -243,6 +247,7 @@ export const WS_METHODS = {
243247 // Provider methods
244248 providerUploadFeedback: "provider.uploadFeedback",
245249 providerAuthStart: "provider.auth.start",
250 providerConsumeResetCredit: "provider.consumeResetCredit",
246251 providerAuthComplete: "provider.auth.complete",
247252 providerAuthCancel: "provider.auth.cancel",
248253 providerAuthLogout: "provider.auth.logout",
......@@ -410,6 +415,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide
410415
411416const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]);
412417
418export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, {
419 payload: ProviderConsumeResetCreditInput,
420 success: ProviderConsumeResetCreditResult,
421 error: ProviderSetupRpcError,
422});
423
413424export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
414425 payload: ProviderSetupInput,
415426 success: ProviderAuthState,
......@@ -1157,6 +1168,7 @@ export const WsRpcGroup = RpcGroup.make(
11571168 WsServerGetConfigRpc,
11581169 WsServerRefreshProvidersRpc,
11591170 WsServerUpdateProviderRpc,
1171 WsProviderConsumeResetCreditRpc,
11601172 WsProviderAuthStartRpc,
11611173 WsProviderAuthCompleteRpc,
11621174 WsProviderAuthCancelRpc,