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 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
@@ -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, modelMeets } from '../routing/capabilities.js';
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
- let selectionReasons = [];
101
+ const selectionReasons = [];
102
+ const rejected = [];
102
103
  if (resolved.kind === 'model') {
103
- candidates = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
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
- throw new GatewayError('capability_not_supported', 'No combo member satisfies the request capabilities or availability', { status: 400 });
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
- throw new GatewayError('upstream_unavailable', 'No available model candidates', { status: 502 });
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
- return [];
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
- return [];
391
- }
392
- if (!m.upstreamAvailable) {
393
- reject('upstream_unavailable');
394
- return [];
395
- }
396
- if (candidate.circuitOpen) {
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
- })).filter((m) => m.enabled && m.upstreamAvailable && providerEnabled.get(m.providerId));
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
- if (call.status === 429)
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', 'Upstream returned invalid JSON', { status: 502 });
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
- out.push({ type: 'image', image: { url: b.source.data } });
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', media_type: 'image/png', data: b.image.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
- if (c.type === 'text' && c.text)
24
- blocks.push({ type: 'text', text: c.text });
25
- else if (c.type === 'image_url' && c.image_url)
26
- blocks.push({ type: 'image', image: { url: c.image_url.url } });
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
- if (b.type === 'text' && b.text)
196
- out.push({ type: 'text', text: b.text });
197
- if (b.type === 'image_url' && b.image_url)
198
- out.push({ type: 'image', image: { url: b.image_url.url } });
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: lower.includes('vision') || lower.includes('gpt-4o') || lower.includes('4-vision') || lower.includes('claude'),
82
- structured_output: lower.includes('gpt-4') || lower.includes('gpt-3.5') || lower.includes('o1') || lower.includes('claude'),
83
- reasoning: lower.includes('o1') || lower.includes('o3') || lower.includes('reasoning'),
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 = comboSlug(body.slug ?? body.name);
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
- if (db.select().from(schema.combos).where(eq(schema.combos.publicModelId, publicModelId)).get()) {
100
- throw new GatewayError('invalid_request_error', 'Combo ID already in use', { status: 400 });
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
- db.insert(schema.combos).values({
112
- id,
113
- name: body.name,
114
- slug,
115
- publicModelId,
116
- mode: body.mode,
117
- enabled: body.enabled ?? true,
118
- maxTotalAttempts: body.maxTotalAttempts ?? 3,
119
- fallbackOnConnection: body.fallbackOnConnection ?? true,
120
- fallbackOnConnectTimeout: body.fallbackOnConnectTimeout ?? true,
121
- fallbackOnFirstTokenTimeout: body.fallbackOnFirstTokenTimeout ?? true,
122
- fallbackOn408: body.fallbackOn408 ?? true,
123
- fallbackOn429: body.fallbackOn429 ?? true,
124
- fallbackOn5xx: body.fallbackOn5xx ?? true,
125
- configVersion: 1,
126
- }).run();
127
- for (const m of body.members) {
128
- db.insert(schema.comboMembers).values({ id: uuid(), comboId: id, modelId: m.modelId, position: m.position, weight: m.weight ?? 1, enabled: m.enabled ?? true }).run();
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
- if (body.slug !== undefined) {
143
- // Same rule as creation: empty slug plain id, provided slug → combo/<slug>.
144
- const slug = comboSlug(body.slug || body.name || c.name);
145
- const publicModelId = body.slug ? `combo/${slug}` : slug;
146
- const clashCombo = db.select().from(schema.combos).where(eq(schema.combos.publicModelId, publicModelId)).get();
147
- if (clashCombo && clashCombo.id !== body.id)
148
- throw new GatewayError('invalid_request_error', 'Combo ID already in use', { status: 400 });
149
- if (db.select().from(schema.models).where(eq(schema.models.publicModelId, publicModelId)).get()) {
150
- throw new GatewayError('invalid_request_error', `A model with ID "${publicModelId}" already exists`, { status: 400 });
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.delete(schema.comboMembers).where(eq(schema.comboMembers.comboId, body.id)).run();
176
- for (const m of body.members) {
177
- db.insert(schema.comboMembers).values({ id: uuid(), comboId: body.id, modelId: m.modelId, position: m.position, weight: m.weight ?? 1, enabled: m.enabled ?? true }).run();
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
- capabilities: z.record(z.any()).optional(),
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
- db.update(schema.models).set({ upstreamAvailable: true, lastSeenUpstreamAt: now, updatedAt: now }).where(eq(schema.models.id, existing.id)).run();
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 = { ...safeJson(m.capabilitiesJson), ...body.capabilities };
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;