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.
- package/CHANGELOG.md +49 -0
- package/README.md +23 -1
- package/dist/server/app.js +10 -1
- package/dist/server/auth/api-key.js +2 -2
- 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 +75 -12
- 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 +3 -3
- package/dist/server/routing/combo.js +20 -14
- package/dist/server/upstream/client.js +54 -38
- package/dist/web/assets/index-Coy-u6h8.css +1 -0
- package/dist/web/assets/index-qDG5c6aL.js +386 -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-CBMHkVXC.js +0 -330
- 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
|
-
|
|
295
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|