ldrouter 1.5.1
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 +49 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/cli.js +13 -0
- package/dist/server/app.js +138 -0
- package/dist/server/auth/api-key.js +75 -0
- package/dist/server/auth/crypto.js +94 -0
- package/dist/server/auth/ids.js +40 -0
- package/dist/server/auth/middleware.js +36 -0
- package/dist/server/auth/recovery.js +11 -0
- package/dist/server/caching/store.js +119 -0
- package/dist/server/config/index.js +96 -0
- package/dist/server/db/index.js +64 -0
- package/dist/server/db/migrate.js +408 -0
- package/dist/server/db/repositories/audit.js +75 -0
- package/dist/server/db/repositories/settings.js +63 -0
- package/dist/server/db/schema.js +396 -0
- package/dist/server/errors.js +65 -0
- package/dist/server/gateway/runner.js +745 -0
- package/dist/server/logging/logger.js +35 -0
- package/dist/server/maintenance/retention.js +48 -0
- package/dist/server/metrics/registry.js +169 -0
- package/dist/server/protocols/anthropic.js +154 -0
- package/dist/server/protocols/canonical.js +201 -0
- package/dist/server/providers/index.js +89 -0
- package/dist/server/routes/admin/aliases.js +98 -0
- package/dist/server/routes/admin/api-keys.js +194 -0
- package/dist/server/routes/admin/audit.js +19 -0
- package/dist/server/routes/admin/auth.js +124 -0
- package/dist/server/routes/admin/backup.js +113 -0
- package/dist/server/routes/admin/combos.js +198 -0
- package/dist/server/routes/admin/dashboard.js +55 -0
- package/dist/server/routes/admin/models.js +178 -0
- package/dist/server/routes/admin/providers.js +212 -0
- package/dist/server/routes/admin/requests.js +156 -0
- package/dist/server/routes/admin/settings.js +197 -0
- package/dist/server/routes/admin/setup.js +80 -0
- package/dist/server/routes/admin/stats.js +180 -0
- package/dist/server/routes/admin.js +39 -0
- package/dist/server/routes/gateway/anthropic.js +112 -0
- package/dist/server/routes/gateway/openai.js +257 -0
- package/dist/server/routes/gateway.js +7 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routing/capabilities.js +52 -0
- package/dist/server/routing/circuit.js +37 -0
- package/dist/server/routing/combo.js +100 -0
- package/dist/server/routing/quota.js +51 -0
- package/dist/server/routing/ratelimit.js +58 -0
- package/dist/server/routing/resolver.js +43 -0
- package/dist/server/security/redact.js +111 -0
- package/dist/server/selfupdate/index.js +154 -0
- package/dist/server/upstream/client.js +179 -0
- package/dist/server/util/cidr.js +91 -0
- package/dist/server/util/client-ip.js +15 -0
- package/dist/server/util/stable-json.js +19 -0
- package/dist/shared/types.js +2 -0
- package/dist/web/assets/index-COSbvF8Z.css +1 -0
- package/dist/web/assets/index-DbnEzuxq.js +251 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.png +0 -0
- package/migrations/0001_initial_schema.sql +323 -0
- package/migrations/0002_source_api_key_secrets.sql +7 -0
- package/package.json +117 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Admin API: combos CRUD + member management.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { eq, sql } from 'drizzle-orm';
|
|
4
|
+
import { getDb, schema } from '../../db/index.js';
|
|
5
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
6
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
7
|
+
import { uuid } from '../../auth/ids.js';
|
|
8
|
+
import { GatewayError } from '../../errors.js';
|
|
9
|
+
const MemberSpec = z.object({ modelId: z.string(), position: z.number().int().min(0), weight: z.number().int().min(1).default(1), enabled: z.boolean().default(true) });
|
|
10
|
+
const ComboCreate = z.object({
|
|
11
|
+
name: z.string().min(1).max(128),
|
|
12
|
+
slug: z.string().min(1).max(64).optional(),
|
|
13
|
+
mode: z.enum(['fallback', 'weighted_round_robin']),
|
|
14
|
+
maxTotalAttempts: z.number().int().min(1).max(8).optional(),
|
|
15
|
+
enabled: z.boolean().optional(),
|
|
16
|
+
fallbackOnConnection: z.boolean().optional(),
|
|
17
|
+
fallbackOnConnectTimeout: z.boolean().optional(),
|
|
18
|
+
fallbackOnFirstTokenTimeout: z.boolean().optional(),
|
|
19
|
+
fallbackOn408: z.boolean().optional(),
|
|
20
|
+
fallbackOn429: z.boolean().optional(),
|
|
21
|
+
fallbackOn5xx: z.boolean().optional(),
|
|
22
|
+
members: z.array(MemberSpec).min(1).max(16),
|
|
23
|
+
});
|
|
24
|
+
const ComboUpdate = ComboCreate.partial().extend({ id: z.string() });
|
|
25
|
+
// Combo public IDs default to the (normalized) name WITHOUT a prefix — e.g.
|
|
26
|
+
// name "gpt-5.5" becomes model id "gpt-5.5". Only when the client explicitly
|
|
27
|
+
// supplies a slug does the id get the "combo/" prefix ("combo/<slug>").
|
|
28
|
+
// Unlike slugify(), dots are preserved: they are legal in model IDs
|
|
29
|
+
// (e.g. "gpt-5.6-sol").
|
|
30
|
+
function comboSlug(input) {
|
|
31
|
+
return (input
|
|
32
|
+
.trim()
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[\s/]+/g, '-')
|
|
35
|
+
.replace(/[^a-z0-9._-]+/g, '')
|
|
36
|
+
.slice(0, 64) || 'item');
|
|
37
|
+
}
|
|
38
|
+
export async function registerComboRoutes(app) {
|
|
39
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
40
|
+
app.get('/api/admin/combos', async () => {
|
|
41
|
+
const db = getDb();
|
|
42
|
+
const combos = db.select().from(schema.combos).all();
|
|
43
|
+
const allMembers = db.select().from(schema.comboMembers).all();
|
|
44
|
+
const models = db.select().from(schema.models).all();
|
|
45
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
46
|
+
return {
|
|
47
|
+
combos: combos.map((c) => {
|
|
48
|
+
const members = allMembers.filter((m) => m.comboId === c.id);
|
|
49
|
+
const healthy = members.filter((m) => {
|
|
50
|
+
const model = modelMap.get(m.modelId);
|
|
51
|
+
return model && model.enabled && model.upstreamAvailable;
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
id: c.id,
|
|
55
|
+
name: c.name,
|
|
56
|
+
slug: c.slug,
|
|
57
|
+
publicModelId: c.publicModelId,
|
|
58
|
+
mode: c.mode,
|
|
59
|
+
enabled: c.enabled,
|
|
60
|
+
memberCount: members.length,
|
|
61
|
+
healthyMemberCount: healthy.length,
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
app.get('/api/admin/combos/:id', async (req) => {
|
|
67
|
+
const { id } = req.params;
|
|
68
|
+
const db = getDb();
|
|
69
|
+
const c = db.select().from(schema.combos).where(eq(schema.combos.id, id)).get();
|
|
70
|
+
if (!c)
|
|
71
|
+
throw new GatewayError('invalid_request_error', 'Combo not found', { status: 404 });
|
|
72
|
+
const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, id)).all();
|
|
73
|
+
const models = db.select().from(schema.models).all();
|
|
74
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
75
|
+
return {
|
|
76
|
+
combo: {
|
|
77
|
+
...c,
|
|
78
|
+
members: members.map((m) => ({
|
|
79
|
+
id: m.id,
|
|
80
|
+
modelId: m.modelId,
|
|
81
|
+
publicModelId: modelMap.get(m.modelId)?.publicModelId ?? '',
|
|
82
|
+
displayName: modelMap.get(m.modelId)?.displayName ?? '',
|
|
83
|
+
providerSlug: '',
|
|
84
|
+
position: m.position,
|
|
85
|
+
weight: m.weight,
|
|
86
|
+
enabled: m.enabled,
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
app.post('/api/admin/combos', async (req) => {
|
|
92
|
+
const body = ComboCreate.parse(req.body);
|
|
93
|
+
const db = getDb();
|
|
94
|
+
// 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;
|
|
97
|
+
// The id must be globally unique across combos AND physical models — the
|
|
98
|
+
// 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 });
|
|
110
|
+
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
|
+
}
|
|
130
|
+
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
|
+
return { id, slug, publicModelId };
|
|
132
|
+
});
|
|
133
|
+
app.patch('/api/admin/combos', async (req) => {
|
|
134
|
+
const body = ComboUpdate.parse(req.body);
|
|
135
|
+
const db = getDb();
|
|
136
|
+
const c = db.select().from(schema.combos).where(eq(schema.combos.id, body.id)).get();
|
|
137
|
+
if (!c)
|
|
138
|
+
throw new GatewayError('invalid_request_error', 'Combo not found', { status: 404 });
|
|
139
|
+
const update = { updatedAt: new Date().toISOString(), configVersion: c.configVersion + 1 };
|
|
140
|
+
if (body.name)
|
|
141
|
+
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 });
|
|
151
|
+
}
|
|
152
|
+
update.slug = slug;
|
|
153
|
+
update.publicModelId = publicModelId;
|
|
154
|
+
}
|
|
155
|
+
if (body.mode)
|
|
156
|
+
update.mode = body.mode;
|
|
157
|
+
if (body.enabled !== undefined)
|
|
158
|
+
update.enabled = body.enabled;
|
|
159
|
+
if (body.maxTotalAttempts !== undefined)
|
|
160
|
+
update.maxTotalAttempts = body.maxTotalAttempts;
|
|
161
|
+
if (body.fallbackOnConnection !== undefined)
|
|
162
|
+
update.fallbackOnConnection = body.fallbackOnConnection;
|
|
163
|
+
if (body.fallbackOnConnectTimeout !== undefined)
|
|
164
|
+
update.fallbackOnConnectTimeout = body.fallbackOnConnectTimeout;
|
|
165
|
+
if (body.fallbackOnFirstTokenTimeout !== undefined)
|
|
166
|
+
update.fallbackOnFirstTokenTimeout = body.fallbackOnFirstTokenTimeout;
|
|
167
|
+
if (body.fallbackOn408 !== undefined)
|
|
168
|
+
update.fallbackOn408 = body.fallbackOn408;
|
|
169
|
+
if (body.fallbackOn429 !== undefined)
|
|
170
|
+
update.fallbackOn429 = body.fallbackOn429;
|
|
171
|
+
if (body.fallbackOn5xx !== undefined)
|
|
172
|
+
update.fallbackOn5xx = body.fallbackOn5xx;
|
|
173
|
+
db.update(schema.combos).set(update).where(eq(schema.combos.id, body.id)).run();
|
|
174
|
+
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
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Invalidate cache for this combo
|
|
181
|
+
const { invalidateCacheFor } = await import('../../caching/store.js');
|
|
182
|
+
invalidateCacheFor('combo', body.id);
|
|
183
|
+
recordAudit({ action: 'combo.update', success: true, targetType: 'combo', targetId: body.id, targetName: c.name, ip: req.ip });
|
|
184
|
+
return { ok: true };
|
|
185
|
+
});
|
|
186
|
+
app.delete('/api/admin/combos/:id', async (req) => {
|
|
187
|
+
const { id } = req.params;
|
|
188
|
+
const db = getDb();
|
|
189
|
+
const c = db.select().from(schema.combos).where(eq(schema.combos.id, id)).get();
|
|
190
|
+
if (!c)
|
|
191
|
+
throw new GatewayError('invalid_request_error', 'Combo not found', { status: 404 });
|
|
192
|
+
db.delete(schema.combos).where(eq(schema.combos.id, id)).run();
|
|
193
|
+
const { invalidateCacheFor } = await import('../../caching/store.js');
|
|
194
|
+
invalidateCacheFor('combo', id);
|
|
195
|
+
recordAudit({ action: 'combo.delete', success: true, targetType: 'combo', targetId: id, targetName: c.name, ip: req.ip });
|
|
196
|
+
return { ok: true };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Admin API: dashboard summary.
|
|
2
|
+
import { and, desc, gte, sql } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../../db/index.js';
|
|
4
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
export async function registerDashboardRoutes(app) {
|
|
6
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
7
|
+
app.get('/api/admin/dashboard', async () => {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
const startOfDay = new Date();
|
|
10
|
+
startOfDay.setUTCHours(0, 0, 0, 0);
|
|
11
|
+
const startIso = startOfDay.toISOString();
|
|
12
|
+
const today = db
|
|
13
|
+
.select({
|
|
14
|
+
total: sql `COUNT(*)`,
|
|
15
|
+
success: sql `SUM(CASE WHEN success=1 THEN 1 ELSE 0 END)`,
|
|
16
|
+
failed: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
|
|
17
|
+
totalTokens: sql `COALESCE(SUM(total_tokens),0)`,
|
|
18
|
+
})
|
|
19
|
+
.from(schema.requests)
|
|
20
|
+
.where(gte(schema.requests.createdAt, startIso))
|
|
21
|
+
.get();
|
|
22
|
+
const providerHealth = db.select().from(schema.providers).all();
|
|
23
|
+
const recentFailures = db
|
|
24
|
+
.select()
|
|
25
|
+
.from(schema.requests)
|
|
26
|
+
.where(and(sql `success = 0`, gte(schema.requests.createdAt, startIso)))
|
|
27
|
+
.orderBy(desc(schema.requests.createdAt))
|
|
28
|
+
.limit(5)
|
|
29
|
+
.all();
|
|
30
|
+
return {
|
|
31
|
+
today: {
|
|
32
|
+
total: Number(today?.total ?? 0),
|
|
33
|
+
success: Number(today?.success ?? 0),
|
|
34
|
+
failed: Number(today?.failed ?? 0),
|
|
35
|
+
totalTokens: Number(today?.totalTokens ?? 0),
|
|
36
|
+
},
|
|
37
|
+
providers: providerHealth.map((p) => ({
|
|
38
|
+
id: p.id,
|
|
39
|
+
name: p.name,
|
|
40
|
+
slug: p.slug,
|
|
41
|
+
type: p.type,
|
|
42
|
+
health: p.healthState,
|
|
43
|
+
enabled: p.enabled,
|
|
44
|
+
})),
|
|
45
|
+
recentFailures: recentFailures.map((r) => ({
|
|
46
|
+
id: r.id,
|
|
47
|
+
createdAt: r.createdAt,
|
|
48
|
+
requestedModel: r.requestedModel,
|
|
49
|
+
errorType: r.errorType,
|
|
50
|
+
errorMessage: r.errorMessage,
|
|
51
|
+
httpStatus: r.httpStatus,
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { eq, sql } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../../db/index.js';
|
|
4
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
6
|
+
import { uuid } from '../../auth/ids.js';
|
|
7
|
+
import { GatewayError } from '../../errors.js';
|
|
8
|
+
const ImportModelsBody = z.object({
|
|
9
|
+
providerId: z.string(),
|
|
10
|
+
modelIds: z.array(z.string().min(1)).min(1),
|
|
11
|
+
});
|
|
12
|
+
const ModelUpdate = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
displayName: z.string().min(1).max(128).optional(),
|
|
15
|
+
enabled: z.boolean().optional(),
|
|
16
|
+
upstreamAvailable: z.boolean().optional(),
|
|
17
|
+
capabilities: z.record(z.any()).optional(),
|
|
18
|
+
cacheOverrideEnabled: z.boolean().nullable().optional(),
|
|
19
|
+
maxContextTokens: z.number().int().min(1).nullable().optional(),
|
|
20
|
+
maxOutputTokens: z.number().int().min(1).nullable().optional(),
|
|
21
|
+
});
|
|
22
|
+
export async function registerModelRoutes(app) {
|
|
23
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
24
|
+
app.get('/api/admin/models', async (req) => {
|
|
25
|
+
const q = req.query;
|
|
26
|
+
const db = getDb();
|
|
27
|
+
let rows = db.select().from(schema.models).all();
|
|
28
|
+
const providers = db.select().from(schema.providers).all();
|
|
29
|
+
const providerMap = new Map(providers.map((p) => [p.id, p]));
|
|
30
|
+
if (q.providerId)
|
|
31
|
+
rows = rows.filter((r) => r.providerId === q.providerId);
|
|
32
|
+
if (q.enabled !== undefined)
|
|
33
|
+
rows = rows.filter((r) => r.enabled === (q.enabled === 'true'));
|
|
34
|
+
if (q.upstreamAvailable !== undefined)
|
|
35
|
+
rows = rows.filter((r) => r.upstreamAvailable === (q.upstreamAvailable === 'true'));
|
|
36
|
+
if (q.capability)
|
|
37
|
+
rows = rows.filter((r) => {
|
|
38
|
+
try {
|
|
39
|
+
const caps = JSON.parse(r.capabilitiesJson);
|
|
40
|
+
return caps[q.capability] === true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const models = rows.map((m) => ({
|
|
47
|
+
id: m.id,
|
|
48
|
+
providerId: m.providerId,
|
|
49
|
+
providerSlug: providerMap.get(m.providerId)?.slug ?? '',
|
|
50
|
+
providerType: providerMap.get(m.providerId)?.type ?? 'openai',
|
|
51
|
+
publicModelId: m.publicModelId,
|
|
52
|
+
upstreamModelId: m.upstreamModelId,
|
|
53
|
+
displayName: m.displayName,
|
|
54
|
+
enabled: m.enabled,
|
|
55
|
+
upstreamAvailable: m.upstreamAvailable,
|
|
56
|
+
capabilities: safeJson(m.capabilitiesJson),
|
|
57
|
+
maxContextTokens: m.maxContextTokens,
|
|
58
|
+
maxOutputTokens: m.maxOutputTokens,
|
|
59
|
+
lastSeenUpstreamAt: m.lastSeenUpstreamAt,
|
|
60
|
+
createdAt: m.createdAt,
|
|
61
|
+
}));
|
|
62
|
+
return { models };
|
|
63
|
+
});
|
|
64
|
+
app.post('/api/admin/models/import', async (req) => {
|
|
65
|
+
const body = ImportModelsBody.parse(req.body);
|
|
66
|
+
const db = getDb();
|
|
67
|
+
const provider = db.select().from(schema.providers).where(eq(schema.providers.id, body.providerId)).get();
|
|
68
|
+
if (!provider)
|
|
69
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
70
|
+
// Fetch discovered model metadata fresh (so import uses current discovery data)
|
|
71
|
+
// For simplicity: re-discover and match by upstream id.
|
|
72
|
+
const { discoverProviderModels } = await import('../../providers/index.js');
|
|
73
|
+
const { decryptSecret, decryptCustomHeaders } = await import('../../auth/crypto.js');
|
|
74
|
+
const apiKey = decryptSecret({ ciphertext: provider.encryptedApiKey, nonce: provider.apiKeyNonce, version: provider.apiKeyVersion });
|
|
75
|
+
const headers = decryptCustomHeaders(provider.customHeadersEncrypted && provider.customHeadersNonce ? { ciphertext: provider.customHeadersEncrypted, nonce: provider.customHeadersNonce, version: 1 } : null);
|
|
76
|
+
let discovered;
|
|
77
|
+
try {
|
|
78
|
+
discovered = await discoverProviderModels({ type: provider.type, baseUrl: provider.baseUrl, apiKey, customHeaders: headers, connectTimeoutMs: 5000, totalTimeoutMs: 30000 });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
discovered = [];
|
|
82
|
+
}
|
|
83
|
+
const discMap = new Map(discovered.map((d) => [d.upstreamId, d]));
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
let imported = 0;
|
|
86
|
+
for (const upstreamId of body.modelIds) {
|
|
87
|
+
const existing = db.select().from(schema.models).where(sql `provider_id = ${provider.id} AND upstream_model_id = ${upstreamId}`).get();
|
|
88
|
+
const disc = discMap.get(upstreamId);
|
|
89
|
+
const caps = disc?.capabilities ?? { chat: true, streaming: true, tools: true };
|
|
90
|
+
if (existing) {
|
|
91
|
+
db.update(schema.models).set({ upstreamAvailable: true, lastSeenUpstreamAt: now, updatedAt: now }).where(eq(schema.models.id, existing.id)).run();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const publicModelId = `${provider.slug}/${upstreamId}`;
|
|
95
|
+
db.insert(schema.models).values({
|
|
96
|
+
id: uuid(),
|
|
97
|
+
providerId: provider.id,
|
|
98
|
+
upstreamModelId: upstreamId,
|
|
99
|
+
publicModelId,
|
|
100
|
+
displayName: disc?.displayName ?? upstreamId,
|
|
101
|
+
enabled: true,
|
|
102
|
+
upstreamAvailable: true,
|
|
103
|
+
capabilitiesJson: JSON.stringify(caps),
|
|
104
|
+
maxContextTokens: typeof caps.max_context_tokens === 'number' ? caps.max_context_tokens : null,
|
|
105
|
+
maxOutputTokens: typeof caps.max_output_tokens === 'number' ? caps.max_output_tokens : null,
|
|
106
|
+
lastSeenUpstreamAt: now,
|
|
107
|
+
}).run();
|
|
108
|
+
imported++;
|
|
109
|
+
}
|
|
110
|
+
recordAudit({ action: 'model.import', success: true, targetType: 'provider', targetId: provider.id, targetName: provider.name, ip: req.ip, metadata: { count: body.modelIds.length, imported } });
|
|
111
|
+
return { ok: true, imported, requested: body.modelIds.length };
|
|
112
|
+
});
|
|
113
|
+
app.patch('/api/admin/models', async (req) => {
|
|
114
|
+
const body = ModelUpdate.parse(req.body);
|
|
115
|
+
const db = getDb();
|
|
116
|
+
const m = db.select().from(schema.models).where(eq(schema.models.id, body.id)).get();
|
|
117
|
+
if (!m)
|
|
118
|
+
throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
|
|
119
|
+
const update = { updatedAt: new Date().toISOString() };
|
|
120
|
+
if (body.displayName)
|
|
121
|
+
update.displayName = body.displayName;
|
|
122
|
+
if (body.enabled !== undefined)
|
|
123
|
+
update.enabled = body.enabled;
|
|
124
|
+
if (body.upstreamAvailable !== undefined)
|
|
125
|
+
update.upstreamAvailable = body.upstreamAvailable;
|
|
126
|
+
if (body.capabilities) {
|
|
127
|
+
const merged = { ...safeJson(m.capabilitiesJson), ...body.capabilities };
|
|
128
|
+
update.capabilitiesJson = JSON.stringify(merged);
|
|
129
|
+
if (typeof merged.max_context_tokens === 'number')
|
|
130
|
+
update.maxContextTokens = merged.max_context_tokens;
|
|
131
|
+
if (typeof merged.max_output_tokens === 'number')
|
|
132
|
+
update.maxOutputTokens = merged.max_output_tokens;
|
|
133
|
+
}
|
|
134
|
+
if (body.cacheOverrideEnabled !== undefined)
|
|
135
|
+
update.cacheOverrideEnabled = body.cacheOverrideEnabled ?? null;
|
|
136
|
+
if (body.maxContextTokens !== undefined)
|
|
137
|
+
update.maxContextTokens = body.maxContextTokens;
|
|
138
|
+
if (body.maxOutputTokens !== undefined)
|
|
139
|
+
update.maxOutputTokens = body.maxOutputTokens;
|
|
140
|
+
db.update(schema.models).set(update).where(eq(schema.models.id, body.id)).run();
|
|
141
|
+
recordAudit({ action: 'model.update', success: true, targetType: 'model', targetId: m.id, targetName: m.publicModelId, ip: req.ip });
|
|
142
|
+
return { ok: true };
|
|
143
|
+
});
|
|
144
|
+
app.post('/api/admin/models/:id/toggle', async (req) => {
|
|
145
|
+
const { id } = req.params;
|
|
146
|
+
const db = getDb();
|
|
147
|
+
const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
|
|
148
|
+
if (!m)
|
|
149
|
+
throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
|
|
150
|
+
db.update(schema.models).set({ enabled: !m.enabled, updatedAt: new Date().toISOString() }).where(eq(schema.models.id, id)).run();
|
|
151
|
+
recordAudit({ action: m.enabled ? 'model.disable' : 'model.enable', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
152
|
+
return { ok: true, enabled: !m.enabled };
|
|
153
|
+
});
|
|
154
|
+
app.delete('/api/admin/models/:id', async (req) => {
|
|
155
|
+
const { id } = req.params;
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
|
|
158
|
+
if (!m)
|
|
159
|
+
throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
|
|
160
|
+
const inCombo = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.modelId, id)).all();
|
|
161
|
+
if (inCombo.length > 0) {
|
|
162
|
+
db.update(schema.models).set({ enabled: false, upstreamAvailable: false, updatedAt: new Date().toISOString() }).where(eq(schema.models.id, id)).run();
|
|
163
|
+
recordAudit({ action: 'model.soft_disable', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip, metadata: { reason: 'in_combo' } });
|
|
164
|
+
return { ok: true, softDisabled: true };
|
|
165
|
+
}
|
|
166
|
+
db.delete(schema.models).where(eq(schema.models.id, id)).run();
|
|
167
|
+
recordAudit({ action: 'model.delete', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
168
|
+
return { ok: true };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function safeJson(s) {
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(s);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { sql, eq } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../../db/index.js';
|
|
4
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
6
|
+
import { encryptSecret, decryptSecret, encryptCustomHeaders, decryptCustomHeaders, isMasterKeyConfigured } from '../../auth/crypto.js';
|
|
7
|
+
import { uuid, slugify } from '../../auth/ids.js';
|
|
8
|
+
import { GatewayError } from '../../errors.js';
|
|
9
|
+
import { probeProvider, discoverProviderModels } from '../../providers/index.js';
|
|
10
|
+
const ProviderCreate = z.object({
|
|
11
|
+
name: z.string().min(1).max(128),
|
|
12
|
+
slug: z.string().min(1).max(64).optional(),
|
|
13
|
+
type: z.enum(['openai', 'anthropic']),
|
|
14
|
+
baseUrl: z.string().url().max(512),
|
|
15
|
+
apiKey: z.string().min(1).max(512),
|
|
16
|
+
customHeaders: z.record(z.string(), z.string()).optional(),
|
|
17
|
+
enabled: z.boolean().optional(),
|
|
18
|
+
connectTimeoutMs: z.number().int().min(100).max(60000).optional(),
|
|
19
|
+
firstTokenTimeoutMs: z.number().int().min(100).max(300000).optional(),
|
|
20
|
+
streamIdleTimeoutMs: z.number().int().min(100).max(600000).optional(),
|
|
21
|
+
totalTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
|
22
|
+
maxRetries: z.number().int().min(0).max(8).optional(),
|
|
23
|
+
cbFailureThreshold: z.number().int().min(1).max(50).optional(),
|
|
24
|
+
cbCooldownSeconds: z.number().int().min(1).max(3600).optional(),
|
|
25
|
+
});
|
|
26
|
+
const ProviderUpdate = ProviderCreate.partial().extend({ id: z.string() });
|
|
27
|
+
function requireMasterKey() {
|
|
28
|
+
if (!isMasterKeyConfigured()) {
|
|
29
|
+
throw new GatewayError('gateway_error', 'Master key not configured — set LATEDEV_MASTER_KEY', { status: 503 });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function providerToSummary(p, modelCount, recentErrorRate, recentAvgLatencyMs) {
|
|
33
|
+
return {
|
|
34
|
+
id: p.id,
|
|
35
|
+
name: p.name,
|
|
36
|
+
slug: p.slug,
|
|
37
|
+
type: p.type,
|
|
38
|
+
baseUrl: p.baseUrl,
|
|
39
|
+
enabled: p.enabled,
|
|
40
|
+
health: p.healthState,
|
|
41
|
+
modelCount,
|
|
42
|
+
recentErrorRate,
|
|
43
|
+
recentAvgLatencyMs,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function registerProviderRoutes(app) {
|
|
47
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
48
|
+
app.get('/api/admin/providers', async () => {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const providers = db.select().from(schema.providers).all();
|
|
51
|
+
const modelCounts = db
|
|
52
|
+
.select({ providerId: schema.models.providerId, c: sql `COUNT(*)` })
|
|
53
|
+
.from(schema.models)
|
|
54
|
+
.groupBy(schema.models.providerId)
|
|
55
|
+
.all();
|
|
56
|
+
const counts = new Map(modelCounts.map((r) => [r.providerId, Number(r.c)]));
|
|
57
|
+
return {
|
|
58
|
+
providers: providers.map((p) => providerToSummary(p, counts.get(p.id) ?? 0, null, null)),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
app.post('/api/admin/providers', async (req) => {
|
|
62
|
+
const body = ProviderCreate.parse(req.body);
|
|
63
|
+
requireMasterKey(); // Need master key to encrypt new credentials
|
|
64
|
+
const db = getDb();
|
|
65
|
+
const slug = body.slug ? slugify(body.slug) : slugify(body.name);
|
|
66
|
+
const dup = db.select().from(schema.providers).where(eq(schema.providers.slug, slug)).get();
|
|
67
|
+
if (dup)
|
|
68
|
+
throw new GatewayError('invalid_request_error', `Provider slug '${slug}' is already in use`, { status: 400 });
|
|
69
|
+
const enc = encryptSecret(body.apiKey);
|
|
70
|
+
const headersEnc = body.customHeaders ? encryptCustomHeaders(body.customHeaders) : null;
|
|
71
|
+
const id = uuid();
|
|
72
|
+
db.insert(schema.providers).values({
|
|
73
|
+
id,
|
|
74
|
+
name: body.name,
|
|
75
|
+
slug,
|
|
76
|
+
type: body.type,
|
|
77
|
+
baseUrl: body.baseUrl,
|
|
78
|
+
encryptedApiKey: enc.ciphertext,
|
|
79
|
+
apiKeyNonce: enc.nonce,
|
|
80
|
+
apiKeyVersion: enc.version,
|
|
81
|
+
customHeadersEncrypted: headersEnc?.ciphertext ?? null,
|
|
82
|
+
customHeadersNonce: headersEnc?.nonce ?? null,
|
|
83
|
+
enabled: body.enabled ?? true,
|
|
84
|
+
connectTimeoutMs: body.connectTimeoutMs ?? 10000,
|
|
85
|
+
firstTokenTimeoutMs: body.firstTokenTimeoutMs ?? 30000,
|
|
86
|
+
streamIdleTimeoutMs: body.streamIdleTimeoutMs ?? 60000,
|
|
87
|
+
totalTimeoutMs: body.totalTimeoutMs ?? 180000,
|
|
88
|
+
maxRetries: body.maxRetries ?? 2,
|
|
89
|
+
cbFailureThreshold: body.cbFailureThreshold ?? 5,
|
|
90
|
+
cbCooldownSeconds: body.cbCooldownSeconds ?? 60,
|
|
91
|
+
}).run();
|
|
92
|
+
recordAudit({ action: 'provider.create', success: true, targetType: 'provider', targetId: id, targetName: body.name, ip: req.ip });
|
|
93
|
+
return { id, slug };
|
|
94
|
+
});
|
|
95
|
+
app.patch('/api/admin/providers', async (req) => {
|
|
96
|
+
const body = ProviderUpdate.parse(req.body);
|
|
97
|
+
requireMasterKey(); // Need master key to encrypt/decrypt credentials
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const p = db.select().from(schema.providers).where(eq(schema.providers.id, body.id)).get();
|
|
100
|
+
if (!p)
|
|
101
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
102
|
+
const update = { updatedAt: new Date().toISOString() };
|
|
103
|
+
if (body.name)
|
|
104
|
+
update.name = body.name;
|
|
105
|
+
if (body.slug)
|
|
106
|
+
update.slug = slugify(body.slug);
|
|
107
|
+
if (body.baseUrl)
|
|
108
|
+
update.baseUrl = body.baseUrl;
|
|
109
|
+
if (body.enabled !== undefined)
|
|
110
|
+
update.enabled = body.enabled;
|
|
111
|
+
if (body.connectTimeoutMs !== undefined)
|
|
112
|
+
update.connectTimeoutMs = body.connectTimeoutMs;
|
|
113
|
+
if (body.firstTokenTimeoutMs !== undefined)
|
|
114
|
+
update.firstTokenTimeoutMs = body.firstTokenTimeoutMs;
|
|
115
|
+
if (body.streamIdleTimeoutMs !== undefined)
|
|
116
|
+
update.streamIdleTimeoutMs = body.streamIdleTimeoutMs;
|
|
117
|
+
if (body.totalTimeoutMs !== undefined)
|
|
118
|
+
update.totalTimeoutMs = body.totalTimeoutMs;
|
|
119
|
+
if (body.maxRetries !== undefined)
|
|
120
|
+
update.maxRetries = body.maxRetries;
|
|
121
|
+
if (body.cbFailureThreshold !== undefined)
|
|
122
|
+
update.cbFailureThreshold = body.cbFailureThreshold;
|
|
123
|
+
if (body.cbCooldownSeconds !== undefined)
|
|
124
|
+
update.cbCooldownSeconds = body.cbCooldownSeconds;
|
|
125
|
+
if (body.apiKey) {
|
|
126
|
+
const enc = encryptSecret(body.apiKey);
|
|
127
|
+
update.encryptedApiKey = enc.ciphertext;
|
|
128
|
+
update.apiKeyNonce = enc.nonce;
|
|
129
|
+
update.apiKeyVersion = enc.version;
|
|
130
|
+
}
|
|
131
|
+
if (body.customHeaders) {
|
|
132
|
+
const enc = encryptCustomHeaders(body.customHeaders);
|
|
133
|
+
update.customHeadersEncrypted = enc?.ciphertext ?? null;
|
|
134
|
+
update.customHeadersNonce = enc?.nonce ?? null;
|
|
135
|
+
}
|
|
136
|
+
db.update(schema.providers).set(update).where(eq(schema.providers.id, body.id)).run();
|
|
137
|
+
recordAudit({ action: 'provider.update', success: true, targetType: 'provider', targetId: p.id, targetName: body.name ?? p.name, ip: req.ip });
|
|
138
|
+
return { ok: true };
|
|
139
|
+
});
|
|
140
|
+
app.delete('/api/admin/providers/:id', async (req) => {
|
|
141
|
+
const { id } = req.params;
|
|
142
|
+
const db = getDb();
|
|
143
|
+
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
144
|
+
if (!p)
|
|
145
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
146
|
+
const used = db.select().from(schema.models).where(eq(schema.models.providerId, id)).all();
|
|
147
|
+
if (used.length > 0) {
|
|
148
|
+
// Soft-disable if there are dependent models
|
|
149
|
+
db.update(schema.providers).set({ enabled: false, updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
|
|
150
|
+
recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
|
|
151
|
+
return { ok: true, softDisabled: true };
|
|
152
|
+
}
|
|
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 };
|
|
156
|
+
});
|
|
157
|
+
app.post('/api/admin/providers/:id/test', async (req) => {
|
|
158
|
+
const { id } = req.params;
|
|
159
|
+
const db = getDb();
|
|
160
|
+
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
161
|
+
if (!p)
|
|
162
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
163
|
+
const apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
|
|
164
|
+
const headers = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 } : null);
|
|
165
|
+
const result = await probeProvider({
|
|
166
|
+
type: p.type,
|
|
167
|
+
baseUrl: p.baseUrl,
|
|
168
|
+
apiKey,
|
|
169
|
+
customHeaders: headers,
|
|
170
|
+
connectTimeoutMs: p.connectTimeoutMs,
|
|
171
|
+
totalTimeoutMs: Math.min(p.totalTimeoutMs, 20000),
|
|
172
|
+
});
|
|
173
|
+
if (result.ok) {
|
|
174
|
+
db.update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
db.update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
|
|
178
|
+
}
|
|
179
|
+
recordAudit({ action: 'provider.test', success: result.ok, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { detail: result.detail } });
|
|
180
|
+
return result;
|
|
181
|
+
});
|
|
182
|
+
app.post('/api/admin/providers/:id/discover', async (req) => {
|
|
183
|
+
const { id } = req.params;
|
|
184
|
+
const db = getDb();
|
|
185
|
+
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
186
|
+
if (!p)
|
|
187
|
+
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
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
|
+
});
|
|
198
|
+
const existing = db
|
|
199
|
+
.select()
|
|
200
|
+
.from(schema.models)
|
|
201
|
+
.where(eq(schema.models.providerId, p.id))
|
|
202
|
+
.all();
|
|
203
|
+
const existingMap = new Map(existing.map((m) => [m.upstreamModelId, m]));
|
|
204
|
+
const enriched = discovered.map((d) => ({
|
|
205
|
+
...d,
|
|
206
|
+
alreadyImported: existingMap.has(d.upstreamId),
|
|
207
|
+
existingModelId: existingMap.get(d.upstreamId)?.id ?? null,
|
|
208
|
+
}));
|
|
209
|
+
recordAudit({ action: 'provider.discover', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip, metadata: { count: discovered.length } });
|
|
210
|
+
return { models: enriched };
|
|
211
|
+
});
|
|
212
|
+
}
|