@opengeni/api-router 0.5.7 → 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,
@@ -42,10 +57,8 @@ import {
42
57
  updateCodexRotationSettings,
43
58
  upsertCodexSubscriptionCredential,
44
59
  withCodexCapacityMutation,
45
- CODEX_ROTATION_STRATEGIES,
46
60
  type CodexAccountStatus,
47
61
  type CodexCapacityWakeTarget,
48
- type CodexRotationStrategy,
49
62
  } from "@opengeni/db";
50
63
 
51
64
  // The picker surfaces codex models under their own "no credits" provider group so
@@ -78,6 +91,11 @@ function codexAccountJson(row: CodexAccountStatus) {
78
91
  CODEX_WEEKLY_WINDOW_SECONDS,
79
92
  ),
80
93
  usageCheckedAt: row.usageCheckedAt,
94
+ allocatorEnabled: row.allocatorEnabled,
95
+ allocatorVersion: row.allocatorVersion,
96
+ allocatorUpdatedAt: row.allocatorUpdatedAt,
97
+ resetCreditAvailableCount: row.resetCreditAvailableCount,
98
+ resetCreditsCheckedAt: row.resetCreditsCheckedAt,
81
99
  // P3 rotation cooldown: when set and in the future, this account is cooling-down.
82
100
  exhaustedUntil: row.exhaustedUntil,
83
101
  };
@@ -93,9 +111,13 @@ function codexUsageJson(payload: CodexUsagePayload): {
93
111
  return { status: payload.status, usage: payload };
94
112
  }
95
113
 
96
- export function codexModelsForPicker(
97
- liveSlugs: readonly string[],
98
- ): 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
+ }> {
99
121
  const available = new Set(liveSlugs);
100
122
  const missing = CODEX_FALLBACK_MODEL_SLUGS.filter((slug) => !available.has(slug));
101
123
  if (missing.length > 0) {
@@ -110,10 +132,340 @@ export function codexModelsForPicker(
110
132
  }));
111
133
  }
112
134
  import { createSignedState, readSignedState } from "@opengeni/github";
113
- import type { ApiRouteDeps } from "@opengeni/core";
114
- import type { Hono } from "hono";
135
+ import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
136
+ import type { Context, Hono } from "hono";
115
137
  import { HTTPException } from "hono/http-exception";
116
- 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
+ }
117
469
 
118
470
  type CodexConnectState = {
119
471
  workspaceId?: string;
@@ -202,7 +554,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
202
554
  !payload.deviceAuthId ||
203
555
  !payload.userCode
204
556
  ) {
205
- 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
+ });
206
560
  }
207
561
  // The device code itself expires 15 minutes after start; surface that to the
208
562
  // client (the 1-hour signed-state TTL is longer than the device window).
@@ -243,6 +597,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
243
597
  });
244
598
  }
245
599
  const id = parseIdToken(tokens.idToken);
600
+ const connectingHuman = await managedCookieHuman(c, deps);
246
601
  const key = environmentsEncryptionKeyBytes(settings);
247
602
  if (!key) {
248
603
  throw new HTTPException(500, {
@@ -273,11 +628,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
273
628
  lastRefreshAt: new Date(),
274
629
  accountEmail: id.email ?? null,
275
630
  label: id.email ?? id.chatgptAccountId ?? null,
631
+ connectedBySubjectId:
632
+ connectingHuman?.subjectId === grant.subjectId ? connectingHuman.subjectId : null,
276
633
  });
277
- return { result: upserted, changed: true };
634
+ return { result: upserted, changed: upserted.kind === "upserted" };
278
635
  },
279
636
  );
280
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
+ }
281
644
  // Ensure the per-workspace rotation-settings row exists, then auto-activate
282
645
  // the FIRST account only. Additional new accounts do NOT auto-activate — a
283
646
  // manual switch is required (no auto-rotation in P1). A re-connect of the
@@ -369,7 +732,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
369
732
  activeAccountId,
370
733
  settings: {
371
734
  rotationEnabled: rotation?.rotationEnabled ?? false,
372
- rotationStrategy: rotation?.rotationStrategy ?? "most_remaining",
735
+ // sharded-rotation policy: rotation-enabled always behaves as sticky-sharded; report the
736
+ // effective truth, never the stored legacy residue.
737
+ rotationStrategy: "sharded",
373
738
  activeCredentialId: activeAccountId,
374
739
  },
375
740
  });
@@ -397,8 +762,11 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
397
762
  return c.json({ activated: true, accountId });
398
763
  });
399
764
 
400
- // P3: update rotation settings (enable auto-rotation + pick the strategy). admin access.
401
- // ensureCodexRotationSettings guarantees the row exists, then a one-cell patch.
765
+ // Update rotation settings. admin access. sharded-rotation policy: the strategy picker is GONE —
766
+ // rotation-enabled always behaves as sticky-sharded (worker-side
767
+ // effectiveRotationStrategy normalization). `rotationStrategy` in the body is
768
+ // ACCEPTED-BUT-IGNORED so no existing SDK/UI caller breaks (deprecation), and
769
+ // the stored column is only legacy residue kept for old-binary rollback.
402
770
  app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
403
771
  const workspaceId = c.req.param("workspaceId");
404
772
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -406,19 +774,18 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
406
774
  rotationEnabled?: unknown;
407
775
  rotationStrategy?: unknown;
408
776
  };
409
- const patch: { rotationEnabled?: boolean; rotationStrategy?: CodexRotationStrategy } = {};
777
+ const patch: { rotationEnabled?: boolean } = {};
410
778
  if (typeof body.rotationEnabled === "boolean") {
411
779
  patch.rotationEnabled = body.rotationEnabled;
412
780
  }
413
- if (typeof body.rotationStrategy === "string") {
414
- if (!CODEX_ROTATION_STRATEGIES.includes(body.rotationStrategy as CodexRotationStrategy)) {
415
- throw new HTTPException(400, { message: "invalid rotation strategy" });
416
- }
417
- patch.rotationStrategy = body.rotationStrategy as CodexRotationStrategy;
418
- }
419
- if (patch.rotationEnabled === undefined && patch.rotationStrategy === undefined) {
781
+ if (patch.rotationEnabled === undefined && body.rotationStrategy === undefined) {
420
782
  throw new HTTPException(400, { message: "no settings to update" });
421
783
  }
784
+ if (patch.rotationEnabled === undefined) {
785
+ // Strategy-only writes are a deprecated no-op (no db touch): report the
786
+ // (only) truth. Callers that also flip rotationEnabled fall through.
787
+ return c.json({ rotationStrategy: "sharded", rotationStrategyDeprecated: true });
788
+ }
422
789
  await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
423
790
  const mutation = await withCodexCapacityMutation(
424
791
  db,
@@ -430,12 +797,15 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
430
797
  );
431
798
  const updated = mutation.result;
432
799
  if (!updated) {
433
- throw new HTTPException(404, { message: "codex rotation settings not found" });
800
+ throw new HTTPException(404, {
801
+ message: "codex rotation settings not found",
802
+ });
434
803
  }
435
804
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
436
805
  return c.json({
437
806
  rotationEnabled: updated.rotationEnabled,
438
- rotationStrategy: updated.rotationStrategy,
807
+ // sharded-rotation policy: sharded is the only behavior; the stored column is residue.
808
+ rotationStrategy: "sharded",
439
809
  activeCredentialId: updated.activeCredentialId,
440
810
  });
441
811
  });
@@ -459,6 +829,45 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
459
829
  return c.json(codexAccountJson(row));
460
830
  });
461
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
+
462
871
  // Disconnect ONE account by id. The accessor re-picks active when the removed
463
872
  // row was active (FK ON DELETE SET NULL + re-pick in the same RLS txn).
464
873
  app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
@@ -474,6 +883,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
474
883
  },
475
884
  );
476
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
+ }
477
892
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
478
893
  return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
479
894
  });
@@ -487,13 +902,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
487
902
  db,
488
903
  { workspaceId, reason: "codex_credentials_disconnected" },
489
904
  async (tx) => {
490
- const removed = await disconnectAllCodexAccounts(tx, workspaceId);
491
- return { result: removed, changed: removed > 0 };
905
+ const result = await disconnectAllCodexAccounts(tx, workspaceId);
906
+ return { result, changed: result.removed > 0 };
492
907
  },
493
908
  );
494
- 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
+ }
495
916
  await signalCodexCapacityTargets(deps, mutation.wakeTargets);
496
- return c.json({ disconnected: removed > 0 });
917
+ return c.json({ disconnected: result.removed > 0 });
497
918
  });
498
919
 
499
920
  // Back-compat: remaining usage / limits for the ACTIVE account only. Repointed
@@ -504,7 +925,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
504
925
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
505
926
  const status = await getCodexCredentialStatus(db, workspaceId);
506
927
  if (!status?.credentialId) {
507
- throw new HTTPException(404, { message: "codex subscription is not connected" });
928
+ throw new HTTPException(404, {
929
+ message: "codex subscription is not connected",
930
+ });
508
931
  }
509
932
  const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
510
933
  await signalPendingCodexCapacityTargets(deps, workspaceId);
@@ -561,6 +984,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
561
984
  weekly: null,
562
985
  limitReached: false,
563
986
  fetchedAt: new Date().toISOString(),
987
+ rateLimitResetCredits: null,
564
988
  },
565
989
  };
566
990
  }
@@ -571,4 +995,435 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
571
995
  await signalPendingCodexCapacityTargets(deps, workspaceId);
572
996
  return c.json({ usage });
573
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
+ );
574
1429
  }