ldrouter 1.16.2 → 1.16.4
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 +23 -0
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +58 -55
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -3
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +31 -4
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +74 -14
- package/dist/server/routing/combo.js +8 -19
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +17 -7
- package/dist/web/assets/index-2L7raen4.js +391 -0
- package/dist/web/assets/index-Drmqi8MO.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.16.4] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Codex account pools are now part of `main`: native Codex OAuth providers, encrypted JSON/JSONL account import, token refresh/rotation, account-aware routing and fallback, model discovery, quota/usage panel, browser PKCE connect, and the Codex accounts UI.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Admin sessions with a missing or expired CSRF row could not mutate anything (`Unable to acquire CSRF token`); the CSRF endpoint now re-issues a token for a valid session, and the admin client invalidates its cached token and retries once on an auth rejection.
|
|
16
|
+
|
|
17
|
+
## [1.16.3] - 2026-09-14
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Routing rejections now name the model and the reason.** A request a model could not serve answered `No available model candidates` (direct model) or `No combo member satisfies the request capabilities or availability` (combo) — the same failure described differently, naming neither the model nor the cause. Both paths now share one taxonomy of capability gaps and availability reasons, grouped per model and reported with the model name without its provider prefix (`vl/gpt-5.5` is reported as `gpt-5.5`).
|
|
22
|
+
- **Error codes reached clients again.** `GatewayError.code` was dropped when each gateway route rebuilt the error from the runner outcome, so `rpm_limit`, `tpm_limit`, `quota_limit`, `concurrency_limit`, and `upstream_http_*` never left the process.
|
|
23
|
+
- **Admin-disabled models answered `404 Unknown model`.** The resolver skipped disabled models, so callers looked for a typo in a model that had just been switched off; it now reports `model disabled`. The candidate loader also pre-filtered disabled models and providers, making the recorded reasons unreachable and collapsing every case into `model not found`.
|
|
24
|
+
- **Upstream HTTP failures agreed across paths.** A 429 surfaced as 529 without a code on the non-streaming path and with one on another; all paths now share one mapping (429 → `upstream_http_429`, 401/403 → `upstream_http_401`/`upstream_http_403`, ≥500 → `upstream_http_503`).
|
|
25
|
+
- **No internal JS errors in responses.** A 200 with an unexpected body shape surfaced `Cannot read properties of undefined (reading '0')`; it is now `Upstream response for "<model>" has no "choices" array` with code `upstream_bad_response`.
|
|
26
|
+
- **Combo create/update no longer 500, leave orphans, or rename the public ID.** Uniqueness checked only `publicModelId` while `combos.slug` is its own UNIQUE column, duplicate members hit `UNIQUE(combo_id, model_id)`, and both operations ran outside a transaction — update deleting members before inserting the new ones. Saving the edit form unchanged also rewrote a public ID such as `smart` into `combo/smart`, breaking aliases and API keys. All uniqueness checks share one path, both operations are atomic, and the ID is never derived from the name.
|
|
27
|
+
- **Image requests to image-capable models failed.** `/v1/responses` dropped `input_image` blocks, Anthropic inbound read URLs from `source.data`, and outbound Anthropic conversion emitted `{"type":"url","media_type","data"}` instead of `source.url`.
|
|
28
|
+
- **Model capabilities are no longer guessed.** `inferOpenAICapabilities` wrote `image_input`, `structured_output`, and `reasoning` as `false` whenever a model name missed a substring heuristic, hard-rejecting `gpt-4o-2024-11-20`, `gemini-*`, `qwen-vl-*`, and `grok-*`. Unknown stays unknown; manual admin edits survive re-import.
|
|
29
|
+
|
|
7
30
|
## [1.16.2] - 2026-09-14
|
|
8
31
|
|
|
9
32
|
### Fixed
|
package/dist/server/errors.js
CHANGED
|
@@ -63,3 +63,14 @@ export function toAnthropicError(g, requestId) {
|
|
|
63
63
|
...(requestId ? { request_id: requestId } : {}),
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Rehydrate the error for the client from a gateway outcome. `errorCode` matters:
|
|
68
|
+
* without it every propagated failure lost its specific code (rpm_limit,
|
|
69
|
+
* upstream_http_429, …) and arrived as a bare type.
|
|
70
|
+
*/
|
|
71
|
+
export function outcomeError(outcome) {
|
|
72
|
+
return new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', {
|
|
73
|
+
status: outcome.httpStatus,
|
|
74
|
+
...(outcome.errorCode ? { code: outcome.errorCode } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -3,14 +3,14 @@ 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,
|
|
6
|
+
import { bareModelName, deriveRequiredCapabilities, describeRejections, firstMissingCapability } from '../routing/capabilities.js';
|
|
7
7
|
import { loadCombo, selectCandidates, orderCandidates, shouldFallback, expandCodexAccountCandidates } from '../routing/combo.js';
|
|
8
8
|
import { listCodexAccountsForProvider, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
|
|
9
9
|
import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
|
|
10
10
|
import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
|
|
11
11
|
import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
|
|
12
12
|
import { keyAllowedFor } from '../auth/api-key.js';
|
|
13
|
-
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl } from '../upstream/client.js';
|
|
13
|
+
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl, upstreamHttpError } from '../upstream/client.js';
|
|
14
14
|
import { callCodexNonStreaming, callCodexStreaming } from '../providers/codex.js';
|
|
15
15
|
import { canonicalToOpenAIRequest, openAIResponseToCanonical } from '../protocols/canonical.js';
|
|
16
16
|
import { canonicalToAnthropicRequest, anthropicResponseToCanonical } from '../protocols/anthropic.js';
|
|
@@ -100,21 +100,22 @@ export class GatewayRunner {
|
|
|
100
100
|
]);
|
|
101
101
|
// --- Determine candidates ---
|
|
102
102
|
let candidates = [];
|
|
103
|
-
|
|
103
|
+
const selectionReasons = [];
|
|
104
|
+
const rejected = [];
|
|
104
105
|
if (resolved.kind === 'model') {
|
|
105
|
-
|
|
106
|
+
const loaded = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
|
|
107
|
+
candidates = loaded.candidates;
|
|
108
|
+
rejected.push(...loaded.rejected);
|
|
106
109
|
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
110
|
}
|
|
115
111
|
else if (comboPlan) {
|
|
112
|
+
// A disabled combo is a configuration decision, not a routing failure:
|
|
113
|
+
// say so instead of blaming its members for not being available.
|
|
114
|
+
if (!resolved.enabled) {
|
|
115
|
+
const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, [{ publicModelId: resolved.publicModelId, reason: 'combo_disabled' }]);
|
|
116
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
117
|
+
}
|
|
116
118
|
const all = await this.loadAllModels();
|
|
117
|
-
const rejected = [];
|
|
118
119
|
const filtered = selectCandidates(comboPlan, all, required, (c, reason) => {
|
|
119
120
|
rejected.push({ publicModelId: c.publicModelId, reason });
|
|
120
121
|
debugHttp(ctx.requestId, 'CAPABILITY REJECT', [`model=${c.publicModelId}`, `reason=${reason}`]);
|
|
@@ -127,7 +128,8 @@ export class GatewayRunner {
|
|
|
127
128
|
...rejected.map((r) => `rejected: ${r.publicModelId} reason=${r.reason}`),
|
|
128
129
|
]);
|
|
129
130
|
if (filtered.length === 0) {
|
|
130
|
-
|
|
131
|
+
const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, rejected);
|
|
132
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
131
133
|
}
|
|
132
134
|
candidates = orderCandidates(comboPlan, filtered).flatMap((candidate) => {
|
|
133
135
|
const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
|
|
@@ -140,7 +142,8 @@ export class GatewayRunner {
|
|
|
140
142
|
selectionReasons.push('combo');
|
|
141
143
|
}
|
|
142
144
|
if (candidates.length === 0) {
|
|
143
|
-
|
|
145
|
+
const s = describeRejections({ kind: 'model', publicModelId: resolved.publicModelId }, rejected);
|
|
146
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
144
147
|
}
|
|
145
148
|
// --- Gateway response cache check ---
|
|
146
149
|
const settings = getSettings();
|
|
@@ -319,6 +322,7 @@ export class GatewayRunner {
|
|
|
319
322
|
httpStatus: lastError ? lastError.status : 200,
|
|
320
323
|
errorType: lastError?.type ?? null,
|
|
321
324
|
errorMessage: lastError ? redactString(lastError.message) : null,
|
|
325
|
+
errorCode: lastError?.code ?? null,
|
|
322
326
|
text: lastError ? null : resultText,
|
|
323
327
|
toolCalls: lastError ? null : resultToolCalls,
|
|
324
328
|
finishReason: lastError ? null : resultFinishReason,
|
|
@@ -367,52 +371,43 @@ export class GatewayRunner {
|
|
|
367
371
|
metrics.activeRequests.dec();
|
|
368
372
|
}
|
|
369
373
|
}
|
|
374
|
+
/** Resolve a direct physical model into a candidate, reporting the exact
|
|
375
|
+
* reason it cannot serve the request instead of a blanket "no candidates". */
|
|
370
376
|
async loadModelCandidate(modelId, required, requestId) {
|
|
371
377
|
const db = getDb();
|
|
372
|
-
const reject = (reason) => {
|
|
378
|
+
const reject = (publicModelId, reason) => {
|
|
373
379
|
if (requestId)
|
|
374
380
|
debugHttp(requestId, 'CAPABILITY REJECT', [`modelId=${modelId}`, `reason=${reason}`]);
|
|
381
|
+
return [{ publicModelId, reason }];
|
|
375
382
|
};
|
|
376
383
|
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
|
-
}
|
|
384
|
+
if (!m)
|
|
385
|
+
return { candidates: [], rejected: reject(modelId, 'model_not_found') };
|
|
381
386
|
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
|
-
}
|
|
387
|
+
if (!p)
|
|
388
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'provider_not_found') };
|
|
389
|
+
if (!p.enabled)
|
|
390
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'provider_disabled') };
|
|
390
391
|
const caps = safeJson(m.capabilitiesJson);
|
|
391
392
|
const candidate = {
|
|
392
393
|
modelId: m.id,
|
|
393
394
|
publicModelId: m.publicModelId,
|
|
394
395
|
providerId: m.providerId,
|
|
395
396
|
enabled: m.enabled,
|
|
397
|
+
providerEnabled: p.enabled,
|
|
396
398
|
upstreamAvailable: m.upstreamAvailable,
|
|
397
399
|
circuitOpen: isOpen(m.providerId),
|
|
398
400
|
capabilities: caps,
|
|
399
401
|
};
|
|
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
|
-
}
|
|
402
|
+
if (!m.enabled)
|
|
403
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'model_disabled') };
|
|
404
|
+
if (!m.upstreamAvailable)
|
|
405
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'upstream_unavailable') };
|
|
406
|
+
if (candidate.circuitOpen)
|
|
407
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'circuit_open') };
|
|
408
|
+
const missing = firstMissingCapability(caps, required);
|
|
409
|
+
if (missing)
|
|
410
|
+
return { candidates: [], rejected: reject(m.publicModelId, missing) };
|
|
416
411
|
debugHttp(requestId ?? '-', 'CAPABILITY CANDIDATE', [
|
|
417
412
|
`model=${m.publicModelId}`,
|
|
418
413
|
`caps.tools=${caps.tools}`,
|
|
@@ -421,28 +416,34 @@ export class GatewayRunner {
|
|
|
421
416
|
`caps.image_input=${caps.image_input}`,
|
|
422
417
|
`caps.structured_output=${caps.structured_output}`,
|
|
423
418
|
]);
|
|
419
|
+
// Codex providers route through their account pool: one candidate per
|
|
420
|
+
// eligible account, so fallback can move between accounts of the same model.
|
|
424
421
|
if (p.type === 'codex') {
|
|
425
422
|
const expanded = expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(p.id));
|
|
426
423
|
if (expanded.length === 0)
|
|
427
|
-
reject('codex_account_unavailable');
|
|
428
|
-
return expanded;
|
|
424
|
+
return { candidates: [], rejected: reject(m.publicModelId, 'codex_account_unavailable') };
|
|
425
|
+
return { candidates: expanded, rejected: [] };
|
|
429
426
|
}
|
|
430
|
-
return [candidate];
|
|
427
|
+
return { candidates: [candidate], rejected: [] };
|
|
431
428
|
}
|
|
432
429
|
async loadAllModels() {
|
|
433
430
|
const db = getDb();
|
|
434
431
|
const models = db.select().from(schema.models).all();
|
|
435
432
|
const providers = db.select().from(schema.providers).all();
|
|
436
433
|
const providerEnabled = new Map(providers.map((p) => [p.id, p.enabled]));
|
|
434
|
+
// Deliberately unfiltered: every combo member must reach selectCandidates so
|
|
435
|
+
// it can report WHY it was skipped. Pre-filtering here erased the model rows
|
|
436
|
+
// and turned every distinct reason into "model not found".
|
|
437
437
|
return models.map((m) => ({
|
|
438
438
|
modelId: m.id,
|
|
439
439
|
publicModelId: m.publicModelId,
|
|
440
440
|
providerId: m.providerId,
|
|
441
441
|
enabled: m.enabled,
|
|
442
|
+
providerEnabled: providerEnabled.get(m.providerId),
|
|
442
443
|
upstreamAvailable: m.upstreamAvailable,
|
|
443
444
|
circuitOpen: isOpen(m.providerId),
|
|
444
445
|
capabilities: safeJson(m.capabilitiesJson),
|
|
445
|
-
}))
|
|
446
|
+
}));
|
|
446
447
|
}
|
|
447
448
|
async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
|
|
448
449
|
if (req.canonical.stream) {
|
|
@@ -468,20 +469,21 @@ export class GatewayRunner {
|
|
|
468
469
|
call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload, ctx.requestId);
|
|
469
470
|
}
|
|
470
471
|
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 } });
|
|
472
|
+
throw upstreamHttpError(call.status, redactString(call.text.slice(0, 300)), { status: call.status });
|
|
478
473
|
}
|
|
479
474
|
let parsed;
|
|
480
475
|
try {
|
|
481
476
|
parsed = JSON.parse(call.text);
|
|
482
477
|
}
|
|
483
478
|
catch {
|
|
484
|
-
throw new GatewayError('upstream_error',
|
|
479
|
+
throw new GatewayError('upstream_error', `Upstream returned invalid JSON for "${bareModelName(candidate.publicModelId)}"`, { status: 502, code: 'upstream_bad_response' });
|
|
480
|
+
}
|
|
481
|
+
// A 200 carrying the wrong shape used to explode into a raw
|
|
482
|
+
// "Cannot read properties of undefined (reading '0')" that reached the client
|
|
483
|
+
// verbatim. Name the model and the missing field instead.
|
|
484
|
+
const expectedField = cfg.type === 'openai' ? 'choices' : 'content';
|
|
485
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed[expectedField])) {
|
|
486
|
+
throw new GatewayError('upstream_error', `Upstream response for "${bareModelName(candidate.publicModelId)}" has no "${expectedField}" array`, { status: 502, code: 'upstream_bad_response' });
|
|
485
487
|
}
|
|
486
488
|
let result;
|
|
487
489
|
let usage;
|
|
@@ -813,6 +815,7 @@ export class GatewayRunner {
|
|
|
813
815
|
httpStatus: 200,
|
|
814
816
|
errorType: null,
|
|
815
817
|
errorMessage: null,
|
|
818
|
+
errorCode: null,
|
|
816
819
|
text,
|
|
817
820
|
toolCalls,
|
|
818
821
|
finishReason,
|
|
@@ -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);
|
|
@@ -83,12 +83,34 @@ function inferOpenAICapabilities(id) {
|
|
|
83
83
|
chat: true,
|
|
84
84
|
streaming: true,
|
|
85
85
|
tools: !(lower.includes('embedding') || lower.includes('whisper') || lower.includes('dall-e') || lower.includes('tts')),
|
|
86
|
-
image_input
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
// image_input / structured_output / reasoning are intentionally omitted.
|
|
87
|
+
// A guessed `false` is read as "known unsupported" and hard-rejects matching
|
|
88
|
+
// requests (docs/04 §capability filtering); docs/00 requires unknown
|
|
89
|
+
// capabilities to stay unknown, so leave them undefined (undefined = allow).
|
|
90
|
+
// ponytail: name-based guessing was wrong for most ids (e.g. qwen-vl-max,
|
|
91
|
+
// claude-sonnet-4-5). Upgrade path: a real /models metadata probe, or the
|
|
92
|
+
// admin capability override UI.
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
function stripSlash(u) {
|
|
92
96
|
return u.endsWith('/') ? u.slice(0, -1) : u;
|
|
93
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Merge freshly discovered capabilities into a stored model record.
|
|
100
|
+
*
|
|
101
|
+
* `baseline` is what discovery last wrote for this model. An admin edit is by
|
|
102
|
+
* definition a divergence from that baseline, so those keys survive a
|
|
103
|
+
* re-import; everything else is refreshed (this is what clears stale guesses
|
|
104
|
+
* such as `image_input: false`). A record with no baseline predates this
|
|
105
|
+
* tracking, so it is refreshed wholesale rather than preserved blindly.
|
|
106
|
+
*/
|
|
107
|
+
export function mergeDiscoveredCapabilities(stored, baseline, discovered) {
|
|
108
|
+
const overrides = {};
|
|
109
|
+
if (baseline) {
|
|
110
|
+
for (const [k, v] of Object.entries(stored))
|
|
111
|
+
if (stored[k] !== baseline[k])
|
|
112
|
+
overrides[k] = v;
|
|
113
|
+
}
|
|
114
|
+
return { capabilities: { ...discovered, ...overrides }, baseline: discovered };
|
|
115
|
+
}
|
|
94
116
|
export { buildHeaders, fetchWithTimeout, stripSlash };
|
|
@@ -35,6 +35,55 @@ function comboSlug(input) {
|
|
|
35
35
|
.replace(/[^a-z0-9._-]+/g, '')
|
|
36
36
|
.slice(0, 64) || 'item');
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* A combo's slug and public id both have to be unique, and `slug` is NOT
|
|
40
|
+
* derivable from `public_model_id` (a slugless combo has slug "beta" and id
|
|
41
|
+
* "beta"; a slugged one has slug "beta" and id "combo/beta"). Checking only the
|
|
42
|
+
* public id lets a new name collide with an existing *slug*: the insert then
|
|
43
|
+
* dies as a raw SQLITE_CONSTRAINT and reaches the admin as an opaque
|
|
44
|
+
* 500 "Gateway error".
|
|
45
|
+
*/
|
|
46
|
+
function assertComboIdFree(db, slug, publicModelId, excludeId) {
|
|
47
|
+
const clash = db
|
|
48
|
+
.select()
|
|
49
|
+
.from(schema.combos)
|
|
50
|
+
.where(sql `public_model_id = ${publicModelId} OR slug = ${slug}`)
|
|
51
|
+
.all()
|
|
52
|
+
.find((c) => c.id !== excludeId);
|
|
53
|
+
if (clash)
|
|
54
|
+
throw new GatewayError('invalid_request_error', 'Combo ID already in use', { status: 400 });
|
|
55
|
+
if (db.select().from(schema.models).where(eq(schema.models.publicModelId, publicModelId)).get()) {
|
|
56
|
+
throw new GatewayError('invalid_request_error', `A model with ID "${publicModelId}" already exists`, { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Slug triple the route / slug triple the runtime resolver expects. */
|
|
60
|
+
function comboIds(name, slug) {
|
|
61
|
+
const s = comboSlug(slug || name);
|
|
62
|
+
return { slug: s, publicModelId: slug ? `combo/${s}` : s };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `combo_members` is UNIQUE(combo_id, model_id) and the admin UI's member rows
|
|
66
|
+
* are the payload of record, so two rows for one model collapse silently. Reject
|
|
67
|
+
* that here: a member list is a set, and telling the operator beats a lost row.
|
|
68
|
+
*/
|
|
69
|
+
function assertMembersUsable(db, members) {
|
|
70
|
+
const ids = members.map((m) => m.modelId);
|
|
71
|
+
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
72
|
+
if (dupes.length > 0) {
|
|
73
|
+
throw new GatewayError('invalid_request_error', `Duplicate members: ${[...new Set(dupes)].join(', ')}`, { status: 400 });
|
|
74
|
+
}
|
|
75
|
+
assertModelsExist(db, members);
|
|
76
|
+
}
|
|
77
|
+
function assertModelsExist(db, members) {
|
|
78
|
+
const models = db
|
|
79
|
+
.select()
|
|
80
|
+
.from(schema.models)
|
|
81
|
+
.where(sql `id IN (${sql.join(members.map((m) => sql `${m.modelId}`), sql `, `)})`)
|
|
82
|
+
.all();
|
|
83
|
+
if (models.length !== members.length) {
|
|
84
|
+
throw new GatewayError('invalid_request_error', 'One or more members are not valid physical models', { status: 400 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
38
87
|
export async function registerComboRoutes(app) {
|
|
39
88
|
app.addHook('preHandler', requireAdminAuth);
|
|
40
89
|
app.get('/api/admin/combos', async () => {
|
|
@@ -92,41 +141,35 @@ export async function registerComboRoutes(app) {
|
|
|
92
141
|
const body = ComboCreate.parse(req.body);
|
|
93
142
|
const db = getDb();
|
|
94
143
|
// No slug given → the public id IS the normalized name (no "combo/" prefix).
|
|
95
|
-
const slug =
|
|
96
|
-
const publicModelId = body.slug ? `combo/${slug}` : slug;
|
|
144
|
+
const { slug, publicModelId } = comboIds(body.name, body.slug);
|
|
97
145
|
// The id must be globally unique across combos AND physical models — the
|
|
98
146
|
// resolver treats every name as one routing surface.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
if (db.select().from(schema.models).where(eq(schema.models.publicModelId, publicModelId)).get()) {
|
|
103
|
-
throw new GatewayError('invalid_request_error', `A model with ID "${publicModelId}" already exists`, { status: 400 });
|
|
104
|
-
}
|
|
105
|
-
// Verify all referenced models exist and are physical
|
|
106
|
-
const modelIds = body.members.map((m) => m.modelId);
|
|
107
|
-
const models = db.select().from(schema.models).where(sql `id IN (${sql.join(modelIds.map((id) => sql `${id}`), sql `, `)})`).all();
|
|
108
|
-
if (models.length !== new Set(modelIds).size)
|
|
109
|
-
throw new GatewayError('invalid_request_error', 'One or more members are not valid physical models', { status: 400 });
|
|
147
|
+
assertComboIdFree(db, slug, publicModelId);
|
|
148
|
+
assertMembersUsable(db, body.members);
|
|
110
149
|
const id = uuid();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
// ponytail: one transaction — a combo row without its members is unroutable,
|
|
151
|
+
// and a half-applied create used to burn the name and report only "Gateway error".
|
|
152
|
+
db.transaction((tx) => {
|
|
153
|
+
tx.insert(schema.combos).values({
|
|
154
|
+
id,
|
|
155
|
+
name: body.name,
|
|
156
|
+
slug,
|
|
157
|
+
publicModelId,
|
|
158
|
+
mode: body.mode,
|
|
159
|
+
enabled: body.enabled ?? true,
|
|
160
|
+
maxTotalAttempts: body.maxTotalAttempts ?? 3,
|
|
161
|
+
fallbackOnConnection: body.fallbackOnConnection ?? true,
|
|
162
|
+
fallbackOnConnectTimeout: body.fallbackOnConnectTimeout ?? true,
|
|
163
|
+
fallbackOnFirstTokenTimeout: body.fallbackOnFirstTokenTimeout ?? true,
|
|
164
|
+
fallbackOn408: body.fallbackOn408 ?? true,
|
|
165
|
+
fallbackOn429: body.fallbackOn429 ?? true,
|
|
166
|
+
fallbackOn5xx: body.fallbackOn5xx ?? true,
|
|
167
|
+
configVersion: 1,
|
|
168
|
+
}).run();
|
|
169
|
+
for (const m of body.members) {
|
|
170
|
+
tx.insert(schema.comboMembers).values({ id: uuid(), comboId: id, modelId: m.modelId, position: m.position, weight: m.weight ?? 1, enabled: m.enabled ?? true }).run();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
130
173
|
recordAudit({ action: 'combo.create', success: true, targetType: 'combo', targetId: id, targetName: body.name, ip: req.ip, metadata: { members: body.members.length, mode: body.mode } });
|
|
131
174
|
return { id, slug, publicModelId };
|
|
132
175
|
});
|
|
@@ -139,18 +182,17 @@ export async function registerComboRoutes(app) {
|
|
|
139
182
|
const update = { updatedAt: new Date().toISOString(), configVersion: c.configVersion + 1 };
|
|
140
183
|
if (body.name)
|
|
141
184
|
update.name = body.name;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
185
|
+
// Same rule as creation: no slug → the id IS the (normalized) name, slug →
|
|
186
|
+
// combo/<slug>. Deliberately no `|| body.name || c.name` fallback: a request
|
|
187
|
+
// carrying neither field would otherwise re-derive the id from the current
|
|
188
|
+
// name and silently rename the combo (or degrade it to "item").
|
|
189
|
+
if (body.name !== undefined || body.slug !== undefined) {
|
|
190
|
+
const { slug, publicModelId } = comboIds(body.name ?? c.name, body.slug);
|
|
191
|
+
if (slug !== c.slug || publicModelId !== c.publicModelId) {
|
|
192
|
+
assertComboIdFree(db, slug, publicModelId, body.id);
|
|
193
|
+
update.slug = slug;
|
|
194
|
+
update.publicModelId = publicModelId;
|
|
151
195
|
}
|
|
152
|
-
update.slug = slug;
|
|
153
|
-
update.publicModelId = publicModelId;
|
|
154
196
|
}
|
|
155
197
|
if (body.mode)
|
|
156
198
|
update.mode = body.mode;
|
|
@@ -170,12 +212,20 @@ export async function registerComboRoutes(app) {
|
|
|
170
212
|
update.fallbackOn429 = body.fallbackOn429;
|
|
171
213
|
if (body.fallbackOn5xx !== undefined)
|
|
172
214
|
update.fallbackOn5xx = body.fallbackOn5xx;
|
|
173
|
-
db.update(schema.combos).set(update).where(eq(schema.combos.id, body.id)).run();
|
|
174
215
|
if (body.members) {
|
|
175
|
-
db
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
216
|
+
assertMembersUsable(db, body.members);
|
|
217
|
+
// Replace members inside one transaction: the delete-then-insert used to
|
|
218
|
+
// leave the combo memberless (and so unroutable) if any insert failed.
|
|
219
|
+
db.transaction((tx) => {
|
|
220
|
+
tx.update(schema.combos).set(update).where(eq(schema.combos.id, body.id)).run();
|
|
221
|
+
tx.delete(schema.comboMembers).where(eq(schema.comboMembers.comboId, body.id)).run();
|
|
222
|
+
for (const m of body.members) {
|
|
223
|
+
tx.insert(schema.comboMembers).values({ id: uuid(), comboId: body.id, modelId: m.modelId, position: m.position, weight: m.weight ?? 1, enabled: m.enabled ?? true }).run();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
db.update(schema.combos).set(update).where(eq(schema.combos.id, body.id)).run();
|
|
179
229
|
}
|
|
180
230
|
// Invalidate cache for this combo
|
|
181
231
|
const { invalidateCacheFor } = await import('../../caching/store.js');
|