ldrouter 1.16.2 → 1.16.3

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