ldrouter 1.16.4 → 1.17.0

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +17 -0
  3. package/dist/server/db/repositories/codex-accounts.js +15 -3
  4. package/dist/server/db/repositories/qoder-accounts.js +167 -0
  5. package/dist/server/db/schema.js +33 -1
  6. package/dist/server/gateway/runner.js +73 -4
  7. package/dist/server/providers/codex-refresh.js +5 -2
  8. package/dist/server/providers/codex.js +53 -19
  9. package/dist/server/providers/index.js +4 -0
  10. package/dist/server/providers/pooled.js +84 -0
  11. package/dist/server/providers/qoder/catalog.js +138 -0
  12. package/dist/server/providers/qoder/client.js +248 -0
  13. package/dist/server/providers/qoder/constants.js +34 -0
  14. package/dist/server/providers/qoder/cosy.js +81 -0
  15. package/dist/server/providers/qoder/credentials.js +125 -0
  16. package/dist/server/providers/qoder/encode.js +28 -0
  17. package/dist/server/providers/qoder/qoder-import.js +109 -0
  18. package/dist/server/providers/qoder/rsa.js +7 -0
  19. package/dist/server/providers/qoder/sse.js +211 -0
  20. package/dist/server/providers/qoder/tier.js +105 -0
  21. package/dist/server/routes/admin/backup.js +7 -2
  22. package/dist/server/routes/admin/models.js +14 -0
  23. package/dist/server/routes/admin/providers.js +53 -34
  24. package/dist/server/routes/admin/qoder.js +271 -0
  25. package/dist/server/routes/admin.js +8 -1
  26. package/dist/server/routing/capabilities.js +1 -0
  27. package/dist/server/routing/combo.js +22 -3
  28. package/dist/server/upstream/client.js +14 -0
  29. package/dist/web/assets/index-DdtyoPpS.js +391 -0
  30. package/dist/web/index.html +1 -1
  31. package/migrations/0007_qoder_accounts.sql +217 -0
  32. package/package.json +1 -1
  33. package/dist/web/assets/index-2L7raen4.js +0 -391
@@ -7,15 +7,15 @@ import { encryptSecret, decryptSecret, encryptCustomHeaders, decryptCustomHeader
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';
10
+ import { getPooledProvider } from '../../providers/pooled.js';
13
11
  import { redactString } from '../../security/redact.js';
14
12
  const ProviderCreate = z.object({
15
- name: z.string().min(1).max(128),
13
+ // A pool provider is nothing but a group: the admin clicks once and the server owns every field.
14
+ // Compatible providers still need name, endpoint, and credential up front (checked in the handler).
15
+ name: z.string().min(1).max(128).optional(),
16
16
  slug: z.string().min(1).max(64).optional(),
17
- type: z.enum(['openai', 'anthropic', 'codex']),
18
- baseUrl: z.string().url().max(512),
17
+ type: z.enum(['openai', 'anthropic', 'codex', 'qoder']),
18
+ baseUrl: z.string().url().max(512).optional(),
19
19
  apiKey: z.string().min(1).max(20000).optional(),
20
20
  customHeaders: z.record(z.string(), z.string()).optional(),
21
21
  enabled: z.boolean().optional(),
@@ -64,24 +64,42 @@ export async function registerProviderRoutes(app) {
64
64
  });
65
65
  app.post('/api/admin/providers', async (req) => {
66
66
  const body = ProviderCreate.parse(req.body);
67
- if (body.type !== 'codex' && !body.apiKey) {
67
+ const pool = getPooledProvider(body.type);
68
+ const defaults = pool?.defaults;
69
+ const name = body.name ?? defaults?.name;
70
+ // Pool defaults win over the request: a stored Codex base URL pointing anywhere but
71
+ // chatgpt.com makes every upstream call 404 (see CODEX_BASE_URL).
72
+ const baseUrl = defaults?.baseUrl ?? body.baseUrl;
73
+ if (!name)
74
+ throw new GatewayError('invalid_request_error', 'Provider name is required', { status: 400 });
75
+ // apiKey before baseUrl: a compatible provider created without either must be told the
76
+ // credential is missing (the UI always supplies baseUrl from its dialog default).
77
+ if (!pool && !body.apiKey) {
68
78
  throw new GatewayError('invalid_request_error', 'API key is required for this provider type', { status: 400 });
69
79
  }
80
+ if (!baseUrl)
81
+ throw new GatewayError('invalid_request_error', 'Base URL is required', { status: 400 });
70
82
  requireMasterKey(); // Need master key to encrypt new credentials
71
83
  const db = getDb();
72
- const slug = body.slug ? slugify(body.slug) : slugify(body.name);
84
+ const slug = slugify(body.slug ?? name);
73
85
  const dup = db.select().from(schema.providers).where(eq(schema.providers.slug, slug)).get();
74
- if (dup)
86
+ if (dup) {
87
+ // Pool path carries the code spec §5.7 names, so the UI can render "already added".
88
+ if (pool)
89
+ throw new GatewayError('invalid_request_error', `A ${dup.name} provider already exists ('${dup.slug}')`, { status: 400, code: 'slug_taken' });
75
90
  throw new GatewayError('invalid_request_error', `Provider slug '${slug}' is already in use`, { status: 400 });
76
- const enc = body.apiKey ? encryptSecret(body.apiKey) : null;
91
+ }
92
+ // A pool type has no API-key field (spec §5.7): a key sent with `{type}` must not be encrypted
93
+ // onto the row.
94
+ const enc = !pool && body.apiKey ? encryptSecret(body.apiKey) : null;
77
95
  const headersEnc = body.customHeaders ? encryptCustomHeaders(body.customHeaders) : null;
78
96
  const id = uuid();
79
97
  db.insert(schema.providers).values({
80
98
  id,
81
- name: body.name,
99
+ name,
82
100
  slug,
83
101
  type: body.type,
84
- baseUrl: body.baseUrl,
102
+ baseUrl,
85
103
  encryptedApiKey: enc?.ciphertext ?? null,
86
104
  apiKeyNonce: enc?.nonce ?? null,
87
105
  apiKeyVersion: enc?.version ?? 1,
@@ -96,7 +114,7 @@ export async function registerProviderRoutes(app) {
96
114
  cbFailureThreshold: body.cbFailureThreshold ?? 5,
97
115
  cbCooldownSeconds: body.cbCooldownSeconds ?? 60,
98
116
  }).run();
99
- recordAudit({ action: 'provider.create', success: true, targetType: 'provider', targetId: id, targetName: body.name, ip: req.ip });
117
+ recordAudit({ action: 'provider.create', success: true, targetType: 'provider', targetId: id, targetName: name, ip: req.ip });
100
118
  return { id, slug };
101
119
  });
102
120
  app.patch('/api/admin/providers', async (req) => {
@@ -107,12 +125,15 @@ export async function registerProviderRoutes(app) {
107
125
  if (!p)
108
126
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
109
127
  const update = { updatedAt: new Date().toISOString() };
128
+ // A pool type has no API-key field (spec §5.7). Same rule as the create path: skip the whole
129
+ // encryption block, so an existing key on the row is not disturbed either.
130
+ const pool = getPooledProvider(p.type);
110
131
  if (body.name)
111
132
  update.name = body.name;
112
133
  if (body.slug)
113
134
  update.slug = slugify(body.slug);
114
135
  if (body.baseUrl)
115
- update.baseUrl = body.baseUrl;
136
+ update.baseUrl = pool?.defaults.baseUrl ?? body.baseUrl;
116
137
  if (body.enabled !== undefined)
117
138
  update.enabled = body.enabled;
118
139
  if (body.connectTimeoutMs !== undefined)
@@ -129,7 +150,7 @@ export async function registerProviderRoutes(app) {
129
150
  update.cbFailureThreshold = body.cbFailureThreshold;
130
151
  if (body.cbCooldownSeconds !== undefined)
131
152
  update.cbCooldownSeconds = body.cbCooldownSeconds;
132
- if (body.apiKey) {
153
+ if (body.apiKey && !pool) {
133
154
  const enc = encryptSecret(body.apiKey);
134
155
  update.encryptedApiKey = enc.ciphertext;
135
156
  update.apiKeyNonce = enc.nonce;
@@ -157,21 +178,25 @@ export async function registerProviderRoutes(app) {
157
178
  recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
158
179
  return { ok: true, softDisabled: true };
159
180
  }
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;
181
+ // A pool provider owns its account pool. <table>.provider_id is ON DELETE RESTRICT, so the
182
+ // accounts must go in the same transaction or the delete fails with a raw SQLite constraint
183
+ // error (which surfaces as an opaque 500 "Gateway error").
184
+ const pool = getPooledProvider(p.type);
185
+ let pooledAccountsDeleted = 0;
164
186
  try {
165
187
  getRawDb().transaction(() => {
166
- codexAccountsDeleted = getRawDb().prepare('DELETE FROM codex_accounts WHERE provider_id=?').run(id).changes;
188
+ // pool.accountsTable is a fixed registry literal, never user input.
189
+ if (pool)
190
+ pooledAccountsDeleted = getRawDb().prepare(`DELETE FROM ${pool.accountsTable} WHERE provider_id=?`).run(id).changes;
167
191
  db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
168
192
  })();
169
193
  }
170
194
  catch (error) {
171
195
  throw new GatewayError('invalid_request_error', 'Provider is still referenced and cannot be deleted', { status: 409, cause: error });
172
196
  }
173
- recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { codexAccountsDeleted } });
174
- return { ok: true, codexAccountsDeleted };
197
+ recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { pooledAccountsDeleted } });
198
+ // codexAccountsDeleted kept: pre-existing clients and tests read that field name.
199
+ return { ok: true, pooledAccountsDeleted, codexAccountsDeleted: pooledAccountsDeleted };
175
200
  });
176
201
  app.post('/api/admin/providers/:id/test', async (req) => {
177
202
  const { id } = req.params;
@@ -179,12 +204,9 @@ export async function registerProviderRoutes(app) {
179
204
  const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
180
205
  if (!p)
181
206
  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); });
207
+ const pool = getPooledProvider(p.type);
208
+ if (pool) {
209
+ const result = await pool.probe({ id: p.id, baseUrl: p.baseUrl, totalTimeoutMs: p.totalTimeoutMs });
188
210
  db.update(schema.providers).set({ healthState: result.ok ? 'healthy' : 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
189
211
  recordAudit({ action: 'provider.test', success: result.ok, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { detail: redactString(result.detail) } });
190
212
  return { ...result, detail: redactString(result.detail) };
@@ -217,12 +239,9 @@ export async function registerProviderRoutes(app) {
217
239
  if (!p)
218
240
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
219
241
  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); });
242
+ const pool = getPooledProvider(p.type);
243
+ if (pool) {
244
+ discovered = await pool.discover({ id: p.id, baseUrl: p.baseUrl, totalTimeoutMs: p.totalTimeoutMs });
226
245
  }
227
246
  else {
228
247
  if (!p.encryptedApiKey || !p.apiKeyNonce)
@@ -0,0 +1,271 @@
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 { getRawDb } from '../../db/index.js';
6
+ import { uuid } from '../../auth/ids.js';
7
+ import { listQoderAccountSummaries, getQoderAccountDetailById, setQoderAccountHealth, upsertQoderAccount, findQoderAccountForImport, saveQoderCatalog, toQoderAccountSummary, } from '../../db/repositories/qoder-accounts.js';
8
+ import { exchangeQoderPat, fetchQoderCatalog, serializeCatalog } from '../../providers/qoder/catalog.js';
9
+ import { probeQoder } from '../../providers/qoder/client.js';
10
+ import { qoderCredentialsFor, withQoderCredentials } from '../../providers/qoder/credentials.js';
11
+ import { parseQoderImportText, toQoderPreview, qoderTokenFingerprint } from '../../providers/qoder/qoder-import.js';
12
+ import { redactString } from '../../security/redact.js';
13
+ const MAX_BYTES = 2_000_000;
14
+ const MAX_RECORDS = 500;
15
+ const ImportBody = z.object({ providerId: z.string().min(1), selectedIndexes: z.array(z.number().int().min(0)).max(MAX_RECORDS).optional() });
16
+ const MutationOptions = { preHandler: requireAdminCsrf };
17
+ export const QoderAccountUpdate = z.object({
18
+ enabled: z.boolean().optional(),
19
+ label: z.string().max(256).nullable().optional(),
20
+ priority: z.number().int().min(0).max(100000).optional(),
21
+ }).refine((v) => Object.keys(v).length > 0, 'At least one update is required');
22
+ function parseAll(text) {
23
+ const result = parseQoderImportText(text);
24
+ const records = [];
25
+ const failures = [];
26
+ for (const item of result)
27
+ ('personalToken' in item ? records : failures).push(item);
28
+ return { records, failures };
29
+ }
30
+ function providerOrThrow(providerId) {
31
+ const provider = getRawDb().prepare('SELECT id,type,total_timeout_ms FROM providers WHERE id=?').get(providerId);
32
+ if (!provider)
33
+ throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
34
+ if (provider.type !== 'qoder')
35
+ throw new GatewayError('invalid_request_error', 'Provider must be Qoder', { status: 400 });
36
+ return provider;
37
+ }
38
+ const TEXT_EXT = /\.(json|jsonl|txt)$/i;
39
+ async function readInput(req, body) {
40
+ const contentType = String(req.headers['content-type'] ?? '');
41
+ if (contentType.includes('multipart/form-data')) {
42
+ const parts = req.parts();
43
+ let providerId = '';
44
+ let selectedIndexes;
45
+ const texts = [];
46
+ let bytes = 0;
47
+ for await (const part of parts) {
48
+ if (part.type === 'file') {
49
+ const chunks = [];
50
+ for await (const chunk of part.file) {
51
+ bytes += chunk.length;
52
+ if (bytes > MAX_BYTES)
53
+ throw new GatewayError('invalid_request_error', 'Import exceeds maximum size', { status: 413 });
54
+ chunks.push(chunk);
55
+ }
56
+ const raw = Buffer.concat(chunks).toString('utf8');
57
+ texts.push(TEXT_EXT.test(part.filename ?? '') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
58
+ }
59
+ else if (part.fieldname === 'providerId')
60
+ providerId = String(part.value);
61
+ else if (part.fieldname === 'selectedIndexes') {
62
+ let parsedIndexes;
63
+ try {
64
+ parsedIndexes = JSON.parse(String(part.value));
65
+ }
66
+ catch {
67
+ throw new GatewayError('invalid_request_error', 'selectedIndexes must be JSON', { status: 400 });
68
+ }
69
+ selectedIndexes = z.array(z.number().int().min(0)).max(MAX_RECORDS).parse(parsedIndexes);
70
+ }
71
+ else if (part.fieldname === 'text')
72
+ texts.push(String(part.value));
73
+ }
74
+ return { providerId, text: texts.join('\n'), selectedIndexes };
75
+ }
76
+ const parsed = ImportBody.extend({ text: z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]) }).parse(body);
77
+ const text = typeof parsed.text === 'string' ? parsed.text : JSON.stringify(parsed.text);
78
+ if (Buffer.byteLength(text, 'utf8') > MAX_BYTES)
79
+ throw new GatewayError('invalid_request_error', 'Import exceeds maximum size', { status: 413 });
80
+ return { providerId: parsed.providerId, text, selectedIndexes: parsed.selectedIndexes };
81
+ }
82
+ function summaryOrThrow(id) {
83
+ const row = getQoderAccountDetailById(id);
84
+ if (!row)
85
+ throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
86
+ return toQoderAccountSummary(row);
87
+ }
88
+ /**
89
+ * A PAT that works but a catalog that 502s is recoverable — the operator retries later — so the
90
+ * exchange result is stored even when the catalog fetch fails. When the exchange itself fails
91
+ * nothing is written and the error is redacted.
92
+ */
93
+ async function exchangeAndCatalog(personalToken, machineId) {
94
+ let exchanged;
95
+ try {
96
+ exchanged = await exchangeQoderPat(personalToken);
97
+ }
98
+ catch (error) {
99
+ throw new GatewayError('authentication_error', redactString(error instanceof Error ? error.message : 'Qoder personal access token was rejected'), { status: 401 });
100
+ }
101
+ let catalog = null;
102
+ let catalogError = null;
103
+ try {
104
+ catalog = await fetchQoderCatalog({ jobToken: exchanged.jobToken, userId: exchanged.qoderUserId, machineId });
105
+ // fetchQoderCatalog reports failure by returning null rather than throwing, so a null here is
106
+ // exactly the "token works, catalog did not" case the account survives.
107
+ if (!catalog || catalog.entries.size === 0)
108
+ catalogError = 'Qoder model catalog could not be fetched';
109
+ }
110
+ catch (error) {
111
+ catalogError = redactString(error instanceof Error ? error.message : 'Qoder model catalog could not be fetched');
112
+ }
113
+ return {
114
+ record: {
115
+ personalToken, jobToken: exchanged.jobToken, jobTokenExpiresAt: exchanged.jobTokenExpiresAt,
116
+ qoderUserId: exchanged.qoderUserId, machineId, email: exchanged.email, label: exchanged.label,
117
+ },
118
+ catalog,
119
+ catalogError,
120
+ };
121
+ }
122
+ export async function registerQoderRoutes(app) {
123
+ app.addHook('preHandler', requireAdminAuth);
124
+ /** Add one account: exchange first, then store, so a bad PAT never reaches the database. */
125
+ app.post('/api/admin/qoder/accounts', MutationOptions, async (req) => {
126
+ const body = z.object({ providerId: z.string().min(1), personalToken: z.string().min(1).max(200_000), label: z.string().max(256).nullable().optional() }).parse(req.body);
127
+ providerOrThrow(body.providerId);
128
+ const { record, catalog, catalogError } = await exchangeAndCatalog(body.personalToken, uuid());
129
+ const result = upsertQoderAccount(body.providerId, { ...record, index: 0, label: body.label ?? record.label, source: 'manual' });
130
+ // The catalog fetched during the exchange is the proof the PAT works, so persist it rather
131
+ // than throwing it away and leaving the account with no routable models until a manual refresh.
132
+ if (catalog)
133
+ saveQoderCatalog(result.id, serializeCatalog(catalog), catalog.fetchedAt);
134
+ if (catalogError)
135
+ setQoderAccountHealth(result.id, 'unknown', catalogError);
136
+ recordAudit({ action: 'qoder.accounts.add', success: true, targetType: 'qoder_account', targetId: result.id, targetName: record.label ?? undefined, ip: req.ip, metadata: { status: result.status, catalogError: catalogError ? 'yes' : 'no' } });
137
+ return { account: summaryOrThrow(result.id), status: result.status, catalogError };
138
+ });
139
+ app.post('/api/admin/qoder/accounts/preview', MutationOptions, async (req) => {
140
+ const input = await readInput(req, req.body);
141
+ providerOrThrow(input.providerId);
142
+ const parsed = parseAll(input.text);
143
+ const seen = new Map();
144
+ const records = parsed.records.map((record) => {
145
+ const fingerprint = qoderTokenFingerprint(record.personalToken);
146
+ const duplicateOf = seen.get(fingerprint);
147
+ seen.set(fingerprint, record.index);
148
+ const existing = findQoderAccountForImport(input.providerId, record.personalToken);
149
+ const resolved = duplicateOf === undefined ? (existing ?? null) : String(duplicateOf);
150
+ return toQoderPreview(record, resolved);
151
+ });
152
+ return {
153
+ records: [...records, ...parsed.failures.map((f) => ({ index: f.index, source: f.source, valid: false, error: f.error }))],
154
+ validCount: records.length,
155
+ invalidCount: parsed.failures.length,
156
+ };
157
+ });
158
+ app.post('/api/admin/qoder/accounts/import', MutationOptions, async (req) => {
159
+ const input = await readInput(req, req.body);
160
+ providerOrThrow(input.providerId);
161
+ const parsed = parseAll(input.text);
162
+ const selected = input.selectedIndexes ? new Set(input.selectedIndexes) : null;
163
+ const results = [];
164
+ let added = 0;
165
+ let updated = 0;
166
+ let skipped = 0;
167
+ let failed = 0;
168
+ for (const failure of parsed.failures) {
169
+ if (!selected || selected.has(failure.index)) {
170
+ failed++;
171
+ results.push({ index: failure.index, status: 'failed', error: failure.error });
172
+ }
173
+ else
174
+ skipped++;
175
+ }
176
+ for (const record of parsed.records) {
177
+ if (selected && !selected.has(record.index)) {
178
+ skipped++;
179
+ continue;
180
+ }
181
+ try {
182
+ const { record: exchanged, catalog, catalogError } = await exchangeAndCatalog(record.personalToken, uuid());
183
+ const result = upsertQoderAccount(input.providerId, { ...exchanged, index: record.index, label: record.label, source: record.source });
184
+ if (catalog)
185
+ saveQoderCatalog(result.id, serializeCatalog(catalog), catalog.fetchedAt);
186
+ if (catalogError)
187
+ setQoderAccountHealth(result.id, 'unknown', catalogError);
188
+ if (result.status === 'inserted')
189
+ added++;
190
+ else
191
+ updated++;
192
+ results.push({ index: record.index, status: result.status, label: record.label, masked: toQoderPreview(record).personalTokenMasked });
193
+ }
194
+ catch (error) {
195
+ failed++;
196
+ results.push({ index: record.index, status: 'failed', error: redactString(error instanceof Error ? error.message : 'Import failed') });
197
+ }
198
+ }
199
+ recordAudit({ action: 'qoder.accounts.import', success: failed === 0, targetType: 'provider', targetId: input.providerId, ip: req.ip, metadata: { added, updated, skipped, failed } });
200
+ return { added, updated, skipped, failed, results };
201
+ });
202
+ app.get('/api/admin/qoder/accounts', async (req) => {
203
+ const providerId = z.object({ providerId: z.string().min(1) }).parse(req.query).providerId;
204
+ providerOrThrow(providerId);
205
+ return { accounts: listQoderAccountSummaries(providerId) };
206
+ });
207
+ const ReorderBody = z.object({ providerId: z.string().min(1), ids: z.array(z.string().min(1)).min(1).max(MAX_RECORDS) });
208
+ app.post('/api/admin/qoder/accounts/reorder', MutationOptions, async (req) => {
209
+ const { providerId, ids } = ReorderBody.parse(req.body);
210
+ providerOrThrow(providerId);
211
+ const owned = new Set(listQoderAccountSummaries(providerId).map((account) => account.id));
212
+ if (ids.some((id) => !owned.has(id)))
213
+ throw new GatewayError('invalid_request_error', 'Account list does not match the provider', { status: 400 });
214
+ const raw = getRawDb();
215
+ const now = new Date().toISOString();
216
+ const update = raw.prepare('UPDATE qoder_accounts SET priority=?, updated_at=? WHERE id=?');
217
+ raw.transaction(() => ids.forEach((id, index) => update.run(index, now, id)))();
218
+ recordAudit({ action: 'qoder.accounts.reorder', success: true, targetType: 'provider', targetId: providerId, ip: req.ip, metadata: { count: ids.length } });
219
+ return { accounts: listQoderAccountSummaries(providerId) };
220
+ });
221
+ app.patch('/api/admin/qoder/accounts/:id', MutationOptions, async (req) => {
222
+ const { id } = req.params;
223
+ const body = QoderAccountUpdate.parse(req.body);
224
+ summaryOrThrow(id);
225
+ const raw = getRawDb();
226
+ const fields = [];
227
+ const values = [];
228
+ for (const [key, value] of Object.entries(body)) {
229
+ const column = { enabled: 'enabled', label: 'label', priority: 'priority' }[key];
230
+ if (column) {
231
+ fields.push(`${column}=?`);
232
+ values.push(typeof value === 'boolean' ? (value ? 1 : 0) : value);
233
+ }
234
+ }
235
+ fields.push('updated_at=?');
236
+ values.push(new Date().toISOString(), id);
237
+ raw.prepare(`UPDATE qoder_accounts SET ${fields.join(',')} WHERE id=?`).run(...values);
238
+ recordAudit({ action: 'qoder.accounts.update', success: true, targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { fields: Object.keys(body) } });
239
+ return { account: summaryOrThrow(id) };
240
+ });
241
+ app.delete('/api/admin/qoder/accounts/:id', MutationOptions, async (req) => {
242
+ const { id } = req.params;
243
+ const account = summaryOrThrow(id);
244
+ // Hard delete: the encrypted PAT must not linger on disk. Historical attempts survive via
245
+ // request_attempts.qoder_account_id ON DELETE SET NULL.
246
+ getRawDb().prepare('DELETE FROM qoder_accounts WHERE id=?').run(id);
247
+ recordAudit({ action: 'qoder.accounts.delete', success: true, targetType: 'qoder_account', targetId: id, targetName: account.label ?? undefined, ip: req.ip });
248
+ return { ok: true, deleted: true };
249
+ });
250
+ app.post('/api/admin/qoder/accounts/:id/test', MutationOptions, async (req) => {
251
+ const { id } = req.params;
252
+ const account = getQoderAccountDetailById(id);
253
+ if (!account)
254
+ throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
255
+ const provider = providerOrThrow(account.providerId);
256
+ const result = await withQoderCredentials(id, (config) => probeQoder({ ...config, accountRecordId: id, totalTimeoutMs: Math.min(provider.total_timeout_ms, 20_000) }));
257
+ setQoderAccountHealth(id, result.ok ? 'healthy' : 'down', result.ok ? null : redactString(result.detail));
258
+ recordAudit({ action: 'qoder.accounts.test', success: result.ok, targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { detail: redactString(result.detail) } });
259
+ return { ok: result.ok, detail: redactString(result.detail), latencyMs: result.latencyMs, modelCount: result.modelCount ?? null };
260
+ });
261
+ /** Force-mint a job token and re-fetch the catalog. Returns counts and keys, never the blob. */
262
+ app.post('/api/admin/qoder/accounts/:id/catalog', MutationOptions, async (req) => {
263
+ const { id } = req.params;
264
+ if (!getQoderAccountDetailById(id))
265
+ throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
266
+ const { config } = await qoderCredentialsFor(id, { force: true });
267
+ const keys = config.catalog ? [...config.catalog.entries.keys()] : [];
268
+ recordAudit({ action: 'qoder.accounts.catalog', success: Boolean(config.catalog), targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { modelCount: keys.length } });
269
+ return { modelCount: keys.length, modelKeys: keys, catalogFetchedAt: config.catalog?.fetchedAt ?? null };
270
+ });
271
+ }
@@ -14,6 +14,7 @@ import { registerSettingsRoutes } from './admin/settings.js';
14
14
  import { registerBackupRoutes } from './admin/backup.js';
15
15
  import { registerDashboardRoutes } from './admin/dashboard.js';
16
16
  import { registerCodexRoutes, registerCodexOAuthCallbackRoute } from './admin/codex.js';
17
+ import { registerQoderRoutes } from './admin/qoder.js';
17
18
  export async function registerAdminRoutes(app) {
18
19
  // Setup routes are always reachable (used on first run).
19
20
  await app.register(async (instance) => {
@@ -44,8 +45,14 @@ export async function registerAdminRoutes(app) {
44
45
  await registerStatsRoutes(instance);
45
46
  await registerAuditRoutes(instance);
46
47
  await registerSettingsRoutes(instance);
47
- await registerBackupRoutes(instance);
48
48
  await registerDashboardRoutes(instance);
49
49
  await registerCodexRoutes(instance);
50
+ await registerQoderRoutes(instance);
51
+ });
52
+ // Backup routes get their own scope. The first-run import (setup.tsx) must be reachable before
53
+ // any admin account exists, but this scope's parent runs the other admin modules' auth hooks,
54
+ // and a preHandler cannot short-circuit a later one — so the exemption has to be the only hook.
55
+ await app.register(async (instance) => {
56
+ await registerBackupRoutes(instance);
50
57
  });
51
58
  }
@@ -57,6 +57,7 @@ const NON_CAPABILITY_TEXT = {
57
57
  combo_disabled: 'combo disabled',
58
58
  member_disabled: 'disabled in this combo',
59
59
  codex_account_unavailable: 'no available Codex account',
60
+ qoder_account_unavailable: 'no enabled Qoder account is available — check the account group',
60
61
  };
61
62
  const REASON_TEXT = {
62
63
  ...NON_CAPABILITY_TEXT,
@@ -23,10 +23,29 @@ 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/'))
26
+ /**
27
+ * One Qoder candidate per eligible account so account-level fallback works without touching the
28
+ * combo plan. Keyed off the resolved provider type, never a hardcoded slug prefix.
29
+ *
30
+ * A `degraded` account stays eligible: it is a warning (last migration of a billing/quota block),
31
+ * not a verdict — the runner retries it and the upstream answers honestly. Only `down` and
32
+ * `disabled` are excluded.
33
+ */
34
+ export function expandQoderAccountCandidates(candidate, accounts) {
35
+ if (candidate.providerType !== 'qoder')
28
36
  return [candidate];
29
- const usable = accounts.filter((a) => a.enabled && (a.healthState === 'healthy' || a.healthState === 'unknown') && Date.parse(a.tokenExpiresAt) > now.getTime())
37
+ const usable = accounts
38
+ .filter((account) => account.enabled && account.healthState !== 'down')
39
+ .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
40
+ return usable.map((account) => ({ ...candidate, qoderAccountId: account.id, qoderUserId: account.qoderUserId, selectionReason: 'qoder_account' }));
41
+ }
42
+ export function expandCodexAccountCandidates(candidate, accounts) {
43
+ if (candidate.providerType !== 'codex')
44
+ return [candidate];
45
+ // No token-expiry filter here: `withCodexCredentials` refreshes before the upstream call, so an
46
+ // expired token is still routable. Gating on it rejected exactly the accounts most in need of a
47
+ // refresh, turning a recoverable state into "no available Codex account".
48
+ const usable = accounts.filter((a) => a.enabled && a.healthState !== 'down')
30
49
  .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
31
50
  return usable.map((a) => ({ ...candidate, codexAccountId: a.id, codexChatgptAccountId: a.chatgptAccountId, selectionReason: 'codex_account' }));
32
51
  }
@@ -4,6 +4,7 @@ import { GatewayError } from '../errors.js';
4
4
  import { buildHeaders, stripSlash } from '../providers/index.js';
5
5
  import { redactString } from '../security/redact.js';
6
6
  import { getCodexAccountForProvider, getCodexAccountById } from '../db/repositories/codex-accounts.js';
7
+ import { findEligibleQoderAccount } from '../db/repositories/qoder-accounts.js';
7
8
  import { debugUpstream, errorLine, formatError, truncate } from '../logging/debug.js';
8
9
  export function providerToUpstreamConfig(p, codexAccountId) {
9
10
  let apiKey;
@@ -19,6 +20,19 @@ export function providerToUpstreamConfig(p, codexAccountId) {
19
20
  codexAccountId: account.chatgptAccountId, accountRecordId: account.id,
20
21
  };
21
22
  }
23
+ if (p.type === 'qoder') {
24
+ // Account-pool provider: no API key on the row. The runner normally overrides the account
25
+ // from the expanded candidate; this resolves the first eligible one for direct callers.
26
+ const account = findEligibleQoderAccount(p.id);
27
+ if (!account)
28
+ throw new GatewayError('authentication_error', 'No usable Qoder account is configured', { status: 503 });
29
+ return {
30
+ type: 'qoder', baseUrl: p.baseUrl, customHeaders: {},
31
+ connectTimeoutMs: p.connectTimeoutMs, firstTokenTimeoutMs: p.firstTokenTimeoutMs,
32
+ streamIdleTimeoutMs: p.streamIdleTimeoutMs, totalTimeoutMs: p.totalTimeoutMs,
33
+ qoderUserId: account.qoderUserId, qoderAccountRecordId: account.id,
34
+ };
35
+ }
22
36
  if (!p.encryptedApiKey || !p.apiKeyNonce) {
23
37
  throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
24
38
  }