ldrouter 1.16.2 → 1.16.3
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 +9 -38
- package/README.md +1 -23
- package/dist/server/app.js +1 -10
- package/dist/server/auth/middleware.js +1 -37
- package/dist/server/db/index.js +0 -5
- package/dist/server/db/migrate.js +5 -38
- package/dist/server/db/schema.js +3 -44
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +64 -126
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -8
- package/dist/server/routes/admin/auth.js +1 -4
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +34 -24
- package/dist/server/routes/admin/providers.js +20 -63
- package/dist/server/routes/admin/requests.js +0 -1
- package/dist/server/routes/admin.js +0 -12
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +73 -14
- package/dist/server/routing/combo.js +20 -39
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +55 -61
- package/dist/web/assets/index-C5h2WXK5.css +1 -0
- package/dist/web/assets/index-CRnoua24.js +335 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -5
- package/dist/server/db/repositories/codex-accounts.js +0 -187
- package/dist/server/providers/codex-autostart.js +0 -98
- package/dist/server/providers/codex-import.js +0 -156
- package/dist/server/providers/codex-oauth.js +0 -77
- package/dist/server/providers/codex-refresh.js +0 -165
- package/dist/server/providers/codex-usage.js +0 -192
- package/dist/server/providers/codex.js +0 -186
- package/dist/server/routes/admin/codex.js +0 -331
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
- package/migrations/0005_codex_accounts.sql +0 -105
- package/migrations/0006_codex_usage.sql +0 -9
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { getCodexCredentials, getCodexAccountRefreshState, persistCodexRefresh, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
|
|
2
|
-
import { GatewayError } from '../errors.js';
|
|
3
|
-
/**
|
|
4
|
-
* Maps the credential-layer's opaque error codes onto typed admin-facing errors. Call sites that
|
|
5
|
-
* must not leak a bare 500 wrap their `withCodexCredentials` call in this. Codex fixes credential
|
|
6
|
-
* failures by re-importing the account, not by re-saving a provider API key.
|
|
7
|
-
*/
|
|
8
|
-
export function codexCredentialError(error) {
|
|
9
|
-
const code = error instanceof Error ? error.message : '';
|
|
10
|
-
if (code === 'account_not_found')
|
|
11
|
-
return new GatewayError('invalid_request_error', 'Codex account not found', { status: 404 });
|
|
12
|
-
if (code === 'oauth_refresh_failed' || code === 'invalid_refresh_response' || code === 'credential_unavailable') {
|
|
13
|
-
return new GatewayError('authentication_error', 'Codex credentials could not be refreshed — re-import the account', { status: 401 });
|
|
14
|
-
}
|
|
15
|
-
return error;
|
|
16
|
-
}
|
|
17
|
-
const REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
18
|
-
const REFRESH_TIMEOUT_MS = 10_000;
|
|
19
|
-
const flights = new Map();
|
|
20
|
-
let refreshClient = defaultRefreshClient;
|
|
21
|
-
let refreshTimeoutMs = REFRESH_TIMEOUT_MS;
|
|
22
|
-
export function configureCodexOAuthRefreshClient(client, timeoutMs = REFRESH_TIMEOUT_MS) {
|
|
23
|
-
refreshClient = client ?? defaultRefreshClient;
|
|
24
|
-
refreshTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : REFRESH_TIMEOUT_MS;
|
|
25
|
-
}
|
|
26
|
-
export function needsCodexRefresh(account, now = new Date()) {
|
|
27
|
-
const expires = Date.parse(account.tokenExpiresAt);
|
|
28
|
-
return !Number.isFinite(expires) || expires - now.getTime() <= REFRESH_LEAD_MS;
|
|
29
|
-
}
|
|
30
|
-
async function defaultRefreshClient(input) {
|
|
31
|
-
const response = await fetch('https://auth.openai.com/oauth/token', {
|
|
32
|
-
method: 'POST',
|
|
33
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
34
|
-
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: input.refreshToken }),
|
|
35
|
-
signal: input.signal,
|
|
36
|
-
});
|
|
37
|
-
if (!response.ok)
|
|
38
|
-
throw new Error(`oauth refresh http ${response.status}`);
|
|
39
|
-
const value = await response.json();
|
|
40
|
-
if (typeof value.access_token !== 'string' || !value.access_token)
|
|
41
|
-
throw new Error('invalid refresh response');
|
|
42
|
-
return {
|
|
43
|
-
accessToken: value.access_token,
|
|
44
|
-
refreshToken: typeof value.refresh_token === 'string' ? value.refresh_token : undefined,
|
|
45
|
-
idToken: typeof value.id_token === 'string' ? value.id_token : undefined,
|
|
46
|
-
expiresIn: typeof value.expires_in === 'number' ? value.expires_in : undefined,
|
|
47
|
-
expiresAt: typeof value.expires_at === 'string' ? value.expires_at : undefined,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
function safeError(_error) {
|
|
51
|
-
return 'oauth_refresh_failed';
|
|
52
|
-
}
|
|
53
|
-
function markDegraded(accountId, error) {
|
|
54
|
-
try {
|
|
55
|
-
setCodexAccountHealth(accountId, 'degraded', error);
|
|
56
|
-
}
|
|
57
|
-
catch { /* fail closed */ }
|
|
58
|
-
}
|
|
59
|
-
async function refreshOnce(accountId, now, force) {
|
|
60
|
-
let state;
|
|
61
|
-
try {
|
|
62
|
-
state = getCodexAccountRefreshState(accountId);
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
markDegraded(accountId, safeError(error));
|
|
66
|
-
return { ok: false, error: 'oauth_refresh_failed' };
|
|
67
|
-
}
|
|
68
|
-
if (!state)
|
|
69
|
-
return { ok: false, error: 'account_not_found' };
|
|
70
|
-
if (!force && !needsCodexRefresh(state, now))
|
|
71
|
-
return { ok: true, expiresAt: state.tokenExpiresAt };
|
|
72
|
-
const controller = new AbortController();
|
|
73
|
-
const timer = setTimeout(() => controller.abort(), refreshTimeoutMs);
|
|
74
|
-
try {
|
|
75
|
-
const result = await refreshClient({ refreshToken: state.refreshToken, signal: controller.signal });
|
|
76
|
-
if (typeof result.accessToken !== 'string' || !result.accessToken) {
|
|
77
|
-
markDegraded(accountId, 'invalid_refresh_response');
|
|
78
|
-
return { ok: false, error: 'invalid_refresh_response' };
|
|
79
|
-
}
|
|
80
|
-
const expiresAt = result.expiresAt ?? (typeof result.expiresIn === 'number' && Number.isFinite(result.expiresIn) && result.expiresIn > 0
|
|
81
|
-
? new Date(now.getTime() + result.expiresIn * 1000).toISOString()
|
|
82
|
-
: '');
|
|
83
|
-
const expiresMs = Date.parse(expiresAt);
|
|
84
|
-
if (!Number.isFinite(expiresMs) || expiresMs <= now.getTime()) {
|
|
85
|
-
markDegraded(accountId, 'invalid_refresh_response');
|
|
86
|
-
return { ok: false, error: 'invalid_refresh_response' };
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
persistCodexRefresh(accountId, {
|
|
90
|
-
accessToken: result.accessToken,
|
|
91
|
-
refreshToken: result.refreshToken ?? state.refreshToken,
|
|
92
|
-
idToken: result.idToken ?? state.idToken,
|
|
93
|
-
expiresAt,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
markDegraded(accountId, safeError(error));
|
|
98
|
-
return { ok: false, error: 'oauth_refresh_failed' };
|
|
99
|
-
}
|
|
100
|
-
return { ok: true, expiresAt };
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
markDegraded(accountId, safeError(error));
|
|
104
|
-
return { ok: false, error: 'oauth_refresh_failed' };
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
export function refreshCodexAccount(accountId, now = new Date(), force = false) {
|
|
111
|
-
const existing = flights.get(accountId);
|
|
112
|
-
if (existing)
|
|
113
|
-
return existing;
|
|
114
|
-
const flight = refreshOnce(accountId, now, force).finally(() => flights.delete(accountId));
|
|
115
|
-
flights.set(accountId, flight);
|
|
116
|
-
return flight;
|
|
117
|
-
}
|
|
118
|
-
function isUnauthorized(error) {
|
|
119
|
-
return Boolean(error && typeof error === 'object' && ('status' in error) && ((error.status === 401) || (error.status === 403)));
|
|
120
|
-
}
|
|
121
|
-
/* eslint-disable preserve-caught-error -- raw credential errors must never escape this boundary */
|
|
122
|
-
export async function withCodexCredentials(accountId, fn, now = new Date()) {
|
|
123
|
-
let state;
|
|
124
|
-
try {
|
|
125
|
-
state = getCodexAccountRefreshState(accountId);
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
markDegraded(accountId, safeError(error));
|
|
129
|
-
throw new Error('credential_unavailable', { cause: new Error('credential_unavailable') });
|
|
130
|
-
}
|
|
131
|
-
if (!state)
|
|
132
|
-
throw new Error('account_not_found');
|
|
133
|
-
if (needsCodexRefresh(state, now)) {
|
|
134
|
-
const refreshed = await refreshCodexAccount(accountId, now);
|
|
135
|
-
if (!refreshed.ok)
|
|
136
|
-
throw new Error(refreshed.error, { cause: new Error(refreshed.error) });
|
|
137
|
-
}
|
|
138
|
-
let credentials;
|
|
139
|
-
try {
|
|
140
|
-
credentials = getCodexCredentials(accountId);
|
|
141
|
-
}
|
|
142
|
-
catch (error) {
|
|
143
|
-
markDegraded(accountId, safeError(error));
|
|
144
|
-
throw new Error('credential_unavailable', { cause: new Error('credential_unavailable') });
|
|
145
|
-
}
|
|
146
|
-
try {
|
|
147
|
-
return await fn(credentials);
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
if (!isUnauthorized(error))
|
|
151
|
-
throw error;
|
|
152
|
-
const refreshed = await refreshCodexAccount(accountId, now, true);
|
|
153
|
-
if (!refreshed.ok)
|
|
154
|
-
throw new Error(refreshed.error, { cause: new Error(refreshed.error) });
|
|
155
|
-
try {
|
|
156
|
-
credentials = getCodexCredentials(accountId);
|
|
157
|
-
}
|
|
158
|
-
catch (credentialError) {
|
|
159
|
-
markDegraded(accountId, safeError(credentialError));
|
|
160
|
-
throw new Error('credential_unavailable', { cause: new Error('credential_unavailable') });
|
|
161
|
-
}
|
|
162
|
-
return fn(credentials);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
/* eslint-enable preserve-caught-error */
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
// Codex account quota: usage snapshot (wham/usage), weekly reset credits, and the
|
|
2
|
-
// 5-hour window auto-start ("auto ping") request. Contracts mirror 9router.
|
|
3
|
-
import { codexRequest, codexHeaders } from './codex.js';
|
|
4
|
-
const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
|
5
|
-
const RESET_CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
|
|
6
|
-
const RESET_CREDITS_CONSUME_URL = `${RESET_CREDITS_URL}/consume`;
|
|
7
|
-
const UA = 'codex_cli_rs/0.136.0';
|
|
8
|
-
/** Auto-start ping: only starts a 5-hour window once the streaming body is fully drained. */
|
|
9
|
-
export const CODEX_PING = { model: 'gpt-5.5', text: 'hi', instructions: 'Reply with OK.' };
|
|
10
|
-
/** codex_autostart_enabled accounts are never pinged more often than this. */
|
|
11
|
-
export const CODEX_AUTOSTART_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
12
|
-
function numberOr(value, fallback) {
|
|
13
|
-
if (typeof value === 'number' && Number.isFinite(value))
|
|
14
|
-
return value;
|
|
15
|
-
if (typeof value === 'string' && value.trim()) {
|
|
16
|
-
const parsed = Number(value);
|
|
17
|
-
if (Number.isFinite(parsed))
|
|
18
|
-
return parsed;
|
|
19
|
-
}
|
|
20
|
-
return fallback;
|
|
21
|
-
}
|
|
22
|
-
function isoOrNull(value) {
|
|
23
|
-
if (!value)
|
|
24
|
-
return null;
|
|
25
|
-
const date = value instanceof Date ? value : new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value);
|
|
26
|
-
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
27
|
-
}
|
|
28
|
-
/** Codex nests the 5h/weekly windows under several upstream key spellings. */
|
|
29
|
-
function limitContainer(value) {
|
|
30
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
31
|
-
return null;
|
|
32
|
-
const record = value;
|
|
33
|
-
return record.rate_limit && typeof record.rate_limit === 'object' ? record.rate_limit : record;
|
|
34
|
-
}
|
|
35
|
-
function innerWindow(container, ...keys) {
|
|
36
|
-
if (!container)
|
|
37
|
-
return null;
|
|
38
|
-
for (const key of keys) {
|
|
39
|
-
const inner = container[key];
|
|
40
|
-
if (inner && typeof inner === 'object' && !Array.isArray(inner))
|
|
41
|
-
return inner;
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
function toQuota(window) {
|
|
46
|
-
const used = Math.max(0, Math.min(100, numberOr(window.used_percent ?? window.percent_used, 0)));
|
|
47
|
-
return { used, total: 100, remaining: Math.max(0, 100 - used), resetAt: isoOrNull(window.reset_at ?? window.resets_at ?? window.resetAt) };
|
|
48
|
-
}
|
|
49
|
-
export function readCodexQuotas(body) {
|
|
50
|
-
const root = (body && typeof body === 'object' ? body : {});
|
|
51
|
-
const limitIds = root.rate_limits_by_limit_id;
|
|
52
|
-
const container = limitContainer(root.rate_limit ?? root.rate_limits ?? limitIds?.codex);
|
|
53
|
-
if (!container)
|
|
54
|
-
return {};
|
|
55
|
-
const quotas = {};
|
|
56
|
-
// Primary window is the 5-hour quota; secondary is the weekly one. Bare objects are treated as primary.
|
|
57
|
-
const primary = innerWindow(container, 'primary_window', 'primary')
|
|
58
|
-
?? ('used_percent' in container || 'percent_used' in container || 'reset_at' in container || 'resets_at' in container ? container : null);
|
|
59
|
-
if (primary)
|
|
60
|
-
quotas.session = toQuota(primary);
|
|
61
|
-
const additional = Array.isArray(root.additional_rate_limits) ? root.additional_rate_limits : [];
|
|
62
|
-
const secondary = innerWindow(container, 'secondary_window', 'secondary')
|
|
63
|
-
?? innerWindow(limitContainer(additional.find((entry) => {
|
|
64
|
-
const name = String(entry?.limit_name ?? entry?.metered_feature ?? entry?.id ?? '').toLowerCase();
|
|
65
|
-
return !name.includes('session');
|
|
66
|
-
})), 'primary_window', 'primary');
|
|
67
|
-
if (secondary)
|
|
68
|
-
quotas.blocking = toQuota(secondary);
|
|
69
|
-
return quotas;
|
|
70
|
-
}
|
|
71
|
-
export function parseCodexUsage(body, now = new Date()) {
|
|
72
|
-
const root = (body && typeof body === 'object' ? body : {});
|
|
73
|
-
return {
|
|
74
|
-
plan: typeof root.plan_type === 'string' ? root.plan_type : 'unknown',
|
|
75
|
-
limitReached: limitContainer(root.rate_limit)?.limit_reached === true,
|
|
76
|
-
resetCredits: Math.max(0, numberOr(root.rate_limit_reset_credits?.available_count, 0)),
|
|
77
|
-
quotas: readCodexQuotas(root),
|
|
78
|
-
fetchedAt: now.toISOString(),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
function timeout(ms) {
|
|
82
|
-
const controller = new AbortController();
|
|
83
|
-
const timer = setTimeout(() => controller.abort(), ms);
|
|
84
|
-
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
|
|
85
|
-
}
|
|
86
|
-
function credentialHeaders(accessToken, accountId, extra = {}) {
|
|
87
|
-
return {
|
|
88
|
-
accept: 'application/json',
|
|
89
|
-
authorization: `Bearer ${accessToken}`,
|
|
90
|
-
'openai-beta': 'codex-1',
|
|
91
|
-
originator: 'codex_cli_rs',
|
|
92
|
-
'user-agent': UA,
|
|
93
|
-
...(accountId ? { 'chatgpt-account-id': accountId } : {}),
|
|
94
|
-
...extra,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/** Reads the 5h/blocking windows for one account. Throws on transport/auth failure. */
|
|
98
|
-
export async function fetchCodexUsage(accessToken, accountId, timeoutMs = 15_000) {
|
|
99
|
-
const ctl = timeout(timeoutMs);
|
|
100
|
-
try {
|
|
101
|
-
const response = await fetch(USAGE_URL, { headers: credentialHeaders(accessToken, accountId), signal: ctl.signal });
|
|
102
|
-
if (!response.ok)
|
|
103
|
-
throw Object.assign(new Error(`Codex usage API returned HTTP ${response.status}`), { status: response.status });
|
|
104
|
-
return parseCodexUsage(await response.json());
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
ctl.cancel();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
export async function fetchCodexResetCredits(accessToken, accountId, timeoutMs = 15_000) {
|
|
111
|
-
const ctl = timeout(timeoutMs);
|
|
112
|
-
try {
|
|
113
|
-
const response = await fetch(RESET_CREDITS_URL, { headers: credentialHeaders(accessToken, accountId), signal: ctl.signal });
|
|
114
|
-
const body = await response.json().catch(() => null);
|
|
115
|
-
if (!response.ok)
|
|
116
|
-
throw Object.assign(new Error('Codex reset credits API unavailable'), { status: response.status });
|
|
117
|
-
return {
|
|
118
|
-
availableCount: Math.max(0, numberOr(body?.available_count ?? body?.availableCount, 0)),
|
|
119
|
-
credits: (Array.isArray(body?.credits) ? body.credits : []).map((credit) => {
|
|
120
|
-
const entry = credit;
|
|
121
|
-
return { status: String(entry?.status ?? 'unknown'), grantedAt: isoOrNull(entry?.granted_at ?? entry?.grantedAt), expiresAt: isoOrNull(entry?.expires_at ?? entry?.expiresAt) };
|
|
122
|
-
}),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
ctl.cancel();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
/** Spends one weekly reset credit to restart the 5h window immediately. */
|
|
130
|
-
export async function consumeCodexResetCredit(accessToken, accountId, timeoutMs = 20_000) {
|
|
131
|
-
const ctl = timeout(timeoutMs);
|
|
132
|
-
try {
|
|
133
|
-
const response = await fetch(RESET_CREDITS_CONSUME_URL, {
|
|
134
|
-
method: 'POST',
|
|
135
|
-
headers: credentialHeaders(accessToken, accountId, { 'content-type': 'application/json' }),
|
|
136
|
-
body: JSON.stringify({ redeem_request_id: crypto.randomUUID() }),
|
|
137
|
-
signal: ctl.signal,
|
|
138
|
-
});
|
|
139
|
-
const body = await response.json().catch(() => null);
|
|
140
|
-
const code = typeof body?.code === 'string' ? body.code : null;
|
|
141
|
-
const windowsReset = numberOr(body?.windows_reset, 0);
|
|
142
|
-
if (!response.ok)
|
|
143
|
-
throw Object.assign(new Error(response.status === 409 ? 'No Codex reset credits available' : `Codex reset credit failed (HTTP ${response.status})`), { status: response.status });
|
|
144
|
-
return { ok: code === 'reset' || windowsReset > 0, noCredit: code === 'no_credit', code, windowsReset, message: typeof body?.message === 'string' ? body.message : null };
|
|
145
|
-
}
|
|
146
|
-
finally {
|
|
147
|
-
ctl.cancel();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Sends the tiny streaming request that opens the next 5-hour window.
|
|
152
|
-
* Codex only starts the window after the stream completes, so the body is drained.
|
|
153
|
-
*/
|
|
154
|
-
export async function pingCodexAccount(cfg, model = CODEX_PING.model) {
|
|
155
|
-
const ctl = timeout(cfg.totalTimeoutMs);
|
|
156
|
-
try {
|
|
157
|
-
const response = await fetch(codexRequest(cfg, '/responses'), {
|
|
158
|
-
method: 'POST',
|
|
159
|
-
headers: codexHeaders(cfg, 'text/event-stream'),
|
|
160
|
-
body: JSON.stringify({
|
|
161
|
-
model,
|
|
162
|
-
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: CODEX_PING.text }] }],
|
|
163
|
-
instructions: CODEX_PING.instructions,
|
|
164
|
-
reasoning: { effort: 'none', summary: 'auto' },
|
|
165
|
-
store: false,
|
|
166
|
-
stream: true,
|
|
167
|
-
}),
|
|
168
|
-
signal: ctl.signal,
|
|
169
|
-
});
|
|
170
|
-
if (!response.ok) {
|
|
171
|
-
await response.body?.cancel?.().catch(() => undefined);
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
const reader = response.body?.getReader();
|
|
175
|
-
if (!reader)
|
|
176
|
-
return true;
|
|
177
|
-
try {
|
|
178
|
-
for (;;) {
|
|
179
|
-
const { done } = await reader.read();
|
|
180
|
-
if (done)
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
finally {
|
|
185
|
-
reader.releaseLock();
|
|
186
|
-
}
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
finally {
|
|
190
|
-
ctl.cancel();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { withCodexCredentials, refreshCodexAccount } from './codex-refresh.js';
|
|
2
|
-
const base = (url) => url.replace(/\/$/, '');
|
|
3
|
-
/**
|
|
4
|
-
* Reported Codex CLI client version. The models endpoint rejects requests without it:
|
|
5
|
-
* `400 [{'loc': ('query', 'client_version'), 'msg': 'Field required'}]`.
|
|
6
|
-
*/
|
|
7
|
-
export const CODEX_CLIENT_VERSION = '0.144.6';
|
|
8
|
-
export function codexRequest(cfg, path) {
|
|
9
|
-
return `${base(cfg.baseUrl)}/backend-api/codex${path.startsWith('/') ? path : `/${path}`}`;
|
|
10
|
-
}
|
|
11
|
-
export function codexHeaders(cfg, accept = 'application/json') {
|
|
12
|
-
if (!cfg.accessToken)
|
|
13
|
-
throw new Error('Codex credentials were not acquired');
|
|
14
|
-
return {
|
|
15
|
-
'content-type': 'application/json', accept,
|
|
16
|
-
authorization: `Bearer ${cfg.accessToken}`,
|
|
17
|
-
'chatgpt-account-id': cfg.accountId,
|
|
18
|
-
originator: 'codex_cli_rs',
|
|
19
|
-
'openai-beta': 'responses=experimental',
|
|
20
|
-
...cfg.customHeaders,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
function timeout(ms) {
|
|
24
|
-
const controller = new AbortController();
|
|
25
|
-
const timer = setTimeout(() => controller.abort(), ms);
|
|
26
|
-
return { signal: controller.signal, cancel: () => clearTimeout(timer) };
|
|
27
|
-
}
|
|
28
|
-
export async function probeCodex(cfg) {
|
|
29
|
-
const start = Date.now();
|
|
30
|
-
try {
|
|
31
|
-
const models = await codexModels(cfg);
|
|
32
|
-
return { ok: true, detail: 'Connected (200)', latencyMs: Date.now() - start, modelCount: models.length };
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
return { ok: false, detail: error instanceof Error ? error.message : 'Codex upstream unavailable', latencyMs: Date.now() - start };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export async function codexModels(cfg) {
|
|
39
|
-
const ctl = timeout(cfg.totalTimeoutMs);
|
|
40
|
-
try {
|
|
41
|
-
const response = await fetch(`${codexRequest(cfg, '/models')}?client_version=${CODEX_CLIENT_VERSION}`, { headers: codexHeaders(cfg), signal: ctl.signal });
|
|
42
|
-
// status is required: withCodexCredentials keys its one-shot refresh-and-retry off it.
|
|
43
|
-
if (!response.ok)
|
|
44
|
-
throw Object.assign(new Error(`Provider returned HTTP ${response.status}`), { status: response.status });
|
|
45
|
-
const body = await response.json();
|
|
46
|
-
const entries = Array.isArray(body) ? body : body.models ?? [];
|
|
47
|
-
// Upstream identifies models by `slug`; other deployments may use id/model/name.
|
|
48
|
-
return entries.flatMap((entry) => {
|
|
49
|
-
const id = [entry.slug, entry.id, entry.model, entry.name].find((value) => typeof value === 'string' && value.length > 0);
|
|
50
|
-
if (!id)
|
|
51
|
-
return [];
|
|
52
|
-
return [{
|
|
53
|
-
upstreamId: id,
|
|
54
|
-
displayName: entry.display_name ?? entry.displayName ?? entry.name ?? id,
|
|
55
|
-
capabilities: { responses: true, streaming: true, reasoning: true },
|
|
56
|
-
}];
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
finally {
|
|
60
|
-
ctl.cancel();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
export function codexRequestPayload(req, targetModel = req.model) {
|
|
64
|
-
const input = req.messages.map((message) => ({ role: message.role, content: message.content.map((block) => {
|
|
65
|
-
if (block.type === 'text')
|
|
66
|
-
return { type: 'input_text', text: block.text ?? '' };
|
|
67
|
-
if (block.type === 'image' && (block.image?.url || block.image?.base64))
|
|
68
|
-
return { type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` };
|
|
69
|
-
return null;
|
|
70
|
-
}).filter(Boolean) }));
|
|
71
|
-
const payload = { model: targetModel, input, stream: req.stream };
|
|
72
|
-
if (req.system)
|
|
73
|
-
payload.instructions = req.system;
|
|
74
|
-
if (req.maxOutputTokens !== undefined)
|
|
75
|
-
payload.max_output_tokens = req.maxOutputTokens;
|
|
76
|
-
if (req.tools?.length)
|
|
77
|
-
payload.tools = req.tools.map((tool) => ({ type: 'function', name: tool.name, description: tool.description, parameters: tool.inputSchema }));
|
|
78
|
-
if (req.reasoning?.effort)
|
|
79
|
-
payload.reasoning = { effort: req.reasoning.effort };
|
|
80
|
-
return payload;
|
|
81
|
-
}
|
|
82
|
-
function usage(value) {
|
|
83
|
-
const u = (value && typeof value === 'object' ? value : {});
|
|
84
|
-
const input = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
|
|
85
|
-
const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
|
|
86
|
-
return { input, output, total: input + output, cacheRead: typeof u.cached_input_tokens === 'number' ? u.cached_input_tokens : 0, cacheWrite: 0, reasoning: typeof u.reasoning_tokens === 'number' ? u.reasoning_tokens : 0 };
|
|
87
|
-
}
|
|
88
|
-
export function codexResponseToCanonical(body, requestedModel) {
|
|
89
|
-
let text = '';
|
|
90
|
-
const tools = [];
|
|
91
|
-
const output = Array.isArray(body.output) ? body.output : [];
|
|
92
|
-
for (const item of output) {
|
|
93
|
-
if (!item || typeof item !== 'object')
|
|
94
|
-
continue;
|
|
95
|
-
const value = item;
|
|
96
|
-
const content = Array.isArray(value.content) ? value.content : [];
|
|
97
|
-
for (const block of content)
|
|
98
|
-
if (block && typeof block === 'object' && block.type === 'output_text')
|
|
99
|
-
text += String(block.text ?? '');
|
|
100
|
-
if (value.type === 'function_call')
|
|
101
|
-
tools.push({ id: String(value.call_id ?? value.id ?? ''), name: String(value.name ?? ''), input: value.arguments ?? {} });
|
|
102
|
-
}
|
|
103
|
-
const u = usage(body.usage);
|
|
104
|
-
return { model: requestedModel, text, toolCalls: tools, finishReason: typeof body.status === 'string' ? body.status : null, usage: u };
|
|
105
|
-
}
|
|
106
|
-
export function codexStreamEventToCanonical(event) {
|
|
107
|
-
if (event.type === 'response.output_text.delta')
|
|
108
|
-
return { text: typeof event.delta === 'string' ? event.delta : '', isLast: false };
|
|
109
|
-
if (event.type === 'response.completed') {
|
|
110
|
-
const response = event.response && typeof event.response === 'object' ? event.response : {};
|
|
111
|
-
return { text: '', isLast: true, usage: usage(response.usage), finishReason: typeof response.status === 'string' ? response.status : null };
|
|
112
|
-
}
|
|
113
|
-
return { text: '', isLast: false };
|
|
114
|
-
}
|
|
115
|
-
async function responseError(response) {
|
|
116
|
-
const error = Object.assign(new Error(`Codex upstream HTTP ${response.status}`), { status: response.status });
|
|
117
|
-
return error;
|
|
118
|
-
}
|
|
119
|
-
export async function callCodexNonStreaming(cfg, req) {
|
|
120
|
-
const call = async (tokenCfg) => {
|
|
121
|
-
const ctl = timeout(tokenCfg.totalTimeoutMs);
|
|
122
|
-
try {
|
|
123
|
-
const response = await fetch(codexRequest(tokenCfg, '/responses'), { method: 'POST', headers: codexHeaders(tokenCfg), body: JSON.stringify(codexRequestPayload(req)), signal: ctl.signal });
|
|
124
|
-
if (!response.ok)
|
|
125
|
-
throw await responseError(response);
|
|
126
|
-
return { ...codexResponseToCanonical(await response.json(), req.model), status: response.status, upstreamRequestId: response.headers.get('x-request-id') };
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
|
-
ctl.cancel();
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
if (cfg.accountRecordId)
|
|
133
|
-
return withCodexCredentials(cfg.accountRecordId, (credentials) => call({ ...cfg, accessToken: credentials.accessToken }));
|
|
134
|
-
return call(cfg);
|
|
135
|
-
}
|
|
136
|
-
export async function callCodexStreaming(cfg, req, onEvent) {
|
|
137
|
-
const run = async (tokenCfg) => {
|
|
138
|
-
const ctl = timeout(tokenCfg.totalTimeoutMs);
|
|
139
|
-
try {
|
|
140
|
-
const response = await fetch(codexRequest(tokenCfg, '/responses'), { method: 'POST', headers: codexHeaders(tokenCfg, 'text/event-stream'), body: JSON.stringify(codexRequestPayload(req)), signal: ctl.signal });
|
|
141
|
-
if (!response.ok || !response.body)
|
|
142
|
-
throw await responseError(response);
|
|
143
|
-
const reader = response.body.getReader();
|
|
144
|
-
const decoder = new TextDecoder();
|
|
145
|
-
let buffer = '';
|
|
146
|
-
const process = (raw) => { const data = raw.split(/\r?\n/).find((line) => line.startsWith('data:'))?.slice(5).trim(); if (data && data !== '[DONE]')
|
|
147
|
-
onEvent(codexStreamEventToCanonical(JSON.parse(data))); };
|
|
148
|
-
for (;;) {
|
|
149
|
-
const part = await reader.read();
|
|
150
|
-
buffer += decoder.decode(part.value ?? new Uint8Array(), { stream: part.done });
|
|
151
|
-
let index;
|
|
152
|
-
while ((index = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
153
|
-
process(buffer.slice(0, index));
|
|
154
|
-
buffer = buffer.slice(index + (buffer[index] === '\r' ? 4 : 2));
|
|
155
|
-
}
|
|
156
|
-
if (part.done)
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
if (buffer.trim())
|
|
160
|
-
process(buffer.replace(/\r?\n$/, ''));
|
|
161
|
-
return { status: response.status, upstreamRequestId: response.headers.get('x-request-id') };
|
|
162
|
-
}
|
|
163
|
-
finally {
|
|
164
|
-
ctl.cancel();
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
if (cfg.accountRecordId)
|
|
168
|
-
return withCodexCredentials(cfg.accountRecordId, (credentials) => run({ ...cfg, accessToken: credentials.accessToken }));
|
|
169
|
-
return run(cfg);
|
|
170
|
-
}
|
|
171
|
-
/* eslint-disable preserve-caught-error -- safe refresh failure intentionally omits credentials */
|
|
172
|
-
export async function withCodexUpstream(accountId, refresh, call) {
|
|
173
|
-
try {
|
|
174
|
-
return await call();
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
if (!error || typeof error !== 'object' || ![401, 403].includes(error.status))
|
|
178
|
-
throw error;
|
|
179
|
-
const result = await refresh();
|
|
180
|
-
if (!result.ok)
|
|
181
|
-
throw new Error('Codex credential refresh failed', { cause: new Error('refresh_failed') });
|
|
182
|
-
return call();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
/* eslint-enable preserve-caught-error */
|
|
186
|
-
export { refreshCodexAccount, withCodexCredentials };
|