ldrouter 1.16.3 → 1.16.5

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +23 -1
  3. package/dist/server/app.js +10 -1
  4. package/dist/server/auth/middleware.js +37 -1
  5. package/dist/server/db/index.js +5 -0
  6. package/dist/server/db/migrate.js +38 -5
  7. package/dist/server/db/repositories/codex-accounts.js +187 -0
  8. package/dist/server/db/schema.js +44 -3
  9. package/dist/server/gateway/runner.js +76 -11
  10. package/dist/server/providers/codex-autostart.js +98 -0
  11. package/dist/server/providers/codex-import.js +156 -0
  12. package/dist/server/providers/codex-oauth.js +77 -0
  13. package/dist/server/providers/codex-refresh.js +165 -0
  14. package/dist/server/providers/codex-usage.js +192 -0
  15. package/dist/server/providers/codex.js +186 -0
  16. package/dist/server/providers/index.js +5 -0
  17. package/dist/server/routes/admin/auth.js +4 -1
  18. package/dist/server/routes/admin/codex.js +331 -0
  19. package/dist/server/routes/admin/models.js +20 -3
  20. package/dist/server/routes/admin/providers.js +63 -20
  21. package/dist/server/routes/admin/requests.js +1 -0
  22. package/dist/server/routes/admin.js +12 -0
  23. package/dist/server/routing/capabilities.js +1 -0
  24. package/dist/server/routing/combo.js +20 -12
  25. package/dist/server/upstream/client.js +54 -38
  26. package/dist/web/assets/index-CkDJqDcS.js +391 -0
  27. package/dist/web/assets/index-Drmqi8MO.css +1 -0
  28. package/dist/web/index.html +2 -2
  29. package/migrations/0005_codex_accounts.sql +105 -0
  30. package/migrations/0006_codex_usage.sql +9 -0
  31. package/package.json +5 -1
  32. package/dist/web/assets/index-C5h2WXK5.css +0 -1
  33. package/dist/web/assets/index-CRnoua24.js +0 -335
@@ -4,12 +4,14 @@ import { eq } from 'drizzle-orm';
4
4
  import { GatewayError } from '../errors.js';
5
5
  import { resolveRequestedModel, unwrapAlias } from '../routing/resolver.js';
6
6
  import { bareModelName, deriveRequiredCapabilities, describeRejections, firstMissingCapability } from '../routing/capabilities.js';
7
- import { loadCombo, selectCandidates, orderCandidates, shouldFallback } from '../routing/combo.js';
7
+ import { loadCombo, selectCandidates, orderCandidates, shouldFallback, expandCodexAccountCandidates } from '../routing/combo.js';
8
+ import { listCodexAccountsForProvider, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
8
9
  import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
9
10
  import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
10
11
  import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
11
12
  import { keyAllowedFor } from '../auth/api-key.js';
12
13
  import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl, upstreamHttpError } from '../upstream/client.js';
14
+ import { callCodexNonStreaming, callCodexStreaming } from '../providers/codex.js';
13
15
  import { canonicalToOpenAIRequest, openAIResponseToCanonical } from '../protocols/canonical.js';
14
16
  import { canonicalToAnthropicRequest, anthropicResponseToCanonical } from '../protocols/anthropic.js';
15
17
  import { uuid } from '../auth/ids.js';
@@ -129,7 +131,10 @@ export class GatewayRunner {
129
131
  const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, rejected);
130
132
  throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
131
133
  }
132
- candidates = orderCandidates(comboPlan, filtered);
134
+ candidates = orderCandidates(comboPlan, filtered).flatMap((candidate) => {
135
+ const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
136
+ return provider?.type === 'codex' ? expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(provider.id)) : [candidate];
137
+ });
133
138
  debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
134
139
  `mode=${comboPlan.mode}`,
135
140
  ...candidates.map((c, i) => `candidate[${i}]: providerModelId=${c.modelId} publicModelId=${c.publicModelId}`),
@@ -199,7 +204,7 @@ export class GatewayRunner {
199
204
  lastError = new GatewayError('upstream_unavailable', 'Provider circuit is open', { status: 502 });
200
205
  break;
201
206
  }
202
- const cfg = providerToUpstreamConfig(provider);
207
+ const cfg = providerToUpstreamConfig(provider, candidate.codexAccountId);
203
208
  const attemptStart = Date.now();
204
209
  // docs/13 §11–§12: provider/account + upstream request summary
205
210
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
@@ -212,7 +217,7 @@ export class GatewayRunner {
212
217
  `upstreamType=${cfg.type}`,
213
218
  `baseUrl=${cfg.baseUrl}`,
214
219
  `stream=${req.canonical.stream}`,
215
- `providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey)}`,
220
+ `providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey ?? '')}`,
216
221
  ]);
217
222
  const attempt = {
218
223
  attemptNumber: i + 1,
@@ -228,7 +233,8 @@ export class GatewayRunner {
228
233
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
229
234
  streamStarted: false,
230
235
  partialResponse: false,
231
- selectionReason: selectionReasons[0] ?? 'direct',
236
+ selectionReason: candidate.selectionReason ?? selectionReasons[0] ?? 'direct',
237
+ codexAccountId: candidate.codexAccountId,
232
238
  failureReason: null,
233
239
  sanitizedError: null,
234
240
  upstreamRequestId: null,
@@ -261,6 +267,8 @@ export class GatewayRunner {
261
267
  resultToolCalls = out.result.toolCalls;
262
268
  resultFinishReason = out.result.finishReason;
263
269
  recordSuccess(provider.id);
270
+ if (candidate.codexAccountId)
271
+ setCodexAccountHealth(candidate.codexAccountId, 'healthy');
264
272
  getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
265
273
  attempts.push(attempt);
266
274
  sentToClient = out.streamStarted ?? false;
@@ -294,8 +302,12 @@ export class GatewayRunner {
294
302
  }
295
303
  attempts.push(attempt);
296
304
  lastError = err;
297
- recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
298
- getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
305
+ if (isUpstreamHealthFailure(err)) {
306
+ recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
307
+ if (candidate.codexAccountId)
308
+ setCodexAccountHealth(candidate.codexAccountId, 'down', redactString(err.message));
309
+ getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
310
+ }
299
311
  if (shouldRetry && i + 1 < maxAttempts) {
300
312
  metrics.fallbackCount.inc();
301
313
  continue;
@@ -404,6 +416,14 @@ export class GatewayRunner {
404
416
  `caps.image_input=${caps.image_input}`,
405
417
  `caps.structured_output=${caps.structured_output}`,
406
418
  ]);
419
+ // Codex providers route through their account pool: one candidate per
420
+ // eligible account, so fallback can move between accounts of the same model.
421
+ if (p.type === 'codex') {
422
+ const expanded = expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(p.id));
423
+ if (expanded.length === 0)
424
+ return { candidates: [], rejected: reject(m.publicModelId, 'codex_account_unavailable') };
425
+ return { candidates: expanded, rejected: [] };
426
+ }
407
427
  return { candidates: [candidate], rejected: [] };
408
428
  }
409
429
  async loadAllModels() {
@@ -433,6 +453,10 @@ export class GatewayRunner {
433
453
  }
434
454
  async runNonStreamingAttempt(req, candidate, cfg, ctx) {
435
455
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
456
+ if (cfg.type === 'codex') {
457
+ const out = await callCodexNonStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel });
458
+ return { statusCode: out.status, ttftMs: null, upstreamRequestId: out.upstreamRequestId, usage: out.usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
459
+ }
436
460
  let call;
437
461
  if (cfg.type === 'openai') {
438
462
  const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
@@ -445,7 +469,7 @@ export class GatewayRunner {
445
469
  call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload, ctx.requestId);
446
470
  }
447
471
  if (!call.ok) {
448
- throw upstreamHttpError(call.status, redactString(call.text.slice(0, 300)));
472
+ throw upstreamHttpError(call.status, redactString(call.text.slice(0, 300)), { status: call.status });
449
473
  }
450
474
  let parsed;
451
475
  try {
@@ -602,6 +626,26 @@ export class GatewayRunner {
602
626
  return;
603
627
  };
604
628
  try {
629
+ if (cfg.type === 'codex') {
630
+ const events = [];
631
+ let firstCodexEvent = true;
632
+ const meta = await callCodexStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel }, (event) => {
633
+ if (event.text || event.isLast) {
634
+ events.push(event);
635
+ if (event.usage)
636
+ Object.assign(usage, event.usage);
637
+ chunkHandler({ data: codexStreamEventToClient(req.protocol, event, upstreamModel, ctx.requestId) }, firstCodexEvent);
638
+ firstCodexEvent = false;
639
+ }
640
+ });
641
+ if (!headWritten)
642
+ writeHead();
643
+ pipe.write('data: [DONE]\n\n');
644
+ pipe.end();
645
+ if (!usage.total)
646
+ usage.total = usage.input + usage.output;
647
+ return { statusCode: meta.status, ttftMs: events.length ? Date.now() - streamStartTs : null, upstreamRequestId: meta.upstreamRequestId, usage, result: { text: textBuf, toolCalls: toolBuf, finishReason } };
648
+ }
605
649
  const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
606
650
  const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
607
651
  logUpstreamRequest(ctx.requestId, cfg, url, payload, true);
@@ -837,6 +881,7 @@ export class GatewayRunner {
837
881
  attemptNumber: a.attemptNumber,
838
882
  providerId: a.providerId,
839
883
  modelId: a.modelId,
884
+ codexAccountId: a.codexAccountId ?? null,
840
885
  startedAt: a.startedAt,
841
886
  completedAt: a.completedAt,
842
887
  statusCode: a.statusCode,
@@ -859,7 +904,10 @@ export class GatewayRunner {
859
904
  emitRequestLogged(requestId);
860
905
  }
861
906
  }
862
- function classifyFailure(err) {
907
+ export function isUpstreamHealthFailure(err) {
908
+ return ['connection_error', 'connect_timeout', 'first_token_timeout', 'http_status', 'upstream_rate_limit'].includes(classifyFailure(err));
909
+ }
910
+ export function classifyFailure(err) {
863
911
  switch (err.type) {
864
912
  case 'timeout_error':
865
913
  return err.message.includes('first token') ? 'first_token_timeout' : 'connect_timeout';
@@ -867,8 +915,10 @@ function classifyFailure(err) {
867
915
  return 'connection_error';
868
916
  case 'upstream_rate_limit':
869
917
  return 'http_status';
870
- case 'upstream_error':
871
- return err.status >= 500 ? 'http_status' : 'connection_error';
918
+ case 'upstream_error': {
919
+ const upstreamStatus = err.cause?.status;
920
+ return typeof upstreamStatus === 'number' && upstreamStatus >= 500 && upstreamStatus < 600 ? 'http_status' : 'unknown';
921
+ }
872
922
  default:
873
923
  return 'unknown';
874
924
  }
@@ -964,6 +1014,21 @@ function safeJsonParse(s) {
964
1014
  function usageFromCache(u) {
965
1015
  return u ? { ...u, total: u.input + u.output } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
966
1016
  }
1017
+ // Map Codex's native response events to the public protocol stream shape.
1018
+ export function codexStreamEventToClient(protocol, event, model, requestId) {
1019
+ if (protocol === 'openai') {
1020
+ return JSON.stringify({
1021
+ id: `chatcmpl-${requestId}`,
1022
+ object: 'chat.completion.chunk',
1023
+ created: Math.floor(Date.now() / 1000),
1024
+ model,
1025
+ choices: [{ index: 0, delta: event.isLast ? {} : { content: event.text }, finish_reason: event.isLast ? 'stop' : null }],
1026
+ });
1027
+ }
1028
+ return JSON.stringify(event.isLast
1029
+ ? { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } }
1030
+ : { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: event.text } });
1031
+ }
967
1032
  // SSE encoders: canonical stream chunk -> client protocol event
968
1033
  function openaiStreamEncoder(data, _event) {
969
1034
  if (data === '[DONE]')
@@ -0,0 +1,98 @@
1
+ // Codex 5-hour window auto-start: pings opted-in accounts the moment their window resets
2
+ // so a fresh 5h window opens immediately. Mirrors 9router's quota auto-ping (codex profile).
3
+ //
4
+ // Ruling: no separate in-memory reset cache. `last_pinged_reset_key` already guarantees one ping
5
+ // per reset minute, and that survives restarts. A cache would add a second, weaker source of truth.
6
+ import { getRawDb } from '../db/index.js';
7
+ import { listCodexAutostartTargets, markCodexAccountPinged, saveCodexUsage, saveCodexUsageError } from '../db/repositories/codex-accounts.js';
8
+ import { withCodexCredentials } from './codex-refresh.js';
9
+ import { CODEX_AUTOSTART_MIN_INTERVAL_MS, fetchCodexUsage, pingCodexAccount } from './codex-usage.js';
10
+ const TICK_MS = 60_000;
11
+ let timer = null;
12
+ let running = false;
13
+ function providerFor(providerId) {
14
+ return getRawDb().prepare('SELECT id,base_url,total_timeout_ms FROM providers WHERE id=? AND enabled=1').get(providerId) ?? null;
15
+ }
16
+ function accountFor(accountId) {
17
+ return getRawDb().prepare('SELECT id,chatgpt_account_id FROM codex_accounts WHERE id=?').get(accountId) ?? null;
18
+ }
19
+ /** Reads fresh usage; stores the snapshot or a sanitized reason on failure. */
20
+ export async function refreshStoredCodexUsage(accountId, provider, account) {
21
+ try {
22
+ const usage = await withCodexCredentials(accountId, (credentials) => fetchCodexUsage(credentials.accessToken, account.chatgpt_account_id ?? undefined, Math.min(provider.total_timeout_ms, 20_000)));
23
+ saveCodexUsage(accountId, usage);
24
+ return usage;
25
+ }
26
+ catch (error) {
27
+ saveCodexUsageError(accountId, error instanceof Error ? error.message : 'Codex usage unavailable');
28
+ return null;
29
+ }
30
+ }
31
+ /** Minute-precision reset key: guards against duplicate pings from clock drift. */
32
+ function resetKey(resetAt) {
33
+ if (!resetAt)
34
+ return null;
35
+ const ms = Date.parse(resetAt);
36
+ return Number.isFinite(ms) ? new Date(Math.floor(ms / 60_000) * 60_000).toISOString() : resetAt;
37
+ }
38
+ /** One account: refresh usage, then ping if the window is exhausted and its reset already passed. */
39
+ export async function runCodexAutostartForAccount(accountId, now = new Date()) {
40
+ const target = listCodexAutostartTargets().find((row) => row.id === accountId);
41
+ if (!target)
42
+ return 'skipped';
43
+ if (target.lastPingAt && now.getTime() - Date.parse(target.lastPingAt) < CODEX_AUTOSTART_MIN_INTERVAL_MS)
44
+ return 'skipped';
45
+ const provider = providerFor(target.providerId);
46
+ const account = accountFor(accountId);
47
+ if (!provider || !account)
48
+ return 'skipped';
49
+ const usage = await refreshStoredCodexUsage(accountId, provider, account);
50
+ if (!usage)
51
+ return 'failed';
52
+ const session = usage.quotas.session;
53
+ const key = resetKey(session?.resetAt ?? null);
54
+ const row = getRawDb().prepare('SELECT last_pinged_reset_key AS k FROM codex_accounts WHERE id=?').get(accountId);
55
+ if (row?.k && key && row.k === key)
56
+ return 'skipped';
57
+ // A blocking (weekly) window that is exhausted means a ping cannot open anything.
58
+ if (usage.quotas.blocking && usage.quotas.blocking.remaining <= 0)
59
+ return 'skipped';
60
+ if (session && session.remaining > 0)
61
+ return 'skipped';
62
+ const ok = await withCodexCredentials(accountId, (credentials) => pingCodexAccount({
63
+ baseUrl: provider.base_url, accountId: account.chatgpt_account_id ?? '', accessToken: credentials.accessToken,
64
+ accountRecordId: accountId, customHeaders: {}, totalTimeoutMs: Math.min(provider.total_timeout_ms, 120_000),
65
+ }));
66
+ if (!ok)
67
+ return 'failed';
68
+ markCodexAccountPinged(accountId, session?.resetAt ?? null, key);
69
+ return 'pinged';
70
+ }
71
+ export async function runCodexAutostartTick() {
72
+ if (running)
73
+ return;
74
+ running = true;
75
+ try {
76
+ for (const target of listCodexAutostartTargets()) {
77
+ try {
78
+ await runCodexAutostartForAccount(target.id);
79
+ }
80
+ catch { /* per-account isolation */ }
81
+ }
82
+ }
83
+ finally {
84
+ running = false;
85
+ }
86
+ }
87
+ export function startCodexAutostart() {
88
+ if (timer)
89
+ return;
90
+ timer = setInterval(() => { void runCodexAutostartTick(); }, TICK_MS);
91
+ timer.unref?.();
92
+ }
93
+ export function stopCodexAutostart() {
94
+ if (!timer)
95
+ return;
96
+ clearInterval(timer);
97
+ timer = null;
98
+ }
@@ -0,0 +1,156 @@
1
+ import { createHash } from 'node:crypto';
2
+ const MAX_INPUT_BYTES = 2_000_000;
3
+ const MAX_RECORDS = 500;
4
+ const MAX_TOKEN_LENGTH = 200_000;
5
+ const FALLBACK_TTL_MS = 10 * 24 * 60 * 60 * 1000;
6
+ const stringValue = (value) => {
7
+ if (typeof value !== 'string')
8
+ return null;
9
+ const result = value.replace(/^\uFEFF/, '').trim();
10
+ return result || null;
11
+ };
12
+ const firstString = (...values) => {
13
+ for (const value of values) {
14
+ const result = stringValue(value);
15
+ if (result)
16
+ return result;
17
+ }
18
+ return null;
19
+ };
20
+ function objectValue(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
22
+ }
23
+ function decodeJwt(token) {
24
+ const part = token.split('.')[1];
25
+ if (!part)
26
+ return {};
27
+ try {
28
+ const text = Buffer.from(part, 'base64url').toString('utf8');
29
+ return objectValue(JSON.parse(text)) ?? {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ function parseExpiry(value) {
36
+ if (typeof value === 'number' && Number.isFinite(value)) {
37
+ const date = new Date(value < 10_000_000_000 ? value * 1000 : value);
38
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
39
+ }
40
+ const text = stringValue(value);
41
+ if (!text)
42
+ return null;
43
+ const numeric = Number(text);
44
+ if (Number.isFinite(numeric) && /^\d+(\.\d+)?$/.test(text) && text.length < 14)
45
+ return parseExpiry(numeric);
46
+ const date = new Date(text);
47
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
48
+ }
49
+ function relativeExpiry(value, now) {
50
+ const numeric = typeof value === 'number' ? value : (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : NaN);
51
+ if (!Number.isFinite(numeric) || numeric < 0)
52
+ return null;
53
+ const timestamp = now.getTime() + numeric * 1000;
54
+ if (!Number.isFinite(timestamp))
55
+ return null;
56
+ const date = new Date(timestamp);
57
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
58
+ }
59
+ function mask(value) {
60
+ if (!value)
61
+ return null;
62
+ if (value.length <= 8)
63
+ return `${value.slice(0, 2)}…${value.slice(-2)}`;
64
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
65
+ }
66
+ function stableIdentity(workspaceId, accountId, accessToken) {
67
+ if (accountId)
68
+ return `account:${accountId}`;
69
+ if (workspaceId)
70
+ return `workspace:${workspaceId}`;
71
+ return `token:${createHash('sha256').update(accessToken).digest('hex')}`;
72
+ }
73
+ export function normalizeCodexRecord(input, index, now = new Date(), source) {
74
+ const raw = objectValue(input);
75
+ if (!raw)
76
+ return { index, source, error: 'Record must be a JSON object' };
77
+ const tokens = objectValue(raw.tokens);
78
+ const accessToken = firstString(raw.access_token, raw.accessToken, tokens?.access_token, tokens?.accessToken);
79
+ const refreshToken = firstString(raw.refresh_token, raw.refreshToken, tokens?.refresh_token, tokens?.refreshToken);
80
+ const idToken = firstString(raw.id_token, raw.idToken, tokens?.id_token, tokens?.idToken);
81
+ if (!accessToken)
82
+ return { index, source, error: 'Missing access token' };
83
+ if (!refreshToken)
84
+ return { index, source, error: 'Missing refresh token' };
85
+ if (accessToken.length > MAX_TOKEN_LENGTH || refreshToken.length > MAX_TOKEN_LENGTH || (idToken?.length ?? 0) > MAX_TOKEN_LENGTH)
86
+ return { index, source, error: 'Token exceeds maximum length' };
87
+ const accessClaims = decodeJwt(accessToken);
88
+ const idClaims = idToken ? decodeJwt(idToken) : {};
89
+ const accessAuth = objectValue(accessClaims['https://api.openai.com/auth']) ?? {};
90
+ const idAuth = objectValue(idClaims['https://api.openai.com/auth']) ?? {};
91
+ const accessProfile = objectValue(accessClaims['https://api.openai.com/profile']) ?? {};
92
+ const idProfile = objectValue(idClaims['https://api.openai.com/profile']) ?? {};
93
+ const auth = { ...idAuth, ...accessAuth };
94
+ const profile = { ...idProfile, ...accessProfile };
95
+ const email = firstString(profile.email, accessClaims.email, idClaims.email, raw.email);
96
+ const chatgptAccountId = firstString(auth.chatgpt_account_id, auth.account_id, raw.chatgpt_account_id, raw.chatgptAccountId, raw.account_id, raw.accountId);
97
+ const workspaceId = firstString(raw.workspace_id, raw.workspaceId, raw.organization_id, raw.organizationId, auth.workspace_id, auth.workspaceId);
98
+ const planType = firstString(auth.chatgpt_plan_type, auth.plan_type, raw.chatgpt_plan_type, raw.plan_type, raw.planType);
99
+ const explicitExpiryKey = ['expired', 'expires_at', 'expiresAt'].find((key) => Object.prototype.hasOwnProperty.call(raw, key));
100
+ const expiresAt = explicitExpiryKey
101
+ ? parseExpiry(raw[explicitExpiryKey])
102
+ : parseExpiry(accessClaims.exp) ?? parseExpiry(idClaims.exp) ?? (raw.expires_in !== undefined ? relativeExpiry(raw.expires_in, now) : null);
103
+ if (explicitExpiryKey && !expiresAt)
104
+ return { index, source, error: 'Invalid explicit expiry' };
105
+ if (!explicitExpiryKey && raw.expires_in !== undefined && !expiresAt)
106
+ return { index, source, error: 'Invalid relative expiry' };
107
+ const resolvedExpiresAt = expiresAt ?? new Date(now.getTime() + FALLBACK_TTL_MS).toISOString();
108
+ return { index, source, email, workspaceId, chatgptAccountId, planType, expiresAt: resolvedExpiresAt, accessToken, refreshToken, idToken, identity: stableIdentity(workspaceId, chatgptAccountId, accessToken) };
109
+ }
110
+ function expandRoot(value) {
111
+ const root = objectValue(value);
112
+ if (Array.isArray(value))
113
+ return value;
114
+ if (root && Array.isArray(root.accounts))
115
+ return root.accounts;
116
+ return [value];
117
+ }
118
+ export function parseCodexImportText(text, source, now = new Date()) {
119
+ if (Buffer.byteLength(text, 'utf8') > MAX_INPUT_BYTES)
120
+ return [{ index: 0, source, error: 'Input exceeds maximum size' }];
121
+ const cleaned = text.replace(/^\uFEFF/, '').trim();
122
+ if (!cleaned)
123
+ return [{ index: 0, source, error: 'Input is empty' }];
124
+ let roots;
125
+ try {
126
+ roots = expandRoot(JSON.parse(cleaned));
127
+ }
128
+ catch {
129
+ const lines = cleaned.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
130
+ if (lines.length <= 1)
131
+ return [{ index: 0, source, error: 'Malformed JSON input' }];
132
+ roots = lines.map((line) => { try {
133
+ return JSON.parse(line);
134
+ }
135
+ catch {
136
+ return Symbol('malformed');
137
+ } });
138
+ }
139
+ const results = [];
140
+ for (const root of roots) {
141
+ for (const item of expandRoot(root)) {
142
+ if (results.length >= MAX_RECORDS) {
143
+ results.push({ index: results.length, source, error: 'Input exceeds maximum record count' });
144
+ return results;
145
+ }
146
+ results.push(normalizeCodexRecord(item, results.length, now, source));
147
+ }
148
+ }
149
+ return results;
150
+ }
151
+ export function toCodexPreview(record, duplicateOf = null) {
152
+ return { index: record.index, source: record.source, valid: true, email: record.email, accountIdMasked: mask(record.chatgptAccountId), workspaceIdMasked: mask(record.workspaceId), planType: record.planType, expiresAt: record.expiresAt, duplicateOf };
153
+ }
154
+ export function toCodexImportResult(records, failures = []) {
155
+ return { records: records.map((record) => toCodexPreview(record)), failures: failures.map(({ index, source }) => ({ index, source, error: 'Invalid record' })) };
156
+ }
@@ -0,0 +1,77 @@
1
+ // Codex OAuth (PKCE) authorization-code flow, mirroring the Codex CLI client:
2
+ // fixed loopback port, S256 challenge, and a form-encoded token exchange.
3
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
+ import { normalizeCodexRecord } from './codex-import.js';
5
+ export const CODEX_OAUTH = {
6
+ clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
7
+ authorizeUrl: 'https://auth.openai.com/oauth/authorize',
8
+ tokenUrl: 'https://auth.openai.com/oauth/token',
9
+ scope: 'openid profile email offline_access',
10
+ codeChallengeMethod: 'S256',
11
+ /** Codex CLI registers http://localhost:1455/auth/callback, so the port is not negotiable. */
12
+ callbackUrl: 'http://localhost:1455/auth/callback',
13
+ };
14
+ /** Server-side auth URL: challenge always derives from the verifier the exchange will use. */
15
+ export function buildCodexAuthorizeUrl(verifier, state) {
16
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
17
+ const params = new URLSearchParams({
18
+ response_type: 'code',
19
+ client_id: CODEX_OAUTH.clientId,
20
+ redirect_uri: CODEX_OAUTH.callbackUrl,
21
+ scope: CODEX_OAUTH.scope,
22
+ code_challenge: challenge,
23
+ code_challenge_method: CODEX_OAUTH.codeChallengeMethod,
24
+ id_token_add_organizations: 'true',
25
+ codex_cli_simplified_flow: 'true',
26
+ originator: 'codex_cli_rs',
27
+ state,
28
+ });
29
+ return `${CODEX_OAUTH.authorizeUrl}?${params.toString()}`;
30
+ }
31
+ export function newCodexPkce() {
32
+ return { verifier: randomBytes(64).toString('base64url'), state: randomUUID().replace(/-/g, '') };
33
+ }
34
+ /** The browser lands on the loopback redirect; operators may paste that URL or just its code. */
35
+ export function extractCodeFromCallback(input) {
36
+ const text = input.trim();
37
+ if (!text)
38
+ return null;
39
+ try {
40
+ const code = new URL(text).searchParams.get('code');
41
+ if (code)
42
+ return code;
43
+ }
44
+ catch { /* not a URL — fall through to the bare-code case */ }
45
+ return /^[\w.~-]{8,}$/.test(text) ? text : null;
46
+ }
47
+ /** Exchanges the authorization code for tokens; never echoes the code or token values. */
48
+ export async function exchangeCodexCode({ code, verifier, fetchImpl = fetch, timeoutMs = 30_000 }) {
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
51
+ try {
52
+ const response = await fetchImpl(CODEX_OAUTH.tokenUrl, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
55
+ body: new URLSearchParams({
56
+ grant_type: 'authorization_code',
57
+ code,
58
+ redirect_uri: CODEX_OAUTH.callbackUrl,
59
+ client_id: CODEX_OAUTH.clientId,
60
+ code_verifier: verifier,
61
+ }),
62
+ signal: controller.signal,
63
+ });
64
+ const body = await response.json().catch(() => null);
65
+ if (!response.ok || !body) {
66
+ // Deliberately generic: provider error bodies can echo the authorization code.
67
+ throw Object.assign(new Error(`Codex authorization failed (HTTP ${response.status})`), { status: response.status });
68
+ }
69
+ const record = normalizeCodexRecord(body, 0, new Date(), 'oauth');
70
+ if ('error' in record)
71
+ throw new Error('Codex token response did not contain usable credentials');
72
+ return record;
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }