ldrouter 1.14.0 → 1.16.2

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 (34) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +23 -1
  3. package/dist/server/app.js +10 -1
  4. package/dist/server/auth/api-key.js +2 -2
  5. package/dist/server/auth/middleware.js +37 -1
  6. package/dist/server/db/index.js +5 -0
  7. package/dist/server/db/migrate.js +38 -5
  8. package/dist/server/db/repositories/codex-accounts.js +187 -0
  9. package/dist/server/db/schema.js +44 -3
  10. package/dist/server/gateway/runner.js +75 -12
  11. package/dist/server/providers/codex-autostart.js +98 -0
  12. package/dist/server/providers/codex-import.js +156 -0
  13. package/dist/server/providers/codex-oauth.js +77 -0
  14. package/dist/server/providers/codex-refresh.js +165 -0
  15. package/dist/server/providers/codex-usage.js +192 -0
  16. package/dist/server/providers/codex.js +186 -0
  17. package/dist/server/providers/index.js +5 -0
  18. package/dist/server/routes/admin/auth.js +4 -1
  19. package/dist/server/routes/admin/codex.js +331 -0
  20. package/dist/server/routes/admin/models.js +20 -3
  21. package/dist/server/routes/admin/providers.js +63 -20
  22. package/dist/server/routes/admin/requests.js +1 -0
  23. package/dist/server/routes/admin.js +12 -0
  24. package/dist/server/routing/capabilities.js +3 -3
  25. package/dist/server/routing/combo.js +20 -14
  26. package/dist/server/upstream/client.js +54 -38
  27. package/dist/web/assets/index-Coy-u6h8.css +1 -0
  28. package/dist/web/assets/index-qDG5c6aL.js +386 -0
  29. package/dist/web/index.html +2 -2
  30. package/migrations/0005_codex_accounts.sql +105 -0
  31. package/migrations/0006_codex_usage.sql +9 -0
  32. package/package.json +5 -1
  33. package/dist/web/assets/index-CBMHkVXC.js +0 -330
  34. package/dist/web/assets/index-Dswaxg_c.css +0 -1
@@ -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 { deriveRequiredCapabilities, modelMeets } 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 } 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';
@@ -127,7 +129,10 @@ export class GatewayRunner {
127
129
  if (filtered.length === 0) {
128
130
  throw new GatewayError('capability_not_supported', 'No combo member satisfies the request capabilities or availability', { status: 400 });
129
131
  }
130
- candidates = orderCandidates(comboPlan, filtered);
132
+ candidates = orderCandidates(comboPlan, filtered).flatMap((candidate) => {
133
+ const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
134
+ return provider?.type === 'codex' ? expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(provider.id)) : [candidate];
135
+ });
131
136
  debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
132
137
  `mode=${comboPlan.mode}`,
133
138
  ...candidates.map((c, i) => `candidate[${i}]: providerModelId=${c.modelId} publicModelId=${c.publicModelId}`),
@@ -196,7 +201,7 @@ export class GatewayRunner {
196
201
  lastError = new GatewayError('upstream_unavailable', 'Provider circuit is open', { status: 502 });
197
202
  break;
198
203
  }
199
- const cfg = providerToUpstreamConfig(provider);
204
+ const cfg = providerToUpstreamConfig(provider, candidate.codexAccountId);
200
205
  const attemptStart = Date.now();
201
206
  // docs/13 §11–§12: provider/account + upstream request summary
202
207
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
@@ -209,7 +214,7 @@ export class GatewayRunner {
209
214
  `upstreamType=${cfg.type}`,
210
215
  `baseUrl=${cfg.baseUrl}`,
211
216
  `stream=${req.canonical.stream}`,
212
- `providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey)}`,
217
+ `providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey ?? '')}`,
213
218
  ]);
214
219
  const attempt = {
215
220
  attemptNumber: i + 1,
@@ -225,7 +230,8 @@ export class GatewayRunner {
225
230
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
226
231
  streamStarted: false,
227
232
  partialResponse: false,
228
- selectionReason: selectionReasons[0] ?? 'direct',
233
+ selectionReason: candidate.selectionReason ?? selectionReasons[0] ?? 'direct',
234
+ codexAccountId: candidate.codexAccountId,
229
235
  failureReason: null,
230
236
  sanitizedError: null,
231
237
  upstreamRequestId: null,
@@ -258,6 +264,8 @@ export class GatewayRunner {
258
264
  resultToolCalls = out.result.toolCalls;
259
265
  resultFinishReason = out.result.finishReason;
260
266
  recordSuccess(provider.id);
267
+ if (candidate.codexAccountId)
268
+ setCodexAccountHealth(candidate.codexAccountId, 'healthy');
261
269
  getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
262
270
  attempts.push(attempt);
263
271
  sentToClient = out.streamStarted ?? false;
@@ -291,8 +299,12 @@ export class GatewayRunner {
291
299
  }
292
300
  attempts.push(attempt);
293
301
  lastError = err;
294
- recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
295
- getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
302
+ if (isUpstreamHealthFailure(err)) {
303
+ recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
304
+ if (candidate.codexAccountId)
305
+ setCodexAccountHealth(candidate.codexAccountId, 'down', redactString(err.message));
306
+ getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
307
+ }
296
308
  if (shouldRetry && i + 1 < maxAttempts) {
297
309
  metrics.fallbackCount.inc();
298
310
  continue;
@@ -409,6 +421,12 @@ export class GatewayRunner {
409
421
  `caps.image_input=${caps.image_input}`,
410
422
  `caps.structured_output=${caps.structured_output}`,
411
423
  ]);
424
+ if (p.type === 'codex') {
425
+ const expanded = expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(p.id));
426
+ if (expanded.length === 0)
427
+ reject('codex_account_unavailable');
428
+ return expanded;
429
+ }
412
430
  return [candidate];
413
431
  }
414
432
  async loadAllModels() {
@@ -434,6 +452,10 @@ export class GatewayRunner {
434
452
  }
435
453
  async runNonStreamingAttempt(req, candidate, cfg, ctx) {
436
454
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
455
+ if (cfg.type === 'codex') {
456
+ const out = await callCodexNonStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel });
457
+ return { statusCode: out.status, ttftMs: null, upstreamRequestId: out.upstreamRequestId, usage: out.usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
458
+ }
437
459
  let call;
438
460
  if (cfg.type === 'openai') {
439
461
  const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
@@ -451,8 +473,8 @@ export class GatewayRunner {
451
473
  if (call.status === 401 || call.status === 403)
452
474
  throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502 });
453
475
  if (call.status >= 500)
454
- throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}`, { status: 502, code: `upstream_http_${call.status}` });
455
- throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}: ${redactString(call.text.slice(0, 300))}`, { status: 502 });
476
+ throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}`, { status: 502, code: `upstream_http_${call.status}`, cause: { status: call.status } });
477
+ throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}: ${redactString(call.text.slice(0, 300))}`, { status: 502, cause: { status: call.status } });
456
478
  }
457
479
  let parsed;
458
480
  try {
@@ -602,6 +624,26 @@ export class GatewayRunner {
602
624
  return;
603
625
  };
604
626
  try {
627
+ if (cfg.type === 'codex') {
628
+ const events = [];
629
+ let firstCodexEvent = true;
630
+ const meta = await callCodexStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel }, (event) => {
631
+ if (event.text || event.isLast) {
632
+ events.push(event);
633
+ if (event.usage)
634
+ Object.assign(usage, event.usage);
635
+ chunkHandler({ data: codexStreamEventToClient(req.protocol, event, upstreamModel, ctx.requestId) }, firstCodexEvent);
636
+ firstCodexEvent = false;
637
+ }
638
+ });
639
+ if (!headWritten)
640
+ writeHead();
641
+ pipe.write('data: [DONE]\n\n');
642
+ pipe.end();
643
+ if (!usage.total)
644
+ usage.total = usage.input + usage.output;
645
+ return { statusCode: meta.status, ttftMs: events.length ? Date.now() - streamStartTs : null, upstreamRequestId: meta.upstreamRequestId, usage, result: { text: textBuf, toolCalls: toolBuf, finishReason } };
646
+ }
605
647
  const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
606
648
  const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
607
649
  logUpstreamRequest(ctx.requestId, cfg, url, payload, true);
@@ -836,6 +878,7 @@ export class GatewayRunner {
836
878
  attemptNumber: a.attemptNumber,
837
879
  providerId: a.providerId,
838
880
  modelId: a.modelId,
881
+ codexAccountId: a.codexAccountId ?? null,
839
882
  startedAt: a.startedAt,
840
883
  completedAt: a.completedAt,
841
884
  statusCode: a.statusCode,
@@ -858,7 +901,10 @@ export class GatewayRunner {
858
901
  emitRequestLogged(requestId);
859
902
  }
860
903
  }
861
- function classifyFailure(err) {
904
+ export function isUpstreamHealthFailure(err) {
905
+ return ['connection_error', 'connect_timeout', 'first_token_timeout', 'http_status', 'upstream_rate_limit'].includes(classifyFailure(err));
906
+ }
907
+ export function classifyFailure(err) {
862
908
  switch (err.type) {
863
909
  case 'timeout_error':
864
910
  return err.message.includes('first token') ? 'first_token_timeout' : 'connect_timeout';
@@ -866,8 +912,10 @@ function classifyFailure(err) {
866
912
  return 'connection_error';
867
913
  case 'upstream_rate_limit':
868
914
  return 'http_status';
869
- case 'upstream_error':
870
- return err.status >= 500 ? 'http_status' : 'connection_error';
915
+ case 'upstream_error': {
916
+ const upstreamStatus = err.cause?.status;
917
+ return typeof upstreamStatus === 'number' && upstreamStatus >= 500 && upstreamStatus < 600 ? 'http_status' : 'unknown';
918
+ }
871
919
  default:
872
920
  return 'unknown';
873
921
  }
@@ -963,6 +1011,21 @@ function safeJsonParse(s) {
963
1011
  function usageFromCache(u) {
964
1012
  return u ? { ...u, total: u.input + u.output } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
965
1013
  }
1014
+ // Map Codex's native response events to the public protocol stream shape.
1015
+ export function codexStreamEventToClient(protocol, event, model, requestId) {
1016
+ if (protocol === 'openai') {
1017
+ return JSON.stringify({
1018
+ id: `chatcmpl-${requestId}`,
1019
+ object: 'chat.completion.chunk',
1020
+ created: Math.floor(Date.now() / 1000),
1021
+ model,
1022
+ choices: [{ index: 0, delta: event.isLast ? {} : { content: event.text }, finish_reason: event.isLast ? 'stop' : null }],
1023
+ });
1024
+ }
1025
+ return JSON.stringify(event.isLast
1026
+ ? { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } }
1027
+ : { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: event.text } });
1028
+ }
966
1029
  // SSE encoders: canonical stream chunk -> client protocol event
967
1030
  function openaiStreamEncoder(data, _event) {
968
1031
  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
+ }