@opengeni/api-router 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,23 +18,38 @@ import {
18
18
  CODEX_PROVIDER_ID,
19
19
  CODEX_WEEKLY_WINDOW_SECONDS,
20
20
  CodexDeviceError,
21
+ consumeCodexRateLimitResetCredit,
21
22
  exchangeDeviceCode,
22
23
  fetchCodexModels,
23
24
  parseIdToken,
24
25
  pollDeviceCode,
25
26
  startDeviceCode,
26
27
  type CodexUsagePayload,
28
+ type CodexFetch,
29
+ type CodexRateLimitResetCredit,
30
+ type CodexRateLimitResetCreditsDetails,
27
31
  } from "@opengeni/codex";
28
32
  import {
33
+ abandonCodexResetRedemptionBeforeProvider,
34
+ adoptCodexResetRedemptionAttempt,
35
+ buildCodexTokenResolver,
36
+ claimCodexResetRedemption,
37
+ completeCodexResetRedemption,
29
38
  disconnectAllCodexAccounts,
30
39
  disconnectCodexAccount,
31
40
  encryptEnvironmentValue,
32
41
  ensureCodexRotationSettings,
33
42
  fetchCodexUsageForAccount,
43
+ fetchCodexRateLimitResetCreditsForAccount,
44
+ fenceCodexResetRedemptionSend,
45
+ getCodexResetRedemptionAttempt,
34
46
  getCodexCredentialStatus,
35
47
  getCodexRotationSettings,
36
48
  listPendingCodexCapacityWakeTargets,
37
49
  listCodexAccountStatuses,
50
+ listCodexResetRedemptionRecoveries,
51
+ releaseCodexResetRedemptionClaim,
52
+ updateCodexAllocatorEligibility,
38
53
  loadCodexCredentialForRun,
39
54
  renameCodexAccount,
40
55
  setActiveCodexCredential,
@@ -76,6 +91,11 @@ function codexAccountJson(row: CodexAccountStatus) {
76
91
  CODEX_WEEKLY_WINDOW_SECONDS,
77
92
  ),
78
93
  usageCheckedAt: row.usageCheckedAt,
94
+ allocatorEnabled: row.allocatorEnabled,
95
+ allocatorVersion: row.allocatorVersion,
96
+ allocatorUpdatedAt: row.allocatorUpdatedAt,
97
+ resetCreditAvailableCount: row.resetCreditAvailableCount,
98
+ resetCreditsCheckedAt: row.resetCreditsCheckedAt,
79
99
  // P3 rotation cooldown: when set and in the future, this account is cooling-down.
80
100
  exhaustedUntil: row.exhaustedUntil,
81
101
  };
@@ -91,9 +111,13 @@ function codexUsageJson(payload: CodexUsagePayload): {
91
111
  return { status: payload.status, usage: payload };
92
112
  }
93
113
 
94
- export function codexModelsForPicker(
95
- liveSlugs: readonly string[],
96
- ): Array<{ id: string; label: string; provider: string; providerLabel: string; api: "responses" }> {
114
+ export function codexModelsForPicker(liveSlugs: readonly string[]): Array<{
115
+ id: string;
116
+ label: string;
117
+ provider: string;
118
+ providerLabel: string;
119
+ api: "responses";
120
+ }> {
97
121
  const available = new Set(liveSlugs);
98
122
  const missing = CODEX_FALLBACK_MODEL_SLUGS.filter((slug) => !available.has(slug));
99
123
  if (missing.length > 0) {
@@ -108,10 +132,340 @@ export function codexModelsForPicker(
108
132
  }));
109
133
  }
110
134
  import { createSignedState, readSignedState } from "@opengeni/github";
111
- import type { ApiRouteDeps } from "@opengeni/core";
112
- import type { Hono } from "hono";
135
+ import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
136
+ import type { Context, Hono } from "hono";
113
137
  import { HTTPException } from "hono/http-exception";
114
- import { requireAccessGrant } from "@opengeni/core";
138
+ import * as z from "zod/v4";
139
+ import {
140
+ hashCodexBrowserSession,
141
+ signCodexRedemptionConfirmation,
142
+ verifyCodexRedemptionConfirmation,
143
+ } from "../codex-redemption-security";
144
+
145
+ const CODEX_OVERVIEW_STALE_MS = 15 * 60_000;
146
+ const CODEX_REDEMPTION_CONFIRMATION_SECONDS = 5 * 60;
147
+ const CODEX_REDEMPTION_CONFIRMATION = "REDEEM_USAGE_LIMIT_RESET";
148
+
149
+ const redemptionPrepareBody = z.object({
150
+ attemptId: z.string().uuid(),
151
+ creditId: z.string().min(1).max(1024),
152
+ });
153
+ const redemptionBody = redemptionPrepareBody.extend({
154
+ confirmationToken: z.string().min(1).max(8192),
155
+ confirmation: z.literal(CODEX_REDEMPTION_CONFIRMATION),
156
+ });
157
+
158
+ type ManagedCookieHuman = {
159
+ subjectId: string;
160
+ browserSessionHash: string;
161
+ };
162
+
163
+ async function managedCookieHuman(
164
+ c: Context,
165
+ deps: ApiRouteDeps,
166
+ ): Promise<ManagedCookieHuman | null> {
167
+ if (
168
+ deps.settings.productAccessMode !== "managed" ||
169
+ !deps.managedAuth ||
170
+ !c.req.header("cookie") ||
171
+ c.req.header("authorization")
172
+ ) {
173
+ return null;
174
+ }
175
+ const session = await deps.managedAuth.api.getSession({
176
+ headers: c.req.raw.headers,
177
+ });
178
+ if (!session?.user?.id || !session.session?.id) return null;
179
+ return {
180
+ subjectId: `user:${session.user.id}`,
181
+ browserSessionHash: await hashCodexBrowserSession(session.session.id),
182
+ };
183
+ }
184
+
185
+ function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
186
+ const contentType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
187
+ if (contentType !== "application/json") {
188
+ throw new HTTPException(403, {
189
+ message: "JSON browser request required",
190
+ });
191
+ }
192
+ if (!deps.settings.publicBaseUrl) {
193
+ throw new HTTPException(503, {
194
+ message: "managed browser origin is not configured",
195
+ });
196
+ }
197
+ const expectedOrigin = new URL(deps.settings.publicBaseUrl).origin;
198
+ if (c.req.header("origin") !== expectedOrigin) {
199
+ throw new HTTPException(403, {
200
+ message: "same-origin browser request required",
201
+ });
202
+ }
203
+ if (c.req.header("sec-fetch-site")?.toLowerCase() !== "same-origin") {
204
+ throw new HTTPException(403, {
205
+ message: "same-origin fetch metadata required",
206
+ });
207
+ }
208
+ }
209
+
210
+ async function requireRedemptionHuman(
211
+ c: Context,
212
+ deps: ApiRouteDeps,
213
+ workspaceId: string,
214
+ ): Promise<{ human: ManagedCookieHuman; accountId: string }> {
215
+ if (deps.settings.productAccessMode !== "managed") {
216
+ throw new HTTPException(403, {
217
+ message: "reset redemption requires managed product mode",
218
+ });
219
+ }
220
+ // Normal managed auth prefers a bearer over a cookie. This irreversible route
221
+ // rejects the header before grant resolution so an API key/delegated/agent
222
+ // token can never borrow a browser cookie that happens to ride along. Exact
223
+ // JSON content type plus Origin and Fetch Metadata fail closed before auth.
224
+ if (c.req.header("authorization")) {
225
+ throw new HTTPException(403, {
226
+ message: "authorization bearer is not allowed for redemption",
227
+ });
228
+ }
229
+ requireSameOriginBrowserMutation(c, deps);
230
+ const human = await managedCookieHuman(c, deps);
231
+ if (!human) {
232
+ throw new HTTPException(401, {
233
+ message: "managed browser session required",
234
+ });
235
+ }
236
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
237
+ if (grant.subjectId !== human.subjectId) {
238
+ throw new HTTPException(403, {
239
+ message: "managed browser identity mismatch",
240
+ });
241
+ }
242
+ return { human, accountId: grant.accountId };
243
+ }
244
+
245
+ function cachedUsage(row: CodexAccountStatus): CodexUsagePayload | null {
246
+ const fiveHour = buildCodexUsageWindowFromCache(
247
+ row.primaryUsedPercent,
248
+ row.primaryResetAt,
249
+ CODEX_FIVE_HOUR_WINDOW_SECONDS,
250
+ );
251
+ const weekly = buildCodexUsageWindowFromCache(
252
+ row.secondaryUsedPercent,
253
+ row.secondaryResetAt,
254
+ CODEX_WEEKLY_WINDOW_SECONDS,
255
+ );
256
+ if (!fiveHour && !weekly && row.resetCreditAvailableCount == null) return null;
257
+ const limitReached = (fiveHour?.percent ?? 0) >= 100 || (weekly?.percent ?? 0) >= 100;
258
+ return {
259
+ status: limitReached ? "limit_reached" : fiveHour || weekly ? "ok" : "no-data",
260
+ planType: row.planType,
261
+ fiveHour,
262
+ weekly,
263
+ limitReached,
264
+ fetchedAt: (row.usageCheckedAt ?? row.resetCreditsCheckedAt ?? new Date(0)).toISOString(),
265
+ rateLimitResetCredits:
266
+ row.resetCreditAvailableCount == null
267
+ ? null
268
+ : { availableCount: row.resetCreditAvailableCount, credits: null },
269
+ };
270
+ }
271
+
272
+ function staleAt(value: Date | null): boolean {
273
+ return !value || Date.now() - value.getTime() > CODEX_OVERVIEW_STALE_MS;
274
+ }
275
+
276
+ function sortedCredits(credits: CodexRateLimitResetCredit[]): CodexRateLimitResetCredit[] {
277
+ return [...credits].sort((left, right) => {
278
+ if (left.expiresAt == null && right.expiresAt == null) return left.id.localeCompare(right.id);
279
+ if (left.expiresAt == null) return 1;
280
+ if (right.expiresAt == null) return -1;
281
+ return left.expiresAt - right.expiresAt || left.id.localeCompare(right.id);
282
+ });
283
+ }
284
+
285
+ function actionableCredit(credit: CodexRateLimitResetCredit, nowSeconds = Date.now() / 1000) {
286
+ return (
287
+ credit.resetType === "codexRateLimits" &&
288
+ credit.status === "available" &&
289
+ (credit.expiresAt == null || credit.expiresAt > nowSeconds)
290
+ );
291
+ }
292
+
293
+ function freshActionableCredit(
294
+ details: CodexRateLimitResetCreditsDetails,
295
+ creditId: string,
296
+ ): CodexRateLimitResetCredit | null {
297
+ // `availableCount` counts available credits, while the provider detail array
298
+ // may also retain redeeming/redeemed rows. Compare only available detail rows
299
+ // (matching Codex v0.144.6's picker); missing/capped detail and unknown enums
300
+ // are never first-call authority.
301
+ const availableDetailCount = details.credits.filter(
302
+ (credit) => credit.status === "available",
303
+ ).length;
304
+ if (
305
+ details.availableCount !== availableDetailCount ||
306
+ details.credits.some((credit) => credit.resetType === "unknown" || credit.status === "unknown")
307
+ ) {
308
+ return null;
309
+ }
310
+ const credit = details.credits.find((candidate) => candidate.id === creditId);
311
+ return credit && actionableCredit(credit) ? credit : null;
312
+ }
313
+
314
+ type CodexProviderCall = <T>(operation: () => Promise<T>) => Promise<T>;
315
+
316
+ const CODEX_OVERVIEW_ROUTE_TIMEOUT_MS = 12_000;
317
+
318
+ function createProviderCallLimiter(limit: number): CodexProviderCall {
319
+ if (!Number.isInteger(limit) || limit <= 0) {
320
+ throw new Error("Codex provider concurrency limit must be a positive integer");
321
+ }
322
+ let permits = limit;
323
+ const waiters: Array<() => void> = [];
324
+ const acquire = async (): Promise<void> => {
325
+ if (permits > 0) {
326
+ permits -= 1;
327
+ return;
328
+ }
329
+ await new Promise<void>((resolve) => waiters.push(resolve));
330
+ };
331
+ const release = (): void => {
332
+ const next = waiters.shift();
333
+ if (next) next();
334
+ else permits += 1;
335
+ };
336
+ return async <T>(operation: () => Promise<T>): Promise<T> => {
337
+ await acquire();
338
+ try {
339
+ return await operation();
340
+ } finally {
341
+ release();
342
+ }
343
+ };
344
+ }
345
+
346
+ async function fetchCodexAccountOverview(
347
+ deps: ApiRouteDeps,
348
+ workspaceId: string,
349
+ row: CodexAccountStatus,
350
+ canRedeem: boolean,
351
+ canResumeRedemption: boolean,
352
+ redemptions: Awaited<ReturnType<typeof listCodexResetRedemptionRecoveries>> = [],
353
+ providerCall: CodexProviderCall = async (operation) => await operation(),
354
+ ) {
355
+ const fetchImpl = (deps.codexFetch ?? fetch) as CodexFetch;
356
+ const [usageSettled, detailsSettled] = await Promise.allSettled([
357
+ providerCall(
358
+ async () =>
359
+ await fetchCodexUsageForAccount(deps.db, deps.settings, workspaceId, row.id, fetchImpl),
360
+ ),
361
+ providerCall(
362
+ async () =>
363
+ await fetchCodexRateLimitResetCreditsForAccount(
364
+ deps.db,
365
+ deps.settings,
366
+ workspaceId,
367
+ row.id,
368
+ fetchImpl,
369
+ ),
370
+ ),
371
+ ]);
372
+ const liveUsage = usageSettled.status === "fulfilled" ? usageSettled.value : null;
373
+ const cached = cachedUsage(row);
374
+ const usageFromProvider = liveUsage != null && liveUsage.status !== "error";
375
+ const usageValue = usageFromProvider ? liveUsage : cached;
376
+ const usageSource = usageFromProvider ? "provider" : cached ? "cache" : "none";
377
+ const liveSummary = liveUsage?.rateLimitResetCredits ?? null;
378
+ const detailsResult = detailsSettled.status === "fulfilled" ? detailsSettled.value : null;
379
+ const details = detailsResult?.ok ? detailsResult.details : null;
380
+ const availableCount =
381
+ details?.availableCount ?? liveSummary?.availableCount ?? row.resetCreditAvailableCount;
382
+ const availableDetailCount =
383
+ details?.credits.filter((credit) => credit.status === "available").length ?? 0;
384
+ const availableDetailsComplete = !!details && details.availableCount === availableDetailCount;
385
+ const availableDetailsCapped = !!details && availableDetailCount < details.availableCount;
386
+ const availableDetailsImpossible = !!details && availableDetailCount > details.availableCount;
387
+ const summaryAgrees =
388
+ !details || liveSummary == null || liveSummary.availableCount === details.availableCount;
389
+ const hasUnknown =
390
+ details?.credits.some(
391
+ (credit) => credit.resetType === "unknown" || credit.status === "unknown",
392
+ ) ?? false;
393
+ const detailsComplete = availableDetailsComplete && summaryAgrees && !hasUnknown;
394
+ let detailState: "detailed" | "count_only" | "capped" | "unsupported" | "unknown" | "error";
395
+ if (details) {
396
+ detailState =
397
+ !summaryAgrees || hasUnknown || availableDetailsImpossible
398
+ ? "unknown"
399
+ : availableDetailsCapped
400
+ ? "capped"
401
+ : "detailed";
402
+ } else if (availableCount != null) {
403
+ detailState = "count_only";
404
+ } else if (detailsResult && !detailsResult.ok && detailsResult.reason === "invalid_response") {
405
+ detailState = "unknown";
406
+ } else if (
407
+ detailsResult &&
408
+ !detailsResult.ok &&
409
+ detailsResult.reason === "http_error" &&
410
+ detailsResult.status === 404
411
+ ) {
412
+ detailState = "unsupported";
413
+ } else {
414
+ detailState = "error";
415
+ }
416
+ const resetSource =
417
+ details || liveSummary ? "provider" : availableCount != null ? "cache" : "none";
418
+ const sorted = sortedCredits(details?.credits ?? []);
419
+ const actionAuthority = canRedeem && detailsComplete && detailState === "detailed";
420
+ return {
421
+ accountId: row.id,
422
+ usage: {
423
+ source: usageSource,
424
+ fetchedAt: usageValue?.fetchedAt ?? null,
425
+ stale: usageSource === "provider" ? false : staleAt(row.usageCheckedAt),
426
+ error:
427
+ liveUsage?.status === "error"
428
+ ? (liveUsage.reason ?? "unavailable")
429
+ : usageSettled.status === "rejected"
430
+ ? "unavailable"
431
+ : null,
432
+ value: usageValue,
433
+ },
434
+ resetCredits: {
435
+ source: resetSource,
436
+ fetchedAt:
437
+ resetSource === "provider"
438
+ ? (liveUsage?.fetchedAt ?? new Date().toISOString())
439
+ : (row.resetCreditsCheckedAt?.toISOString() ?? null),
440
+ stale: resetSource === "provider" ? false : staleAt(row.resetCreditsCheckedAt),
441
+ error:
442
+ detailsResult && !detailsResult.ok
443
+ ? detailsResult.reason
444
+ : detailsSettled.status === "rejected"
445
+ ? "unavailable"
446
+ : null,
447
+ detailState,
448
+ detailsComplete,
449
+ availableCount: availableCount ?? null,
450
+ credits: sorted.map((credit) => ({
451
+ ...credit,
452
+ actionable: actionAuthority && actionableCredit(credit),
453
+ })),
454
+ },
455
+ canRedeem,
456
+ canResumeRedemption,
457
+ redemptions: redemptions.map((redemption) => ({
458
+ attemptId: redemption.attemptId,
459
+ creditId: redemption.creditId,
460
+ status: redemption.status,
461
+ outcome: redemption.outcome,
462
+ providerStartedAt: redemption.providerStartedAt?.toISOString() ?? null,
463
+ completedAt: redemption.completedAt?.toISOString() ?? null,
464
+ createdAt: redemption.createdAt.toISOString(),
465
+ updatedAt: redemption.updatedAt.toISOString(),
466
+ })),
467
+ };
468
+ }
115
469
 
116
470
  type CodexConnectState = {
117
471
  workspaceId?: string;
@@ -200,7 +554,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
200
554
  !payload.deviceAuthId ||
201
555
  !payload.userCode
202
556
  ) {
203
- throw new HTTPException(400, { message: "codex connect state is invalid or expired" });
557
+ throw new HTTPException(400, {
558
+ message: "codex connect state is invalid or expired",
559
+ });
204
560
  }
205
561
  // The device code itself expires 15 minutes after start; surface that to the
206
562
  // client (the 1-hour signed-state TTL is longer than the device window).
@@ -241,6 +597,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
241
597
  });
242
598
  }
243
599
  const id = parseIdToken(tokens.idToken);
600
+ const connectingHuman = await managedCookieHuman(c, deps);
244
601
  const key = environmentsEncryptionKeyBytes(settings);
245
602
  if (!key) {
246
603
  throw new HTTPException(500, {
@@ -271,11 +628,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
271
628
  lastRefreshAt: new Date(),
272
629
  accountEmail: id.email ?? null,
273
630
  label: id.email ?? id.chatgptAccountId ?? null,
631
+ connectedBySubjectId:
632
+ connectingHuman?.subjectId === grant.subjectId ? connectingHuman.subjectId : null,
274
633
  });
275
- return { result: upserted, changed: true };
634
+ return { result: upserted, changed: upserted.kind === "upserted" };
276
635
  },
277
636
  );
278
637
  const upserted = mutation.result;
638
+ if (upserted.kind === "unresolved_redemption") {
639
+ throw new HTTPException(409, {
640
+ message:
641
+ "this subscription has an unresolved reset redemption; recover it before changing ownership",
642
+ });
643
+ }
279
644
  // Ensure the per-workspace rotation-settings row exists, then auto-activate
280
645
  // the FIRST account only. Additional new accounts do NOT auto-activate — a
281
646
  // manual switch is required (no auto-rotation in P1). A re-connect of the
@@ -432,7 +797,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
432
797
  );
433
798
  const updated = mutation.result;
434
799
  if (!updated) {
435
- throw new HTTPException(404, { message: "codex rotation settings not found" });
800
+ throw new HTTPException(404, {
801
+ message: "codex rotation settings not found",
802
+ });
436
803
  }
437
804
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
438
805
  return c.json({
@@ -462,6 +829,45 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
462
829
  return c.json(codexAccountJson(row));
463
830
  });
464
831
 
832
+ // Codex quota: independent OCC for new-turn allocator eligibility. Same-state is
833
+ // idempotent even with a stale expected version; conflicting stale state is
834
+ // an explicit 409 carrying the current version.
835
+ app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId/allocator", async (c) => {
836
+ const workspaceId = c.req.param("workspaceId");
837
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
838
+ const parsed = z
839
+ .object({
840
+ enabled: z.boolean(),
841
+ expectedVersion: z.number().int().positive(),
842
+ })
843
+ .safeParse(await c.req.json().catch(() => null));
844
+ if (!parsed.success) {
845
+ throw new HTTPException(400, {
846
+ message: "enabled and expectedVersion are required",
847
+ });
848
+ }
849
+ const mutation = await updateCodexAllocatorEligibility(db, {
850
+ accountId: grant.accountId,
851
+ workspaceId,
852
+ credentialId: c.req.param("accountId"),
853
+ subjectId: grant.subjectId,
854
+ enabled: parsed.data.enabled,
855
+ expectedVersion: parsed.data.expectedVersion,
856
+ });
857
+ const result = mutation.result;
858
+ if (result.kind === "not_found") {
859
+ throw new HTTPException(404, { message: "codex account not found" });
860
+ }
861
+ const response = {
862
+ allocatorEnabled: result.allocatorEnabled,
863
+ allocatorVersion: result.allocatorVersion,
864
+ allocatorUpdatedAt: result.allocatorUpdatedAt,
865
+ changed: result.kind === "updated",
866
+ };
867
+ await signalCodexCapacityTargets(deps, mutation.wakeTargets);
868
+ return result.kind === "conflict" ? c.json(response, 409) : c.json(response);
869
+ });
870
+
465
871
  // Disconnect ONE account by id. The accessor re-picks active when the removed
466
872
  // row was active (FK ON DELETE SET NULL + re-pick in the same RLS txn).
467
873
  app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
@@ -477,6 +883,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
477
883
  },
478
884
  );
479
885
  const result = mutation.result;
886
+ if (result.blockedByUnresolvedRedemption) {
887
+ throw new HTTPException(409, {
888
+ message:
889
+ "this subscription has an unresolved reset redemption; recover it before disconnecting",
890
+ });
891
+ }
480
892
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
481
893
  return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
482
894
  });
@@ -490,13 +902,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
490
902
  db,
491
903
  { workspaceId, reason: "codex_credentials_disconnected" },
492
904
  async (tx) => {
493
- const removed = await disconnectAllCodexAccounts(tx, workspaceId);
494
- return { result: removed, changed: removed > 0 };
905
+ const result = await disconnectAllCodexAccounts(tx, workspaceId);
906
+ return { result, changed: result.removed > 0 };
495
907
  },
496
908
  );
497
- const removed = mutation.result;
909
+ const result = mutation.result;
910
+ if (result.blockedCredentialIds.length > 0) {
911
+ throw new HTTPException(409, {
912
+ message:
913
+ "one or more subscriptions have unresolved reset redemptions; recover them before disconnecting",
914
+ });
915
+ }
498
916
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
499
- return c.json({ disconnected: removed > 0 });
917
+ return c.json({ disconnected: result.removed > 0 });
500
918
  });
501
919
 
502
920
  // Back-compat: remaining usage / limits for the ACTIVE account only. Repointed
@@ -507,7 +925,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
507
925
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
508
926
  const status = await getCodexCredentialStatus(db, workspaceId);
509
927
  if (!status?.credentialId) {
510
- throw new HTTPException(404, { message: "codex subscription is not connected" });
928
+ throw new HTTPException(404, {
929
+ message: "codex subscription is not connected",
930
+ });
511
931
  }
512
932
  const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
513
933
  await signalPendingCodexCapacityTargets(deps, workspaceId);
@@ -564,6 +984,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
564
984
  weekly: null,
565
985
  limitReached: false,
566
986
  fetchedAt: new Date().toISOString(),
987
+ rateLimitResetCredits: null,
567
988
  },
568
989
  };
569
990
  }
@@ -574,4 +995,435 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
574
995
  await signalPendingCodexCapacityTargets(deps, workspaceId);
575
996
  return c.json({ usage });
576
997
  });
998
+
999
+ // Trustworthy live overview: usage and reset details settle independently per
1000
+ // account and one failed subscription cannot sink the batch. Provider calls
1001
+ // are capped at four accounts at a time and never run on a browser interval.
1002
+ app.get("/v1/workspaces/:workspaceId/codex/overview", async (c) => {
1003
+ const workspaceId = c.req.param("workspaceId");
1004
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1005
+ const human = await managedCookieHuman(c, deps);
1006
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
1007
+ const ownerRecoveries =
1008
+ human &&
1009
+ human.subjectId === grant.subjectId &&
1010
+ hasPermission(grant.permissions, "workspace:admin")
1011
+ ? await listCodexResetRedemptionRecoveries(db, {
1012
+ accountId: grant.accountId,
1013
+ workspaceId,
1014
+ subjectId: human.subjectId,
1015
+ })
1016
+ : [];
1017
+ const overview: Record<string, Awaited<ReturnType<typeof fetchCodexAccountOverview>>> = {};
1018
+ const queue = [...accounts];
1019
+ // Usage + detailed inventory are two independent calls per account. Limit
1020
+ // the actual provider calls, not merely account workers, so aggregate
1021
+ // concurrency never exceeds four.
1022
+ const providerCall = createProviderCallLimiter(4);
1023
+ let routeTimedOut = false;
1024
+ const worker = async (): Promise<void> => {
1025
+ for (;;) {
1026
+ if (routeTimedOut) return;
1027
+ const account = queue.shift();
1028
+ if (!account) return;
1029
+ const canResumeRedemption = Boolean(
1030
+ human &&
1031
+ human.subjectId === grant.subjectId &&
1032
+ human.subjectId === account.connectedBySubjectId &&
1033
+ hasPermission(grant.permissions, "workspace:admin"),
1034
+ );
1035
+ const canRedeem = canResumeRedemption && account.status === "active";
1036
+ overview[account.id] = await fetchCodexAccountOverview(
1037
+ deps,
1038
+ workspaceId,
1039
+ account,
1040
+ canRedeem,
1041
+ canResumeRedemption,
1042
+ canResumeRedemption
1043
+ ? ownerRecoveries.filter((recovery) => recovery.credentialId === account.id)
1044
+ : [],
1045
+ providerCall,
1046
+ );
1047
+ }
1048
+ };
1049
+ const workers = Promise.all(
1050
+ Array.from({ length: Math.min(4, Math.max(1, accounts.length)) }, () => worker()),
1051
+ );
1052
+ let deadline: ReturnType<typeof setTimeout> | undefined;
1053
+ await Promise.race([
1054
+ workers,
1055
+ new Promise<void>((resolve) => {
1056
+ deadline = setTimeout(() => {
1057
+ routeTimedOut = true;
1058
+ queue.length = 0;
1059
+ resolve();
1060
+ }, CODEX_OVERVIEW_ROUTE_TIMEOUT_MS);
1061
+ }),
1062
+ ]);
1063
+ if (deadline) clearTimeout(deadline);
1064
+ if (routeTimedOut) {
1065
+ // Fill every unscheduled account from persisted cache without performing
1066
+ // more provider work. In-flight account operations remain rejection-
1067
+ // handled and are themselves bounded; they cannot delay this response or
1068
+ // leave limiter waiters permanently queued.
1069
+ const unavailableProviderCall: CodexProviderCall = async () => {
1070
+ throw new Error("Codex overview route deadline reached");
1071
+ };
1072
+ await Promise.all(
1073
+ accounts
1074
+ .filter((account) => overview[account.id] == null)
1075
+ .map(async (account) => {
1076
+ const canResumeRedemption = Boolean(
1077
+ human &&
1078
+ human.subjectId === grant.subjectId &&
1079
+ human.subjectId === account.connectedBySubjectId &&
1080
+ hasPermission(grant.permissions, "workspace:admin"),
1081
+ );
1082
+ const fallback = await fetchCodexAccountOverview(
1083
+ deps,
1084
+ workspaceId,
1085
+ account,
1086
+ false,
1087
+ canResumeRedemption,
1088
+ canResumeRedemption
1089
+ ? ownerRecoveries.filter((recovery) => recovery.credentialId === account.id)
1090
+ : [],
1091
+ unavailableProviderCall,
1092
+ );
1093
+ // A bounded in-flight worker may have completed while fallback was
1094
+ // assembled; prefer that fresh truth when present.
1095
+ overview[account.id] ??= fallback;
1096
+ }),
1097
+ );
1098
+ void workers.catch(() => undefined);
1099
+ }
1100
+ // Overview writes the same authoritative usage snapshots as the explicit
1101
+ // refresh routes. Deliver any committed capacity outbox entries instead of
1102
+ // leaving quota-recovered waiters dormant until a later unrelated refresh.
1103
+ void signalPendingCodexCapacityTargets(deps, workspaceId).catch(() => undefined);
1104
+ return c.json({ accounts: overview });
1105
+ });
1106
+
1107
+ // Mint a five-minute HMAC confirmation bound to the actual Better Auth
1108
+ // session, human, workspace, credential, credit, and stable logical attempt.
1109
+ // This route never calls the consume endpoint and creates no attempt row when
1110
+ // the default-focused Cancel button wins.
1111
+ app.post(
1112
+ "/v1/workspaces/:workspaceId/codex/accounts/:accountId/reset-credits/prepare",
1113
+ async (c) => {
1114
+ const workspaceId = c.req.param("workspaceId");
1115
+ const credentialId = c.req.param("accountId");
1116
+ const { human, accountId } = await requireRedemptionHuman(c, deps, workspaceId);
1117
+ c.header("cache-control", "no-store");
1118
+ const parsed = redemptionPrepareBody.safeParse(await c.req.json().catch(() => null));
1119
+ if (!parsed.success) {
1120
+ throw new HTTPException(400, {
1121
+ message: "attemptId and creditId are required",
1122
+ });
1123
+ }
1124
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
1125
+ const account = accounts.find((candidate) => candidate.id === credentialId);
1126
+ if (!account) throw new HTTPException(404, { message: "codex account not found" });
1127
+ let existing = await getCodexResetRedemptionAttempt(db, workspaceId, parsed.data.attemptId);
1128
+ if (account.connectedBySubjectId !== human.subjectId) {
1129
+ throw new HTTPException(403, {
1130
+ message: "only the human who connected this subscription may redeem its reset credits",
1131
+ });
1132
+ }
1133
+ if (
1134
+ existing &&
1135
+ (existing.credentialId !== credentialId ||
1136
+ existing.creditId !== parsed.data.creditId ||
1137
+ existing.subjectId !== human.subjectId)
1138
+ ) {
1139
+ throw new HTTPException(409, {
1140
+ message: "logical redemption attempt identity mismatch",
1141
+ });
1142
+ }
1143
+ if (existing) {
1144
+ const adoption = await adoptCodexResetRedemptionAttempt(db, {
1145
+ accountId,
1146
+ workspaceId,
1147
+ attemptId: existing.id,
1148
+ credentialId,
1149
+ creditId: existing.creditId,
1150
+ subjectId: human.subjectId,
1151
+ browserSessionHash: human.browserSessionHash,
1152
+ });
1153
+ if (adoption.kind === "in_progress") {
1154
+ throw new HTTPException(409, {
1155
+ message: "this redemption is still in progress in another browser request",
1156
+ });
1157
+ }
1158
+ if (adoption.kind === "not_found") {
1159
+ throw new HTTPException(409, { message: "redemption recovery state changed" });
1160
+ }
1161
+ if (adoption.kind === "forbidden") {
1162
+ throw new HTTPException(403, { message: "redemption owner is unavailable" });
1163
+ }
1164
+ if (adoption.kind === "conflict") {
1165
+ throw new HTTPException(409, {
1166
+ message: "logical redemption attempt identity mismatch",
1167
+ });
1168
+ }
1169
+ existing = adoption.attempt;
1170
+ }
1171
+ // Starting or retrying provider work requires a healthy credential. A
1172
+ // completed attempt is different: its provider outcome is durable truth,
1173
+ // and a lost HTTP response must remain replayable after a later health
1174
+ // transition without another consume call.
1175
+ if (account.status !== "active" && existing?.status !== "completed") {
1176
+ throw new HTTPException(403, { message: "redemption credential is unavailable" });
1177
+ }
1178
+ const secret = settings.betterAuthSecret;
1179
+ if (!secret) {
1180
+ throw new HTTPException(503, {
1181
+ message: "managed browser confirmation is unavailable",
1182
+ });
1183
+ }
1184
+ const expiresAt = Math.floor(Date.now() / 1000) + CODEX_REDEMPTION_CONFIRMATION_SECONDS;
1185
+ const confirmationToken = await signCodexRedemptionConfirmation(secret, {
1186
+ version: 1,
1187
+ attemptId: parsed.data.attemptId,
1188
+ workspaceId,
1189
+ credentialId,
1190
+ creditId: parsed.data.creditId,
1191
+ subjectId: human.subjectId,
1192
+ browserSessionHash: human.browserSessionHash,
1193
+ expiresAt,
1194
+ });
1195
+ return c.json({
1196
+ attemptId: parsed.data.attemptId,
1197
+ confirmationToken,
1198
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
1199
+ // A completed attempt may have lost its HTTP response after its outcome
1200
+ // committed. Keep that exact logical id replayable without another
1201
+ // provider consume call, just like an ambiguous provider_started attempt.
1202
+ resumable: existing?.status === "provider_started" || existing?.status === "completed",
1203
+ recoveryStatus:
1204
+ existing?.status === "provider_started" || existing?.status === "completed"
1205
+ ? existing.status
1206
+ : null,
1207
+ });
1208
+ },
1209
+ );
1210
+
1211
+ // The only OpenGeni reset-credit mutation route. There is intentionally no
1212
+ // SDK/MCP/worker/scheduled/background equivalent.
1213
+ app.post(
1214
+ "/v1/workspaces/:workspaceId/codex/accounts/:accountId/reset-credits/redeem",
1215
+ async (c) => {
1216
+ const workspaceId = c.req.param("workspaceId");
1217
+ const credentialId = c.req.param("accountId");
1218
+ const { human, accountId } = await requireRedemptionHuman(c, deps, workspaceId);
1219
+ c.header("cache-control", "no-store");
1220
+ const parsed = redemptionBody.safeParse(await c.req.json().catch(() => null));
1221
+ if (!parsed.success) {
1222
+ throw new HTTPException(400, {
1223
+ message: "explicit redemption confirmation is required",
1224
+ });
1225
+ }
1226
+ const secret = settings.betterAuthSecret;
1227
+ if (!secret) {
1228
+ throw new HTTPException(503, {
1229
+ message: "managed browser confirmation is unavailable",
1230
+ });
1231
+ }
1232
+ const claims = await verifyCodexRedemptionConfirmation(secret, parsed.data.confirmationToken);
1233
+ if (
1234
+ !claims ||
1235
+ claims.attemptId !== parsed.data.attemptId ||
1236
+ claims.workspaceId !== workspaceId ||
1237
+ claims.credentialId !== credentialId ||
1238
+ claims.creditId !== parsed.data.creditId ||
1239
+ claims.subjectId !== human.subjectId ||
1240
+ claims.browserSessionHash !== human.browserSessionHash
1241
+ ) {
1242
+ throw new HTTPException(403, {
1243
+ message: "redemption confirmation is invalid or expired",
1244
+ });
1245
+ }
1246
+
1247
+ const claimHolderId = crypto.randomUUID();
1248
+ const claimed = await claimCodexResetRedemption(db, {
1249
+ id: parsed.data.attemptId,
1250
+ accountId,
1251
+ workspaceId,
1252
+ credentialId,
1253
+ subjectId: human.subjectId,
1254
+ browserSessionHash: human.browserSessionHash,
1255
+ creditId: parsed.data.creditId,
1256
+ confirmationExpiresAt: new Date(claims.expiresAt * 1000),
1257
+ claimHolderId,
1258
+ });
1259
+ if (claimed.kind === "not_found") {
1260
+ throw new HTTPException(404, { message: "codex account not found" });
1261
+ }
1262
+ if (claimed.kind === "forbidden") {
1263
+ throw new HTTPException(403, {
1264
+ message: "redemption owner or credential is unavailable",
1265
+ });
1266
+ }
1267
+ if (claimed.kind === "conflict") {
1268
+ throw new HTTPException(409, {
1269
+ message: "logical redemption attempt identity mismatch",
1270
+ });
1271
+ }
1272
+ if (claimed.kind === "in_progress") {
1273
+ return c.json({ status: "in_progress", attemptId: parsed.data.attemptId }, 409);
1274
+ }
1275
+
1276
+ const finishResponse = (outcome: string) =>
1277
+ c.json({
1278
+ status: "completed",
1279
+ attemptId: parsed.data.attemptId,
1280
+ outcome,
1281
+ // Durable provider truth must never wait for best-effort provider
1282
+ // readback. The browser refreshes overview independently after this
1283
+ // response; a hung account cannot suppress a completed outcome.
1284
+ overview: null,
1285
+ });
1286
+ if (claimed.kind === "completed") {
1287
+ return finishResponse(claimed.attempt.outcome!);
1288
+ }
1289
+
1290
+ const attempt = claimed.attempt;
1291
+ const fetchImpl = (deps.codexFetch ?? fetch) as CodexFetch;
1292
+ if (attempt.status === "processing") {
1293
+ const details = await fetchCodexRateLimitResetCreditsForAccount(
1294
+ db,
1295
+ settings,
1296
+ workspaceId,
1297
+ credentialId,
1298
+ fetchImpl,
1299
+ );
1300
+ if (!details.ok) {
1301
+ await abandonCodexResetRedemptionBeforeProvider(db, {
1302
+ accountId,
1303
+ workspaceId,
1304
+ attemptId: attempt.id,
1305
+ claimHolderId,
1306
+ });
1307
+ return c.json(
1308
+ {
1309
+ status: "preflight_unavailable",
1310
+ attemptId: attempt.id,
1311
+ retryable: true,
1312
+ },
1313
+ 503,
1314
+ );
1315
+ }
1316
+ if (!freshActionableCredit(details.details, attempt.creditId)) {
1317
+ await abandonCodexResetRedemptionBeforeProvider(db, {
1318
+ accountId,
1319
+ workspaceId,
1320
+ attemptId: attempt.id,
1321
+ claimHolderId,
1322
+ });
1323
+ return c.json(
1324
+ {
1325
+ status: "not_actionable",
1326
+ attemptId: attempt.id,
1327
+ retryable: false,
1328
+ },
1329
+ 409,
1330
+ );
1331
+ }
1332
+ }
1333
+
1334
+ let token: Awaited<ReturnType<ReturnType<typeof buildCodexTokenResolver>["getToken"]>>;
1335
+ try {
1336
+ token = await buildCodexTokenResolver(db, settings, workspaceId, credentialId).getToken();
1337
+ } catch {
1338
+ if (attempt.status === "processing") {
1339
+ await abandonCodexResetRedemptionBeforeProvider(db, {
1340
+ accountId,
1341
+ workspaceId,
1342
+ attemptId: attempt.id,
1343
+ claimHolderId,
1344
+ });
1345
+ } else {
1346
+ await releaseCodexResetRedemptionClaim(db, {
1347
+ accountId,
1348
+ workspaceId,
1349
+ attemptId: attempt.id,
1350
+ claimHolderId,
1351
+ failureKind: "provider_auth_unavailable",
1352
+ });
1353
+ }
1354
+ return c.json(
1355
+ {
1356
+ status: "provider_unavailable",
1357
+ attemptId: attempt.id,
1358
+ retryable: true,
1359
+ },
1360
+ 503,
1361
+ );
1362
+ }
1363
+ const fenced = await fenceCodexResetRedemptionSend(db, {
1364
+ accountId,
1365
+ workspaceId,
1366
+ attemptId: attempt.id,
1367
+ claimHolderId,
1368
+ credentialId,
1369
+ subjectId: human.subjectId,
1370
+ browserSessionHash: human.browserSessionHash,
1371
+ });
1372
+ if (fenced.kind !== "ready") {
1373
+ if (fenced.reason === "confirmation_expired") {
1374
+ return c.json(
1375
+ { status: "confirmation_expired", attemptId: attempt.id, retryable: true },
1376
+ 403,
1377
+ );
1378
+ }
1379
+ if (fenced.reason === "credential_unavailable") {
1380
+ return c.json(
1381
+ { status: "provider_unavailable", attemptId: attempt.id, retryable: true },
1382
+ 503,
1383
+ );
1384
+ }
1385
+ return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
1386
+ }
1387
+
1388
+ const sendAttempt = fenced.attempt;
1389
+ const consumed = await consumeCodexRateLimitResetCredit(
1390
+ {
1391
+ accessToken: token.accessToken,
1392
+ chatgptAccountId: token.chatgptAccountId,
1393
+ isFedramp: token.isFedramp,
1394
+ clientVersion: CODEX_CLIENT_VERSION,
1395
+ },
1396
+ {
1397
+ idempotencyKey: sendAttempt.upstreamIdempotencyKey,
1398
+ creditId: sendAttempt.creditId,
1399
+ },
1400
+ fetchImpl,
1401
+ );
1402
+ if (!consumed.ok) {
1403
+ await releaseCodexResetRedemptionClaim(db, {
1404
+ accountId,
1405
+ workspaceId,
1406
+ attemptId: attempt.id,
1407
+ claimHolderId,
1408
+ failureKind: `provider_${consumed.reason}`,
1409
+ });
1410
+ return c.json({ status: "ambiguous", attemptId: attempt.id, retryable: true }, 503);
1411
+ }
1412
+ const completion = await completeCodexResetRedemption(db, {
1413
+ accountId,
1414
+ workspaceId,
1415
+ attemptId: attempt.id,
1416
+ claimHolderId,
1417
+ outcome: consumed.result.outcome,
1418
+ });
1419
+ const completed = completion.result;
1420
+ if (!completed) {
1421
+ return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
1422
+ }
1423
+ // The outbox is durable; signaling is best-effort and must not hold the
1424
+ // owning human's already-completed provider outcome hostage.
1425
+ void signalCodexCapacityTargets(deps, completion.wakeTargets).catch(() => undefined);
1426
+ return finishResponse(completed.outcome!);
1427
+ },
1428
+ );
577
1429
  }