@opengeni/api-router 0.7.3 → 0.11.1
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.
- package/dist/app.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/src/routes/codex.ts
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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:
|
|
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, {
|
|
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
|
|
494
|
-
return { result
|
|
905
|
+
const result = await disconnectAllCodexAccounts(tx, workspaceId);
|
|
906
|
+
return { result, changed: result.removed > 0 };
|
|
495
907
|
},
|
|
496
908
|
);
|
|
497
|
-
const
|
|
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, {
|
|
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
|
}
|