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
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { GatewayError } from '../../errors.js';
|
|
3
|
+
import { requireAdminAuth, requireAdminCsrf } from '../../auth/middleware.js';
|
|
4
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
5
|
+
import { listCodexAccountSummaries, setCodexAccountHealth, toCodexAccountSummaryRow, upsertCodexAccount, findCodexAccountForImport, } from '../../db/repositories/codex-accounts.js';
|
|
6
|
+
import { getRawDb } from '../../db/index.js';
|
|
7
|
+
import { parseCodexImportText, toCodexPreview } from '../../providers/codex-import.js';
|
|
8
|
+
import { probeCodex } from '../../providers/codex.js';
|
|
9
|
+
import { fetchCodexResetCredits, consumeCodexResetCredit } from '../../providers/codex-usage.js';
|
|
10
|
+
import { codexCredentialError, withCodexCredentials } from '../../providers/codex-refresh.js';
|
|
11
|
+
import { buildCodexAuthorizeUrl, exchangeCodexCode, extractCodeFromCallback, newCodexPkce } from '../../providers/codex-oauth.js';
|
|
12
|
+
import { refreshStoredCodexUsage } from '../../providers/codex-autostart.js';
|
|
13
|
+
import { redactString } from '../../security/redact.js';
|
|
14
|
+
const MAX_BYTES = 2_000_000;
|
|
15
|
+
const MAX_RECORDS = 500;
|
|
16
|
+
const ImportBody = z.object({ providerId: z.string().min(1), selectedIndexes: z.array(z.number().int().min(0)).max(MAX_RECORDS).optional() });
|
|
17
|
+
const MutationOptions = { preHandler: requireAdminCsrf };
|
|
18
|
+
export const CodexAccountUpdate = z.object({
|
|
19
|
+
enabled: z.boolean().optional(),
|
|
20
|
+
email: z.string().email().nullable().optional(),
|
|
21
|
+
workspaceId: z.string().max(256).nullable().optional(),
|
|
22
|
+
planType: z.string().max(128).nullable().optional(),
|
|
23
|
+
priority: z.number().int().min(0).max(100000).optional(),
|
|
24
|
+
autostart: z.boolean().optional(),
|
|
25
|
+
}).refine((v) => Object.keys(v).length > 0, 'At least one update is required');
|
|
26
|
+
/** Credential failures must surface as typed admin errors, not an opaque 500. */
|
|
27
|
+
async function withCredentials(accountId, fn) {
|
|
28
|
+
try {
|
|
29
|
+
return await withCodexCredentials(accountId, fn);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw codexCredentialError(error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseAll(text) {
|
|
36
|
+
const result = parseCodexImportText(text);
|
|
37
|
+
const records = [];
|
|
38
|
+
const failures = [];
|
|
39
|
+
for (const item of result)
|
|
40
|
+
('accessToken' in item ? records : failures).push(item);
|
|
41
|
+
return { records, failures };
|
|
42
|
+
}
|
|
43
|
+
function providerOrThrow(providerId) {
|
|
44
|
+
const provider = getRawDb().prepare('SELECT id,type,base_url,total_timeout_ms FROM providers WHERE id=?').get(providerId);
|
|
45
|
+
if (!provider)
|
|
46
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
47
|
+
if (provider.type !== 'codex')
|
|
48
|
+
throw new GatewayError('invalid_request_error', 'Provider must be Codex', { status: 400 });
|
|
49
|
+
return provider;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Desktop/e2e flows cannot always hand a real File to a multipart file part, so a file part whose
|
|
53
|
+
* name lacks a text extension is accepted as base64 of the JSON payload.
|
|
54
|
+
*/
|
|
55
|
+
const TEXT_EXT = /\.(json|jsonl|txt)$/i;
|
|
56
|
+
async function readInput(req, body) {
|
|
57
|
+
const contentType = String(req.headers['content-type'] ?? '');
|
|
58
|
+
if (contentType.includes('multipart/form-data')) {
|
|
59
|
+
const parts = req.parts();
|
|
60
|
+
let providerId = '';
|
|
61
|
+
let selectedIndexes;
|
|
62
|
+
const texts = [];
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
for await (const part of parts) {
|
|
65
|
+
if (part.type === 'file') {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for await (const chunk of part.file) {
|
|
68
|
+
bytes += chunk.length;
|
|
69
|
+
if (bytes > MAX_BYTES)
|
|
70
|
+
throw new GatewayError('invalid_request_error', 'Import exceeds maximum size', { status: 413 });
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
74
|
+
texts.push(TEXT_EXT.test(part.filename ?? '') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
|
|
75
|
+
}
|
|
76
|
+
else if (part.fieldname === 'providerId')
|
|
77
|
+
providerId = String(part.value);
|
|
78
|
+
else if (part.fieldname === 'selectedIndexes') {
|
|
79
|
+
let parsedIndexes;
|
|
80
|
+
try {
|
|
81
|
+
parsedIndexes = JSON.parse(String(part.value));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new GatewayError('invalid_request_error', 'selectedIndexes must be JSON', { status: 400 });
|
|
85
|
+
}
|
|
86
|
+
selectedIndexes = z.array(z.number().int().min(0)).max(MAX_RECORDS).parse(parsedIndexes);
|
|
87
|
+
}
|
|
88
|
+
else if (part.fieldname === 'text')
|
|
89
|
+
texts.push(String(part.value));
|
|
90
|
+
}
|
|
91
|
+
return { providerId, text: texts.join('\n'), selectedIndexes };
|
|
92
|
+
}
|
|
93
|
+
const parsed = ImportBody.extend({ text: z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]) }).parse(body);
|
|
94
|
+
const text = typeof parsed.text === 'string' ? parsed.text : JSON.stringify(parsed.text);
|
|
95
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_BYTES)
|
|
96
|
+
throw new GatewayError('invalid_request_error', 'Import exceeds maximum size', { status: 413 });
|
|
97
|
+
return { providerId: parsed.providerId, text, selectedIndexes: parsed.selectedIndexes };
|
|
98
|
+
}
|
|
99
|
+
const selectAccount = "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 id=?";
|
|
100
|
+
function accountById(id) {
|
|
101
|
+
const row = getRawDb().prepare(selectAccount).get(id);
|
|
102
|
+
if (!row)
|
|
103
|
+
throw new GatewayError('invalid_request_error', 'Codex account not found', { status: 404 });
|
|
104
|
+
return row;
|
|
105
|
+
}
|
|
106
|
+
/** Narrow seam onto the Codex account row so quota routes can read provider + identity. */
|
|
107
|
+
function accountTarget(id) {
|
|
108
|
+
const row = getRawDb().prepare('SELECT provider_id AS providerId, chatgpt_account_id AS accountId FROM codex_accounts WHERE id=?').get(id);
|
|
109
|
+
if (!row)
|
|
110
|
+
throw new GatewayError('invalid_request_error', 'Codex account not found', { status: 404 });
|
|
111
|
+
return { ...row, provider: providerOrThrow(row.providerId) };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Server-held PKCE sessions. The challenge must derive from the verifier used at exchange time,
|
|
115
|
+
* so the verifier never leaves the process; the dialog polls this state instead.
|
|
116
|
+
*/
|
|
117
|
+
const oauthSessions = new Map();
|
|
118
|
+
const OAUTH_TTL_MS = 15 * 60_000;
|
|
119
|
+
const reapOauthSessions = () => {
|
|
120
|
+
const cutoff = Date.now() - OAUTH_TTL_MS;
|
|
121
|
+
for (const [state, session] of oauthSessions)
|
|
122
|
+
if (session.createdAt < cutoff)
|
|
123
|
+
oauthSessions.delete(state);
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Loopback capture endpoint for the Codex CLI redirect (http://localhost:1455/auth/callback).
|
|
127
|
+
* Registered outside the admin scope because the browser's redirect carries no session; the code
|
|
128
|
+
* is held in memory against its state and exchanged only by /oauth/complete.
|
|
129
|
+
*/
|
|
130
|
+
export async function registerCodexOAuthCallbackRoute(app) {
|
|
131
|
+
app.get('/oauth/codex/callback', async (req, reply) => {
|
|
132
|
+
const { code, state, error } = req.query;
|
|
133
|
+
reapOauthSessions();
|
|
134
|
+
const session = state ? oauthSessions.get(state) : undefined;
|
|
135
|
+
if (session && code)
|
|
136
|
+
session.code = code;
|
|
137
|
+
const failed = Boolean(error) || !session || !code;
|
|
138
|
+
reply.type('text/html').send(`<!doctype html><meta charset="utf-8"><title>LateDev Router</title><body style="font:14px system-ui;padding:2rem">` +
|
|
139
|
+
(failed
|
|
140
|
+
? `<h1>Authorization failed</h1><p>${session ? 'No authorization code was returned. Close this tab and try again.' : 'This authorization session is unknown or has expired.'}</p>`
|
|
141
|
+
: `<h1>Account connected</h1><p>You can close this tab and return to LateDev Router.</p>`));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export async function registerCodexRoutes(app) {
|
|
145
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
146
|
+
app.post('/api/admin/codex/accounts/preview', { preHandler: requireAdminCsrf }, async (req) => {
|
|
147
|
+
const input = await readInput(req, req.body);
|
|
148
|
+
providerOrThrow(input.providerId);
|
|
149
|
+
const parsed = parseAll(input.text);
|
|
150
|
+
const seen = new Map();
|
|
151
|
+
const records = parsed.records.map((record) => {
|
|
152
|
+
const duplicateOf = seen.get(record.identity);
|
|
153
|
+
seen.set(record.identity, record.index);
|
|
154
|
+
const existing = findCodexAccountForImport(input.providerId, { chatgptAccountId: record.chatgptAccountId, workspaceId: record.workspaceId, email: record.email, tokenDigest: '' });
|
|
155
|
+
return { ...toCodexPreview(record, duplicateOf === undefined ? (existing?.id ?? null) : String(duplicateOf)), duplicateOf: duplicateOf === undefined ? (existing?.id ?? null) : String(duplicateOf) };
|
|
156
|
+
});
|
|
157
|
+
return { records: [...records, ...parsed.failures.map((f) => ({ index: f.index, source: f.source, valid: false, error: 'Invalid record' }))], validCount: records.length, invalidCount: parsed.failures.length };
|
|
158
|
+
});
|
|
159
|
+
app.post('/api/admin/codex/accounts/import', MutationOptions, async (req) => {
|
|
160
|
+
const input = await readInput(req, req.body);
|
|
161
|
+
providerOrThrow(input.providerId);
|
|
162
|
+
const parsed = parseAll(input.text);
|
|
163
|
+
const selected = input.selectedIndexes ? new Set(input.selectedIndexes) : null;
|
|
164
|
+
const results = [];
|
|
165
|
+
let added = 0;
|
|
166
|
+
let updated = 0;
|
|
167
|
+
let skipped = 0;
|
|
168
|
+
let failed = 0;
|
|
169
|
+
for (const failure of parsed.failures) {
|
|
170
|
+
if (!selected || selected.has(failure.index)) {
|
|
171
|
+
failed++;
|
|
172
|
+
results.push({ index: failure.index, status: 'failed', error: 'Invalid record' });
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
skipped++;
|
|
176
|
+
}
|
|
177
|
+
for (const record of parsed.records) {
|
|
178
|
+
if (selected && !selected.has(record.index)) {
|
|
179
|
+
skipped++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const result = upsertCodexAccount(input.providerId, record);
|
|
184
|
+
if (result.status === 'added')
|
|
185
|
+
added++;
|
|
186
|
+
else
|
|
187
|
+
updated++;
|
|
188
|
+
results.push({ index: record.index, status: result.status, email: record.email, accountIdMasked: toCodexPreview(record).accountIdMasked });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
failed++;
|
|
192
|
+
results.push({ index: record.index, status: 'failed', error: 'Import failed' });
|
|
193
|
+
void error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
recordAudit({ action: 'codex.accounts.import', success: failed === 0, targetType: 'provider', targetId: input.providerId, ip: req.ip, metadata: { added, updated, skipped, failed } });
|
|
197
|
+
return { added, updated, skipped, failed, results };
|
|
198
|
+
});
|
|
199
|
+
app.get('/api/admin/codex/accounts', async (req) => {
|
|
200
|
+
const providerId = z.object({ providerId: z.string().min(1) }).parse(req.query).providerId;
|
|
201
|
+
providerOrThrow(providerId);
|
|
202
|
+
return { accounts: listCodexAccountSummaries(providerId) };
|
|
203
|
+
});
|
|
204
|
+
/** Drag-and-drop routing order. The posted array order becomes priority 0..n-1. */
|
|
205
|
+
const ReorderBody = z.object({ providerId: z.string().min(1), ids: z.array(z.string().min(1)).min(1).max(MAX_RECORDS) });
|
|
206
|
+
app.post('/api/admin/codex/accounts/reorder', MutationOptions, async (req) => {
|
|
207
|
+
const { providerId, ids } = ReorderBody.parse(req.body);
|
|
208
|
+
providerOrThrow(providerId);
|
|
209
|
+
const owned = new Set(listCodexAccountSummaries(providerId).map((account) => account.id));
|
|
210
|
+
if (ids.some((id) => !owned.has(id)))
|
|
211
|
+
throw new GatewayError('invalid_request_error', 'Account list does not match the provider', { status: 400 });
|
|
212
|
+
const raw = getRawDb();
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
const update = raw.prepare('UPDATE codex_accounts SET priority=?, updated_at=? WHERE id=?');
|
|
215
|
+
raw.transaction(() => ids.forEach((id, index) => update.run(index, now, id)))();
|
|
216
|
+
recordAudit({ action: 'codex.accounts.reorder', success: true, targetType: 'provider', targetId: providerId, ip: req.ip, metadata: { count: ids.length } });
|
|
217
|
+
return { accounts: listCodexAccountSummaries(providerId) };
|
|
218
|
+
});
|
|
219
|
+
app.post('/api/admin/codex/oauth/start', MutationOptions, async (req) => {
|
|
220
|
+
const { providerId } = z.object({ providerId: z.string().min(1) }).parse(req.body);
|
|
221
|
+
providerOrThrow(providerId);
|
|
222
|
+
reapOauthSessions();
|
|
223
|
+
const { verifier, state } = newCodexPkce();
|
|
224
|
+
oauthSessions.set(state, { verifier, providerId, createdAt: Date.now() });
|
|
225
|
+
return { state, authorizeUrl: buildCodexAuthorizeUrl(verifier, state) };
|
|
226
|
+
});
|
|
227
|
+
app.get('/api/admin/codex/oauth/:state', async (req) => {
|
|
228
|
+
const { state } = req.params;
|
|
229
|
+
reapOauthSessions();
|
|
230
|
+
const session = oauthSessions.get(state);
|
|
231
|
+
// Poll target for the dialog: reports whether the loopback callback already delivered a code.
|
|
232
|
+
if (!session)
|
|
233
|
+
throw new GatewayError('invalid_request_error', 'Authorization session not found or expired', { status: 404 });
|
|
234
|
+
return { pending: true, callbackReceived: Boolean(session.code), providerId: session.providerId, expiresInMs: session.createdAt + OAUTH_TTL_MS - Date.now() };
|
|
235
|
+
});
|
|
236
|
+
/**
|
|
237
|
+
* Completes the flow from either the browser's loopback redirect or a pasted callback URL/code.
|
|
238
|
+
* The matching session is consumed, so a code can only be exchanged once.
|
|
239
|
+
*/
|
|
240
|
+
app.post('/api/admin/codex/oauth/complete', MutationOptions, async (req) => {
|
|
241
|
+
const body = z.object({ providerId: z.string().min(1), state: z.string().min(1).optional(), callbackUrl: z.string().min(1).max(8192) }).parse(req.body);
|
|
242
|
+
providerOrThrow(body.providerId);
|
|
243
|
+
const fallbackState = [...oauthSessions.entries()].filter(([, session]) => session.providerId === body.providerId).sort((a, b) => b[1].createdAt - a[1].createdAt)[0]?.[0];
|
|
244
|
+
const state = body.state ?? fallbackState;
|
|
245
|
+
const session = state ? oauthSessions.get(state) : undefined;
|
|
246
|
+
if (!state || !session || session.providerId !== body.providerId)
|
|
247
|
+
throw new GatewayError('invalid_request_error', 'Authorization session not found or expired', { status: 409 });
|
|
248
|
+
const code = extractCodeFromCallback(body.callbackUrl) ?? session.code;
|
|
249
|
+
if (!code)
|
|
250
|
+
throw new GatewayError('invalid_request_error', 'Could not find an authorization code in the pasted value', { status: 400 });
|
|
251
|
+
try {
|
|
252
|
+
const record = await exchangeCodexCode({ code, verifier: session.verifier });
|
|
253
|
+
oauthSessions.delete(state);
|
|
254
|
+
const result = upsertCodexAccount(body.providerId, record);
|
|
255
|
+
recordAudit({ action: 'codex.oauth.connect', success: true, targetType: 'codex_account', targetId: result.id, targetName: record.email ?? undefined, ip: req.ip, metadata: { status: result.status } });
|
|
256
|
+
return { account: toCodexAccountSummaryRow(accountById(result.id)), status: result.status };
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
const status = error.status ?? 502;
|
|
260
|
+
throw new GatewayError('authentication_error', redactString(error instanceof Error ? error.message : 'Codex authorization failed'), { status });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
app.patch('/api/admin/codex/accounts/:id', MutationOptions, async (req) => {
|
|
264
|
+
const { id } = req.params;
|
|
265
|
+
const body = CodexAccountUpdate.parse(req.body);
|
|
266
|
+
accountById(id);
|
|
267
|
+
const raw = getRawDb();
|
|
268
|
+
const fields = [];
|
|
269
|
+
const values = [];
|
|
270
|
+
for (const [key, value] of Object.entries(body)) {
|
|
271
|
+
const column = { enabled: 'enabled', email: 'email', workspaceId: 'workspace_id', planType: 'plan_type', priority: 'priority', autostart: 'codex_autostart_enabled' }[key];
|
|
272
|
+
if (column) {
|
|
273
|
+
fields.push(`${column}=?`);
|
|
274
|
+
values.push(typeof value === 'boolean' ? (value ? 1 : 0) : value);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
fields.push('updated_at=?');
|
|
278
|
+
values.push(new Date().toISOString(), id);
|
|
279
|
+
raw.prepare(`UPDATE codex_accounts SET ${fields.join(',')} WHERE id=?`).run(...values);
|
|
280
|
+
recordAudit({ action: 'codex.accounts.update', success: true, targetType: 'codex_account', targetId: id, ip: req.ip, metadata: { fields: Object.keys(body) } });
|
|
281
|
+
return { account: toCodexAccountSummaryRow(accountById(id)) };
|
|
282
|
+
});
|
|
283
|
+
app.delete('/api/admin/codex/accounts/:id', MutationOptions, async (req) => {
|
|
284
|
+
const { id } = req.params;
|
|
285
|
+
const account = accountById(id);
|
|
286
|
+
// Hard delete: the encrypted credentials must not linger on disk. Historical request attempts
|
|
287
|
+
// survive because request_attempts.codex_account_id is ON DELETE SET NULL.
|
|
288
|
+
getRawDb().prepare('DELETE FROM codex_accounts WHERE id=?').run(id);
|
|
289
|
+
recordAudit({ action: 'codex.accounts.delete', success: true, targetType: 'codex_account', targetId: id, targetName: account.email ?? undefined, ip: req.ip });
|
|
290
|
+
return { ok: true, deleted: true };
|
|
291
|
+
});
|
|
292
|
+
app.post('/api/admin/codex/accounts/:id/test', MutationOptions, async (req) => {
|
|
293
|
+
const { id } = req.params;
|
|
294
|
+
const { provider, accountId } = accountTarget(id);
|
|
295
|
+
const result = await withCredentials(id, (credentials) => probeCodex({
|
|
296
|
+
baseUrl: provider.base_url, accountId: accountId ?? '', accessToken: credentials.accessToken,
|
|
297
|
+
accountRecordId: id, customHeaders: {}, totalTimeoutMs: Math.min(provider.total_timeout_ms, 20_000),
|
|
298
|
+
}));
|
|
299
|
+
setCodexAccountHealth(id, result.ok ? 'healthy' : 'down', result.ok ? null : redactString(result.detail));
|
|
300
|
+
recordAudit({ action: 'codex.accounts.test', success: result.ok, targetType: 'codex_account', targetId: id, ip: req.ip, metadata: { detail: redactString(result.detail) } });
|
|
301
|
+
return { ok: result.ok, detail: redactString(result.detail), latencyMs: result.latencyMs, modelCount: result.modelCount ?? null };
|
|
302
|
+
});
|
|
303
|
+
/** Refresh the 5h/weekly quota snapshot for one account. */
|
|
304
|
+
app.post('/api/admin/codex/accounts/:id/usage', MutationOptions, async (req) => {
|
|
305
|
+
const { id } = req.params;
|
|
306
|
+
const { provider, accountId } = accountTarget(id);
|
|
307
|
+
await refreshStoredCodexUsage(id, provider, { id, chatgpt_account_id: accountId });
|
|
308
|
+
return { account: toCodexAccountSummaryRow(accountById(id)) };
|
|
309
|
+
});
|
|
310
|
+
/** Weekly reset credits available on the ChatGPT account. */
|
|
311
|
+
app.get('/api/admin/codex/accounts/:id/reset-credits', async (req) => {
|
|
312
|
+
const { id } = req.params;
|
|
313
|
+
const { accountId } = accountTarget(id);
|
|
314
|
+
const result = await withCredentials(id, (credentials) => fetchCodexResetCredits(credentials.accessToken, accountId ?? undefined));
|
|
315
|
+
return result;
|
|
316
|
+
});
|
|
317
|
+
/** Spend one weekly reset credit to restart the 5h window immediately. */
|
|
318
|
+
app.post('/api/admin/codex/accounts/:id/reset-quota', MutationOptions, async (req) => {
|
|
319
|
+
const { id } = req.params;
|
|
320
|
+
const { provider, accountId } = accountTarget(id);
|
|
321
|
+
const result = await withCredentials(id, (credentials) => consumeCodexResetCredit(credentials.accessToken, accountId ?? undefined));
|
|
322
|
+
if (result.ok)
|
|
323
|
+
await refreshStoredCodexUsage(id, provider, { id, chatgpt_account_id: accountId });
|
|
324
|
+
recordAudit({ action: 'codex.accounts.reset_quota', success: result.ok, targetType: 'codex_account', targetId: id, ip: req.ip, metadata: { code: result.code, windowsReset: result.windowsReset } });
|
|
325
|
+
if (!result.ok) {
|
|
326
|
+
const status = result.noCredit ? 409 : 502;
|
|
327
|
+
throw new GatewayError('invalid_request_error', result.noCredit ? 'No Codex reset credits available' : redactString(result.message ?? 'Codex reset credit request failed'), { status });
|
|
328
|
+
}
|
|
329
|
+
return { ok: true, windowsReset: result.windowsReset, account: toCodexAccountSummaryRow(accountById(id)) };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
@@ -71,11 +71,28 @@ export async function registerModelRoutes(app) {
|
|
|
71
71
|
// For simplicity: re-discover and match by upstream id.
|
|
72
72
|
const { discoverProviderModels } = await import('../../providers/index.js');
|
|
73
73
|
const { decryptSecret, decryptCustomHeaders } = await import('../../auth/crypto.js');
|
|
74
|
-
const
|
|
75
|
-
const
|
|
74
|
+
const { codexModels } = await import('../../providers/codex.js');
|
|
75
|
+
const { getCodexAccountById, listCodexAccountSummaries } = await import('../../db/repositories/codex-accounts.js');
|
|
76
|
+
const { withCodexCredentials } = await import('../../providers/codex-refresh.js');
|
|
76
77
|
let discovered;
|
|
77
78
|
try {
|
|
78
|
-
|
|
79
|
+
if (provider.type === 'codex') {
|
|
80
|
+
const summary = listCodexAccountSummaries(provider.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
|
|
81
|
+
const account = summary ? getCodexAccountById(summary.id) : null;
|
|
82
|
+
if (!account)
|
|
83
|
+
throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
|
|
84
|
+
discovered = await withCodexCredentials(account.id, (credentials) => codexModels({
|
|
85
|
+
baseUrl: provider.baseUrl, accountId: account.chatgptAccountId, accessToken: credentials.accessToken,
|
|
86
|
+
accountRecordId: account.id, customHeaders: {}, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 30000),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (!provider.encryptedApiKey || !provider.apiKeyNonce)
|
|
91
|
+
throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
|
|
92
|
+
const apiKey = decryptSecret({ ciphertext: provider.encryptedApiKey, nonce: provider.apiKeyNonce, version: provider.apiKeyVersion });
|
|
93
|
+
const headers = decryptCustomHeaders(provider.customHeadersEncrypted && provider.customHeadersNonce ? { ciphertext: provider.customHeadersEncrypted, nonce: provider.customHeadersNonce, version: 1 } : null);
|
|
94
|
+
discovered = await discoverProviderModels({ type: provider.type, baseUrl: provider.baseUrl, apiKey, customHeaders: headers, connectTimeoutMs: 5000, totalTimeoutMs: 30000 });
|
|
95
|
+
}
|
|
79
96
|
}
|
|
80
97
|
catch {
|
|
81
98
|
discovered = [];
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { sql, eq } from 'drizzle-orm';
|
|
3
|
-
import { getDb, schema } from '../../db/index.js';
|
|
3
|
+
import { getDb, getRawDb, schema } from '../../db/index.js';
|
|
4
4
|
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
5
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
6
6
|
import { encryptSecret, decryptSecret, encryptCustomHeaders, decryptCustomHeaders, isMasterKeyConfigured } from '../../auth/crypto.js';
|
|
7
7
|
import { uuid, slugify } from '../../auth/ids.js';
|
|
8
8
|
import { GatewayError } from '../../errors.js';
|
|
9
9
|
import { probeProvider, discoverProviderModels } from '../../providers/index.js';
|
|
10
|
+
import { probeCodex, codexModels } from '../../providers/codex.js';
|
|
11
|
+
import { listCodexAccountSummaries } from '../../db/repositories/codex-accounts.js';
|
|
12
|
+
import { codexCredentialError, withCodexCredentials } from '../../providers/codex-refresh.js';
|
|
13
|
+
import { redactString } from '../../security/redact.js';
|
|
10
14
|
const ProviderCreate = z.object({
|
|
11
15
|
name: z.string().min(1).max(128),
|
|
12
16
|
slug: z.string().min(1).max(64).optional(),
|
|
13
|
-
type: z.enum(['openai', 'anthropic']),
|
|
17
|
+
type: z.enum(['openai', 'anthropic', 'codex']),
|
|
14
18
|
baseUrl: z.string().url().max(512),
|
|
15
|
-
apiKey: z.string().min(1).max(
|
|
19
|
+
apiKey: z.string().min(1).max(20000).optional(),
|
|
16
20
|
customHeaders: z.record(z.string(), z.string()).optional(),
|
|
17
21
|
enabled: z.boolean().optional(),
|
|
18
22
|
connectTimeoutMs: z.number().int().min(100).max(60000).optional(),
|
|
@@ -60,13 +64,16 @@ export async function registerProviderRoutes(app) {
|
|
|
60
64
|
});
|
|
61
65
|
app.post('/api/admin/providers', async (req) => {
|
|
62
66
|
const body = ProviderCreate.parse(req.body);
|
|
67
|
+
if (body.type !== 'codex' && !body.apiKey) {
|
|
68
|
+
throw new GatewayError('invalid_request_error', 'API key is required for this provider type', { status: 400 });
|
|
69
|
+
}
|
|
63
70
|
requireMasterKey(); // Need master key to encrypt new credentials
|
|
64
71
|
const db = getDb();
|
|
65
72
|
const slug = body.slug ? slugify(body.slug) : slugify(body.name);
|
|
66
73
|
const dup = db.select().from(schema.providers).where(eq(schema.providers.slug, slug)).get();
|
|
67
74
|
if (dup)
|
|
68
75
|
throw new GatewayError('invalid_request_error', `Provider slug '${slug}' is already in use`, { status: 400 });
|
|
69
|
-
const enc = encryptSecret(body.apiKey);
|
|
76
|
+
const enc = body.apiKey ? encryptSecret(body.apiKey) : null;
|
|
70
77
|
const headersEnc = body.customHeaders ? encryptCustomHeaders(body.customHeaders) : null;
|
|
71
78
|
const id = uuid();
|
|
72
79
|
db.insert(schema.providers).values({
|
|
@@ -75,9 +82,9 @@ export async function registerProviderRoutes(app) {
|
|
|
75
82
|
slug,
|
|
76
83
|
type: body.type,
|
|
77
84
|
baseUrl: body.baseUrl,
|
|
78
|
-
encryptedApiKey: enc
|
|
79
|
-
apiKeyNonce: enc
|
|
80
|
-
apiKeyVersion: enc
|
|
85
|
+
encryptedApiKey: enc?.ciphertext ?? null,
|
|
86
|
+
apiKeyNonce: enc?.nonce ?? null,
|
|
87
|
+
apiKeyVersion: enc?.version ?? 1,
|
|
81
88
|
customHeadersEncrypted: headersEnc?.ciphertext ?? null,
|
|
82
89
|
customHeadersNonce: headersEnc?.nonce ?? null,
|
|
83
90
|
enabled: body.enabled ?? true,
|
|
@@ -150,9 +157,21 @@ export async function registerProviderRoutes(app) {
|
|
|
150
157
|
recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
|
|
151
158
|
return { ok: true, softDisabled: true };
|
|
152
159
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
// A Codex provider owns its account pool. codex_accounts.provider_id is ON DELETE RESTRICT,
|
|
161
|
+
// so the accounts must go in the same transaction or the delete fails with a raw SQLite
|
|
162
|
+
// constraint error (which surfaces as an opaque 500 "Gateway error").
|
|
163
|
+
let codexAccountsDeleted = 0;
|
|
164
|
+
try {
|
|
165
|
+
getRawDb().transaction(() => {
|
|
166
|
+
codexAccountsDeleted = getRawDb().prepare('DELETE FROM codex_accounts WHERE provider_id=?').run(id).changes;
|
|
167
|
+
db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new GatewayError('invalid_request_error', 'Provider is still referenced and cannot be deleted', { status: 409, cause: error });
|
|
172
|
+
}
|
|
173
|
+
recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { codexAccountsDeleted } });
|
|
174
|
+
return { ok: true, codexAccountsDeleted };
|
|
156
175
|
});
|
|
157
176
|
app.post('/api/admin/providers/:id/test', async (req) => {
|
|
158
177
|
const { id } = req.params;
|
|
@@ -160,6 +179,18 @@ export async function registerProviderRoutes(app) {
|
|
|
160
179
|
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
161
180
|
if (!p)
|
|
162
181
|
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
182
|
+
if (p.type === 'codex') {
|
|
183
|
+
const account = listCodexAccountSummaries(p.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
|
|
184
|
+
if (!account)
|
|
185
|
+
throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
|
|
186
|
+
const row = getRawDb().prepare('SELECT chatgpt_account_id AS accountId FROM codex_accounts WHERE id=?').get(account.id);
|
|
187
|
+
const result = await withCodexCredentials(account.id, async (credentials) => probeCodex({ baseUrl: p.baseUrl, accountId: row?.accountId ?? '', accessToken: credentials.accessToken, customHeaders: {}, totalTimeoutMs: Math.min(p.totalTimeoutMs, 20000) })).catch((error) => { throw codexCredentialError(error); });
|
|
188
|
+
db.update(schema.providers).set({ healthState: result.ok ? 'healthy' : 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
|
|
189
|
+
recordAudit({ action: 'provider.test', success: result.ok, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { detail: redactString(result.detail) } });
|
|
190
|
+
return { ...result, detail: redactString(result.detail) };
|
|
191
|
+
}
|
|
192
|
+
if (!p.encryptedApiKey || !p.apiKeyNonce)
|
|
193
|
+
throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
|
|
163
194
|
const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
|
|
164
195
|
const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
|
|
165
196
|
const result = await probeProvider({
|
|
@@ -185,16 +216,28 @@ export async function registerProviderRoutes(app) {
|
|
|
185
216
|
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
186
217
|
if (!p)
|
|
187
218
|
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
customHeaders:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
219
|
+
let discovered;
|
|
220
|
+
if (p.type === 'codex') {
|
|
221
|
+
const account = listCodexAccountSummaries(p.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
|
|
222
|
+
if (!account)
|
|
223
|
+
throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
|
|
224
|
+
const row = getRawDb().prepare('SELECT chatgpt_account_id AS accountId FROM codex_accounts WHERE id=?').get(account.id);
|
|
225
|
+
discovered = await withCodexCredentials(account.id, async (credentials) => codexModels({ baseUrl: p.baseUrl, accountId: row?.accountId ?? '', accessToken: credentials.accessToken, customHeaders: {}, totalTimeoutMs: 30000 })).catch((error) => { throw codexCredentialError(error); });
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
if (!p.encryptedApiKey || !p.apiKeyNonce)
|
|
229
|
+
throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
|
|
230
|
+
const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
|
|
231
|
+
const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
|
|
232
|
+
discovered = await discoverProviderModels({
|
|
233
|
+
type: p.type,
|
|
234
|
+
baseUrl: p.baseUrl,
|
|
235
|
+
apiKey,
|
|
236
|
+
customHeaders: headers,
|
|
237
|
+
connectTimeoutMs: 5000,
|
|
238
|
+
totalTimeoutMs: 30000,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
198
241
|
const existing = db
|
|
199
242
|
.select()
|
|
200
243
|
.from(schema.models)
|
|
@@ -230,6 +230,7 @@ export async function registerRequestRoutes(app) {
|
|
|
230
230
|
providerName: providerMap.get(a.providerId)?.name ?? '',
|
|
231
231
|
modelId: a.modelId,
|
|
232
232
|
modelPublicId: modelMap.get(a.modelId)?.publicModelId ?? '',
|
|
233
|
+
codexAccountId: a.codexAccountId,
|
|
233
234
|
startedAt: a.startedAt,
|
|
234
235
|
completedAt: a.completedAt,
|
|
235
236
|
statusCode: a.statusCode,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Admin API routes (mounted at /api/admin/*). All require admin session auth.
|
|
2
|
+
import { requireAdminAuth, requireAdminCsrf } from '../auth/middleware.js';
|
|
2
3
|
import { registerSetupRoutes } from './admin/setup.js';
|
|
3
4
|
import { registerAuthRoutes } from './admin/auth.js';
|
|
4
5
|
import { registerProviderRoutes } from './admin/providers.js';
|
|
@@ -12,6 +13,7 @@ import { registerAuditRoutes } from './admin/audit.js';
|
|
|
12
13
|
import { registerSettingsRoutes } from './admin/settings.js';
|
|
13
14
|
import { registerBackupRoutes } from './admin/backup.js';
|
|
14
15
|
import { registerDashboardRoutes } from './admin/dashboard.js';
|
|
16
|
+
import { registerCodexRoutes, registerCodexOAuthCallbackRoute } from './admin/codex.js';
|
|
15
17
|
export async function registerAdminRoutes(app) {
|
|
16
18
|
// Setup routes are always reachable (used on first run).
|
|
17
19
|
await app.register(async (instance) => {
|
|
@@ -22,8 +24,17 @@ export async function registerAdminRoutes(app) {
|
|
|
22
24
|
await app.register(async (instance) => {
|
|
23
25
|
await registerAuthRoutes(instance);
|
|
24
26
|
});
|
|
27
|
+
// The Codex OAuth loopback callback is hit by the browser's redirect, so it is public too.
|
|
28
|
+
await app.register(async (instance) => {
|
|
29
|
+
await registerCodexOAuthCallbackRoute(instance);
|
|
30
|
+
});
|
|
25
31
|
// Authenticated admin routes
|
|
26
32
|
await app.register(async (instance) => {
|
|
33
|
+
instance.addHook('preHandler', requireAdminAuth);
|
|
34
|
+
instance.addHook('preHandler', async (req) => {
|
|
35
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method))
|
|
36
|
+
await requireAdminCsrf(req);
|
|
37
|
+
});
|
|
27
38
|
await registerProviderRoutes(instance);
|
|
28
39
|
await registerModelRoutes(instance);
|
|
29
40
|
await registerComboRoutes(instance);
|
|
@@ -35,5 +46,6 @@ export async function registerAdminRoutes(app) {
|
|
|
35
46
|
await registerSettingsRoutes(instance);
|
|
36
47
|
await registerBackupRoutes(instance);
|
|
37
48
|
await registerDashboardRoutes(instance);
|
|
49
|
+
await registerCodexRoutes(instance);
|
|
38
50
|
});
|
|
39
51
|
}
|
|
@@ -38,7 +38,9 @@ export function deriveRequiredCapabilities(req) {
|
|
|
38
38
|
* IMPORTANT: Treat undefined as "unknown" rather than "unsupported".
|
|
39
39
|
* For generic OpenAI-compatible providers where capabilities weren't explicitly imported,
|
|
40
40
|
* undefined means we don't know, so we should assume it's potentially supported.
|
|
41
|
-
* Explicit false means "known unsupported"
|
|
41
|
+
* Explicit false means "known unsupported" for protocol capabilities such as
|
|
42
|
+
* tools, images, and streaming. Reasoning is advisory metadata because the
|
|
43
|
+
* upstream may support it even when discovery cannot identify it.
|
|
42
44
|
*/
|
|
43
45
|
export function modelMeets(caps, req) {
|
|
44
46
|
// Only reject if capability is explicitly false, not if unknown (undefined)
|
|
@@ -52,8 +54,6 @@ export function modelMeets(caps, req) {
|
|
|
52
54
|
return false;
|
|
53
55
|
if (req.audioInput && caps.audio_input === false)
|
|
54
56
|
return false;
|
|
55
|
-
if (req.reasoning && caps.reasoning === false)
|
|
56
|
-
return false;
|
|
57
57
|
if (req.responses && caps.responses === false)
|
|
58
58
|
return false;
|
|
59
59
|
return true;
|
|
@@ -23,6 +23,13 @@ export function loadCombo(comboId) {
|
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
export function expandCodexAccountCandidates(candidate, accounts, now = new Date()) {
|
|
27
|
+
if (!candidate.publicModelId.startsWith('codex/'))
|
|
28
|
+
return [candidate];
|
|
29
|
+
const usable = accounts.filter((a) => a.enabled && (a.healthState === 'healthy' || a.healthState === 'unknown') && Date.parse(a.tokenExpiresAt) > now.getTime())
|
|
30
|
+
.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
|
|
31
|
+
return usable.map((a) => ({ ...candidate, codexAccountId: a.id, codexChatgptAccountId: a.chatgptAccountId, selectionReason: 'codex_account' }));
|
|
32
|
+
}
|
|
26
33
|
export function selectCandidates(combo, allModels, req, onReject) {
|
|
27
34
|
// Resolve each combo member to a candidate and apply filters
|
|
28
35
|
const map = new Map(allModels.map((m) => [m.modelId, m]));
|
|
@@ -69,8 +76,6 @@ function capabilityRejection(caps, req) {
|
|
|
69
76
|
return 'image_input';
|
|
70
77
|
if (req.audioInput && caps.audio_input === false)
|
|
71
78
|
return 'audio_input';
|
|
72
|
-
if (req.reasoning && caps.reasoning === false)
|
|
73
|
-
return 'reasoning';
|
|
74
79
|
if (req.responses && caps.responses === false)
|
|
75
80
|
return 'responses';
|
|
76
81
|
return 'capability_mismatch';
|
|
@@ -86,24 +91,25 @@ export function orderCandidates(combo, candidates) {
|
|
|
86
91
|
}
|
|
87
92
|
// Weighted round-robin: stable order with weighted lead bias.
|
|
88
93
|
// We rotate via a process-local cursor keyed by combo id.
|
|
89
|
-
const cursor = nextCursor(combo.comboId, candidates);
|
|
94
|
+
const cursor = nextCursor(combo.comboId, combo.members, candidates);
|
|
90
95
|
return cursor;
|
|
91
96
|
}
|
|
92
97
|
const comboCursors = new Map();
|
|
93
|
-
function nextCursor(comboId, candidates) {
|
|
98
|
+
function nextCursor(comboId, members, candidates) {
|
|
94
99
|
if (candidates.length === 0)
|
|
95
100
|
return [];
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const cur = (comboCursors.get(comboId) ?? 0) %
|
|
101
|
+
// Repeat each available member according to its configured positive weight,
|
|
102
|
+
// then advance one slot per request. This is deterministic weighted RR.
|
|
103
|
+
const slots = members.flatMap((member) => {
|
|
104
|
+
const candidate = candidates.find((c) => c.modelId === member.modelId);
|
|
105
|
+
if (!candidate)
|
|
106
|
+
return [];
|
|
107
|
+
return Array.from({ length: Math.max(1, member.weight) }, () => candidate);
|
|
108
|
+
});
|
|
109
|
+
const cur = (comboCursors.get(comboId) ?? 0) % Math.max(1, slots.length);
|
|
105
110
|
comboCursors.set(comboId, cur + 1);
|
|
106
|
-
|
|
111
|
+
const selected = slots[cur] ?? candidates[0];
|
|
112
|
+
return [selected, ...candidates.filter((candidate) => candidate !== selected)];
|
|
107
113
|
}
|
|
108
114
|
export function shouldFallback(combo, reason) {
|
|
109
115
|
switch (reason.type) {
|