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
package/dist/web/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<meta name="color-scheme" content="light dark" />
|
|
8
8
|
<title>LateDev Router</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CRnoua24.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C5h2WXK5.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ldrouter",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.3",
|
|
4
4
|
"description": "LateDev Router — lightweight self-hosted LLM gateway with admin UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
},
|
|
15
15
|
"main": "dist/server/app.js",
|
|
16
16
|
"bin": {
|
|
17
|
-
"latedev-router": "dist/cli.js",
|
|
18
17
|
"ldrouter": "dist/cli.js"
|
|
19
18
|
},
|
|
20
19
|
"files": [
|
|
@@ -51,9 +50,6 @@
|
|
|
51
50
|
"release:major": "pnpm verify && npm version major -m \"release: v%s\""
|
|
52
51
|
},
|
|
53
52
|
"dependencies": {
|
|
54
|
-
"@dnd-kit/core": "^6.3.1",
|
|
55
|
-
"@dnd-kit/modifiers": "^9.0.0",
|
|
56
|
-
"@dnd-kit/sortable": "^10.0.0",
|
|
57
53
|
"@fastify/cookie": "^11.0.2",
|
|
58
54
|
"@fastify/cors": "^10.0.2",
|
|
59
55
|
"@fastify/helmet": "^13.0.1",
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { decryptSecret, encryptSecret } from '../../auth/crypto.js';
|
|
3
|
-
import { uuid } from '../../auth/ids.js';
|
|
4
|
-
import { getRawDb } from '../index.js';
|
|
5
|
-
const digest = (value) => createHash('sha256').update(value, 'utf8').digest('hex');
|
|
6
|
-
export function maskCodexValue(value) {
|
|
7
|
-
if (!value)
|
|
8
|
-
return null;
|
|
9
|
-
return value.length <= 8 ? `${value.slice(0, 2)}…${value.slice(-2)}` : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
10
|
-
}
|
|
11
|
-
export function identityFromCodexRecord(record) {
|
|
12
|
-
return {
|
|
13
|
-
chatgptAccountId: record.chatgptAccountId,
|
|
14
|
-
workspaceId: record.workspaceId,
|
|
15
|
-
email: record.email,
|
|
16
|
-
tokenDigest: digest(record.accessToken),
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
export function toCodexAccountSummary(row) {
|
|
20
|
-
return {
|
|
21
|
-
id: row.id,
|
|
22
|
-
email: row.email,
|
|
23
|
-
accountIdMasked: maskCodexValue(row.chatgptAccountId),
|
|
24
|
-
workspaceIdMasked: maskCodexValue(row.workspaceId),
|
|
25
|
-
planType: row.planType,
|
|
26
|
-
tokenExpiresAt: row.tokenExpiresAt,
|
|
27
|
-
// SQLite hands raw rows back as 0/1; the API contract (and the web UI) expects a boolean.
|
|
28
|
-
enabled: Boolean(row.enabled),
|
|
29
|
-
healthState: row.healthState,
|
|
30
|
-
lastRefreshAt: row.lastRefreshAt,
|
|
31
|
-
priority: row.priority,
|
|
32
|
-
createdAt: row.createdAt,
|
|
33
|
-
updatedAt: row.updatedAt,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
const client = getRawDb;
|
|
37
|
-
function encrypted(value) {
|
|
38
|
-
return encryptSecret(value);
|
|
39
|
-
}
|
|
40
|
-
export function listCodexAccountsForProvider(providerId) {
|
|
41
|
-
return client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt, priority FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
|
|
42
|
-
}
|
|
43
|
-
export function getCodexAccountForProvider(providerId) {
|
|
44
|
-
const row = listCodexAccountsForProvider(providerId).find((a) => a.enabled && (a.healthState === 'healthy' || a.healthState === 'unknown') && Date.parse(a.tokenExpiresAt) > Date.now());
|
|
45
|
-
return row?.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
|
|
46
|
-
}
|
|
47
|
-
export function getCodexAccountById(id) {
|
|
48
|
-
const row = client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt FROM codex_accounts WHERE id=?`).get(id);
|
|
49
|
-
if (!row || !row.enabled || !['healthy', 'unknown'].includes(row.healthState) || Date.parse(row.tokenExpiresAt) <= Date.now())
|
|
50
|
-
return null;
|
|
51
|
-
return row.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
|
|
52
|
-
}
|
|
53
|
-
export function listCodexAccountSummaries(providerId) {
|
|
54
|
-
const rows = client().prepare(`SELECT id,email,workspace_id AS workspaceId,chatgpt_account_id AS chatgptAccountId,plan_type AS planType,token_expires_at AS tokenExpiresAt,enabled,health_state AS healthState,last_refresh_at AS lastRefreshAt,priority,codex_autostart_enabled AS autostart,codex_usage_json AS usageJson,codex_usage_error AS usageError,codex_usage_updated_at AS usageUpdatedAt,last_pinged_reset_at AS lastPingedResetAt,last_ping_at AS lastPingAt,created_at AS createdAt,updated_at AS updatedAt FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
|
|
55
|
-
return rows.map(toCodexAccountSummaryRow);
|
|
56
|
-
}
|
|
57
|
-
export function findCodexAccountForImport(providerId, identity) {
|
|
58
|
-
const rows = client().prepare(`SELECT id,provider_id AS providerId,email,workspace_id AS workspaceId,chatgpt_account_id AS chatgptAccountId,plan_type AS planType,encrypted_access_token AS encryptedAccessToken,access_token_nonce AS accessTokenNonce,access_token_version AS accessTokenVersion,encrypted_refresh_token AS encryptedRefreshToken,refresh_token_nonce AS refreshTokenNonce,refresh_token_version AS refreshTokenVersion,encrypted_id_token AS encryptedIdToken,id_token_nonce AS idTokenNonce,id_token_version AS idTokenVersion,token_expires_at AS tokenExpiresAt,last_refresh_at AS lastRefreshAt,auth_method AS authMethod,enabled,health_state AS healthState,last_error AS lastError,consecutive_failures AS consecutiveFailures,priority,created_at AS createdAt,updated_at AS updatedAt FROM codex_accounts WHERE provider_id=?`).all(providerId);
|
|
59
|
-
const providerRows = rows;
|
|
60
|
-
if (identity.chatgptAccountId) {
|
|
61
|
-
const account = providerRows.find((row) => row.chatgptAccountId === identity.chatgptAccountId);
|
|
62
|
-
if (account)
|
|
63
|
-
return account;
|
|
64
|
-
}
|
|
65
|
-
if (identity.workspaceId) {
|
|
66
|
-
const workspace = providerRows.find((row) => row.workspaceId === identity.workspaceId);
|
|
67
|
-
if (workspace)
|
|
68
|
-
return workspace;
|
|
69
|
-
}
|
|
70
|
-
for (const row of providerRows) {
|
|
71
|
-
const credentials = getCodexCredentials(row.id);
|
|
72
|
-
if (digest(credentials.accessToken) === identity.tokenDigest)
|
|
73
|
-
return row;
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
function persistImport(providerId, record, existingId) {
|
|
78
|
-
const raw = client();
|
|
79
|
-
const access = encrypted(record.accessToken);
|
|
80
|
-
const refresh = encrypted(record.refreshToken);
|
|
81
|
-
const idToken = record.idToken ? encrypted(record.idToken) : null;
|
|
82
|
-
const now = existingId ? nextUpdatedAt(existingId) : new Date().toISOString();
|
|
83
|
-
const transaction = raw.transaction(() => {
|
|
84
|
-
if (existingId) {
|
|
85
|
-
raw.prepare(`UPDATE codex_accounts SET email=?, workspace_id=?, chatgpt_account_id=?, plan_type=?, encrypted_access_token=?, access_token_nonce=?, access_token_version=?, encrypted_refresh_token=?, refresh_token_nonce=?, refresh_token_version=?, encrypted_id_token=?, id_token_nonce=?, id_token_version=?, token_expires_at=?, last_error=NULL, consecutive_failures=0, health_state='unknown', updated_at=? WHERE id=?`).run(record.email, record.workspaceId, record.chatgptAccountId, record.planType, access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, record.expiresAt, now, existingId);
|
|
86
|
-
return existingId;
|
|
87
|
-
}
|
|
88
|
-
const priority = raw.prepare('SELECT COALESCE(MAX(priority), -1) AS value FROM codex_accounts WHERE provider_id=?').get(providerId).value + 1;
|
|
89
|
-
const id = uuid();
|
|
90
|
-
raw.prepare(`INSERT INTO codex_accounts (id, provider_id, email, workspace_id, chatgpt_account_id, plan_type, encrypted_access_token, access_token_nonce, access_token_version, encrypted_refresh_token, refresh_token_nonce, refresh_token_version, encrypted_id_token, id_token_nonce, id_token_version, token_expires_at, priority) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, providerId, record.email, record.workspaceId, record.chatgptAccountId, record.planType, access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, record.expiresAt, priority);
|
|
91
|
-
return id;
|
|
92
|
-
});
|
|
93
|
-
return transaction();
|
|
94
|
-
}
|
|
95
|
-
export function insertCodexAccount(providerId, record) {
|
|
96
|
-
return persistImport(providerId, record);
|
|
97
|
-
}
|
|
98
|
-
export function updateCodexAccountFromImport(id, record) {
|
|
99
|
-
const row = client().prepare('SELECT provider_id FROM codex_accounts WHERE id=?').get(id);
|
|
100
|
-
if (!row)
|
|
101
|
-
throw new Error('Codex account not found');
|
|
102
|
-
return persistImport(row.provider_id, record, id);
|
|
103
|
-
}
|
|
104
|
-
export function getCodexCredentials(id) {
|
|
105
|
-
const row = client().prepare('SELECT encrypted_access_token, access_token_nonce, access_token_version, encrypted_refresh_token, refresh_token_nonce, refresh_token_version, encrypted_id_token, id_token_nonce, id_token_version FROM codex_accounts WHERE id=?').get(id);
|
|
106
|
-
if (!row)
|
|
107
|
-
throw new Error('Codex account not found');
|
|
108
|
-
return {
|
|
109
|
-
accessToken: decryptSecret({ ciphertext: row.encrypted_access_token, nonce: row.access_token_nonce, version: row.access_token_version }),
|
|
110
|
-
refreshToken: decryptSecret({ ciphertext: row.encrypted_refresh_token, nonce: row.refresh_token_nonce, version: row.refresh_token_version }),
|
|
111
|
-
idToken: row.encrypted_id_token ? decryptSecret({ ciphertext: row.encrypted_id_token, nonce: row.id_token_nonce, version: row.id_token_version }) : null,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
export function getCodexAccountRefreshState(id) {
|
|
115
|
-
const row = client().prepare('SELECT token_expires_at AS tokenExpiresAt FROM codex_accounts WHERE id=?').get(id);
|
|
116
|
-
if (!row)
|
|
117
|
-
return null;
|
|
118
|
-
const credentials = getCodexCredentials(id);
|
|
119
|
-
return { tokenExpiresAt: row.tokenExpiresAt, refreshToken: credentials.refreshToken, idToken: credentials.idToken };
|
|
120
|
-
}
|
|
121
|
-
export function persistCodexRefresh(id, update) {
|
|
122
|
-
const access = encrypted(update.accessToken);
|
|
123
|
-
const refresh = encrypted(update.refreshToken);
|
|
124
|
-
const idToken = update.idToken ? encrypted(update.idToken) : null;
|
|
125
|
-
const updatedAt = nextUpdatedAt(id);
|
|
126
|
-
client().transaction(() => {
|
|
127
|
-
client().prepare(`UPDATE codex_accounts SET encrypted_access_token=?, access_token_nonce=?, access_token_version=?, encrypted_refresh_token=?, refresh_token_nonce=?, refresh_token_version=?, encrypted_id_token=?, id_token_nonce=?, id_token_version=?, token_expires_at=?, last_refresh_at=?, last_error=NULL, consecutive_failures=0, health_state='healthy', updated_at=? WHERE id=?`).run(access.ciphertext, access.nonce, access.version, refresh.ciphertext, refresh.nonce, refresh.version, idToken?.ciphertext ?? null, idToken?.nonce ?? null, idToken?.version ?? 1, update.expiresAt, updatedAt, updatedAt, id);
|
|
128
|
-
})();
|
|
129
|
-
}
|
|
130
|
-
function nextUpdatedAt(id) {
|
|
131
|
-
const current = client().prepare('SELECT updated_at AS updatedAt FROM codex_accounts WHERE id=?').get(id);
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
const previous = current ? Date.parse(current.updatedAt) : 0;
|
|
134
|
-
return new Date(Math.max(now, previous + 1)).toISOString();
|
|
135
|
-
}
|
|
136
|
-
export function setCodexAccountHealth(id, healthState, lastError = null, enabled) {
|
|
137
|
-
const fields = enabled === undefined ? 'health_state=?, last_error=?, updated_at=?' : 'health_state=?, last_error=?, enabled=?, updated_at=?';
|
|
138
|
-
const updatedAt = nextUpdatedAt(id);
|
|
139
|
-
const values = enabled === undefined ? [healthState, lastError, updatedAt, id] : [healthState, lastError, enabled ? 1 : 0, updatedAt, id];
|
|
140
|
-
client().prepare(`UPDATE codex_accounts SET ${fields} WHERE id=?`).run(...values);
|
|
141
|
-
}
|
|
142
|
-
export function upsertCodexAccount(providerId, record) {
|
|
143
|
-
const existing = findCodexAccountForImport(providerId, identityFromCodexRecord(record));
|
|
144
|
-
return existing ? { id: updateCodexAccountFromImport(existing.id, record), status: 'updated' } : { id: insertCodexAccount(providerId, record), status: 'added' };
|
|
145
|
-
}
|
|
146
|
-
export function saveCodexUsage(id, usage) {
|
|
147
|
-
client().prepare('UPDATE codex_accounts SET codex_usage_json=?, codex_usage_updated_at=?, codex_usage_error=NULL, updated_at=? WHERE id=?')
|
|
148
|
-
.run(JSON.stringify(usage), usage.fetchedAt, nextUpdatedAt(id), id);
|
|
149
|
-
}
|
|
150
|
-
export function saveCodexUsageError(id, message) {
|
|
151
|
-
client().prepare('UPDATE codex_accounts SET codex_usage_error=?, updated_at=? WHERE id=?').run(message.slice(0, 500), nextUpdatedAt(id), id);
|
|
152
|
-
}
|
|
153
|
-
function parseUsage(raw) {
|
|
154
|
-
if (!raw)
|
|
155
|
-
return null;
|
|
156
|
-
try {
|
|
157
|
-
return JSON.parse(raw);
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
export function listCodexAccountUsage(providerId) {
|
|
164
|
-
const rows = client().prepare('SELECT id,provider_id AS providerId,chatgpt_account_id AS accountId,enabled,codex_autostart_enabled AS autostart,last_pinged_reset_at AS lastPingedResetAt,last_pinged_reset_key AS lastPingedResetKey,last_ping_at AS lastPingAt,codex_usage_json AS usageJson,codex_usage_error AS usageError FROM codex_accounts WHERE provider_id=? ORDER BY priority,id').all(providerId);
|
|
165
|
-
return rows.map(({ usageJson, ...row }) => ({ ...row, autostart: Boolean(row.autostart), enabled: Boolean(row.enabled), usage: parseUsage(usageJson) }));
|
|
166
|
-
}
|
|
167
|
-
/** Auto-start targets: enabled accounts that opted in, for the 10-minute scheduler tick. */
|
|
168
|
-
export function listCodexAutostartTargets() {
|
|
169
|
-
const rows = client().prepare('SELECT id,provider_id AS providerId,last_ping_at AS lastPingAt FROM codex_accounts WHERE enabled=1 AND codex_autostart_enabled=1').all();
|
|
170
|
-
return rows;
|
|
171
|
-
}
|
|
172
|
-
export function markCodexAccountPinged(id, resetAt, resetKey) {
|
|
173
|
-
const now = new Date().toISOString();
|
|
174
|
-
client().prepare('UPDATE codex_accounts SET last_pinged_reset_at=?, last_pinged_reset_key=?, last_ping_at=?, updated_at=? WHERE id=?')
|
|
175
|
-
.run(resetAt, resetKey, now, nextUpdatedAt(id), id);
|
|
176
|
-
}
|
|
177
|
-
export function toCodexAccountSummaryRow(row) {
|
|
178
|
-
return {
|
|
179
|
-
...toCodexAccountSummary(row),
|
|
180
|
-
autostart: Boolean(row.autostart),
|
|
181
|
-
usage: parseUsage(row.usageJson ?? null),
|
|
182
|
-
usageError: row.usageError ?? null,
|
|
183
|
-
usageUpdatedAt: row.usageUpdatedAt ?? null,
|
|
184
|
-
lastPingedResetAt: row.lastPingedResetAt ?? null,
|
|
185
|
-
lastPingAt: row.lastPingAt ?? null,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|