@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.
- package/dist/app.d.ts +21 -3
- package/dist/app.js +3 -1
- package/dist/{chunk-HBEJMWD3.js → chunk-QOYQBYHM.js} +4302 -926
- package/dist/chunk-QOYQBYHM.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +12 -11
- package/src/app.ts +83 -18
- package/src/codex-redemption-security.ts +96 -0
- package/src/github-access.ts +46 -0
- package/src/github-browser-flow.ts +83 -0
- package/src/http/auth.ts +12 -4
- package/src/http/sse.ts +526 -92
- package/src/index.ts +2 -1
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +828 -146
- package/src/mcp/session-view.ts +721 -201
- package/src/mcp/toolspace.ts +482 -132
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +883 -28
- package/src/routes/enrollments.ts +2 -2
- package/src/routes/files.ts +153 -0
- package/src/routes/github.ts +63 -202
- package/src/routes/install.ts +1 -1
- package/src/routes/machines.ts +2 -2
- package/src/routes/sessions.ts +789 -81
- package/src/routes/workspace-capture.ts +56 -38
- package/src/routes/workspaces.ts +66 -13
- package/src/sandbox/access.ts +1 -1
- package/src/sandbox/auth-callout.ts +1 -1
- package/src/sandbox/channel-a.ts +14 -1
- package/src/sandbox/enrollment.ts +4 -4
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +1 -1
- package/src/sandbox/viewer.ts +8 -2
- package/dist/chunk-HBEJMWD3.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,
|
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
//
|
|
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
|
|
777
|
+
const patch: { rotationEnabled?: boolean } = {};
|
|
410
778
|
if (typeof body.rotationEnabled === "boolean") {
|
|
411
779
|
patch.rotationEnabled = body.rotationEnabled;
|
|
412
780
|
}
|
|
413
|
-
if (
|
|
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, {
|
|
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
|
-
|
|
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
|
|
491
|
-
return { result
|
|
905
|
+
const result = await disconnectAllCodexAccounts(tx, workspaceId);
|
|
906
|
+
return { result, changed: result.removed > 0 };
|
|
492
907
|
},
|
|
493
908
|
);
|
|
494
|
-
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
|
+
}
|
|
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, {
|
|
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
|
}
|