ldrouter 1.16.2 → 1.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -38
- package/README.md +1 -23
- package/dist/server/app.js +1 -10
- package/dist/server/auth/middleware.js +1 -37
- package/dist/server/db/index.js +0 -5
- package/dist/server/db/migrate.js +5 -38
- package/dist/server/db/schema.js +3 -44
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +64 -126
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -8
- package/dist/server/routes/admin/auth.js +1 -4
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +34 -24
- package/dist/server/routes/admin/providers.js +20 -63
- package/dist/server/routes/admin/requests.js +0 -1
- package/dist/server/routes/admin.js +0 -12
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +73 -14
- package/dist/server/routing/combo.js +20 -39
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +55 -61
- package/dist/web/assets/index-C5h2WXK5.css +1 -0
- package/dist/web/assets/index-CRnoua24.js +335 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -5
- package/dist/server/db/repositories/codex-accounts.js +0 -187
- package/dist/server/providers/codex-autostart.js +0 -98
- package/dist/server/providers/codex-import.js +0 -156
- package/dist/server/providers/codex-oauth.js +0 -77
- package/dist/server/providers/codex-refresh.js +0 -165
- package/dist/server/providers/codex-usage.js +0 -192
- package/dist/server/providers/codex.js +0 -186
- package/dist/server/routes/admin/codex.js +0 -331
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
- package/migrations/0005_codex_accounts.sql +0 -105
- package/migrations/0006_codex_usage.sql +0 -9
|
@@ -3,15 +3,13 @@ import { getDb, schema } from '../db/index.js';
|
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
4
|
import { GatewayError } from '../errors.js';
|
|
5
5
|
import { resolveRequestedModel, unwrapAlias } from '../routing/resolver.js';
|
|
6
|
-
import { deriveRequiredCapabilities,
|
|
7
|
-
import { loadCombo, selectCandidates, orderCandidates, shouldFallback
|
|
8
|
-
import { listCodexAccountsForProvider, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
|
|
6
|
+
import { bareModelName, deriveRequiredCapabilities, describeRejections, firstMissingCapability } from '../routing/capabilities.js';
|
|
7
|
+
import { loadCombo, selectCandidates, orderCandidates, shouldFallback } from '../routing/combo.js';
|
|
9
8
|
import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
|
|
10
9
|
import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
|
|
11
10
|
import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
|
|
12
11
|
import { keyAllowedFor } from '../auth/api-key.js';
|
|
13
|
-
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl } from '../upstream/client.js';
|
|
14
|
-
import { callCodexNonStreaming, callCodexStreaming } from '../providers/codex.js';
|
|
12
|
+
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl, upstreamHttpError } from '../upstream/client.js';
|
|
15
13
|
import { canonicalToOpenAIRequest, openAIResponseToCanonical } from '../protocols/canonical.js';
|
|
16
14
|
import { canonicalToAnthropicRequest, anthropicResponseToCanonical } from '../protocols/anthropic.js';
|
|
17
15
|
import { uuid } from '../auth/ids.js';
|
|
@@ -100,21 +98,22 @@ export class GatewayRunner {
|
|
|
100
98
|
]);
|
|
101
99
|
// --- Determine candidates ---
|
|
102
100
|
let candidates = [];
|
|
103
|
-
|
|
101
|
+
const selectionReasons = [];
|
|
102
|
+
const rejected = [];
|
|
104
103
|
if (resolved.kind === 'model') {
|
|
105
|
-
|
|
104
|
+
const loaded = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
|
|
105
|
+
candidates = loaded.candidates;
|
|
106
|
+
rejected.push(...loaded.rejected);
|
|
106
107
|
selectionReasons.push('direct_model');
|
|
107
|
-
if (candidates.length === 0) {
|
|
108
|
-
debugHttp(ctx.requestId, 'CAPABILITY REJECT', [
|
|
109
|
-
`model=${resolved.publicModelId}`,
|
|
110
|
-
`reason=direct_model_unavailable_or_capability_mismatch`,
|
|
111
|
-
'(direct model candidates rejected: not found / provider disabled / model disabled / upstream unavailable / circuit open / capability mismatch)',
|
|
112
|
-
]);
|
|
113
|
-
}
|
|
114
108
|
}
|
|
115
109
|
else if (comboPlan) {
|
|
110
|
+
// A disabled combo is a configuration decision, not a routing failure:
|
|
111
|
+
// say so instead of blaming its members for not being available.
|
|
112
|
+
if (!resolved.enabled) {
|
|
113
|
+
const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, [{ publicModelId: resolved.publicModelId, reason: 'combo_disabled' }]);
|
|
114
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
115
|
+
}
|
|
116
116
|
const all = await this.loadAllModels();
|
|
117
|
-
const rejected = [];
|
|
118
117
|
const filtered = selectCandidates(comboPlan, all, required, (c, reason) => {
|
|
119
118
|
rejected.push({ publicModelId: c.publicModelId, reason });
|
|
120
119
|
debugHttp(ctx.requestId, 'CAPABILITY REJECT', [`model=${c.publicModelId}`, `reason=${reason}`]);
|
|
@@ -127,12 +126,10 @@ export class GatewayRunner {
|
|
|
127
126
|
...rejected.map((r) => `rejected: ${r.publicModelId} reason=${r.reason}`),
|
|
128
127
|
]);
|
|
129
128
|
if (filtered.length === 0) {
|
|
130
|
-
|
|
129
|
+
const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, rejected);
|
|
130
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
131
131
|
}
|
|
132
|
-
candidates = orderCandidates(comboPlan, filtered)
|
|
133
|
-
const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
|
|
134
|
-
return provider?.type === 'codex' ? expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(provider.id)) : [candidate];
|
|
135
|
-
});
|
|
132
|
+
candidates = orderCandidates(comboPlan, filtered);
|
|
136
133
|
debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
|
|
137
134
|
`mode=${comboPlan.mode}`,
|
|
138
135
|
...candidates.map((c, i) => `candidate[${i}]: providerModelId=${c.modelId} publicModelId=${c.publicModelId}`),
|
|
@@ -140,7 +137,8 @@ export class GatewayRunner {
|
|
|
140
137
|
selectionReasons.push('combo');
|
|
141
138
|
}
|
|
142
139
|
if (candidates.length === 0) {
|
|
143
|
-
|
|
140
|
+
const s = describeRejections({ kind: 'model', publicModelId: resolved.publicModelId }, rejected);
|
|
141
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
144
142
|
}
|
|
145
143
|
// --- Gateway response cache check ---
|
|
146
144
|
const settings = getSettings();
|
|
@@ -201,7 +199,7 @@ export class GatewayRunner {
|
|
|
201
199
|
lastError = new GatewayError('upstream_unavailable', 'Provider circuit is open', { status: 502 });
|
|
202
200
|
break;
|
|
203
201
|
}
|
|
204
|
-
const cfg = providerToUpstreamConfig(provider
|
|
202
|
+
const cfg = providerToUpstreamConfig(provider);
|
|
205
203
|
const attemptStart = Date.now();
|
|
206
204
|
// docs/13 §11–§12: provider/account + upstream request summary
|
|
207
205
|
const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
|
|
@@ -214,7 +212,7 @@ export class GatewayRunner {
|
|
|
214
212
|
`upstreamType=${cfg.type}`,
|
|
215
213
|
`baseUrl=${cfg.baseUrl}`,
|
|
216
214
|
`stream=${req.canonical.stream}`,
|
|
217
|
-
`providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey
|
|
215
|
+
`providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey)}`,
|
|
218
216
|
]);
|
|
219
217
|
const attempt = {
|
|
220
218
|
attemptNumber: i + 1,
|
|
@@ -230,8 +228,7 @@ export class GatewayRunner {
|
|
|
230
228
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
|
|
231
229
|
streamStarted: false,
|
|
232
230
|
partialResponse: false,
|
|
233
|
-
selectionReason:
|
|
234
|
-
codexAccountId: candidate.codexAccountId,
|
|
231
|
+
selectionReason: selectionReasons[0] ?? 'direct',
|
|
235
232
|
failureReason: null,
|
|
236
233
|
sanitizedError: null,
|
|
237
234
|
upstreamRequestId: null,
|
|
@@ -264,8 +261,6 @@ export class GatewayRunner {
|
|
|
264
261
|
resultToolCalls = out.result.toolCalls;
|
|
265
262
|
resultFinishReason = out.result.finishReason;
|
|
266
263
|
recordSuccess(provider.id);
|
|
267
|
-
if (candidate.codexAccountId)
|
|
268
|
-
setCodexAccountHealth(candidate.codexAccountId, 'healthy');
|
|
269
264
|
getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
|
|
270
265
|
attempts.push(attempt);
|
|
271
266
|
sentToClient = out.streamStarted ?? false;
|
|
@@ -299,12 +294,8 @@ export class GatewayRunner {
|
|
|
299
294
|
}
|
|
300
295
|
attempts.push(attempt);
|
|
301
296
|
lastError = err;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (candidate.codexAccountId)
|
|
305
|
-
setCodexAccountHealth(candidate.codexAccountId, 'down', redactString(err.message));
|
|
306
|
-
getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
|
|
307
|
-
}
|
|
297
|
+
recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
|
|
298
|
+
getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
|
|
308
299
|
if (shouldRetry && i + 1 < maxAttempts) {
|
|
309
300
|
metrics.fallbackCount.inc();
|
|
310
301
|
continue;
|
|
@@ -319,6 +310,7 @@ export class GatewayRunner {
|
|
|
319
310
|
httpStatus: lastError ? lastError.status : 200,
|
|
320
311
|
errorType: lastError?.type ?? null,
|
|
321
312
|
errorMessage: lastError ? redactString(lastError.message) : null,
|
|
313
|
+
errorCode: lastError?.code ?? null,
|
|
322
314
|
text: lastError ? null : resultText,
|
|
323
315
|
toolCalls: lastError ? null : resultToolCalls,
|
|
324
316
|
finishReason: lastError ? null : resultFinishReason,
|
|
@@ -367,52 +359,43 @@ export class GatewayRunner {
|
|
|
367
359
|
metrics.activeRequests.dec();
|
|
368
360
|
}
|
|
369
361
|
}
|
|
362
|
+
/** Resolve a direct physical model into a candidate, reporting the exact
|
|
363
|
+
* reason it cannot serve the request instead of a blanket "no candidates". */
|
|
370
364
|
async loadModelCandidate(modelId, required, requestId) {
|
|
371
365
|
const db = getDb();
|
|
372
|
-
const reject = (reason) => {
|
|
366
|
+
const reject = (publicModelId, reason) => {
|
|
373
367
|
if (requestId)
|
|
374
368
|
debugHttp(requestId, 'CAPABILITY REJECT', [`modelId=${modelId}`, `reason=${reason}`]);
|
|
369
|
+
return [{ publicModelId, reason }];
|
|
375
370
|
};
|
|
376
371
|
const m = db.select().from(schema.models).where(eq(schema.models.id, modelId)).get();
|
|
377
|
-
if (!m)
|
|
378
|
-
reject('model_not_found');
|
|
379
|
-
return [];
|
|
380
|
-
}
|
|
372
|
+
if (!m)
|
|
373
|
+
return { candidates: [], rejected: reject(modelId, 'model_not_found') };
|
|
381
374
|
const p = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
|
|
382
|
-
if (!p)
|
|
383
|
-
reject('provider_not_found');
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (!p.enabled) {
|
|
387
|
-
reject('provider_disabled');
|
|
388
|
-
return [];
|
|
389
|
-
}
|
|
375
|
+
if (!p)
|
|
376
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'provider_not_found') };
|
|
377
|
+
if (!p.enabled)
|
|
378
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'provider_disabled') };
|
|
390
379
|
const caps = safeJson(m.capabilitiesJson);
|
|
391
380
|
const candidate = {
|
|
392
381
|
modelId: m.id,
|
|
393
382
|
publicModelId: m.publicModelId,
|
|
394
383
|
providerId: m.providerId,
|
|
395
384
|
enabled: m.enabled,
|
|
385
|
+
providerEnabled: p.enabled,
|
|
396
386
|
upstreamAvailable: m.upstreamAvailable,
|
|
397
387
|
circuitOpen: isOpen(m.providerId),
|
|
398
388
|
capabilities: caps,
|
|
399
389
|
};
|
|
400
|
-
if (!m.enabled)
|
|
401
|
-
reject('model_disabled');
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
reject('
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
reject('circuit_open');
|
|
410
|
-
return [];
|
|
411
|
-
}
|
|
412
|
-
if (!modelMeets(caps, required)) {
|
|
413
|
-
reject('capability_mismatch');
|
|
414
|
-
return [];
|
|
415
|
-
}
|
|
390
|
+
if (!m.enabled)
|
|
391
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'model_disabled') };
|
|
392
|
+
if (!m.upstreamAvailable)
|
|
393
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'upstream_unavailable') };
|
|
394
|
+
if (candidate.circuitOpen)
|
|
395
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'circuit_open') };
|
|
396
|
+
const missing = firstMissingCapability(caps, required);
|
|
397
|
+
if (missing)
|
|
398
|
+
return { candidates: [], rejected: reject(m.publicModelId, missing) };
|
|
416
399
|
debugHttp(requestId ?? '-', 'CAPABILITY CANDIDATE', [
|
|
417
400
|
`model=${m.publicModelId}`,
|
|
418
401
|
`caps.tools=${caps.tools}`,
|
|
@@ -421,28 +404,26 @@ export class GatewayRunner {
|
|
|
421
404
|
`caps.image_input=${caps.image_input}`,
|
|
422
405
|
`caps.structured_output=${caps.structured_output}`,
|
|
423
406
|
]);
|
|
424
|
-
|
|
425
|
-
const expanded = expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(p.id));
|
|
426
|
-
if (expanded.length === 0)
|
|
427
|
-
reject('codex_account_unavailable');
|
|
428
|
-
return expanded;
|
|
429
|
-
}
|
|
430
|
-
return [candidate];
|
|
407
|
+
return { candidates: [candidate], rejected: [] };
|
|
431
408
|
}
|
|
432
409
|
async loadAllModels() {
|
|
433
410
|
const db = getDb();
|
|
434
411
|
const models = db.select().from(schema.models).all();
|
|
435
412
|
const providers = db.select().from(schema.providers).all();
|
|
436
413
|
const providerEnabled = new Map(providers.map((p) => [p.id, p.enabled]));
|
|
414
|
+
// Deliberately unfiltered: every combo member must reach selectCandidates so
|
|
415
|
+
// it can report WHY it was skipped. Pre-filtering here erased the model rows
|
|
416
|
+
// and turned every distinct reason into "model not found".
|
|
437
417
|
return models.map((m) => ({
|
|
438
418
|
modelId: m.id,
|
|
439
419
|
publicModelId: m.publicModelId,
|
|
440
420
|
providerId: m.providerId,
|
|
441
421
|
enabled: m.enabled,
|
|
422
|
+
providerEnabled: providerEnabled.get(m.providerId),
|
|
442
423
|
upstreamAvailable: m.upstreamAvailable,
|
|
443
424
|
circuitOpen: isOpen(m.providerId),
|
|
444
425
|
capabilities: safeJson(m.capabilitiesJson),
|
|
445
|
-
}))
|
|
426
|
+
}));
|
|
446
427
|
}
|
|
447
428
|
async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
|
|
448
429
|
if (req.canonical.stream) {
|
|
@@ -452,10 +433,6 @@ export class GatewayRunner {
|
|
|
452
433
|
}
|
|
453
434
|
async runNonStreamingAttempt(req, candidate, cfg, ctx) {
|
|
454
435
|
const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
|
|
455
|
-
if (cfg.type === 'codex') {
|
|
456
|
-
const out = await callCodexNonStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel });
|
|
457
|
-
return { statusCode: out.status, ttftMs: null, upstreamRequestId: out.upstreamRequestId, usage: out.usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
|
|
458
|
-
}
|
|
459
436
|
let call;
|
|
460
437
|
if (cfg.type === 'openai') {
|
|
461
438
|
const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
|
|
@@ -468,20 +445,21 @@ export class GatewayRunner {
|
|
|
468
445
|
call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload, ctx.requestId);
|
|
469
446
|
}
|
|
470
447
|
if (!call.ok) {
|
|
471
|
-
|
|
472
|
-
throw new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${call.status})`, { status: 429, code: 'upstream_http_429' });
|
|
473
|
-
if (call.status === 401 || call.status === 403)
|
|
474
|
-
throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502 });
|
|
475
|
-
if (call.status >= 500)
|
|
476
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}`, { status: 502, code: `upstream_http_${call.status}`, cause: { status: call.status } });
|
|
477
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}: ${redactString(call.text.slice(0, 300))}`, { status: 502, cause: { status: call.status } });
|
|
448
|
+
throw upstreamHttpError(call.status, redactString(call.text.slice(0, 300)));
|
|
478
449
|
}
|
|
479
450
|
let parsed;
|
|
480
451
|
try {
|
|
481
452
|
parsed = JSON.parse(call.text);
|
|
482
453
|
}
|
|
483
454
|
catch {
|
|
484
|
-
throw new GatewayError('upstream_error',
|
|
455
|
+
throw new GatewayError('upstream_error', `Upstream returned invalid JSON for "${bareModelName(candidate.publicModelId)}"`, { status: 502, code: 'upstream_bad_response' });
|
|
456
|
+
}
|
|
457
|
+
// A 200 carrying the wrong shape used to explode into a raw
|
|
458
|
+
// "Cannot read properties of undefined (reading '0')" that reached the client
|
|
459
|
+
// verbatim. Name the model and the missing field instead.
|
|
460
|
+
const expectedField = cfg.type === 'openai' ? 'choices' : 'content';
|
|
461
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed[expectedField])) {
|
|
462
|
+
throw new GatewayError('upstream_error', `Upstream response for "${bareModelName(candidate.publicModelId)}" has no "${expectedField}" array`, { status: 502, code: 'upstream_bad_response' });
|
|
485
463
|
}
|
|
486
464
|
let result;
|
|
487
465
|
let usage;
|
|
@@ -624,26 +602,6 @@ export class GatewayRunner {
|
|
|
624
602
|
return;
|
|
625
603
|
};
|
|
626
604
|
try {
|
|
627
|
-
if (cfg.type === 'codex') {
|
|
628
|
-
const events = [];
|
|
629
|
-
let firstCodexEvent = true;
|
|
630
|
-
const meta = await callCodexStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel }, (event) => {
|
|
631
|
-
if (event.text || event.isLast) {
|
|
632
|
-
events.push(event);
|
|
633
|
-
if (event.usage)
|
|
634
|
-
Object.assign(usage, event.usage);
|
|
635
|
-
chunkHandler({ data: codexStreamEventToClient(req.protocol, event, upstreamModel, ctx.requestId) }, firstCodexEvent);
|
|
636
|
-
firstCodexEvent = false;
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
|
-
if (!headWritten)
|
|
640
|
-
writeHead();
|
|
641
|
-
pipe.write('data: [DONE]\n\n');
|
|
642
|
-
pipe.end();
|
|
643
|
-
if (!usage.total)
|
|
644
|
-
usage.total = usage.input + usage.output;
|
|
645
|
-
return { statusCode: meta.status, ttftMs: events.length ? Date.now() - streamStartTs : null, upstreamRequestId: meta.upstreamRequestId, usage, result: { text: textBuf, toolCalls: toolBuf, finishReason } };
|
|
646
|
-
}
|
|
647
605
|
const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
|
|
648
606
|
const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
|
|
649
607
|
logUpstreamRequest(ctx.requestId, cfg, url, payload, true);
|
|
@@ -813,6 +771,7 @@ export class GatewayRunner {
|
|
|
813
771
|
httpStatus: 200,
|
|
814
772
|
errorType: null,
|
|
815
773
|
errorMessage: null,
|
|
774
|
+
errorCode: null,
|
|
816
775
|
text,
|
|
817
776
|
toolCalls,
|
|
818
777
|
finishReason,
|
|
@@ -878,7 +837,6 @@ export class GatewayRunner {
|
|
|
878
837
|
attemptNumber: a.attemptNumber,
|
|
879
838
|
providerId: a.providerId,
|
|
880
839
|
modelId: a.modelId,
|
|
881
|
-
codexAccountId: a.codexAccountId ?? null,
|
|
882
840
|
startedAt: a.startedAt,
|
|
883
841
|
completedAt: a.completedAt,
|
|
884
842
|
statusCode: a.statusCode,
|
|
@@ -901,10 +859,7 @@ export class GatewayRunner {
|
|
|
901
859
|
emitRequestLogged(requestId);
|
|
902
860
|
}
|
|
903
861
|
}
|
|
904
|
-
|
|
905
|
-
return ['connection_error', 'connect_timeout', 'first_token_timeout', 'http_status', 'upstream_rate_limit'].includes(classifyFailure(err));
|
|
906
|
-
}
|
|
907
|
-
export function classifyFailure(err) {
|
|
862
|
+
function classifyFailure(err) {
|
|
908
863
|
switch (err.type) {
|
|
909
864
|
case 'timeout_error':
|
|
910
865
|
return err.message.includes('first token') ? 'first_token_timeout' : 'connect_timeout';
|
|
@@ -912,10 +867,8 @@ export function classifyFailure(err) {
|
|
|
912
867
|
return 'connection_error';
|
|
913
868
|
case 'upstream_rate_limit':
|
|
914
869
|
return 'http_status';
|
|
915
|
-
case 'upstream_error':
|
|
916
|
-
|
|
917
|
-
return typeof upstreamStatus === 'number' && upstreamStatus >= 500 && upstreamStatus < 600 ? 'http_status' : 'unknown';
|
|
918
|
-
}
|
|
870
|
+
case 'upstream_error':
|
|
871
|
+
return err.status >= 500 ? 'http_status' : 'connection_error';
|
|
919
872
|
default:
|
|
920
873
|
return 'unknown';
|
|
921
874
|
}
|
|
@@ -1011,21 +964,6 @@ function safeJsonParse(s) {
|
|
|
1011
964
|
function usageFromCache(u) {
|
|
1012
965
|
return u ? { ...u, total: u.input + u.output } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
|
|
1013
966
|
}
|
|
1014
|
-
// Map Codex's native response events to the public protocol stream shape.
|
|
1015
|
-
export function codexStreamEventToClient(protocol, event, model, requestId) {
|
|
1016
|
-
if (protocol === 'openai') {
|
|
1017
|
-
return JSON.stringify({
|
|
1018
|
-
id: `chatcmpl-${requestId}`,
|
|
1019
|
-
object: 'chat.completion.chunk',
|
|
1020
|
-
created: Math.floor(Date.now() / 1000),
|
|
1021
|
-
model,
|
|
1022
|
-
choices: [{ index: 0, delta: event.isLast ? {} : { content: event.text }, finish_reason: event.isLast ? 'stop' : null }],
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
return JSON.stringify(event.isLast
|
|
1026
|
-
? { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } }
|
|
1027
|
-
: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: event.text } });
|
|
1028
|
-
}
|
|
1029
967
|
// SSE encoders: canonical stream chunk -> client protocol event
|
|
1030
968
|
function openaiStreamEncoder(data, _event) {
|
|
1031
969
|
if (data === '[DONE]')
|
|
@@ -53,7 +53,9 @@ function parseAnthropicUserContent(content) {
|
|
|
53
53
|
out.push({ type: 'image', image: { base64: b.source.data, mimeType: b.source.media_type } });
|
|
54
54
|
}
|
|
55
55
|
else if (b.source.type === 'url') {
|
|
56
|
-
|
|
56
|
+
// Anthropic's url source carries the image in `url`; only our own older
|
|
57
|
+
// base64->url downgrade ever put it in `data` (see canonicalToAnthropicRequest).
|
|
58
|
+
out.push({ type: 'image', image: { url: b.source.url ?? b.source.data } });
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
if (b.type === 'tool_result') {
|
|
@@ -84,8 +86,8 @@ export function canonicalToAnthropicRequest(req, targetModel) {
|
|
|
84
86
|
blocks.push({ type: 'text', text: b.text });
|
|
85
87
|
if (b.type === 'image' && b.image?.base64)
|
|
86
88
|
blocks.push({ type: 'image', source: { type: 'base64', media_type: b.image.mimeType ?? 'image/png', data: b.image.base64 } });
|
|
87
|
-
if (b.type === 'image' && b.image?.url)
|
|
88
|
-
blocks.push({ type: 'image', source: { type: 'url',
|
|
89
|
+
else if (b.type === 'image' && b.image?.url)
|
|
90
|
+
blocks.push({ type: 'image', source: { type: 'url', url: b.image.url } });
|
|
89
91
|
if (b.type === 'tool_result')
|
|
90
92
|
blocks.push({ type: 'tool_result', tool_use_id: b.toolResult.toolUseId, content: b.toolResult.content, is_error: b.toolResult.isError });
|
|
91
93
|
}
|
|
@@ -20,10 +20,14 @@ export function openAIToCanonical(req) {
|
|
|
20
20
|
blocks.push({ type: 'text', text: m.content });
|
|
21
21
|
else if (Array.isArray(m.content)) {
|
|
22
22
|
for (const c of m.content) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const t = textBlock(c);
|
|
24
|
+
if (t)
|
|
25
|
+
blocks.push(t);
|
|
26
|
+
else {
|
|
27
|
+
const u = imageUrlOf(c);
|
|
28
|
+
if (u)
|
|
29
|
+
blocks.push({ type: 'image', image: { url: u } });
|
|
30
|
+
}
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
if (m.tool_calls) {
|
|
@@ -192,15 +196,32 @@ function normalizeContent(content) {
|
|
|
192
196
|
if (Array.isArray(content)) {
|
|
193
197
|
const out = [];
|
|
194
198
|
for (const b of content) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
+
const t = textBlock(b);
|
|
200
|
+
if (t)
|
|
201
|
+
out.push(t);
|
|
202
|
+
const u = imageUrlOf(b);
|
|
203
|
+
if (u)
|
|
204
|
+
out.push({ type: 'image', image: { url: u } });
|
|
199
205
|
}
|
|
200
206
|
return out;
|
|
201
207
|
}
|
|
202
208
|
throw new GatewayError('invalid_request_error', 'Unsupported message content', { status: 400 });
|
|
203
209
|
}
|
|
210
|
+
// OpenAI Responses (`input_text`/`output_text`, `input_image`) and Chat
|
|
211
|
+
// (`text`/`image_url`) use different part names for the same content.
|
|
212
|
+
function textBlock(c) {
|
|
213
|
+
if (!c.text)
|
|
214
|
+
return null;
|
|
215
|
+
return c.type === 'text' || c.type === 'input_text' || c.type === 'output_text' ? { type: 'text', text: c.text } : null;
|
|
216
|
+
}
|
|
217
|
+
// `image_url` is an object in Chat Completions and a plain string in Responses.
|
|
218
|
+
function imageUrlOf(c) {
|
|
219
|
+
if (c.type !== 'image_url' && c.type !== 'input_image')
|
|
220
|
+
return undefined;
|
|
221
|
+
if (typeof c.image_url === 'string')
|
|
222
|
+
return c.image_url;
|
|
223
|
+
return typeof c.image_url?.url === 'string' ? c.image_url.url : undefined;
|
|
224
|
+
}
|
|
204
225
|
function safeJson(s) {
|
|
205
226
|
try {
|
|
206
227
|
return JSON.parse(s);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Upstream provider adapters: OpenAI-compatible + Anthropic-compatible.
|
|
2
|
-
export { probeCodex, codexModels } from './codex.js';
|
|
3
2
|
function buildHeaders(cfg, extra) {
|
|
4
3
|
const h = {
|
|
5
4
|
'content-type': 'application/json',
|
|
@@ -25,8 +24,6 @@ async function fetchWithTimeout(url, init, totalTimeoutMs) {
|
|
|
25
24
|
}
|
|
26
25
|
export async function probeProvider(cfg) {
|
|
27
26
|
const start = Date.now();
|
|
28
|
-
if (cfg.type === 'codex')
|
|
29
|
-
return { ok: false, detail: 'Codex providers require the Codex account adapter', latencyMs: 0 };
|
|
30
27
|
try {
|
|
31
28
|
const url = cfg.type === 'openai' ? `${stripSlash(cfg.baseUrl)}/v1/models` : `${stripSlash(cfg.baseUrl)}/v1/models`;
|
|
32
29
|
const res = await fetchWithTimeout(url, { method: 'GET', headers: buildHeaders(cfg) }, cfg.totalTimeoutMs);
|
|
@@ -43,8 +40,6 @@ export async function probeProvider(cfg) {
|
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
42
|
export async function discoverProviderModels(cfg) {
|
|
46
|
-
if (cfg.type === 'codex')
|
|
47
|
-
throw new Error('Codex providers require the Codex account adapter');
|
|
48
43
|
if (cfg.type === 'openai')
|
|
49
44
|
return discoverOpenAI(cfg);
|
|
50
45
|
return discoverAnthropic(cfg);
|
|
@@ -83,12 +78,34 @@ function inferOpenAICapabilities(id) {
|
|
|
83
78
|
chat: true,
|
|
84
79
|
streaming: true,
|
|
85
80
|
tools: !(lower.includes('embedding') || lower.includes('whisper') || lower.includes('dall-e') || lower.includes('tts')),
|
|
86
|
-
image_input
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// image_input / structured_output / reasoning are intentionally omitted.
|
|
82
|
+
// A guessed `false` is read as "known unsupported" and hard-rejects matching
|
|
83
|
+
// requests (docs/04 §capability filtering); docs/00 requires unknown
|
|
84
|
+
// capabilities to stay unknown, so leave them undefined (undefined = allow).
|
|
85
|
+
// ponytail: name-based guessing was wrong for most ids (e.g. qwen-vl-max,
|
|
86
|
+
// claude-sonnet-4-5). Upgrade path: a real /models metadata probe, or the
|
|
87
|
+
// admin capability override UI.
|
|
89
88
|
};
|
|
90
89
|
}
|
|
91
90
|
function stripSlash(u) {
|
|
92
91
|
return u.endsWith('/') ? u.slice(0, -1) : u;
|
|
93
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Merge freshly discovered capabilities into a stored model record.
|
|
95
|
+
*
|
|
96
|
+
* `baseline` is what discovery last wrote for this model. An admin edit is by
|
|
97
|
+
* definition a divergence from that baseline, so those keys survive a
|
|
98
|
+
* re-import; everything else is refreshed (this is what clears stale guesses
|
|
99
|
+
* such as `image_input: false`). A record with no baseline predates this
|
|
100
|
+
* tracking, so it is refreshed wholesale rather than preserved blindly.
|
|
101
|
+
*/
|
|
102
|
+
export function mergeDiscoveredCapabilities(stored, baseline, discovered) {
|
|
103
|
+
const overrides = {};
|
|
104
|
+
if (baseline) {
|
|
105
|
+
for (const [k, v] of Object.entries(stored))
|
|
106
|
+
if (stored[k] !== baseline[k])
|
|
107
|
+
overrides[k] = v;
|
|
108
|
+
}
|
|
109
|
+
return { capabilities: { ...discovered, ...overrides }, baseline: discovered };
|
|
110
|
+
}
|
|
94
111
|
export { buildHeaders, fetchWithTimeout, stripSlash };
|
|
@@ -3,8 +3,7 @@ import argon2 from 'argon2';
|
|
|
3
3
|
import { getDb, schema } from '../../db/index.js';
|
|
4
4
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
5
5
|
import { uuid, generateSessionToken, sha256Hex } from '../../auth/ids.js';
|
|
6
|
-
import
|
|
7
|
-
import { requireAdminAuth, csrfTokenForSession } from '../../auth/middleware.js';
|
|
6
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
8
7
|
import { GatewayError } from '../../errors.js';
|
|
9
8
|
const LoginBody = z.object({
|
|
10
9
|
username: z.string().min(1).max(64),
|
|
@@ -77,7 +76,6 @@ export async function registerAuthRoutes(app) {
|
|
|
77
76
|
ip: req.ip,
|
|
78
77
|
userAgent: (req.headers['user-agent'] ?? '').toString().slice(0, 256),
|
|
79
78
|
}).run();
|
|
80
|
-
db.insert(schema.csrfTokens).values({ id: uuid(), sessionId: id, token: crypto.randomBytes(32).toString('base64url'), expiresAt: sessionExpiry() }).run();
|
|
81
79
|
db.update(schema.adminAccount).set({ lastLoginAt: new Date().toISOString() }).where(sql `id = ${account.id}`).run();
|
|
82
80
|
recordAudit({ action: 'admin.login', success: true, ip: req.ip, targetType: 'admin', targetId: account.id, targetName: account.username });
|
|
83
81
|
reply.setCookie(SessionCookie, token, {
|
|
@@ -99,7 +97,6 @@ export async function registerAuthRoutes(app) {
|
|
|
99
97
|
reply.clearCookie(SessionCookie, { path: '/' });
|
|
100
98
|
return { ok: true };
|
|
101
99
|
});
|
|
102
|
-
app.get('/api/admin/csrf', { preHandler: requireAdminAuth }, async (req) => ({ csrfToken: csrfTokenForSession(req.adminSessionId) }));
|
|
103
100
|
app.get('/api/admin/me', { preHandler: requireAdminAuth }, async (req) => {
|
|
104
101
|
const account = req.adminAccount;
|
|
105
102
|
return {
|