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 @@...@@ -1,6 +1,10 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type {2import type {
3 EnvironmentId,
4 ProviderConsumeResetCreditOutcome,
5 ProviderInstanceId,
3 ServerProvider,6 ServerProvider,
7 ServerProviderResetCredits,
4 ServerProviderUsageWindow,8 ServerProviderUsageWindow,
5 UsageLimitSourceAccount,9 UsageLimitSourceAccount,
6} from "@t3tools/contracts";10} from "@t3tools/contracts";
...@@ -8,16 +12,19 @@ import {...@@ -8,16 +12,19 @@ import {
8 collectLimitSources,12 collectLimitSources,
9 collectLimitsGroups,13 collectLimitsGroups,
10 elapsedShare,14 elapsedShare,
15 formatDuration,
11 formatResetsIn,16 formatResetsIn,
12 limitsNotice,17 limitsNotice,
13 paceOf,18 paceOf,
14 providerLimitsLabel,19 providerLimitsLabel,
15} from "@t3tools/shared/usageLimits";20} from "@t3tools/shared/usageLimits";
16import { useState } from "react";21import { type ReactNode, useState } from "react";
17import { View } from "react-native";22import { Alert, Pressable, View } from "react-native";
1823
19import { AppText as Text } from "../../components/AppText";24import { AppText as Text } from "../../components/AppText";
20import { environmentPresentations } from "../../state/presentation";25import { environmentPresentations } from "../../state/presentation";
26import { serverEnvironment } from "../../state/server";
27import { useAtomCommand } from "../../state/use-atom-command";
21import { SettingsSection } from "../settings/components/SettingsSection";28import { SettingsSection } from "../settings/components/SettingsSection";
2229
23const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;30const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
...@@ -71,6 +78,7 @@ function AccountLimits(props: {...@@ -71,6 +78,7 @@ function AccountLimits(props: {
71 readonly limits: ServerProvider["usageLimits"];78 readonly limits: ServerProvider["usageLimits"];
72 readonly now: number;79 readonly now: number;
73 readonly first: boolean;80 readonly first: boolean;
81 readonly footer?: ReactNode;
74}) {82}) {
75 const { limits, now } = props;83 const { limits, now } = props;
76 if (!limits) return null;84 if (!limits) return null;
...@@ -88,23 +96,120 @@ function AccountLimits(props: {...@@ -88,23 +96,120 @@ function AccountLimits(props: {
88 ) : (96 ) : (
89 limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />)97 limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />)
90 )}98 )}
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}
91 </View>184 </View>
92 );185 );
93}186}
94187
95function ProviderLimits(props: {188function ProviderLimits(props: {
96 readonly provider: ServerProvider;189 readonly provider: ServerProvider;
190 readonly environmentId: EnvironmentId;
97 readonly now: number;191 readonly now: number;
98 readonly first: boolean;192 readonly first: boolean;
99}) {193}) {
100 const { provider } = props;194 const { provider, environmentId, now } = props;
195 const credits = provider.usageLimits?.resetCredits;
101 return (196 return (
102 <AccountLimits197 <AccountLimits
103 label={providerLimitsLabel(provider, () => undefined)}198 label={providerLimitsLabel(provider, () => undefined)}
104 detail={provider.auth.label}199 detail={provider.auth.label}
105 limits={provider.usageLimits}200 limits={provider.usageLimits}
106 now={props.now}201 now={now}
107 first={props.first}202 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 }
108 />213 />
109 );214 );
110}215}
...@@ -172,6 +277,7 @@ export function UsageLimitsSection() {...@@ -172,6 +277,7 @@ export function UsageLimitsSection() {
172 <ProviderLimits277 <ProviderLimits
173 key={provider.instanceId}278 key={provider.instanceId}
174 provider={provider}279 provider={provider}
280 environmentId={group.environmentId}
175 now={now}281 now={now}
176 first={index === 0}282 first={index === 0}
177 />283 />
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -34,6 +34,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -34,6 +34,7 @@ export const RPC_REQUIRED_SCOPES = {
34 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,34 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,35 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
36 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,36 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
37 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
37 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,38 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
38 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,39 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
39 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,40 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
apps/server/src/provider/Drivers/CodexDriver.ts+70
...@@ -36,10 +36,15 @@ import { ServerConfig } from "../../config.ts";...@@ -36,10 +36,15 @@ import { ServerConfig } from "../../config.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";36import { ServerSettingsService } from "../../serverSettings.ts";
37import { ProviderDriverError } from "../Errors.ts";37import { ProviderDriverError } from "../Errors.ts";
38import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";38import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
39import {
40 CODEX_RESET_CREDIT_TIMEOUT,
41 CodexResetCreditCoordinator,
42} from "../Layers/codexResetCredit.ts";
39import {43import {
40 checkCodexProviderStatus,44 checkCodexProviderStatus,
41 makePendingCodexProvider,45 makePendingCodexProvider,
42 probeCodexSkillsForCwd,46 probeCodexSkillsForCwd,
47 withCodexAppServerClient,
43} from "../Layers/CodexProvider.ts";48} from "../Layers/CodexProvider.ts";
44import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";49import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
45import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";50import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
...@@ -81,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -81,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
81export type CodexDriverEnv =86export type CodexDriverEnv =
82 | BackgroundPolicy.BackgroundPolicy87 | BackgroundPolicy.BackgroundPolicy
83 | ChildProcessSpawner.ChildProcessSpawner88 | ChildProcessSpawner.ChildProcessSpawner
89 | CodexResetCreditCoordinator
84 | Crypto.Crypto90 | Crypto.Crypto
85 | FileSystem.FileSystem91 | FileSystem.FileSystem
86 | HttpClient.HttpClient92 | HttpClient.HttpClient
...@@ -101,6 +107,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -101,6 +107,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
101 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>107 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
102 Effect.gen(function* () {108 Effect.gen(function* () {
103 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;109 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
110 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;
104 const httpClient = yield* HttpClient.HttpClient;111 const httpClient = yield* HttpClient.HttpClient;
105 const serverSettings = yield* ServerSettingsService;112 const serverSettings = yield* ServerSettingsService;
106 const eventLoggers = yield* ProviderEventLoggers;113 const eventLoggers = yield* ProviderEventLoggers;
...@@ -229,6 +236,68 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -229,6 +236,68 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
229 ),236 ),
230 );237 );
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
232 return {301 return {
233 instanceId,302 instanceId,
234 driverKind: DRIVER_KIND,303 driverKind: DRIVER_KIND,
...@@ -238,6 +307,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -238,6 +307,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
238 enabled,307 enabled,
239 snapshot,308 snapshot,
240 snapshotForCwd,309 snapshotForCwd,
310 consumeResetCredit,
241 adapter,311 adapter,
242 textGeneration,312 textGeneration,
243 } satisfies ProviderInstance;313 } satisfies ProviderInstance;
apps/server/src/provider/Layers/CodexProvider.ts+39-59
...@@ -39,13 +39,17 @@ import {...@@ -39,13 +39,17 @@ import {
39 codexRateLimitsFailureMessage,39 codexRateLimitsFailureMessage,
40 codexRateLimitsToLimits,40 codexRateLimitsToLimits,
41 type CodexRateLimitSnapshot,41 type CodexRateLimitSnapshot,
42 type CodexResetCreditsSummary,
42} from "./codexUsageLimits.ts";43} from "./codexUsageLimits.ts";
43import packageJson from "../../../package.json" with { type: "json" };44import packageJson from "../../../package.json" with { type: "json" };
44const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);45const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
45const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;46const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
4647
47type CodexRateLimitsProbe =48type CodexRateLimitsProbe =
48 | { readonly snapshot: CodexRateLimitSnapshot }49 | {
50 readonly snapshot: CodexRateLimitSnapshot;
51 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
52 }
49 | { readonly failure: string };53 | { readonly failure: string };
5054
51const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;55const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
...@@ -334,13 +338,18 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {...@@ -334,13 +338,18 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
334 };338 };
335}339}
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: {
338 readonly binaryPath: string;348 readonly binaryPath: string;
339 readonly homePath?: string;349 readonly homePath?: string | undefined;
340 readonly launchArgs?: string;350 readonly launchArgs?: string | undefined;
341 readonly cwd: string;351 readonly cwd: string;
342 readonly customModels?: ReadonlyArray<string>;352 readonly environment?: NodeJS.ProcessEnv | undefined;
343 readonly environment?: NodeJS.ProcessEnv;
344}) {353}) {
345 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,354 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,
346 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip355 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
...@@ -355,10 +364,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -355,10 +364,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
355 const spawnCommand = yield* resolveSpawnCommand(364 const spawnCommand = yield* resolveSpawnCommand(
356 input.binaryPath,365 input.binaryPath,
357 codexAppServerArgs(input.launchArgs),366 codexAppServerArgs(input.launchArgs),
358 {367 { env: environment, extendEnv: true },
359 env: environment,
360 extendEnv: true,
361 },
362 );368 );
363 const child = yield* spawner369 const child = yield* spawner
364 .spawn(370 .spawn(
...@@ -383,18 +389,20 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -383,18 +389,20 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
383 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(389 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
384 Effect.provide(clientContext),390 Effect.provide(clientContext),
385 );391 );
386392 const initialize = yield* client.request("initialize", buildCodexInitializeParams());
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 });
397 yield* client.notify("initialized", undefined);393 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
399 // Extract the version string after the first '/' in userAgent, up to the next space or the end407 // Extract the version string after the first '/' in userAgent, up to the next space or the end
400 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);408 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
...@@ -419,7 +427,10 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -419,7 +427,10 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
419 // Usage is an enrichment: a failure or a slow answer degrades to "no427 // Usage is an enrichment: a failure or a slow answer degrades to "no
420 // usage this probe" rather than costing the account and models.428 // usage this probe" rather than costing the account and models.
421 client.request("account/rateLimits/read", undefined).pipe(429 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 })),
423 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),434 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
424 Effect.map(435 Effect.map(
425 Option.getOrElse((): CodexRateLimitsProbe => ({436 Option.getOrElse((): CodexRateLimitsProbe => ({
...@@ -454,42 +465,7 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi...@@ -454,42 +465,7 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
454 readonly cwd: string;465 readonly cwd: string;
455 readonly environment?: NodeJS.ProcessEnv;466 readonly environment?: NodeJS.ProcessEnv;
456}) {467}) {
457 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;468 const { client } = yield* withCodexAppServerClient(input);
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);
493 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });469 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
494 return parseCodexSkillsListResponse(skillsResponse, input.cwd);470 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
495});471});
...@@ -682,7 +658,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -682,7 +658,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
682 reason: "probeFailed",658 reason: "probeFailed",
683 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),659 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
684 })660 })
685 : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, checkedAt });661 : codexRateLimitsToLimits({
662 snapshot: snapshot.rateLimits.snapshot,
663 resetCredits: snapshot.rateLimits.resetCredits,
664 checkedAt,
665 });
686666
687 return buildServerProvider({667 return buildServerProvider({
688 presentation: CODEX_PRESENTATION,668 presentation: CODEX_PRESENTATION,
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+3
...@@ -52,6 +52,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";...@@ -52,6 +52,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts";
52import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";52import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
53import * as ModelManifest from "../ModelManifest.ts";53import * as ModelManifest from "../ModelManifest.ts";
54import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";54import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
55import * as CodexResetCredit from "./codexResetCredit.ts";
55import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";56import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
56import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";57import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
5758
...@@ -152,6 +153,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -152,6 +153,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
152 Layer.provideMerge(TestHttpClientLive),153 Layer.provideMerge(TestHttpClientLive),
153 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),154 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
154 Layer.provideMerge(ModelManifest.layerTest),155 Layer.provideMerge(ModelManifest.layerTest),
156 Layer.provideMerge(CodexResetCredit.layerTest),
155 );157 );
156158
157 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>159 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
...@@ -321,6 +323,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -321,6 +323,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
321 Layer.provideMerge(TestHttpClientLive),323 Layer.provideMerge(TestHttpClientLive),
322 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),324 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
323 Layer.provideMerge(ModelManifest.layerTest),325 Layer.provideMerge(ModelManifest.layerTest),
326 Layer.provideMerge(CodexResetCredit.layerTest),
324 );327 );
325328
326 it.live("boots one instance of every shipped driver from a single config map", () =>329 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";...@@ -37,6 +37,7 @@ import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
37import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";37import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
38import { AntigravityInstallation } from "../AntigravityInstallation.ts";38import { AntigravityInstallation } from "../AntigravityInstallation.ts";
39import * as ModelManifest from "../ModelManifest.ts";39import * as ModelManifest from "../ModelManifest.ts";
40import * as CodexResetCredit from "./codexResetCredit.ts";
40import * as OpenCodeRuntime from "../opencodeRuntime.ts";41import * as OpenCodeRuntime from "../opencodeRuntime.ts";
41import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";42import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
42import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";43import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
...@@ -2007,6 +2008,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2007,6 +2008,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2007 ),2008 ),
2008 ),2009 ),
2009 Layer.provideMerge(ModelManifest.layerTest),2010 Layer.provideMerge(ModelManifest.layerTest),
2011 Layer.provideMerge(CodexResetCredit.layerTest),
2010 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2012 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2011 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2013 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
2012 // NO spawner mock — `ChildProcessSpawner` is supplied by the2014 // NO spawner mock — `ChildProcessSpawner` is supplied by the
...@@ -2107,6 +2109,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2107,6 +2109,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2107 ),2109 ),
2108 ),2110 ),
2109 Layer.provideMerge(ModelManifest.layerTest),2111 Layer.provideMerge(ModelManifest.layerTest),
2112 Layer.provideMerge(CodexResetCredit.layerTest),
2110 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2113 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2111 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>2114 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
2112 ChildProcessSpawner.make((command) => {2115 ChildProcessSpawner.make((command) => {
...@@ -2222,6 +2225,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2222,6 +2225,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2222 ),2225 ),
2223 ),2226 ),
2224 Layer.provideMerge(ModelManifest.layerTest),2227 Layer.provideMerge(ModelManifest.layerTest),
2228 Layer.provideMerge(CodexResetCredit.layerTest),
2225 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2229 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2226 Layer.provideMerge(NodeServices.layer),2230 Layer.provideMerge(NodeServices.layer),
2227 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2231 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
...@@ -2283,6 +2287,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2283,6 +2287,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2283 ),2287 ),
2284 ),2288 ),
2285 Layer.provideMerge(ModelManifest.layerTest),2289 Layer.provideMerge(ModelManifest.layerTest),
2290 Layer.provideMerge(CodexResetCredit.layerTest),
2291 Layer.provideMerge(CodexResetCredit.layerTest),
2286 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2292 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
2287 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),2293 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
2288 Layer.provideMerge(2294 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 {...@@ -5,6 +5,7 @@ import {
5 codexRateLimitsFailureMessage,5 codexRateLimitsFailureMessage,
6 codexRateLimitsToLimits,6 codexRateLimitsToLimits,
7 codexRateLimitsToUpdate,7 codexRateLimitsToUpdate,
8 codexResetCreditsToContract,
8} from "./codexUsageLimits.ts";9} from "./codexUsageLimits.ts";
910
10const checkedAt = "2026-07-18T10:00:00.000Z";11const checkedAt = "2026-07-18T10:00:00.000Z";
...@@ -101,3 +102,30 @@ describe("codexRateLimitsFailureMessage", () => {...@@ -101,3 +102,30 @@ describe("codexRateLimitsFailureMessage", () => {
101 ).toBe("Codex exited before it could report usage.");102 ).toBe("Codex exited before it could report usage.");
102 });103 });
103});104});
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 @@...@@ -8,6 +8,7 @@
8 */8 */
9import type {9import type {
10 ProviderUsageLimitsUpdate,10 ProviderUsageLimitsUpdate,
11 ServerProviderResetCredits,
11 ServerProviderUsageLimits,12 ServerProviderUsageLimits,
12 ServerProviderUsageWindow,13 ServerProviderUsageWindow,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -30,6 +31,15 @@ export interface CodexRateLimitSnapshot {...@@ -30,6 +31,15 @@ export interface CodexRateLimitSnapshot {
30 readonly secondary?: CodexRateLimitWindow | null;31 readonly secondary?: CodexRateLimitWindow | null;
31}32}
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
33const SESSION_MINS = 5 * 60;43const SESSION_MINS = 5 * 60;
34const WEEK_MINS = 7 * 24 * 60;44const WEEK_MINS = 7 * 24 * 60;
35const MONTH_MINS = 30 * 24 * 60;45const MONTH_MINS = 30 * 24 * 60;
...@@ -82,14 +92,35 @@ export function codexRateLimitsToWindows(...@@ -82,14 +92,35 @@ export function codexRateLimitsToWindows(
82 return windows;92 return windows;
83}93}
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
85export function codexRateLimitsToLimits(input: {111export function codexRateLimitsToLimits(input: {
86 readonly snapshot: CodexRateLimitSnapshot;112 readonly snapshot: CodexRateLimitSnapshot;
113 readonly resetCredits?: CodexResetCreditsSummary | null | undefined;
87 readonly checkedAt: string;114 readonly checkedAt: string;
88}): ServerProviderUsageLimits {115}): ServerProviderUsageLimits {
89 return makeUsageLimits({116 const resetCredits = codexResetCreditsToContract(input.resetCredits);
90 checkedAt: input.checkedAt,117 return {
91 windows: codexRateLimitsToWindows(input.snapshot),118 ...makeUsageLimits({
92 });119 checkedAt: input.checkedAt,
120 windows: codexRateLimitsToWindows(input.snapshot),
121 }),
122 ...(resetCredits ? { resetCredits } : {}),
123 };
93}124}
94125
95export function codexRateLimitsToUpdate(126export function codexRateLimitsToUpdate(
apps/server/src/provider/ProviderDriver.ts+10
...@@ -22,6 +22,7 @@...@@ -22,6 +22,7 @@
22 * @module provider/ProviderDriver22 * @module provider/ProviderDriver
23 */23 */
24import type {24import type {
25 ProviderConsumeResetCreditOutcome,
25 ProviderDriverKind,26 ProviderDriverKind,
26 ProviderInstanceEnvironment,27 ProviderInstanceEnvironment,
27 ProviderInstanceId,28 ProviderInstanceId,
...@@ -73,6 +74,15 @@ export interface ProviderInstance {...@@ -73,6 +74,15 @@ export interface ProviderInstance {
73 readonly snapshot: ServerProviderShape;74 readonly snapshot: ServerProviderShape;
74 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;75 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
75 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;76 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 >;
76 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;86 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
77 readonly textGeneration: TextGeneration.TextGeneration["Service"];87 readonly textGeneration: TextGeneration.TextGeneration["Service"];
78 readonly auth?: ProviderAuthController;88 readonly auth?: ProviderAuthController;
apps/server/src/server.ts+4-1
...@@ -34,6 +34,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD...@@ -34,6 +34,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
34import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";34import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
35import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";35import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
36import * as ModelManifest from "./provider/ModelManifest.ts";36import * as ModelManifest from "./provider/ModelManifest.ts";
37import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts";
37import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";38import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
38import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";39import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
39import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";40import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
...@@ -479,7 +480,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -479,7 +480,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
479 // `ModelManifest.layer` is the legacy-model classification data, refreshed480 // `ModelManifest.layer` is the legacy-model classification data, refreshed
480 // from the repo's `model-manifest.json` on `main` and applied by the481 // from the repo's `model-manifest.json` on `main` and applied by the
481 // Codex/Claude drivers.482 // 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 ),
483 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old486 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
484 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but487 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
485 // the rewritten registry reads snapshots off the instance registry and488 // the rewritten registry reads snapshots off the instance registry and
apps/server/src/ws.ts+35
...@@ -1746,6 +1746,41 @@ const makeWsRpcLayer = (...@@ -1746,6 +1746,41 @@ const makeWsRpcLayer = (
1746 "rpc.aggregate": "server",1746 "rpc.aggregate": "server",
1747 },1747 },
1748 ),1748 ),
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 ),
1749 [WS_METHODS.providerAuthStart]: (input) =>1784 [WS_METHODS.providerAuthStart]: (input) =>
1750 observeRpcEffect(1785 observeRpcEffect(
1751 WS_METHODS.providerAuthStart,1786 WS_METHODS.providerAuthStart,
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx+11-7
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { UsageLimitSourceId } from "@t3tools/contracts";1import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts";
2import { useState } from "react";2import { useState } from "react";
33
4import { useUpdatePrimarySettings } from "../../hooks/useSettings";4import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
5import { Button } from "../ui/button";5import { Button } from "../ui/button";
6import {6import {
7 Dialog,7 Dialog,
...@@ -35,18 +35,22 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId {...@@ -35,18 +35,22 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId {
35}35}
3636
37/**37/**
38 * Adds a CLIProxyAPI hub as a usage-limit source on the primary environment.38 * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The
39 * The management key is sent once and kept in the server's secret store;39 * management key is sent once and kept in that server's secret store;
40 * settings only ever carry a redaction marker for it afterwards.40 * settings only ever carry a redaction marker for it afterwards.
41 */41 */
42export function AddUsageLimitSourceDialog({42export function AddUsageLimitSourceDialog({
43 open,43 open,
44 onOpenChange,44 onOpenChange,
45 environmentId,
46 environmentLabel,
45}: {47}: {
46 readonly open: boolean;48 readonly open: boolean;
47 readonly onOpenChange: (open: boolean) => void;49 readonly onOpenChange: (open: boolean) => void;
50 readonly environmentId: EnvironmentId;
51 readonly environmentLabel: string;
48}) {52}) {
49 const updateSettings = useUpdatePrimarySettings();53 const updateSettings = useUpdateEnvironmentSettings(environmentId);
50 const [label, setLabel] = useState("");54 const [label, setLabel] = useState("");
51 const [url, setUrl] = useState("");55 const [url, setUrl] = useState("");
52 const [managementKey, setManagementKey] = useState("");56 const [managementKey, setManagementKey] = useState("");
...@@ -90,8 +94,8 @@ export function AddUsageLimitSourceDialog({...@@ -90,8 +94,8 @@ export function AddUsageLimitSourceDialog({
90 <DialogHeader>94 <DialogHeader>
91 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>95 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>
92 <DialogDescription>96 <DialogDescription>
93 Show the quota of every account the hub pools, next to the providers on this machine.97 Show the quota of every account the hub pools, next to the providers on{" "}
94 The key stays on the server.98 {environmentLabel}. The key stays on that server.
95 </DialogDescription>99 </DialogDescription>
96 </DialogHeader>100 </DialogHeader>
97 <DialogPanel>101 <DialogPanel>
apps/web/src/components/usage/UsageLimits.tsx+272-39
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import type {1import {
2 EnvironmentId,
3 type ProviderConsumeResetCreditOutcome,
4 ProviderInstanceId,
2 ServerProvider,5 ServerProvider,
6 ServerProviderResetCredits,
3 ServerProviderUsageWindow,7 ServerProviderUsageWindow,
4 UsageLimitSourceAccount,8 UsageLimitSourceAccount,
5 UsageLimitSourceId,
6 UsageLimitSourceSnapshot,9 UsageLimitSourceSnapshot,
7 UsageProviderKind,10 UsageProviderKind,
8} from "@t3tools/contracts";11} from "@t3tools/contracts";
...@@ -11,6 +14,7 @@ import {...@@ -11,6 +14,7 @@ import {
11 collectLimitSources,14 collectLimitSources,
12 collectLimitsGroups,15 collectLimitsGroups,
13 elapsedShare,16 elapsedShare,
17 formatDuration,
14 formatResetsIn,18 formatResetsIn,
15 limitsNotice,19 limitsNotice,
16 type LimitPace,20 type LimitPace,
...@@ -20,16 +24,25 @@ import {...@@ -20,16 +24,25 @@ import {
20import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";24import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
21import { Fragment, useState } from "react";25import { Fragment, useState } from "react";
2226
27import { isElectron } from "../../env";
28import { usePrimarySessionState } from "../../environments/primary";
29import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
23import {30import {
24 usePrimarySettings,31 type EnvironmentPresentation,
25 usePrimarySettingsAvailable,32 useEnvironments,
26 useUpdatePrimarySettings,33 usePrimaryEnvironmentId,
27} from "../../hooks/useSettings";34} from "../../state/environments";
28import { usePrimaryEnvironmentId } from "../../state/environments";35import { useEnvironmentSessionState } from "../../state/session";
29import { environmentPresentations } from "../../state/presentation";36import { environmentPresentations } from "../../state/presentation";
37import { serverEnvironment } from "../../state/server";
38import { useAtomCommand } from "../../state/use-atom-command";
30import { formatUpcomingTimestamp } from "../../timestampFormat";39import { formatUpcomingTimestamp } from "../../timestampFormat";
31import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";40import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
32import { getDriverOption } from "../settings/providerDriverMeta";41import { getDriverOption } from "../settings/providerDriverMeta";
42import {
43 resolvePrimaryOperateAccess,
44 resolveRemoteOperateAccess,
45} from "../settings/ProviderSettingsPanel.logic";
33import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";46import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
34import {47import {
35 AlertDialog,48 AlertDialog,
...@@ -42,6 +55,7 @@ import {...@@ -42,6 +55,7 @@ import {
42} from "../ui/alert-dialog";55} from "../ui/alert-dialog";
43import { Badge } from "../ui/badge";56import { Badge } from "../ui/badge";
44import { Button } from "../ui/button";57import { Button } from "../ui/button";
58import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";60import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
47import { PROVIDER_PRESENTATION } from "./usageProviders";61import { PROVIDER_PRESENTATION } from "./usageProviders";
...@@ -245,9 +259,11 @@ function AccountHeading({...@@ -245,9 +259,11 @@ function AccountHeading({
245259
246function ProviderLimits({260function ProviderLimits({
247 provider,261 provider,
262 environmentId,
248 now,263 now,
249}: {264}: {
250 readonly provider: ServerProvider;265 readonly provider: ServerProvider;
266 readonly environmentId: EnvironmentId;
251 readonly now: number;267 readonly now: number;
252}) {268}) {
253 const limits = provider.usageLimits;269 const limits = provider.usageLimits;
...@@ -267,10 +283,101 @@ function ProviderLimits({...@@ -267,10 +283,101 @@ function ProviderLimits({
267 ) : (283 ) : (
268 <LimitWindows driver={provider.driver} windows={limits.windows} now={now} />284 <LimitWindows driver={provider.driver} windows={limits.windows} now={now} />
269 )}285 )}
286 {limits.resetCredits ? (
287 <ResetCredits
288 environmentId={environmentId}
289 instanceId={provider.instanceId}
290 credits={limits.resetCredits}
291 now={now}
292 />
293 ) : null}
270 </section>294 </section>
271 );295 );
272}296}
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
274/** One account pooled by a usage-limit source, drawn like a provider row. */381/** One account pooled by a usage-limit source, drawn like a provider row. */
275function SourceAccountLimits({382function SourceAccountLimits({
276 account,383 account,
...@@ -386,6 +493,58 @@ function SourceLimits({...@@ -386,6 +493,58 @@ function SourceLimits({
386 );493 );
387}494}
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
389/**548/**
390 * Subscription quota windows from every connected environment's providers.549 * Subscription quota windows from every connected environment's providers.
391 * Countdowns anchor to render time rather than ticking: a live clock would550 * Countdowns anchor to render time rather than ticking: a live clock would
...@@ -395,49 +554,108 @@ export function UsageLimitsSection() {...@@ -395,49 +554,108 @@ export function UsageLimitsSection() {
395 const presentations = useAtomValue(environmentPresentations.presentationsAtom);554 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
396 const groups = collectLimitsGroups(presentations);555 const groups = collectLimitsGroups(presentations);
397 const sources = collectLimitSources(presentations);556 const sources = collectLimitSources(presentations);
398 const configuredSources = usePrimarySettings((settings) => settings.usageLimitSources);
399 const canEditSources = usePrimarySettingsAvailable();
400 const primaryEnvironmentId = usePrimaryEnvironmentId();557 const primaryEnvironmentId = usePrimaryEnvironmentId();
401 const updateSettings = useUpdatePrimarySettings();558 const { environments } = useEnvironments();
402 const [adding, setAdding] = useState(false);559 const [adding, setAdding] = useState(false);
403 // Anchored once per mount on purpose: countdowns must not tick (see below).560 // Anchored once per mount on purpose: countdowns must not tick (see below).
404 const [now] = useState(() => Date.now());561 const [now] = useState(() => Date.now());
405562
406 // The patch names only this entry, so two edits in flight cannot clobber563 // Sources live in one environment's settings. Writing them needs only the
407 // each other's map.564 // operate scope, like any provider control, so a T3 Connect client can add
408 const removeSource = (id: UsageLimitSourceId) => {565 // a hub to whichever environment it is connected to: the primary when there
409 updateSettings({ usageLimitSources: { [id]: null } });566 // is one, else the first connected environment, with a picker for more.
410 };567 const connected = environments.filter(
411568 (environment) => environment.connection.phase === "connected",
412 const addHubButton = canEditSources ? (569 );
413 <Button size="xs" variant="outline" onClick={() => setAdding(true)}>570 const [pickedEnvironmentId, setPickedEnvironmentId] = useState<EnvironmentId | null>(null);
414 <PlusIcon className="size-3" aria-hidden />571 const targetEnvironment =
415 Add CLIProxyAPI hub572 (pickedEnvironmentId !== null
416 </Button>573 ? connected.find((environment) => environment.environmentId === pickedEnvironmentId)
417 ) : null;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
419 return (582 return (
420 <div className="flex flex-col gap-8">583 <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>
421 {groups.length === 0 && sources.length === 0 ? (652 {groups.length === 0 && sources.length === 0 ? (
422 <p className="text-sm text-muted-foreground">653 <p className="text-sm text-muted-foreground">
423 No provider on a connected environment reports subscription limits.654 No provider on a connected environment reports subscription limits.
424 </p>655 </p>
425 ) : null}656 ) : null}
426 {sources.map((source) => (657 {sources.map((source) => (
427 <SourceLimits658 <SourceLimitsRow key={source.key} source={source} now={now} />
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 />
441 ))}659 ))}
442 {groups.map((group) => (660 {groups.map((group) => (
443 <div key={group.environmentId} className="flex flex-col gap-6">661 <div key={group.environmentId} className="flex flex-col gap-6">
...@@ -447,12 +665,27 @@ export function UsageLimitsSection() {...@@ -447,12 +665,27 @@ export function UsageLimitsSection() {
447 </h2>665 </h2>
448 ) : null}666 ) : null}
449 {group.providers.map((provider) => (667 {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 />
451 ))}674 ))}
452 </div>675 </div>
453 ))}676 ))}
454 {addHubButton ? <div>{addHubButton}</div> : null}677 {targetEnvironment && canOperateTarget ? (
455 <AddUsageLimitSourceDialog open={adding} onOpenChange={setAdding} />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}
456 </div>689 </div>
457 );690 );
458}691}
packages/client-runtime/src/state/server.ts+9
...@@ -902,6 +902,15 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -902,6 +902,15 @@ export function createServerEnvironmentAtoms<R, E>(
902 Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),902 Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),
903 ),903 ),
904 }),904 }),
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 }),
905 refreshProviders: createEnvironmentRpcCommand(runtime, {914 refreshProviders: createEnvironmentRpcCommand(runtime, {
906 label: "environment-data:server:refresh-providers",915 label: "environment-data:server:refresh-providers",
907 tag: WS_METHODS.serverRefreshProviders,916 tag: WS_METHODS.serverRefreshProviders,
packages/contracts/src/providerUsageLimits.ts+32-1
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 NonNegativeInt,6 NonNegativeInt,
7 TrimmedNonEmptyString,7 TrimmedNonEmptyString,
8} from "./baseSchemas.ts";8} from "./baseSchemas.ts";
9import { ProviderDriverKind } from "./providerInstance.ts";9import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
10import { UsageLimitSourceId } from "./usageLimitSourceId.ts";10import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
1111
12/**12/**
...@@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({...@@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({
27});27});
28export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;28export 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
30/**41/**
31 * Subscription usage the provider knows about the signed-in account.42 * Subscription usage the provider knows about the signed-in account.
32 *43 *
...@@ -37,6 +48,7 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;...@@ -37,6 +48,7 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type;
37export const ServerProviderUsageLimits = Schema.Struct({48export const ServerProviderUsageLimits = Schema.Struct({
38 checkedAt: IsoDateTime,49 checkedAt: IsoDateTime,
39 windows: ForwardCompatibleArray(ServerProviderUsageWindow),50 windows: ForwardCompatibleArray(ServerProviderUsageWindow),
51 resetCredits: Schema.optional(ServerProviderResetCredits),
40 unavailable: Schema.optional(52 unavailable: Schema.optional(
41 Schema.Struct({53 Schema.Struct({
42 reason: Schema.Literals(["unsupported", "probeFailed"]),54 reason: Schema.Literals(["unsupported", "probeFailed"]),
...@@ -90,3 +102,22 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;...@@ -90,3 +102,22 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type;
90102
91export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);103export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot);
92export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type;104export 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 {...@@ -206,6 +206,10 @@ import {
206 ResourceTelemetryRetryResult,206 ResourceTelemetryRetryResult,
207 ResourceTelemetrySnapshot,207 ResourceTelemetrySnapshot,
208} from "./resourceTelemetry.ts";208} from "./resourceTelemetry.ts";
209import {
210 ProviderConsumeResetCreditInput,
211 ProviderConsumeResetCreditResult,
212} from "./providerUsageLimits.ts";
209import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";213import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
210import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";214import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
211import {215import {
...@@ -243,6 +247,7 @@ export const WS_METHODS = {...@@ -243,6 +247,7 @@ export const WS_METHODS = {
243 // Provider methods247 // Provider methods
244 providerUploadFeedback: "provider.uploadFeedback",248 providerUploadFeedback: "provider.uploadFeedback",
245 providerAuthStart: "provider.auth.start",249 providerAuthStart: "provider.auth.start",
250 providerConsumeResetCredit: "provider.consumeResetCredit",
246 providerAuthComplete: "provider.auth.complete",251 providerAuthComplete: "provider.auth.complete",
247 providerAuthCancel: "provider.auth.cancel",252 providerAuthCancel: "provider.auth.cancel",
248 providerAuthLogout: "provider.auth.logout",253 providerAuthLogout: "provider.auth.logout",
...@@ -410,6 +415,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide...@@ -410,6 +415,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide
410415
411const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]);416const 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
413export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {424export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
414 payload: ProviderSetupInput,425 payload: ProviderSetupInput,
415 success: ProviderAuthState,426 success: ProviderAuthState,
...@@ -1157,6 +1168,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1157,6 +1168,7 @@ export const WsRpcGroup = RpcGroup.make(
1157 WsServerGetConfigRpc,1168 WsServerGetConfigRpc,
1158 WsServerRefreshProvidersRpc,1169 WsServerRefreshProvidersRpc,
1159 WsServerUpdateProviderRpc,1170 WsServerUpdateProviderRpc,
1171 WsProviderConsumeResetCreditRpc,
1160 WsProviderAuthStartRpc,1172 WsProviderAuthStartRpc,
1161 WsProviderAuthCompleteRpc,1173 WsProviderAuthCompleteRpc,
1162 WsProviderAuthCancelRpc,1174 WsProviderAuthCancelRpc,