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.
- package/CHANGELOG.md +9 -38
- package/README.md +1 -23
- package/dist/server/app.js +1 -10
- package/dist/server/auth/middleware.js +1 -37
- package/dist/server/db/index.js +0 -5
- package/dist/server/db/migrate.js +5 -38
- package/dist/server/db/schema.js +3 -44
- package/dist/server/errors.js +11 -0
- package/dist/server/gateway/runner.js +64 -126
- package/dist/server/protocols/anthropic.js +5 -3
- package/dist/server/protocols/canonical.js +29 -8
- package/dist/server/providers/index.js +25 -8
- package/dist/server/routes/admin/auth.js +1 -4
- package/dist/server/routes/admin/combos.js +98 -48
- package/dist/server/routes/admin/models.js +34 -24
- package/dist/server/routes/admin/providers.js +20 -63
- package/dist/server/routes/admin/requests.js +0 -1
- package/dist/server/routes/admin.js +0 -12
- package/dist/server/routes/gateway/anthropic.js +3 -3
- package/dist/server/routes/gateway/openai.js +5 -5
- package/dist/server/routing/capabilities.js +73 -14
- package/dist/server/routing/combo.js +20 -39
- package/dist/server/routing/resolver.js +16 -11
- package/dist/server/upstream/client.js +55 -61
- package/dist/web/assets/index-C5h2WXK5.css +1 -0
- package/dist/web/assets/index-CRnoua24.js +335 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -5
- package/dist/server/db/repositories/codex-accounts.js +0 -187
- package/dist/server/providers/codex-autostart.js +0 -98
- package/dist/server/providers/codex-import.js +0 -156
- package/dist/server/providers/codex-oauth.js +0 -77
- package/dist/server/providers/codex-refresh.js +0 -165
- package/dist/server/providers/codex-usage.js +0 -192
- package/dist/server/providers/codex.js +0 -186
- package/dist/server/routes/admin/codex.js +0 -331
- package/dist/web/assets/index-Coy-u6h8.css +0 -1
- package/dist/web/assets/index-qDG5c6aL.js +0 -386
- package/migrations/0005_codex_accounts.sql +0 -105
- 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 =
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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 {
|
|
75
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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'
|
|
13
|
+
type: z.enum(['openai', 'anthropic']),
|
|
18
14
|
baseUrl: z.string().url().max(512),
|
|
19
|
-
apiKey: z.string().min(1).max(
|
|
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 =
|
|
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
|
|
86
|
-
apiKeyNonce: enc
|
|
87
|
-
apiKeyVersion: enc
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
187
|
+
const g = outcomeError(outcome);
|
|
188
188
|
reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
|
|
189
189
|
return;
|
|
190
190
|
}
|