ldrouter 1.17.9 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -66
- package/dist/server/app.js +0 -2
- package/dist/server/db/migrate.js +1 -2
- package/dist/server/db/schema.js +1 -2
- package/dist/server/gateway/runner.js +4 -2
- package/dist/server/routes/admin/combos.js +128 -11
- package/dist/server/routes/admin/dashboard.js +24 -0
- package/dist/server/routes/admin/models.js +4 -0
- package/dist/server/routes/admin/providers.js +25 -64
- package/dist/server/routes/admin/requests.js +32 -0
- package/dist/server/routes/admin.js +0 -8
- package/dist/server/routing/combo.js +8 -31
- package/dist/server/upstream/client.js +3 -26
- package/dist/web/assets/index-CyboDiyN.js +349 -0
- package/dist/web/assets/index-n9bGQMSj.css +1 -0
- package/dist/web/index.html +2 -2
- package/migrations/0010_ordered_combos.sql +55 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CbKRlw3a.css +0 -1
- package/dist/web/assets/index-D-rviQ3G.js +0 -391
|
@@ -32,9 +32,12 @@ export function toSummary(r, maps) {
|
|
|
32
32
|
endpoint: r.endpoint,
|
|
33
33
|
requestedModel: r.requestedModel,
|
|
34
34
|
resolvedTargetKind: r.resolvedTargetKind,
|
|
35
|
+
resolvedTargetId: r.resolvedTargetId,
|
|
35
36
|
finalModelPublicId: finalModel?.publicModelId ?? null,
|
|
37
|
+
finalModelUpstreamId: finalModel?.upstreamModelId ?? null,
|
|
36
38
|
providerId: provider ? finalModel.providerId : null,
|
|
37
39
|
providerName: provider?.name ?? null,
|
|
40
|
+
providerSlug: provider?.slug ?? null,
|
|
38
41
|
providerType: provider?.type ?? null,
|
|
39
42
|
streaming: Boolean(r.streaming),
|
|
40
43
|
httpStatus: r.httpStatus,
|
|
@@ -53,6 +56,27 @@ export function toSummary(r, maps) {
|
|
|
53
56
|
gatewayCacheHit: Boolean(r.gatewayCacheHit),
|
|
54
57
|
};
|
|
55
58
|
}
|
|
59
|
+
function quoteCurlJson(value) {
|
|
60
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
61
|
+
}
|
|
62
|
+
function buildReproductionCurl(request, redactedPayload) {
|
|
63
|
+
const endpoint = request.protocol === 'anthropic'
|
|
64
|
+
? '/v1/messages'
|
|
65
|
+
: `/v1/${request.endpoint.replace(/^\/?v1\/?/, '')}`;
|
|
66
|
+
let body = { model: request.requestedModel };
|
|
67
|
+
if (redactedPayload) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(redactedPayload);
|
|
70
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
71
|
+
body = { ...parsed, model: request.requestedModel };
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
const headers = request.protocol === 'anthropic'
|
|
76
|
+
? "-H 'x-api-key: REPLACE_WITH_LD_KEY' -H 'anthropic-version: 2023-06-01'"
|
|
77
|
+
: "-H 'Authorization: Bearer ld-REPLACE_WITH_KEY'";
|
|
78
|
+
return `curl http://localhost:8787${endpoint} -H 'content-type: application/json' ${headers} --data-raw ${quoteCurlJson(JSON.stringify(body))}`;
|
|
79
|
+
}
|
|
56
80
|
export async function registerRequestRoutes(app) {
|
|
57
81
|
app.addHook('preHandler', requireAdminAuth);
|
|
58
82
|
app.get('/api/admin/requests', async (req) => {
|
|
@@ -205,7 +229,12 @@ export async function registerRequestRoutes(app) {
|
|
|
205
229
|
endpoint: r.endpoint,
|
|
206
230
|
requestedModel: r.requestedModel,
|
|
207
231
|
resolvedTargetKind: r.resolvedTargetKind,
|
|
232
|
+
resolvedTargetId: r.resolvedTargetId,
|
|
208
233
|
finalModelPublicId: finalModel?.publicModelId ?? null,
|
|
234
|
+
finalModelUpstreamId: finalModel?.upstreamModelId ?? null,
|
|
235
|
+
providerId: finalModel?.providerId ?? null,
|
|
236
|
+
providerName: providerMap.get(finalModel?.providerId ?? '')?.name ?? null,
|
|
237
|
+
providerSlug: providerMap.get(finalModel?.providerId ?? '')?.slug ?? null,
|
|
209
238
|
streaming: Boolean(r.streaming),
|
|
210
239
|
httpStatus: r.httpStatus,
|
|
211
240
|
success: Boolean(r.success),
|
|
@@ -222,6 +251,8 @@ export async function registerRequestRoutes(app) {
|
|
|
222
251
|
errorMessage: r.errorMessage,
|
|
223
252
|
requestPayload: redactJsonString(r.requestPayloadJson),
|
|
224
253
|
responsePayload: redactJsonString(r.responsePayloadJson),
|
|
254
|
+
reproductionCurl: buildReproductionCurl(r, redactJsonString(r.requestPayloadJson)),
|
|
255
|
+
fallbackReasons: [...new Set(attempts.map((a) => a.failureReason).filter((reason) => Boolean(reason)))],
|
|
225
256
|
gatewayCacheHit: Boolean(r.gatewayCacheHit),
|
|
226
257
|
},
|
|
227
258
|
attempts: attempts.map((a) => ({
|
|
@@ -231,6 +262,7 @@ export async function registerRequestRoutes(app) {
|
|
|
231
262
|
providerName: providerMap.get(a.providerId)?.name ?? '',
|
|
232
263
|
modelId: a.modelId,
|
|
233
264
|
modelPublicId: modelMap.get(a.modelId)?.publicModelId ?? '',
|
|
265
|
+
upstreamModelId: modelMap.get(a.modelId)?.upstreamModelId ?? '',
|
|
234
266
|
codexAccountId: a.codexAccountId,
|
|
235
267
|
startedAt: a.startedAt,
|
|
236
268
|
completedAt: a.completedAt,
|
|
@@ -13,8 +13,6 @@ import { registerAuditRoutes } from './admin/audit.js';
|
|
|
13
13
|
import { registerSettingsRoutes } from './admin/settings.js';
|
|
14
14
|
import { registerBackupRoutes } from './admin/backup.js';
|
|
15
15
|
import { registerDashboardRoutes } from './admin/dashboard.js';
|
|
16
|
-
import { registerCodexRoutes, registerCodexOAuthCallbackRoute } from './admin/codex.js';
|
|
17
|
-
import { registerQoderRoutes } from './admin/qoder.js';
|
|
18
16
|
export async function registerAdminRoutes(app) {
|
|
19
17
|
// Setup routes are always reachable (used on first run).
|
|
20
18
|
await app.register(async (instance) => {
|
|
@@ -25,10 +23,6 @@ export async function registerAdminRoutes(app) {
|
|
|
25
23
|
await app.register(async (instance) => {
|
|
26
24
|
await registerAuthRoutes(instance);
|
|
27
25
|
});
|
|
28
|
-
// The Codex OAuth loopback callback is hit by the browser's redirect, so it is public too.
|
|
29
|
-
await app.register(async (instance) => {
|
|
30
|
-
await registerCodexOAuthCallbackRoute(instance);
|
|
31
|
-
});
|
|
32
26
|
// Authenticated admin routes
|
|
33
27
|
await app.register(async (instance) => {
|
|
34
28
|
instance.addHook('preHandler', requireAdminAuth);
|
|
@@ -46,8 +40,6 @@ export async function registerAdminRoutes(app) {
|
|
|
46
40
|
await registerAuditRoutes(instance);
|
|
47
41
|
await registerSettingsRoutes(instance);
|
|
48
42
|
await registerDashboardRoutes(instance);
|
|
49
|
-
await registerCodexRoutes(instance);
|
|
50
|
-
await registerQoderRoutes(instance);
|
|
51
43
|
});
|
|
52
44
|
// Backup routes get their own scope. The first-run import (setup.tsx) must be reachable before
|
|
53
45
|
// any admin account exists, but this scope's parent runs the other admin modules' auth hooks,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Combo routing:
|
|
1
|
+
// Combo routing: ordered fallback.
|
|
2
2
|
import { eq } from 'drizzle-orm';
|
|
3
3
|
import { getDb, schema } from '../db/index.js';
|
|
4
4
|
import { firstMissingCapability } from './capabilities.js';
|
|
@@ -10,9 +10,9 @@ export function loadCombo(comboId) {
|
|
|
10
10
|
const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, comboId)).all();
|
|
11
11
|
return {
|
|
12
12
|
comboId: c.id,
|
|
13
|
-
mode:
|
|
13
|
+
mode: 'fallback',
|
|
14
14
|
maxTotalAttempts: c.maxTotalAttempts,
|
|
15
|
-
members: members.map((m) => ({ id: m.id, modelId: m.modelId, position: m.position,
|
|
15
|
+
members: members.map((m) => ({ id: m.id, modelId: m.modelId, position: m.position, enabled: m.enabled })),
|
|
16
16
|
trigger: {
|
|
17
17
|
connection: c.fallbackOnConnection,
|
|
18
18
|
connectTimeout: c.fallbackOnConnectTimeout,
|
|
@@ -89,35 +89,12 @@ export function selectCandidates(combo, allModels, req, onReject) {
|
|
|
89
89
|
return candidates;
|
|
90
90
|
}
|
|
91
91
|
export function orderCandidates(combo, candidates) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return (am?.position ?? 0) - (bm?.position ?? 0);
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
// Weighted round-robin: stable order with weighted lead bias.
|
|
101
|
-
// We rotate via a process-local cursor keyed by combo id.
|
|
102
|
-
const cursor = nextCursor(combo.comboId, combo.members, candidates);
|
|
103
|
-
return cursor;
|
|
104
|
-
}
|
|
105
|
-
const comboCursors = new Map();
|
|
106
|
-
function nextCursor(comboId, members, candidates) {
|
|
107
|
-
if (candidates.length === 0)
|
|
108
|
-
return [];
|
|
109
|
-
// Repeat each available member according to its configured positive weight,
|
|
110
|
-
// then advance one slot per request. This is deterministic weighted RR.
|
|
111
|
-
const slots = members.flatMap((member) => {
|
|
112
|
-
const candidate = candidates.find((c) => c.modelId === member.modelId);
|
|
113
|
-
if (!candidate)
|
|
114
|
-
return [];
|
|
115
|
-
return Array.from({ length: Math.max(1, member.weight) }, () => candidate);
|
|
92
|
+
// Preserve declared position order.
|
|
93
|
+
return [...candidates].sort((a, b) => {
|
|
94
|
+
const am = combo.members.find((m) => m.modelId === a.modelId);
|
|
95
|
+
const bm = combo.members.find((m) => m.modelId === b.modelId);
|
|
96
|
+
return (am?.position ?? 0) - (bm?.position ?? 0);
|
|
116
97
|
});
|
|
117
|
-
const cur = (comboCursors.get(comboId) ?? 0) % Math.max(1, slots.length);
|
|
118
|
-
comboCursors.set(comboId, cur + 1);
|
|
119
|
-
const selected = slots[cur] ?? candidates[0];
|
|
120
|
-
return [selected, ...candidates.filter((candidate) => candidate !== selected)];
|
|
121
98
|
}
|
|
122
99
|
export function shouldFallback(combo, reason) {
|
|
123
100
|
switch (reason.type) {
|
|
@@ -3,35 +3,12 @@ import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
|
|
|
3
3
|
import { GatewayError } from '../errors.js';
|
|
4
4
|
import { buildHeaders, stripSlash } from '../providers/index.js';
|
|
5
5
|
import { redactString } from '../security/redact.js';
|
|
6
|
-
import { getCodexAccountForProvider, getCodexAccountById } from '../db/repositories/codex-accounts.js';
|
|
7
|
-
import { findEligibleQoderAccount } from '../db/repositories/qoder-accounts.js';
|
|
8
6
|
import { debugUpstream, errorLine, formatError, truncate } from '../logging/debug.js';
|
|
9
|
-
export function providerToUpstreamConfig(p,
|
|
7
|
+
export function providerToUpstreamConfig(p, _codexAccountId) {
|
|
10
8
|
let apiKey;
|
|
11
9
|
let customHeaders;
|
|
12
|
-
if (p.type === 'codex') {
|
|
13
|
-
|
|
14
|
-
if (!account)
|
|
15
|
-
throw new GatewayError('authentication_error', 'No usable Codex account is configured', { status: 503 });
|
|
16
|
-
return {
|
|
17
|
-
type: 'codex', baseUrl: p.baseUrl, customHeaders: {},
|
|
18
|
-
connectTimeoutMs: p.connectTimeoutMs, firstTokenTimeoutMs: p.firstTokenTimeoutMs,
|
|
19
|
-
streamIdleTimeoutMs: p.streamIdleTimeoutMs, totalTimeoutMs: p.totalTimeoutMs,
|
|
20
|
-
codexAccountId: account.chatgptAccountId, accountRecordId: account.id,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
if (p.type === 'qoder') {
|
|
24
|
-
// Account-pool provider: no API key on the row. The runner normally overrides the account
|
|
25
|
-
// from the expanded candidate; this resolves the first eligible one for direct callers.
|
|
26
|
-
const account = findEligibleQoderAccount(p.id);
|
|
27
|
-
if (!account)
|
|
28
|
-
throw new GatewayError('authentication_error', 'No usable Qoder account is configured', { status: 503 });
|
|
29
|
-
return {
|
|
30
|
-
type: 'qoder', baseUrl: p.baseUrl, customHeaders: {},
|
|
31
|
-
connectTimeoutMs: p.connectTimeoutMs, firstTokenTimeoutMs: p.firstTokenTimeoutMs,
|
|
32
|
-
streamIdleTimeoutMs: p.streamIdleTimeoutMs, totalTimeoutMs: p.totalTimeoutMs,
|
|
33
|
-
qoderUserId: account.qoderUserId, qoderAccountRecordId: account.id,
|
|
34
|
-
};
|
|
10
|
+
if (p.type === 'codex' || p.type === 'qoder') {
|
|
11
|
+
throw new GatewayError('invalid_request_error', `${p.type} providers are no longer supported`, { status: 410 });
|
|
35
12
|
}
|
|
36
13
|
if (!p.encryptedApiKey || !p.apiKeyNonce) {
|
|
37
14
|
throw new GatewayError('invalid_request_error', 'Provider credentials are missing', { status: 501 });
|