ldrouter 1.14.1 → 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 +13 -0
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +54 -53
- 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 +73 -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-C5h2WXK5.css +1 -0
- package/dist/web/assets/index-CRnoua24.js +335 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CBMHkVXC.js +0 -330
- package/dist/web/assets/index-Dswaxg_c.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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.3] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **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`).
|
|
12
|
+
- **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.
|
|
13
|
+
- **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`.
|
|
14
|
+
- **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`).
|
|
15
|
+
- **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`.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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`.
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
7
20
|
## [1.14.1] - 2026-09-12
|
|
8
21
|
|
|
9
22
|
### 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,13 +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,
|
|
6
|
+
import { bareModelName, deriveRequiredCapabilities, describeRejections, firstMissingCapability } from '../routing/capabilities.js';
|
|
7
7
|
import { loadCombo, selectCandidates, orderCandidates, shouldFallback } from '../routing/combo.js';
|
|
8
8
|
import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
|
|
9
9
|
import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
|
|
10
10
|
import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
|
|
11
11
|
import { keyAllowedFor } from '../auth/api-key.js';
|
|
12
|
-
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl } from '../upstream/client.js';
|
|
12
|
+
import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl, upstreamHttpError } from '../upstream/client.js';
|
|
13
13
|
import { canonicalToOpenAIRequest, openAIResponseToCanonical } from '../protocols/canonical.js';
|
|
14
14
|
import { canonicalToAnthropicRequest, anthropicResponseToCanonical } from '../protocols/anthropic.js';
|
|
15
15
|
import { uuid } from '../auth/ids.js';
|
|
@@ -98,21 +98,22 @@ export class GatewayRunner {
|
|
|
98
98
|
]);
|
|
99
99
|
// --- Determine candidates ---
|
|
100
100
|
let candidates = [];
|
|
101
|
-
|
|
101
|
+
const selectionReasons = [];
|
|
102
|
+
const rejected = [];
|
|
102
103
|
if (resolved.kind === 'model') {
|
|
103
|
-
|
|
104
|
+
const loaded = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
|
|
105
|
+
candidates = loaded.candidates;
|
|
106
|
+
rejected.push(...loaded.rejected);
|
|
104
107
|
selectionReasons.push('direct_model');
|
|
105
|
-
if (candidates.length === 0) {
|
|
106
|
-
debugHttp(ctx.requestId, 'CAPABILITY REJECT', [
|
|
107
|
-
`model=${resolved.publicModelId}`,
|
|
108
|
-
`reason=direct_model_unavailable_or_capability_mismatch`,
|
|
109
|
-
'(direct model candidates rejected: not found / provider disabled / model disabled / upstream unavailable / circuit open / capability mismatch)',
|
|
110
|
-
]);
|
|
111
|
-
}
|
|
112
108
|
}
|
|
113
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
|
+
}
|
|
114
116
|
const all = await this.loadAllModels();
|
|
115
|
-
const rejected = [];
|
|
116
117
|
const filtered = selectCandidates(comboPlan, all, required, (c, reason) => {
|
|
117
118
|
rejected.push({ publicModelId: c.publicModelId, reason });
|
|
118
119
|
debugHttp(ctx.requestId, 'CAPABILITY REJECT', [`model=${c.publicModelId}`, `reason=${reason}`]);
|
|
@@ -125,7 +126,8 @@ export class GatewayRunner {
|
|
|
125
126
|
...rejected.map((r) => `rejected: ${r.publicModelId} reason=${r.reason}`),
|
|
126
127
|
]);
|
|
127
128
|
if (filtered.length === 0) {
|
|
128
|
-
|
|
129
|
+
const s = describeRejections({ kind: 'combo', publicModelId: resolved.publicModelId }, rejected);
|
|
130
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
129
131
|
}
|
|
130
132
|
candidates = orderCandidates(comboPlan, filtered);
|
|
131
133
|
debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
|
|
@@ -135,7 +137,8 @@ export class GatewayRunner {
|
|
|
135
137
|
selectionReasons.push('combo');
|
|
136
138
|
}
|
|
137
139
|
if (candidates.length === 0) {
|
|
138
|
-
|
|
140
|
+
const s = describeRejections({ kind: 'model', publicModelId: resolved.publicModelId }, rejected);
|
|
141
|
+
throw new GatewayError(s.type, s.message, { status: s.status, code: s.type });
|
|
139
142
|
}
|
|
140
143
|
// --- Gateway response cache check ---
|
|
141
144
|
const settings = getSettings();
|
|
@@ -307,6 +310,7 @@ export class GatewayRunner {
|
|
|
307
310
|
httpStatus: lastError ? lastError.status : 200,
|
|
308
311
|
errorType: lastError?.type ?? null,
|
|
309
312
|
errorMessage: lastError ? redactString(lastError.message) : null,
|
|
313
|
+
errorCode: lastError?.code ?? null,
|
|
310
314
|
text: lastError ? null : resultText,
|
|
311
315
|
toolCalls: lastError ? null : resultToolCalls,
|
|
312
316
|
finishReason: lastError ? null : resultFinishReason,
|
|
@@ -355,52 +359,43 @@ export class GatewayRunner {
|
|
|
355
359
|
metrics.activeRequests.dec();
|
|
356
360
|
}
|
|
357
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". */
|
|
358
364
|
async loadModelCandidate(modelId, required, requestId) {
|
|
359
365
|
const db = getDb();
|
|
360
|
-
const reject = (reason) => {
|
|
366
|
+
const reject = (publicModelId, reason) => {
|
|
361
367
|
if (requestId)
|
|
362
368
|
debugHttp(requestId, 'CAPABILITY REJECT', [`modelId=${modelId}`, `reason=${reason}`]);
|
|
369
|
+
return [{ publicModelId, reason }];
|
|
363
370
|
};
|
|
364
371
|
const m = db.select().from(schema.models).where(eq(schema.models.id, modelId)).get();
|
|
365
|
-
if (!m)
|
|
366
|
-
reject('model_not_found');
|
|
367
|
-
return [];
|
|
368
|
-
}
|
|
372
|
+
if (!m)
|
|
373
|
+
return { candidates: [], rejected: reject(modelId, 'model_not_found') };
|
|
369
374
|
const p = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
|
|
370
|
-
if (!p)
|
|
371
|
-
reject('provider_not_found');
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (!p.enabled) {
|
|
375
|
-
reject('provider_disabled');
|
|
376
|
-
return [];
|
|
377
|
-
}
|
|
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') };
|
|
378
379
|
const caps = safeJson(m.capabilitiesJson);
|
|
379
380
|
const candidate = {
|
|
380
381
|
modelId: m.id,
|
|
381
382
|
publicModelId: m.publicModelId,
|
|
382
383
|
providerId: m.providerId,
|
|
383
384
|
enabled: m.enabled,
|
|
385
|
+
providerEnabled: p.enabled,
|
|
384
386
|
upstreamAvailable: m.upstreamAvailable,
|
|
385
387
|
circuitOpen: isOpen(m.providerId),
|
|
386
388
|
capabilities: caps,
|
|
387
389
|
};
|
|
388
|
-
if (!m.enabled)
|
|
389
|
-
reject('model_disabled');
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (
|
|
393
|
-
reject('
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
reject('circuit_open');
|
|
398
|
-
return [];
|
|
399
|
-
}
|
|
400
|
-
if (!modelMeets(caps, required)) {
|
|
401
|
-
reject('capability_mismatch');
|
|
402
|
-
return [];
|
|
403
|
-
}
|
|
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) };
|
|
404
399
|
debugHttp(requestId ?? '-', 'CAPABILITY CANDIDATE', [
|
|
405
400
|
`model=${m.publicModelId}`,
|
|
406
401
|
`caps.tools=${caps.tools}`,
|
|
@@ -409,22 +404,26 @@ export class GatewayRunner {
|
|
|
409
404
|
`caps.image_input=${caps.image_input}`,
|
|
410
405
|
`caps.structured_output=${caps.structured_output}`,
|
|
411
406
|
]);
|
|
412
|
-
return [candidate];
|
|
407
|
+
return { candidates: [candidate], rejected: [] };
|
|
413
408
|
}
|
|
414
409
|
async loadAllModels() {
|
|
415
410
|
const db = getDb();
|
|
416
411
|
const models = db.select().from(schema.models).all();
|
|
417
412
|
const providers = db.select().from(schema.providers).all();
|
|
418
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".
|
|
419
417
|
return models.map((m) => ({
|
|
420
418
|
modelId: m.id,
|
|
421
419
|
publicModelId: m.publicModelId,
|
|
422
420
|
providerId: m.providerId,
|
|
423
421
|
enabled: m.enabled,
|
|
422
|
+
providerEnabled: providerEnabled.get(m.providerId),
|
|
424
423
|
upstreamAvailable: m.upstreamAvailable,
|
|
425
424
|
circuitOpen: isOpen(m.providerId),
|
|
426
425
|
capabilities: safeJson(m.capabilitiesJson),
|
|
427
|
-
}))
|
|
426
|
+
}));
|
|
428
427
|
}
|
|
429
428
|
async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
|
|
430
429
|
if (req.canonical.stream) {
|
|
@@ -446,20 +445,21 @@ export class GatewayRunner {
|
|
|
446
445
|
call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload, ctx.requestId);
|
|
447
446
|
}
|
|
448
447
|
if (!call.ok) {
|
|
449
|
-
|
|
450
|
-
throw new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${call.status})`, { status: 429, code: 'upstream_http_429' });
|
|
451
|
-
if (call.status === 401 || call.status === 403)
|
|
452
|
-
throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502 });
|
|
453
|
-
if (call.status >= 500)
|
|
454
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}`, { status: 502, code: `upstream_http_${call.status}` });
|
|
455
|
-
throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}: ${redactString(call.text.slice(0, 300))}`, { status: 502 });
|
|
448
|
+
throw upstreamHttpError(call.status, redactString(call.text.slice(0, 300)));
|
|
456
449
|
}
|
|
457
450
|
let parsed;
|
|
458
451
|
try {
|
|
459
452
|
parsed = JSON.parse(call.text);
|
|
460
453
|
}
|
|
461
454
|
catch {
|
|
462
|
-
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' });
|
|
463
463
|
}
|
|
464
464
|
let result;
|
|
465
465
|
let usage;
|
|
@@ -771,6 +771,7 @@ export class GatewayRunner {
|
|
|
771
771
|
httpStatus: 200,
|
|
772
772
|
errorType: null,
|
|
773
773
|
errorMessage: null,
|
|
774
|
+
errorCode: null,
|
|
774
775
|
text,
|
|
775
776
|
toolCalls,
|
|
776
777
|
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);
|
|
@@ -78,12 +78,34 @@ function inferOpenAICapabilities(id) {
|
|
|
78
78
|
chat: true,
|
|
79
79
|
streaming: true,
|
|
80
80
|
tools: !(lower.includes('embedding') || lower.includes('whisper') || lower.includes('dall-e') || lower.includes('tts')),
|
|
81
|
-
image_input
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
84
88
|
};
|
|
85
89
|
}
|
|
86
90
|
function stripSlash(u) {
|
|
87
91
|
return u.endsWith('/') ? u.slice(0, -1) : u;
|
|
88
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
|
+
}
|
|
89
111
|
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');
|
|
@@ -14,11 +14,24 @@ const ModelUpdate = z.object({
|
|
|
14
14
|
displayName: z.string().min(1).max(128).optional(),
|
|
15
15
|
enabled: z.boolean().optional(),
|
|
16
16
|
upstreamAvailable: z.boolean().optional(),
|
|
17
|
-
|
|
17
|
+
// Values may be true / false / null. null clears the key back to "unknown",
|
|
18
|
+
// which the router treats as "not verified" rather than "unsupported".
|
|
19
|
+
capabilities: z.record(z.union([z.boolean(), z.null()])).optional(),
|
|
18
20
|
cacheOverrideEnabled: z.boolean().nullable().optional(),
|
|
19
21
|
maxContextTokens: z.number().int().min(1).nullable().optional(),
|
|
20
22
|
maxOutputTokens: z.number().int().min(1).nullable().optional(),
|
|
21
23
|
});
|
|
24
|
+
/** Apply an admin capability override. `null` removes the key (= unknown). */
|
|
25
|
+
function applyCapabilityOverrides(stored, patch) {
|
|
26
|
+
const out = { ...stored };
|
|
27
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
28
|
+
if (v === null)
|
|
29
|
+
delete out[k];
|
|
30
|
+
else
|
|
31
|
+
out[k] = v;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
22
35
|
export async function registerModelRoutes(app) {
|
|
23
36
|
app.addHook('preHandler', requireAdminAuth);
|
|
24
37
|
app.get('/api/admin/models', async (req) => {
|
|
@@ -54,6 +67,7 @@ export async function registerModelRoutes(app) {
|
|
|
54
67
|
enabled: m.enabled,
|
|
55
68
|
upstreamAvailable: m.upstreamAvailable,
|
|
56
69
|
capabilities: safeJson(m.capabilitiesJson),
|
|
70
|
+
discoveredCapabilities: m.discoveredMetadataJson ? safeJson(m.discoveredMetadataJson) : null,
|
|
57
71
|
maxContextTokens: m.maxContextTokens,
|
|
58
72
|
maxOutputTokens: m.maxOutputTokens,
|
|
59
73
|
lastSeenUpstreamAt: m.lastSeenUpstreamAt,
|
|
@@ -69,7 +83,7 @@ export async function registerModelRoutes(app) {
|
|
|
69
83
|
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
70
84
|
// Fetch discovered model metadata fresh (so import uses current discovery data)
|
|
71
85
|
// For simplicity: re-discover and match by upstream id.
|
|
72
|
-
const { discoverProviderModels } = await import('../../providers/index.js');
|
|
86
|
+
const { discoverProviderModels, mergeDiscoveredCapabilities } = await import('../../providers/index.js');
|
|
73
87
|
const { decryptSecret, decryptCustomHeaders } = await import('../../auth/crypto.js');
|
|
74
88
|
const apiKey = decryptSecret({ ciphertext: provider.encryptedApiKey, nonce: provider.apiKeyNonce, version: provider.apiKeyVersion });
|
|
75
89
|
const headers = decryptCustomHeaders(provider.customHeadersEncrypted && provider.customHeadersNonce ? { ciphertext: provider.customHeadersEncrypted, nonce: provider.customHeadersNonce, version: 1 } : null);
|
|
@@ -88,7 +102,19 @@ export async function registerModelRoutes(app) {
|
|
|
88
102
|
const disc = discMap.get(upstreamId);
|
|
89
103
|
const caps = disc?.capabilities ?? { chat: true, streaming: true, tools: true };
|
|
90
104
|
if (existing) {
|
|
91
|
-
|
|
105
|
+
// Refresh capabilities so stale discoveries (e.g. a wrong
|
|
106
|
+
// `image_input: false`) heal on re-import, while admin edits survive.
|
|
107
|
+
const merged = mergeDiscoveredCapabilities(safeJson(existing.capabilitiesJson), existing.discoveredMetadataJson ? safeJson(existing.discoveredMetadataJson) : null, caps);
|
|
108
|
+
db.update(schema.models)
|
|
109
|
+
.set({
|
|
110
|
+
upstreamAvailable: true,
|
|
111
|
+
lastSeenUpstreamAt: now,
|
|
112
|
+
updatedAt: now,
|
|
113
|
+
capabilitiesJson: JSON.stringify(merged.capabilities),
|
|
114
|
+
discoveredMetadataJson: JSON.stringify(merged.baseline),
|
|
115
|
+
})
|
|
116
|
+
.where(eq(schema.models.id, existing.id))
|
|
117
|
+
.run();
|
|
92
118
|
continue;
|
|
93
119
|
}
|
|
94
120
|
const publicModelId = `${provider.slug}/${upstreamId}`;
|
|
@@ -101,6 +127,7 @@ export async function registerModelRoutes(app) {
|
|
|
101
127
|
enabled: true,
|
|
102
128
|
upstreamAvailable: true,
|
|
103
129
|
capabilitiesJson: JSON.stringify(caps),
|
|
130
|
+
discoveredMetadataJson: JSON.stringify(caps),
|
|
104
131
|
maxContextTokens: typeof caps.max_context_tokens === 'number' ? caps.max_context_tokens : null,
|
|
105
132
|
maxOutputTokens: typeof caps.max_output_tokens === 'number' ? caps.max_output_tokens : null,
|
|
106
133
|
lastSeenUpstreamAt: now,
|
|
@@ -124,7 +151,7 @@ export async function registerModelRoutes(app) {
|
|
|
124
151
|
if (body.upstreamAvailable !== undefined)
|
|
125
152
|
update.upstreamAvailable = body.upstreamAvailable;
|
|
126
153
|
if (body.capabilities) {
|
|
127
|
-
const merged =
|
|
154
|
+
const merged = applyCapabilityOverrides(safeJson(m.capabilitiesJson), body.capabilities);
|
|
128
155
|
update.capabilitiesJson = JSON.stringify(merged);
|
|
129
156
|
if (typeof merged.max_context_tokens === 'number')
|
|
130
157
|
update.maxContextTokens = merged.max_context_tokens;
|