ldrouter 1.16.5 → 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.
- package/CHANGELOG.md +15 -0
- package/README.md +17 -0
- package/dist/server/db/repositories/codex-accounts.js +15 -3
- package/dist/server/db/repositories/qoder-accounts.js +167 -0
- package/dist/server/db/schema.js +33 -1
- package/dist/server/gateway/runner.js +73 -4
- package/dist/server/providers/codex-refresh.js +5 -2
- package/dist/server/providers/codex.js +53 -19
- package/dist/server/providers/index.js +4 -0
- package/dist/server/providers/pooled.js +84 -0
- package/dist/server/providers/qoder/catalog.js +138 -0
- package/dist/server/providers/qoder/client.js +248 -0
- package/dist/server/providers/qoder/constants.js +34 -0
- package/dist/server/providers/qoder/cosy.js +81 -0
- package/dist/server/providers/qoder/credentials.js +125 -0
- package/dist/server/providers/qoder/encode.js +28 -0
- package/dist/server/providers/qoder/qoder-import.js +109 -0
- package/dist/server/providers/qoder/rsa.js +7 -0
- package/dist/server/providers/qoder/sse.js +211 -0
- package/dist/server/providers/qoder/tier.js +105 -0
- package/dist/server/routes/admin/backup.js +7 -2
- package/dist/server/routes/admin/models.js +14 -0
- package/dist/server/routes/admin/providers.js +53 -34
- package/dist/server/routes/admin/qoder.js +271 -0
- package/dist/server/routes/admin.js +8 -1
- package/dist/server/routing/capabilities.js +1 -0
- package/dist/server/routing/combo.js +22 -3
- package/dist/server/upstream/client.js +14 -0
- package/dist/web/assets/index-DdtyoPpS.js +391 -0
- package/dist/web/index.html +1 -1
- package/migrations/0007_qoder_accounts.sql +217 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CkDJqDcS.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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
99
|
+
name,
|
|
82
100
|
slug,
|
|
83
101
|
type: body.type,
|
|
84
|
-
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:
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
-
|
|
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: {
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
}
|