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