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.
- package/CHANGELOG.md +58 -0
- package/README.md +23 -1
- package/dist/server/app.js +10 -1
- package/dist/server/auth/middleware.js +37 -1
- package/dist/server/db/index.js +5 -0
- package/dist/server/db/migrate.js +38 -5
- package/dist/server/db/repositories/codex-accounts.js +187 -0
- package/dist/server/db/schema.js +44 -3
- package/dist/server/gateway/runner.js +76 -11
- package/dist/server/providers/codex-autostart.js +98 -0
- package/dist/server/providers/codex-import.js +156 -0
- package/dist/server/providers/codex-oauth.js +77 -0
- package/dist/server/providers/codex-refresh.js +165 -0
- package/dist/server/providers/codex-usage.js +192 -0
- package/dist/server/providers/codex.js +186 -0
- package/dist/server/providers/index.js +5 -0
- package/dist/server/routes/admin/auth.js +4 -1
- package/dist/server/routes/admin/codex.js +331 -0
- package/dist/server/routes/admin/models.js +20 -3
- package/dist/server/routes/admin/providers.js +63 -20
- package/dist/server/routes/admin/requests.js +1 -0
- package/dist/server/routes/admin.js +12 -0
- package/dist/server/routing/capabilities.js +1 -0
- package/dist/server/routing/combo.js +20 -12
- package/dist/server/upstream/client.js +54 -38
- package/dist/web/assets/index-CkDJqDcS.js +391 -0
- package/dist/web/assets/index-Drmqi8MO.css +1 -0
- package/dist/web/index.html +2 -2
- package/migrations/0005_codex_accounts.sql +105 -0
- package/migrations/0006_codex_usage.sql +9 -0
- package/package.json +5 -1
- package/dist/web/assets/index-C5h2WXK5.css +0 -1
- 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
|
-
|
|
298
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|