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 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
@@ -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, modelMeets } from '../routing/capabilities.js';
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
- let selectionReasons = [];
103
+ const selectionReasons = [];
104
+ const rejected = [];
104
105
  if (resolved.kind === 'model') {
105
- candidates = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
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
- throw new GatewayError('capability_not_supported', 'No combo member satisfies the request capabilities or availability', { status: 400 });
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
- throw new GatewayError('upstream_unavailable', 'No available model candidates', { status: 502 });
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
- return [];
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
- return [];
403
- }
404
- if (!m.upstreamAvailable) {
405
- reject('upstream_unavailable');
406
- return [];
407
- }
408
- if (candidate.circuitOpen) {
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
- })).filter((m) => m.enabled && m.upstreamAvailable && providerEnabled.get(m.providerId));
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
- if (call.status === 429)
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', 'Upstream returned invalid JSON', { status: 502 });
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
- 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);
@@ -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: lower.includes('vision') || lower.includes('gpt-4o') || lower.includes('4-vision') || lower.includes('claude'),
87
- structured_output: lower.includes('gpt-4') || lower.includes('gpt-3.5') || lower.includes('o1') || lower.includes('claude'),
88
- reasoning: lower.includes('o1') || lower.includes('o3') || lower.includes('reasoning'),
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 = 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');