| author | |
| committer | |
| log | 1641b4aba58ab495cad7a9800b173af3416e16dd |
| tree | e782ad752e986dddc53215a69601514d7ced2016 |
| parent | 232de5e8aac61e6f3de4bc61d10746167db9e905 |
| signature | Signed by PGP key B5690EEEBB952194 |
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):

Close-up of the row:

Clicking it opens the confirmation; nothing is sent until **Use credit**:

## 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | 2 | import 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"; |
| 16 | import { useState } from "react"; | 21 | import { type ReactNode, useState } from "react"; |
| 17 | import { View } from "react-native"; | 22 | import { Alert, Pressable, View } from "react-native"; |
| 18 | 23 | ||
| 19 | import { AppText as Text } from "../../components/AppText"; | 24 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { environmentPresentations } from "../../state/presentation"; | 25 | import { environmentPresentations } from "../../state/presentation"; |
| 26 | import { serverEnvironment } from "../../state/server"; | ||
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 21 | import { SettingsSection } from "../settings/components/SettingsSection"; | 28 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 22 | 29 | ||
| 23 | const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; | 30 | const 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 | |||
| 104 | const 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 | */ | ||
| 116 | function 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 | } |
| 94 | 187 | ||
| 95 | function ProviderLimits(props: { | 188 | function 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 | <AccountLimits | 197 | <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 | <ProviderLimits | 277 | <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"; |
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | import { ProviderDriverError } from "../Errors.ts"; | 37 | import { ProviderDriverError } from "../Errors.ts"; |
| 38 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | 38 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
| 39 | import { | ||
| 40 | CODEX_RESET_CREDIT_TIMEOUT, | ||
| 41 | CodexResetCreditCoordinator, | ||
| 42 | } from "../Layers/codexResetCredit.ts"; | ||
| 39 | import { | 43 | import { |
| 40 | checkCodexProviderStatus, | 44 | checkCodexProviderStatus, |
| 41 | makePendingCodexProvider, | 45 | makePendingCodexProvider, |
| 42 | probeCodexSkillsForCwd, | 46 | probeCodexSkillsForCwd, |
| 47 | withCodexAppServerClient, | ||
| 43 | } from "../Layers/CodexProvider.ts"; | 48 | } from "../Layers/CodexProvider.ts"; |
| 44 | import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; | 49 | import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; |
| 45 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 50 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| ... | @@ -81,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ | ... | @@ -81,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 81 | export type CodexDriverEnv = | 86 | export type CodexDriverEnv = |
| 82 | | BackgroundPolicy.BackgroundPolicy | 87 | | BackgroundPolicy.BackgroundPolicy |
| 83 | | ChildProcessSpawner.ChildProcessSpawner | 88 | | ChildProcessSpawner.ChildProcessSpawner |
| 89 | | CodexResetCreditCoordinator | ||
| 84 | | Crypto.Crypto | 90 | | Crypto.Crypto |
| 85 | | FileSystem.FileSystem | 91 | | FileSystem.FileSystem |
| 86 | | HttpClient.HttpClient | 92 | | 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 | ); |
| 231 | 238 | ||
| 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"; |
| 43 | import packageJson from "../../../package.json" with { type: "json" }; | 44 | import packageJson from "../../../package.json" with { type: "json" }; |
| 44 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); | 45 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 45 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; | 46 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; |
| 46 | 47 | ||
| 47 | type CodexRateLimitsProbe = | 48 | type 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 }; |
| 50 | 54 | ||
| 51 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | 55 | const 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 | } |
| 336 | 340 | ||
| 337 | const 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 | */ | ||
| 347 | export 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 trip | 355 | // 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* spawner | 369 | 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 | ); |
| 386 | 392 | 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 | |||
| 397 | const 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); | ||
| 398 | 406 | ||
| 399 | // Extract the version string after the first '/' in userAgent, up to the next space or the end | 407 | // 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 "no | 427 | // 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 | }); | ||
| 686 | 666 | ||
| 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"; |
| 52 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; | 52 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; |
| 53 | import * as ModelManifest from "../ModelManifest.ts"; | 53 | import * as ModelManifest from "../ModelManifest.ts"; |
| 54 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | 54 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; |
| 55 | import * as CodexResetCredit from "./codexResetCredit.ts"; | ||
| 55 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 56 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 56 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | 57 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; |
| 57 | 58 | ||
| ... | @@ -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 | ); |
| 156 | 158 | ||
| 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 | ); |
| 325 | 328 | ||
| 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"; |
| 37 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 37 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 38 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | 38 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; |
| 39 | import * as ModelManifest from "../ModelManifest.ts"; | 39 | import * as ModelManifest from "../ModelManifest.ts"; |
| 40 | import * as CodexResetCredit from "./codexResetCredit.ts"; | ||
| 40 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 41 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 41 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | 42 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; |
| 42 | import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; | 43 | import { 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 the | 2014 | // 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 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; | ||
| 8 | |||
| 9 | describe("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 | */ | ||
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Duration from "effect/Duration"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import type * as PlatformError from "effect/PlatformError"; | ||
| 18 | import * as Ref from "effect/Ref"; | ||
| 19 | import * 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 | */ | ||
| 26 | export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); | ||
| 27 | |||
| 28 | interface AccountRedemptionState { | ||
| 29 | readonly lock: Semaphore.Semaphore; | ||
| 30 | readonly pendingKey: Ref.Ref<string | null>; | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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 | |||
| 48 | export 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 | |||
| 90 | export 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 | */ | ||
| 96 | export 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"; |
| 9 | 10 | ||
| 10 | const checkedAt = "2026-07-18T10:00:00.000Z"; | 11 | const 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 | |||
| 106 | describe("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 | */ |
| 9 | import type { | 9 | import 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 | } |
| 32 | 33 | ||
| 34 | /** Structural view of the read response's `rateLimitResetCredits`. */ | ||
| 35 | export interface CodexResetCreditsSummary { | ||
| 36 | readonly availableCount: number; | ||
| 37 | readonly credits?: ReadonlyArray<{ | ||
| 38 | readonly status: string; | ||
| 39 | readonly expiresAt?: number | null; | ||
| 40 | }> | null; | ||
| 41 | } | ||
| 42 | |||
| 33 | const SESSION_MINS = 5 * 60; | 43 | const SESSION_MINS = 5 * 60; |
| 34 | const WEEK_MINS = 7 * 24 * 60; | 44 | const WEEK_MINS = 7 * 24 * 60; |
| 35 | const MONTH_MINS = 30 * 24 * 60; | 45 | const 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 | } |
| 84 | 94 | ||
| 95 | export 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 | |||
| 85 | export function codexRateLimitsToLimits(input: { | 111 | export 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 | } |
| 94 | 125 | ||
| 95 | export function codexRateLimitsToUpdate( | 126 | export function codexRateLimitsToUpdate( |
apps/server/src/provider/ProviderDriver.ts+10| ... | @@ -22,6 +22,7 @@ | ... | @@ -22,6 +22,7 @@ |
| 22 | * @module provider/ProviderDriver | 22 | * @module provider/ProviderDriver |
| 23 | */ | 23 | */ |
| 24 | import type { | 24 | import 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 |
| 34 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; | 34 | import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; |
| 35 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 35 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 36 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 36 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 37 | import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; | ||
| 37 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 38 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 38 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 39 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 39 | import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; | 40 | import { 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, refreshed | 480 | // `ModelManifest.layer` is the legacy-model classification data, refreshed |
| 480 | // from the repo's `model-manifest.json` on `main` and applied by the | 481 | // 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 old | 486 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 484 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | 487 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but |
| 485 | // the rewritten registry reads snapshots off the instance registry and | 488 | // 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 @@ |
| 1 | import { UsageLimitSourceId } from "@t3tools/contracts"; | 1 | import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | 3 | ||
| 4 | import { useUpdatePrimarySettings } from "../../hooks/useSettings"; | 4 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; |
| 5 | import { Button } from "../ui/button"; | 5 | import { Button } from "../ui/button"; |
| 6 | import { | 6 | import { |
| 7 | Dialog, | 7 | Dialog, |
| ... | @@ -35,18 +35,22 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId { | ... | @@ -35,18 +35,22 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId { |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 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 | */ |
| 42 | export function AddUsageLimitSourceDialog({ | 42 | export 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 @@ |
| 1 | import type { | 1 | import { |
| 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 { |
| 20 | import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | 24 | import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| 21 | import { Fragment, useState } from "react"; | 25 | import { Fragment, useState } from "react"; |
| 22 | 26 | ||
| 27 | import { isElectron } from "../../env"; | ||
| 28 | import { usePrimarySessionState } from "../../environments/primary"; | ||
| 29 | import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 23 | import { | 30 | import { |
| 24 | usePrimarySettings, | 31 | type EnvironmentPresentation, |
| 25 | usePrimarySettingsAvailable, | 32 | useEnvironments, |
| 26 | useUpdatePrimarySettings, | 33 | usePrimaryEnvironmentId, |
| 27 | } from "../../hooks/useSettings"; | 34 | } from "../../state/environments"; |
| 28 | import { usePrimaryEnvironmentId } from "../../state/environments"; | 35 | import { useEnvironmentSessionState } from "../../state/session"; |
| 29 | import { environmentPresentations } from "../../state/presentation"; | 36 | import { environmentPresentations } from "../../state/presentation"; |
| 37 | import { serverEnvironment } from "../../state/server"; | ||
| 38 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 30 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | 39 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| 31 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 40 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 32 | import { getDriverOption } from "../settings/providerDriverMeta"; | 41 | import { getDriverOption } from "../settings/providerDriverMeta"; |
| 42 | import { | ||
| 43 | resolvePrimaryOperateAccess, | ||
| 44 | resolveRemoteOperateAccess, | ||
| 45 | } from "../settings/ProviderSettingsPanel.logic"; | ||
| 33 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | 46 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; |
| 34 | import { | 47 | import { |
| 35 | AlertDialog, | 48 | AlertDialog, |
| ... | @@ -42,6 +55,7 @@ import { | ... | @@ -42,6 +55,7 @@ import { |
| 42 | } from "../ui/alert-dialog"; | 55 | } from "../ui/alert-dialog"; |
| 43 | import { Badge } from "../ui/badge"; | 56 | import { Badge } from "../ui/badge"; |
| 44 | import { Button } from "../ui/button"; | 57 | import { Button } from "../ui/button"; |
| 58 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 59 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 46 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; | 60 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; |
| 47 | import { PROVIDER_PRESENTATION } from "./usageProviders"; | 61 | import { PROVIDER_PRESENTATION } from "./usageProviders"; |
| ... | @@ -245,9 +259,11 @@ function AccountHeading({ | ... | @@ -245,9 +259,11 @@ function AccountHeading({ |
| 245 | 259 | ||
| 246 | function ProviderLimits({ | 260 | function 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 | } |
| 273 | 297 | ||
| 298 | const 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 | */ | ||
| 309 | function 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. */ |
| 275 | function SourceAccountLimits({ | 382 | function SourceAccountLimits({ |
| 276 | account, | 383 | account, |
| ... | @@ -386,6 +493,58 @@ function SourceLimits({ | ... | @@ -386,6 +493,58 @@ function SourceLimits({ |
| 386 | ); | 493 | ); |
| 387 | } | 494 | } |
| 388 | 495 | ||
| 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 | */ | ||
| 502 | function 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. */ | ||
| 527 | function 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 would | 550 | * 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()); |
| 405 | 562 | ||
| 406 | // The patch names only this entry, so two edits in flight cannot clobber | 563 | // 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( |
| 411 | 568 | (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 hub | 572 | (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); | ||
| 418 | 581 | ||
| 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 | <SourceLimits | 658 | <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"; |
| 9 | import { ProviderDriverKind } from "./providerInstance.ts"; | 9 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 10 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; | 10 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; |
| 11 | 11 | ||
| 12 | /** | 12 | /** |
| ... | @@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({ | ... | @@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({ |
| 27 | }); | 27 | }); |
| 28 | export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; | 28 | export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; |
| 29 | 29 | ||
| 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 | */ | ||
| 35 | export const ServerProviderResetCredits = Schema.Struct({ | ||
| 36 | availableCount: NonNegativeInt, | ||
| 37 | nextExpiresAt: Schema.optional(IsoDateTime), | ||
| 38 | }); | ||
| 39 | export 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; |
| 37 | export const ServerProviderUsageLimits = Schema.Struct({ | 48 | export 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; |
| 90 | 102 | ||
| 91 | export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); | 103 | export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); |
| 92 | export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; | 104 | export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; |
| 105 | |||
| 106 | export const ProviderConsumeResetCreditInput = Schema.Struct({ | ||
| 107 | instanceId: ProviderInstanceId, | ||
| 108 | }); | ||
| 109 | export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; | ||
| 110 | |||
| 111 | /** Mirrors Codex's own outcome set; other providers map onto it. */ | ||
| 112 | export const ProviderConsumeResetCreditOutcome = Schema.Literals([ | ||
| 113 | "reset", | ||
| 114 | "nothingToReset", | ||
| 115 | "noCredit", | ||
| 116 | "alreadyRedeemed", | ||
| 117 | ]); | ||
| 118 | export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCreditOutcome.Type; | ||
| 119 | |||
| 120 | export const ProviderConsumeResetCreditResult = Schema.Struct({ | ||
| 121 | outcome: ProviderConsumeResetCreditOutcome, | ||
| 122 | }); | ||
| 123 | export 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"; |
| 209 | import { | ||
| 210 | ProviderConsumeResetCreditInput, | ||
| 211 | ProviderConsumeResetCreditResult, | ||
| 212 | } from "./providerUsageLimits.ts"; | ||
| 209 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; | 213 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; |
| 210 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 214 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 211 | import { | 215 | import { |
| ... | @@ -243,6 +247,7 @@ export const WS_METHODS = { | ... | @@ -243,6 +247,7 @@ export const WS_METHODS = { |
| 243 | // Provider methods | 247 | // 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 |
| 410 | 415 | ||
| 411 | const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); | 416 | const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); |
| 412 | 417 | ||
| 418 | export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { | ||
| 419 | payload: ProviderConsumeResetCreditInput, | ||
| 420 | success: ProviderConsumeResetCreditResult, | ||
| 421 | error: ProviderSetupRpcError, | ||
| 422 | }); | ||
| 423 | |||
| 413 | export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { | 424 | export 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, |