ldrouter 1.17.8 → 1.18.0

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.
@@ -1,20 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  import { sql, eq } from 'drizzle-orm';
3
- import { getDb, getRawDb, schema } from '../../db/index.js';
3
+ import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
5
  import { recordAudit } from '../../db/repositories/audit.js';
6
6
  import { encryptSecret, decryptSecret, encryptCustomHeaders, decryptCustomHeaders, isMasterKeyConfigured } from '../../auth/crypto.js';
7
7
  import { uuid, slugify } from '../../auth/ids.js';
8
8
  import { GatewayError } from '../../errors.js';
9
9
  import { probeProvider, discoverProviderModels } from '../../providers/index.js';
10
- import { getPooledProvider } from '../../providers/pooled.js';
11
- import { redactString } from '../../security/redact.js';
12
10
  const ProviderCreate = z.object({
13
- // A pool provider is nothing but a group: the admin clicks once and the server owns every field.
14
- // Compatible providers still need name, endpoint, and credential up front (checked in the handler).
11
+ // Providers use one encrypted API key and a compatible HTTP endpoint.
15
12
  name: z.string().min(1).max(128).optional(),
16
13
  slug: z.string().min(1).max(64).optional(),
17
- type: z.enum(['openai', 'anthropic', 'codex', 'qoder']),
14
+ type: z.enum(['openai', 'anthropic']),
18
15
  baseUrl: z.string().url().max(512).optional(),
19
16
  apiKey: z.string().min(1).max(20000).optional(),
20
17
  customHeaders: z.record(z.string(), z.string()).optional(),
@@ -51,7 +48,7 @@ export async function registerProviderRoutes(app) {
51
48
  app.addHook('preHandler', requireAdminAuth);
52
49
  app.get('/api/admin/providers', async () => {
53
50
  const db = getDb();
54
- const providers = db.select().from(schema.providers).all();
51
+ const providers = db.select().from(schema.providers).all().filter((provider) => provider.type === 'openai' || provider.type === 'anthropic');
55
52
  const modelCounts = db
56
53
  .select({ providerId: schema.models.providerId, c: sql `COUNT(*)` })
57
54
  .from(schema.models)
@@ -64,17 +61,13 @@ export async function registerProviderRoutes(app) {
64
61
  });
65
62
  app.post('/api/admin/providers', async (req) => {
66
63
  const body = ProviderCreate.parse(req.body);
67
- const pool = getPooledProvider(body.type);
68
- const defaults = pool?.defaults;
69
- const name = body.name ?? defaults?.name;
70
- // Pool defaults win over the request: a stored Codex base URL pointing anywhere but
71
- // chatgpt.com makes every upstream call 404 (see CODEX_BASE_URL).
72
- const baseUrl = defaults?.baseUrl ?? body.baseUrl;
64
+ const name = body.name;
65
+ const baseUrl = body.baseUrl;
73
66
  if (!name)
74
67
  throw new GatewayError('invalid_request_error', 'Provider name is required', { status: 400 });
75
68
  // apiKey before baseUrl: a compatible provider created without either must be told the
76
69
  // credential is missing (the UI always supplies baseUrl from its dialog default).
77
- if (!pool && !body.apiKey) {
70
+ if (!body.apiKey) {
78
71
  throw new GatewayError('invalid_request_error', 'API key is required for this provider type', { status: 400 });
79
72
  }
80
73
  if (!baseUrl)
@@ -84,14 +77,9 @@ export async function registerProviderRoutes(app) {
84
77
  const slug = slugify(body.slug ?? name);
85
78
  const dup = db.select().from(schema.providers).where(eq(schema.providers.slug, slug)).get();
86
79
  if (dup) {
87
- // Pool path carries the code spec §5.7 names, so the UI can render "already added".
88
- if (pool)
89
- throw new GatewayError('invalid_request_error', `A ${dup.name} provider already exists ('${dup.slug}')`, { status: 400, code: 'slug_taken' });
90
80
  throw new GatewayError('invalid_request_error', `Provider slug '${slug}' is already in use`, { status: 400 });
91
81
  }
92
- // A pool type has no API-key field (spec §5.7): a key sent with `{type}` must not be encrypted
93
- // onto the row.
94
- const enc = !pool && body.apiKey ? encryptSecret(body.apiKey) : null;
82
+ const enc = encryptSecret(body.apiKey);
95
83
  const headersEnc = body.customHeaders ? encryptCustomHeaders(body.customHeaders) : null;
96
84
  const id = uuid();
97
85
  db.insert(schema.providers).values({
@@ -125,15 +113,12 @@ export async function registerProviderRoutes(app) {
125
113
  if (!p)
126
114
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
127
115
  const update = { updatedAt: new Date().toISOString() };
128
- // A pool type has no API-key field (spec §5.7). Same rule as the create path: skip the whole
129
- // encryption block, so an existing key on the row is not disturbed either.
130
- const pool = getPooledProvider(p.type);
131
116
  if (body.name)
132
117
  update.name = body.name;
133
118
  if (body.slug)
134
119
  update.slug = slugify(body.slug);
135
120
  if (body.baseUrl)
136
- update.baseUrl = pool?.defaults.baseUrl ?? body.baseUrl;
121
+ update.baseUrl = body.baseUrl;
137
122
  if (body.enabled !== undefined)
138
123
  update.enabled = body.enabled;
139
124
  if (body.connectTimeoutMs !== undefined)
@@ -150,7 +135,7 @@ export async function registerProviderRoutes(app) {
150
135
  update.cbFailureThreshold = body.cbFailureThreshold;
151
136
  if (body.cbCooldownSeconds !== undefined)
152
137
  update.cbCooldownSeconds = body.cbCooldownSeconds;
153
- if (body.apiKey && !pool) {
138
+ if (body.apiKey) {
154
139
  const enc = encryptSecret(body.apiKey);
155
140
  update.encryptedApiKey = enc.ciphertext;
156
141
  update.apiKeyNonce = enc.nonce;
@@ -178,25 +163,14 @@ export async function registerProviderRoutes(app) {
178
163
  recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
179
164
  return { ok: true, softDisabled: true };
180
165
  }
181
- // A pool provider owns its account pool. <table>.provider_id is ON DELETE RESTRICT, so the
182
- // accounts must go in the same transaction or the delete fails with a raw SQLite constraint
183
- // error (which surfaces as an opaque 500 "Gateway error").
184
- const pool = getPooledProvider(p.type);
185
- let pooledAccountsDeleted = 0;
186
166
  try {
187
- getRawDb().transaction(() => {
188
- // pool.accountsTable is a fixed registry literal, never user input.
189
- if (pool)
190
- pooledAccountsDeleted = getRawDb().prepare(`DELETE FROM ${pool.accountsTable} WHERE provider_id=?`).run(id).changes;
191
- db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
192
- })();
167
+ db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
193
168
  }
194
169
  catch (error) {
195
170
  throw new GatewayError('invalid_request_error', 'Provider is still referenced and cannot be deleted', { status: 409, cause: error });
196
171
  }
197
- recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { pooledAccountsDeleted } });
198
- // codexAccountsDeleted kept: pre-existing clients and tests read that field name.
199
- return { ok: true, pooledAccountsDeleted, codexAccountsDeleted: pooledAccountsDeleted };
172
+ recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
173
+ return { ok: true };
200
174
  });
201
175
  app.post('/api/admin/providers/:id/test', async (req) => {
202
176
  const { id } = req.params;
@@ -204,13 +178,6 @@ export async function registerProviderRoutes(app) {
204
178
  const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
205
179
  if (!p)
206
180
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
207
- const pool = getPooledProvider(p.type);
208
- if (pool) {
209
- const result = await pool.probe({ id: p.id, baseUrl: p.baseUrl, totalTimeoutMs: p.totalTimeoutMs });
210
- db.update(schema.providers).set({ healthState: result.ok ? 'healthy' : 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
211
- recordAudit({ action: 'provider.test', success: result.ok, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { detail: redactString(result.detail) } });
212
- return { ...result, detail: redactString(result.detail) };
213
- }
214
181
  if (!p.encryptedApiKey || !p.apiKeyNonce)
215
182
  throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
216
183
  const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
@@ -239,24 +206,18 @@ export async function registerProviderRoutes(app) {
239
206
  if (!p)
240
207
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
241
208
  let discovered;
242
- const pool = getPooledProvider(p.type);
243
- if (pool) {
244
- discovered = await pool.discover({ id: p.id, baseUrl: p.baseUrl, totalTimeoutMs: p.totalTimeoutMs });
245
- }
246
- else {
247
- if (!p.encryptedApiKey || !p.apiKeyNonce)
248
- throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
249
- const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
250
- const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
251
- discovered = await discoverProviderModels({
252
- type: p.type,
253
- baseUrl: p.baseUrl,
254
- apiKey,
255
- customHeaders: headers,
256
- connectTimeoutMs: 5000,
257
- totalTimeoutMs: 30000,
258
- });
259
- }
209
+ if (!p.encryptedApiKey || !p.apiKeyNonce)
210
+ throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
211
+ const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
212
+ const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
213
+ discovered = await discoverProviderModels({
214
+ type: p.type,
215
+ baseUrl: p.baseUrl,
216
+ apiKey,
217
+ customHeaders: headers,
218
+ connectTimeoutMs: 5000,
219
+ totalTimeoutMs: 30000,
220
+ });
260
221
  const existing = db
261
222
  .select()
262
223
  .from(schema.models)
@@ -32,9 +32,12 @@ export function toSummary(r, maps) {
32
32
  endpoint: r.endpoint,
33
33
  requestedModel: r.requestedModel,
34
34
  resolvedTargetKind: r.resolvedTargetKind,
35
+ resolvedTargetId: r.resolvedTargetId,
35
36
  finalModelPublicId: finalModel?.publicModelId ?? null,
37
+ finalModelUpstreamId: finalModel?.upstreamModelId ?? null,
36
38
  providerId: provider ? finalModel.providerId : null,
37
39
  providerName: provider?.name ?? null,
40
+ providerSlug: provider?.slug ?? null,
38
41
  providerType: provider?.type ?? null,
39
42
  streaming: Boolean(r.streaming),
40
43
  httpStatus: r.httpStatus,
@@ -53,6 +56,27 @@ export function toSummary(r, maps) {
53
56
  gatewayCacheHit: Boolean(r.gatewayCacheHit),
54
57
  };
55
58
  }
59
+ function quoteCurlJson(value) {
60
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
61
+ }
62
+ function buildReproductionCurl(request, redactedPayload) {
63
+ const endpoint = request.protocol === 'anthropic'
64
+ ? '/v1/messages'
65
+ : `/v1/${request.endpoint.replace(/^\/?v1\/?/, '')}`;
66
+ let body = { model: request.requestedModel };
67
+ if (redactedPayload) {
68
+ try {
69
+ const parsed = JSON.parse(redactedPayload);
70
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
71
+ body = { ...parsed, model: request.requestedModel };
72
+ }
73
+ catch { }
74
+ }
75
+ const headers = request.protocol === 'anthropic'
76
+ ? "-H 'x-api-key: REPLACE_WITH_LD_KEY' -H 'anthropic-version: 2023-06-01'"
77
+ : "-H 'Authorization: Bearer ld-REPLACE_WITH_KEY'";
78
+ return `curl http://localhost:8787${endpoint} -H 'content-type: application/json' ${headers} --data-raw ${quoteCurlJson(JSON.stringify(body))}`;
79
+ }
56
80
  export async function registerRequestRoutes(app) {
57
81
  app.addHook('preHandler', requireAdminAuth);
58
82
  app.get('/api/admin/requests', async (req) => {
@@ -205,7 +229,12 @@ export async function registerRequestRoutes(app) {
205
229
  endpoint: r.endpoint,
206
230
  requestedModel: r.requestedModel,
207
231
  resolvedTargetKind: r.resolvedTargetKind,
232
+ resolvedTargetId: r.resolvedTargetId,
208
233
  finalModelPublicId: finalModel?.publicModelId ?? null,
234
+ finalModelUpstreamId: finalModel?.upstreamModelId ?? null,
235
+ providerId: finalModel?.providerId ?? null,
236
+ providerName: providerMap.get(finalModel?.providerId ?? '')?.name ?? null,
237
+ providerSlug: providerMap.get(finalModel?.providerId ?? '')?.slug ?? null,
209
238
  streaming: Boolean(r.streaming),
210
239
  httpStatus: r.httpStatus,
211
240
  success: Boolean(r.success),
@@ -222,6 +251,8 @@ export async function registerRequestRoutes(app) {
222
251
  errorMessage: r.errorMessage,
223
252
  requestPayload: redactJsonString(r.requestPayloadJson),
224
253
  responsePayload: redactJsonString(r.responsePayloadJson),
254
+ reproductionCurl: buildReproductionCurl(r, redactJsonString(r.requestPayloadJson)),
255
+ fallbackReasons: [...new Set(attempts.map((a) => a.failureReason).filter((reason) => Boolean(reason)))],
225
256
  gatewayCacheHit: Boolean(r.gatewayCacheHit),
226
257
  },
227
258
  attempts: attempts.map((a) => ({
@@ -231,6 +262,7 @@ export async function registerRequestRoutes(app) {
231
262
  providerName: providerMap.get(a.providerId)?.name ?? '',
232
263
  modelId: a.modelId,
233
264
  modelPublicId: modelMap.get(a.modelId)?.publicModelId ?? '',
265
+ upstreamModelId: modelMap.get(a.modelId)?.upstreamModelId ?? '',
234
266
  codexAccountId: a.codexAccountId,
235
267
  startedAt: a.startedAt,
236
268
  completedAt: a.completedAt,
@@ -13,8 +13,6 @@ import { registerAuditRoutes } from './admin/audit.js';
13
13
  import { registerSettingsRoutes } from './admin/settings.js';
14
14
  import { registerBackupRoutes } from './admin/backup.js';
15
15
  import { registerDashboardRoutes } from './admin/dashboard.js';
16
- import { registerCodexRoutes, registerCodexOAuthCallbackRoute } from './admin/codex.js';
17
- import { registerQoderRoutes } from './admin/qoder.js';
18
16
  export async function registerAdminRoutes(app) {
19
17
  // Setup routes are always reachable (used on first run).
20
18
  await app.register(async (instance) => {
@@ -25,10 +23,6 @@ export async function registerAdminRoutes(app) {
25
23
  await app.register(async (instance) => {
26
24
  await registerAuthRoutes(instance);
27
25
  });
28
- // The Codex OAuth loopback callback is hit by the browser's redirect, so it is public too.
29
- await app.register(async (instance) => {
30
- await registerCodexOAuthCallbackRoute(instance);
31
- });
32
26
  // Authenticated admin routes
33
27
  await app.register(async (instance) => {
34
28
  instance.addHook('preHandler', requireAdminAuth);
@@ -46,8 +40,6 @@ export async function registerAdminRoutes(app) {
46
40
  await registerAuditRoutes(instance);
47
41
  await registerSettingsRoutes(instance);
48
42
  await registerDashboardRoutes(instance);
49
- await registerCodexRoutes(instance);
50
- await registerQoderRoutes(instance);
51
43
  });
52
44
  // Backup routes get their own scope. The first-run import (setup.tsx) must be reachable before
53
45
  // any admin account exists, but this scope's parent runs the other admin modules' auth hooks,
@@ -35,3 +35,16 @@ export function getEffectiveState(providerId, cooldownSeconds) {
35
35
  export function halfOpenProbeAllowed(providerId) {
36
36
  return circuitState(providerId) === 'half_open';
37
37
  }
38
+ /**
39
+ * Should the candidate filter refuse this provider outright?
40
+ *
41
+ * `isOpen()` reports the raw state, which stays `'open'` until a request walks the
42
+ * attempt loop — so a tripped breaker rejected every candidate for the lifetime of the
43
+ * process and made the provider unroutable until a restart (the combo path and the
44
+ * direct-model path both read the flag off the candidate record). A candidate must be
45
+ * allowed through once the cooldown has elapsed so the probe that closes the breaker
46
+ * can actually run; `getEffectiveState` is what performs that open -> half_open decay.
47
+ */
48
+ export function circuitBlocks(providerId, cooldownSeconds) {
49
+ return getEffectiveState(providerId, cooldownSeconds) === 'open';
50
+ }
@@ -1,4 +1,4 @@
1
- // Combo routing: fallback (ordered) or weighted round-robin.
1
+ // Combo routing: ordered fallback.
2
2
  import { eq } from 'drizzle-orm';
3
3
  import { getDb, schema } from '../db/index.js';
4
4
  import { firstMissingCapability } from './capabilities.js';
@@ -10,9 +10,9 @@ export function loadCombo(comboId) {
10
10
  const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, comboId)).all();
11
11
  return {
12
12
  comboId: c.id,
13
- mode: c.mode,
13
+ mode: 'fallback',
14
14
  maxTotalAttempts: c.maxTotalAttempts,
15
- members: members.map((m) => ({ id: m.id, modelId: m.modelId, position: m.position, weight: m.weight, enabled: m.enabled })),
15
+ members: members.map((m) => ({ id: m.id, modelId: m.modelId, position: m.position, enabled: m.enabled })),
16
16
  trigger: {
17
17
  connection: c.fallbackOnConnection,
18
18
  connectTimeout: c.fallbackOnConnectTimeout,
@@ -89,35 +89,12 @@ export function selectCandidates(combo, allModels, req, onReject) {
89
89
  return candidates;
90
90
  }
91
91
  export function orderCandidates(combo, candidates) {
92
- if (combo.mode === 'fallback') {
93
- // Preserve declared position order
94
- return [...candidates].sort((a, b) => {
95
- const am = combo.members.find((m) => m.modelId === a.modelId);
96
- const bm = combo.members.find((m) => m.modelId === b.modelId);
97
- return (am?.position ?? 0) - (bm?.position ?? 0);
98
- });
99
- }
100
- // Weighted round-robin: stable order with weighted lead bias.
101
- // We rotate via a process-local cursor keyed by combo id.
102
- const cursor = nextCursor(combo.comboId, combo.members, candidates);
103
- return cursor;
104
- }
105
- const comboCursors = new Map();
106
- function nextCursor(comboId, members, candidates) {
107
- if (candidates.length === 0)
108
- return [];
109
- // Repeat each available member according to its configured positive weight,
110
- // then advance one slot per request. This is deterministic weighted RR.
111
- const slots = members.flatMap((member) => {
112
- const candidate = candidates.find((c) => c.modelId === member.modelId);
113
- if (!candidate)
114
- return [];
115
- return Array.from({ length: Math.max(1, member.weight) }, () => candidate);
92
+ // Preserve declared position order.
93
+ return [...candidates].sort((a, b) => {
94
+ const am = combo.members.find((m) => m.modelId === a.modelId);
95
+ const bm = combo.members.find((m) => m.modelId === b.modelId);
96
+ return (am?.position ?? 0) - (bm?.position ?? 0);
116
97
  });
117
- const cur = (comboCursors.get(comboId) ?? 0) % Math.max(1, slots.length);
118
- comboCursors.set(comboId, cur + 1);
119
- const selected = slots[cur] ?? candidates[0];
120
- return [selected, ...candidates.filter((candidate) => candidate !== selected)];
121
98
  }
122
99
  export function shouldFallback(combo, reason) {
123
100
  switch (reason.type) {
@@ -3,35 +3,12 @@ import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
3
3
  import { GatewayError } from '../errors.js';
4
4
  import { buildHeaders, stripSlash } from '../providers/index.js';
5
5
  import { redactString } from '../security/redact.js';
6
- import { getCodexAccountForProvider, getCodexAccountById } from '../db/repositories/codex-accounts.js';
7
- import { findEligibleQoderAccount } from '../db/repositories/qoder-accounts.js';
8
6
  import { debugUpstream, errorLine, formatError, truncate } from '../logging/debug.js';
9
- export function providerToUpstreamConfig(p, codexAccountId) {
7
+ export function providerToUpstreamConfig(p, _codexAccountId) {
10
8
  let apiKey;
11
9
  let customHeaders;
12
- if (p.type === 'codex') {
13
- const account = codexAccountId ? getCodexAccountById(codexAccountId) : getCodexAccountForProvider(p.id);
14
- if (!account)
15
- throw new GatewayError('authentication_error', 'No usable Codex account is configured', { status: 503 });
16
- return {
17
- type: 'codex', baseUrl: p.baseUrl, customHeaders: {},
18
- connectTimeoutMs: p.connectTimeoutMs, firstTokenTimeoutMs: p.firstTokenTimeoutMs,
19
- streamIdleTimeoutMs: p.streamIdleTimeoutMs, totalTimeoutMs: p.totalTimeoutMs,
20
- codexAccountId: account.chatgptAccountId, accountRecordId: account.id,
21
- };
22
- }
23
- if (p.type === 'qoder') {
24
- // Account-pool provider: no API key on the row. The runner normally overrides the account
25
- // from the expanded candidate; this resolves the first eligible one for direct callers.
26
- const account = findEligibleQoderAccount(p.id);
27
- if (!account)
28
- throw new GatewayError('authentication_error', 'No usable Qoder account is configured', { status: 503 });
29
- return {
30
- type: 'qoder', baseUrl: p.baseUrl, customHeaders: {},
31
- connectTimeoutMs: p.connectTimeoutMs, firstTokenTimeoutMs: p.firstTokenTimeoutMs,
32
- streamIdleTimeoutMs: p.streamIdleTimeoutMs, totalTimeoutMs: p.totalTimeoutMs,
33
- qoderUserId: account.qoderUserId, qoderAccountRecordId: account.id,
34
- };
10
+ if (p.type === 'codex' || p.type === 'qoder') {
11
+ throw new GatewayError('invalid_request_error', `${p.type} providers are no longer supported`, { status: 410 });
35
12
  }
36
13
  if (!p.encryptedApiKey || !p.apiKeyNonce) {
37
14
  throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });