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
@@ -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,30 +83,13 @@ 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
- const { codexModels } = await import('../../providers/codex.js');
75
- const { getCodexAccountById, listCodexAccountSummaries } = await import('../../db/repositories/codex-accounts.js');
76
- const { withCodexCredentials } = await import('../../providers/codex-refresh.js');
88
+ const apiKey = decryptSecret({ ciphertext: provider.encryptedApiKey, nonce: provider.apiKeyNonce, version: provider.apiKeyVersion });
89
+ const headers = decryptCustomHeaders(provider.customHeadersEncrypted && provider.customHeadersNonce ? { ciphertext: provider.customHeadersEncrypted, nonce: provider.customHeadersNonce, version: 1 } : null);
77
90
  let discovered;
78
91
  try {
79
- if (provider.type === 'codex') {
80
- const summary = listCodexAccountSummaries(provider.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
81
- const account = summary ? getCodexAccountById(summary.id) : null;
82
- if (!account)
83
- throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
84
- discovered = await withCodexCredentials(account.id, (credentials) => codexModels({
85
- baseUrl: provider.baseUrl, accountId: account.chatgptAccountId, accessToken: credentials.accessToken,
86
- accountRecordId: account.id, customHeaders: {}, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 30000),
87
- }));
88
- }
89
- else {
90
- if (!provider.encryptedApiKey || !provider.apiKeyNonce)
91
- throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
92
- const apiKey = decryptSecret({ ciphertext: provider.encryptedApiKey, nonce: provider.apiKeyNonce, version: provider.apiKeyVersion });
93
- const headers = decryptCustomHeaders(provider.customHeadersEncrypted && provider.customHeadersNonce ? { ciphertext: provider.customHeadersEncrypted, nonce: provider.customHeadersNonce, version: 1 } : null);
94
- discovered = await discoverProviderModels({ type: provider.type, baseUrl: provider.baseUrl, apiKey, customHeaders: headers, connectTimeoutMs: 5000, totalTimeoutMs: 30000 });
95
- }
92
+ discovered = await discoverProviderModels({ type: provider.type, baseUrl: provider.baseUrl, apiKey, customHeaders: headers, connectTimeoutMs: 5000, totalTimeoutMs: 30000 });
96
93
  }
97
94
  catch {
98
95
  discovered = [];
@@ -105,7 +102,19 @@ export async function registerModelRoutes(app) {
105
102
  const disc = discMap.get(upstreamId);
106
103
  const caps = disc?.capabilities ?? { chat: true, streaming: true, tools: true };
107
104
  if (existing) {
108
- 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();
109
118
  continue;
110
119
  }
111
120
  const publicModelId = `${provider.slug}/${upstreamId}`;
@@ -118,6 +127,7 @@ export async function registerModelRoutes(app) {
118
127
  enabled: true,
119
128
  upstreamAvailable: true,
120
129
  capabilitiesJson: JSON.stringify(caps),
130
+ discoveredMetadataJson: JSON.stringify(caps),
121
131
  maxContextTokens: typeof caps.max_context_tokens === 'number' ? caps.max_context_tokens : null,
122
132
  maxOutputTokens: typeof caps.max_output_tokens === 'number' ? caps.max_output_tokens : null,
123
133
  lastSeenUpstreamAt: now,
@@ -141,7 +151,7 @@ export async function registerModelRoutes(app) {
141
151
  if (body.upstreamAvailable !== undefined)
142
152
  update.upstreamAvailable = body.upstreamAvailable;
143
153
  if (body.capabilities) {
144
- const merged = { ...safeJson(m.capabilitiesJson), ...body.capabilities };
154
+ const merged = applyCapabilityOverrides(safeJson(m.capabilitiesJson), body.capabilities);
145
155
  update.capabilitiesJson = JSON.stringify(merged);
146
156
  if (typeof merged.max_context_tokens === 'number')
147
157
  update.maxContextTokens = merged.max_context_tokens;
@@ -1,22 +1,18 @@
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 { probeCodex, codexModels } from '../../providers/codex.js';
11
- import { listCodexAccountSummaries } from '../../db/repositories/codex-accounts.js';
12
- import { codexCredentialError, withCodexCredentials } from '../../providers/codex-refresh.js';
13
- import { redactString } from '../../security/redact.js';
14
10
  const ProviderCreate = z.object({
15
11
  name: z.string().min(1).max(128),
16
12
  slug: z.string().min(1).max(64).optional(),
17
- type: z.enum(['openai', 'anthropic', 'codex']),
13
+ type: z.enum(['openai', 'anthropic']),
18
14
  baseUrl: z.string().url().max(512),
19
- apiKey: z.string().min(1).max(20000).optional(),
15
+ apiKey: z.string().min(1).max(512),
20
16
  customHeaders: z.record(z.string(), z.string()).optional(),
21
17
  enabled: z.boolean().optional(),
22
18
  connectTimeoutMs: z.number().int().min(100).max(60000).optional(),
@@ -64,16 +60,13 @@ export async function registerProviderRoutes(app) {
64
60
  });
65
61
  app.post('/api/admin/providers', async (req) => {
66
62
  const body = ProviderCreate.parse(req.body);
67
- if (body.type !== 'codex' && !body.apiKey) {
68
- throw new GatewayError('invalid_request_error', 'API key is required for this provider type', { status: 400 });
69
- }
70
63
  requireMasterKey(); // Need master key to encrypt new credentials
71
64
  const db = getDb();
72
65
  const slug = body.slug ? slugify(body.slug) : slugify(body.name);
73
66
  const dup = db.select().from(schema.providers).where(eq(schema.providers.slug, slug)).get();
74
67
  if (dup)
75
68
  throw new GatewayError('invalid_request_error', `Provider slug '${slug}' is already in use`, { status: 400 });
76
- const enc = body.apiKey ? encryptSecret(body.apiKey) : null;
69
+ const enc = encryptSecret(body.apiKey);
77
70
  const headersEnc = body.customHeaders ? encryptCustomHeaders(body.customHeaders) : null;
78
71
  const id = uuid();
79
72
  db.insert(schema.providers).values({
@@ -82,9 +75,9 @@ export async function registerProviderRoutes(app) {
82
75
  slug,
83
76
  type: body.type,
84
77
  baseUrl: body.baseUrl,
85
- encryptedApiKey: enc?.ciphertext ?? null,
86
- apiKeyNonce: enc?.nonce ?? null,
87
- apiKeyVersion: enc?.version ?? 1,
78
+ encryptedApiKey: enc.ciphertext,
79
+ apiKeyNonce: enc.nonce,
80
+ apiKeyVersion: enc.version,
88
81
  customHeadersEncrypted: headersEnc?.ciphertext ?? null,
89
82
  customHeadersNonce: headersEnc?.nonce ?? null,
90
83
  enabled: body.enabled ?? true,
@@ -157,21 +150,9 @@ export async function registerProviderRoutes(app) {
157
150
  recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
158
151
  return { ok: true, softDisabled: true };
159
152
  }
160
- // A Codex provider owns its account pool. codex_accounts.provider_id is ON DELETE RESTRICT,
161
- // so the accounts must go in the same transaction or the delete fails with a raw SQLite
162
- // constraint error (which surfaces as an opaque 500 "Gateway error").
163
- let codexAccountsDeleted = 0;
164
- try {
165
- getRawDb().transaction(() => {
166
- codexAccountsDeleted = getRawDb().prepare('DELETE FROM codex_accounts WHERE provider_id=?').run(id).changes;
167
- db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
168
- })();
169
- }
170
- catch (error) {
171
- throw new GatewayError('invalid_request_error', 'Provider is still referenced and cannot be deleted', { status: 409, cause: error });
172
- }
173
- recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { codexAccountsDeleted } });
174
- return { ok: true, codexAccountsDeleted };
153
+ db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
154
+ recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
155
+ return { ok: true };
175
156
  });
176
157
  app.post('/api/admin/providers/:id/test', async (req) => {
177
158
  const { id } = req.params;
@@ -179,18 +160,6 @@ export async function registerProviderRoutes(app) {
179
160
  const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
180
161
  if (!p)
181
162
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
182
- if (p.type === 'codex') {
183
- const account = listCodexAccountSummaries(p.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
184
- if (!account)
185
- throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
186
- const row = getRawDb().prepare('SELECT chatgpt_account_id AS accountId FROM codex_accounts WHERE id=?').get(account.id);
187
- const result = await withCodexCredentials(account.id, async (credentials) => probeCodex({ baseUrl: p.baseUrl, accountId: row?.accountId ?? '', accessToken: credentials.accessToken, customHeaders: {}, totalTimeoutMs: Math.min(p.totalTimeoutMs, 20000) })).catch((error) => { throw codexCredentialError(error); });
188
- db.update(schema.providers).set({ healthState: result.ok ? 'healthy' : 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
189
- recordAudit({ action: 'provider.test', success: result.ok, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { detail: redactString(result.detail) } });
190
- return { ...result, detail: redactString(result.detail) };
191
- }
192
- if (!p.encryptedApiKey || !p.apiKeyNonce)
193
- throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
194
163
  const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
195
164
  const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
196
165
  const result = await probeProvider({
@@ -216,28 +185,16 @@ export async function registerProviderRoutes(app) {
216
185
  const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
217
186
  if (!p)
218
187
  throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
219
- let discovered;
220
- if (p.type === 'codex') {
221
- const account = listCodexAccountSummaries(p.id).find((candidate) => candidate.enabled && candidate.healthState !== 'down');
222
- if (!account)
223
- throw new GatewayError('authentication_error', 'No eligible Codex account is configured', { status: 503 });
224
- const row = getRawDb().prepare('SELECT chatgpt_account_id AS accountId FROM codex_accounts WHERE id=?').get(account.id);
225
- discovered = await withCodexCredentials(account.id, async (credentials) => codexModels({ baseUrl: p.baseUrl, accountId: row?.accountId ?? '', accessToken: credentials.accessToken, customHeaders: {}, totalTimeoutMs: 30000 })).catch((error) => { throw codexCredentialError(error); });
226
- }
227
- else {
228
- if (!p.encryptedApiKey || !p.apiKeyNonce)
229
- throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
230
- const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
231
- const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
232
- discovered = await discoverProviderModels({
233
- type: p.type,
234
- baseUrl: p.baseUrl,
235
- apiKey,
236
- customHeaders: headers,
237
- connectTimeoutMs: 5000,
238
- totalTimeoutMs: 30000,
239
- });
240
- }
188
+ const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
189
+ const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
190
+ const discovered = await discoverProviderModels({
191
+ type: p.type,
192
+ baseUrl: p.baseUrl,
193
+ apiKey,
194
+ customHeaders: headers,
195
+ connectTimeoutMs: 5000,
196
+ totalTimeoutMs: 30000,
197
+ });
241
198
  const existing = db
242
199
  .select()
243
200
  .from(schema.models)
@@ -230,7 +230,6 @@ export async function registerRequestRoutes(app) {
230
230
  providerName: providerMap.get(a.providerId)?.name ?? '',
231
231
  modelId: a.modelId,
232
232
  modelPublicId: modelMap.get(a.modelId)?.publicModelId ?? '',
233
- codexAccountId: a.codexAccountId,
234
233
  startedAt: a.startedAt,
235
234
  completedAt: a.completedAt,
236
235
  statusCode: a.statusCode,
@@ -1,5 +1,4 @@
1
1
  // Admin API routes (mounted at /api/admin/*). All require admin session auth.
2
- import { requireAdminAuth, requireAdminCsrf } from '../auth/middleware.js';
3
2
  import { registerSetupRoutes } from './admin/setup.js';
4
3
  import { registerAuthRoutes } from './admin/auth.js';
5
4
  import { registerProviderRoutes } from './admin/providers.js';
@@ -13,7 +12,6 @@ import { registerAuditRoutes } from './admin/audit.js';
13
12
  import { registerSettingsRoutes } from './admin/settings.js';
14
13
  import { registerBackupRoutes } from './admin/backup.js';
15
14
  import { registerDashboardRoutes } from './admin/dashboard.js';
16
- import { registerCodexRoutes, registerCodexOAuthCallbackRoute } from './admin/codex.js';
17
15
  export async function registerAdminRoutes(app) {
18
16
  // Setup routes are always reachable (used on first run).
19
17
  await app.register(async (instance) => {
@@ -24,17 +22,8 @@ export async function registerAdminRoutes(app) {
24
22
  await app.register(async (instance) => {
25
23
  await registerAuthRoutes(instance);
26
24
  });
27
- // The Codex OAuth loopback callback is hit by the browser's redirect, so it is public too.
28
- await app.register(async (instance) => {
29
- await registerCodexOAuthCallbackRoute(instance);
30
- });
31
25
  // Authenticated admin routes
32
26
  await app.register(async (instance) => {
33
- instance.addHook('preHandler', requireAdminAuth);
34
- instance.addHook('preHandler', async (req) => {
35
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method))
36
- await requireAdminCsrf(req);
37
- });
38
27
  await registerProviderRoutes(instance);
39
28
  await registerModelRoutes(instance);
40
29
  await registerComboRoutes(instance);
@@ -46,6 +35,5 @@ export async function registerAdminRoutes(app) {
46
35
  await registerSettingsRoutes(instance);
47
36
  await registerBackupRoutes(instance);
48
37
  await registerDashboardRoutes(instance);
49
- await registerCodexRoutes(instance);
50
38
  });
51
39
  }
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { authenticateGatewayKey } from '../../auth/api-key.js';
4
4
  import { resolveClientIp } from '../../util/client-ip.js';
5
5
  import { anthropicToCanonical } from '../../protocols/anthropic.js';
6
- import { GatewayError, toAnthropicError } from '../../errors.js';
6
+ import { GatewayError, toAnthropicError, outcomeError } from '../../errors.js';
7
7
  import { GatewayRunner } from '../../gateway/runner.js';
8
8
  import { uuid } from '../../auth/ids.js';
9
9
  import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
@@ -58,7 +58,7 @@ export async function registerAnthropicRoutes(app) {
58
58
  return reply;
59
59
  }
60
60
  if (!outcome.success) {
61
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
61
+ const g = outcomeError(outcome);
62
62
  reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
63
63
  return;
64
64
  }
@@ -66,7 +66,7 @@ export async function registerAnthropicRoutes(app) {
66
66
  return;
67
67
  }
68
68
  if (!outcome.success) {
69
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
69
+ const g = outcomeError(outcome);
70
70
  lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
71
71
  reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
72
72
  return;
@@ -5,7 +5,7 @@ import { getDb, schema } from '../../db/index.js';
5
5
  import { authenticateGatewayKey } from '../../auth/api-key.js';
6
6
  import { resolveClientIp } from '../../util/client-ip.js';
7
7
  import { openAIToCanonical, openAIModelList } from '../../protocols/canonical.js';
8
- import { GatewayError, toOpenAIError } from '../../errors.js';
8
+ import { GatewayError, toOpenAIError, outcomeError } from '../../errors.js';
9
9
  import { GatewayRunner } from '../../gateway/runner.js';
10
10
  import { uuid } from '../../auth/ids.js';
11
11
  import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeBody, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
@@ -86,7 +86,7 @@ export async function registerOpenAIRoutes(app) {
86
86
  // the client should get a regular protocol error instead of a dangling
87
87
  // stream.
88
88
  if (!outcome.success) {
89
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
89
+ const g = outcomeError(outcome);
90
90
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
91
91
  return;
92
92
  }
@@ -95,7 +95,7 @@ export async function registerOpenAIRoutes(app) {
95
95
  return;
96
96
  }
97
97
  if (!outcome.success) {
98
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
98
+ const g = outcomeError(outcome);
99
99
  lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
100
100
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
101
101
  return;
@@ -176,7 +176,7 @@ export async function registerOpenAIRoutes(app) {
176
176
  return reply;
177
177
  }
178
178
  if (!outcome.success) {
179
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
179
+ const g = outcomeError(outcome);
180
180
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
181
181
  return;
182
182
  }
@@ -184,7 +184,7 @@ export async function registerOpenAIRoutes(app) {
184
184
  return;
185
185
  }
186
186
  if (!outcome.success) {
187
- const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
187
+ const g = outcomeError(outcome);
188
188
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
189
189
  return;
190
190
  }