@khanglvm/llm-router 1.0.5
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/.env.test-suite.example +19 -0
- package/README.md +230 -0
- package/package.json +26 -0
- package/src/cli/router-module.js +3987 -0
- package/src/cli-entry.js +144 -0
- package/src/index.js +18 -0
- package/src/node/config-store.js +74 -0
- package/src/node/config-workflows.js +245 -0
- package/src/node/instance-state.js +206 -0
- package/src/node/local-server.js +294 -0
- package/src/node/provider-probe.js +905 -0
- package/src/node/start-command.js +498 -0
- package/src/node/startup-manager.js +369 -0
- package/src/runtime/config.js +655 -0
- package/src/runtime/handler/auth.js +32 -0
- package/src/runtime/handler/config-loading.js +45 -0
- package/src/runtime/handler/fallback.js +424 -0
- package/src/runtime/handler/http.js +71 -0
- package/src/runtime/handler/network-guards.js +137 -0
- package/src/runtime/handler/provider-call.js +245 -0
- package/src/runtime/handler/provider-translation.js +232 -0
- package/src/runtime/handler/request.js +194 -0
- package/src/runtime/handler/utils.js +41 -0
- package/src/runtime/handler.js +301 -0
- package/src/translator/formats.js +7 -0
- package/src/translator/index.js +73 -0
- package/src/translator/request/claude-to-openai.js +228 -0
- package/src/translator/request/openai-to-claude.js +241 -0
- package/src/translator/response/claude-to-openai.js +204 -0
- package/src/translator/response/openai-to-claude.js +197 -0
- package/wrangler.toml +20 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime config helpers for both local Node route and Cloudflare Worker route.
|
|
3
|
+
* Config source is user-managed (e.g. ~/.llm-router.json or LLM_ROUTER_CONFIG_JSON secret).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { FORMATS } from "../translator/index.js";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_VERSION = 1;
|
|
9
|
+
export const PROVIDER_ID_PATTERN = /^[a-z][a-zA-Z0-9-]*$/;
|
|
10
|
+
export const DEFAULT_PROVIDER_USER_AGENT = "llm-router (+https://github.com/khanglvm/llm-router)";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
|
|
13
|
+
let runtimeEnvCache = null;
|
|
14
|
+
|
|
15
|
+
function toArray(value) {
|
|
16
|
+
if (Array.isArray(value)) return value;
|
|
17
|
+
if (value === undefined || value === null) return [];
|
|
18
|
+
return [value];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dedupeStrings(values) {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
if (typeof value !== "string") continue;
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (!trimmed) continue;
|
|
28
|
+
if (seen.has(trimmed)) continue;
|
|
29
|
+
seen.add(trimmed);
|
|
30
|
+
result.push(trimmed);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function slugifyId(value, fallback = "provider") {
|
|
36
|
+
const slug = String(value || fallback)
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
40
|
+
.replace(/^-+|-+$/g, "");
|
|
41
|
+
return slug || fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sanitizeEndpointUrl(value) {
|
|
45
|
+
const text = String(value || "").trim();
|
|
46
|
+
if (!text) return "";
|
|
47
|
+
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(text);
|
|
51
|
+
} catch {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Explicitly drop auth/hash components from persisted endpoint URLs.
|
|
60
|
+
parsed.username = "";
|
|
61
|
+
parsed.password = "";
|
|
62
|
+
parsed.hash = "";
|
|
63
|
+
return parsed.toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeAuthConfig(rawAuth) {
|
|
67
|
+
if (!rawAuth || typeof rawAuth !== "object") return null;
|
|
68
|
+
const type = rawAuth.type || rawAuth.kind;
|
|
69
|
+
if (!type) return null;
|
|
70
|
+
const headerName = typeof rawAuth.headerName === "string"
|
|
71
|
+
? rawAuth.headerName.trim()
|
|
72
|
+
: (typeof rawAuth.header === "string" ? rawAuth.header.trim() : "");
|
|
73
|
+
if (headerName && /[\r\n]/.test(headerName)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
type,
|
|
78
|
+
headerName: headerName || undefined,
|
|
79
|
+
prefix: rawAuth.prefix || undefined
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeModelEntry(model) {
|
|
84
|
+
if (typeof model === "string") {
|
|
85
|
+
return { id: model };
|
|
86
|
+
}
|
|
87
|
+
if (!model || typeof model !== "object") return null;
|
|
88
|
+
const id = model.id || model.name || model.model;
|
|
89
|
+
if (!id || typeof id !== "string") return null;
|
|
90
|
+
const rawFallbacks =
|
|
91
|
+
model.fallbackModels ??
|
|
92
|
+
model["fallback-models"] ??
|
|
93
|
+
model.silentFallbacks ??
|
|
94
|
+
model["silent-fallbacks"] ??
|
|
95
|
+
model.fallbacks;
|
|
96
|
+
const fallbackModels = dedupeStrings(toArray(rawFallbacks));
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
aliases: dedupeStrings(model.aliases || model.alias || []),
|
|
100
|
+
formats: dedupeStrings(model.formats || model.format || [])
|
|
101
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE),
|
|
102
|
+
enabled: model.enabled !== false,
|
|
103
|
+
contextWindow: Number.isFinite(model.contextWindow) ? Number(model.contextWindow) : undefined,
|
|
104
|
+
cost: model.cost,
|
|
105
|
+
metadata: model.metadata && typeof model.metadata === "object" ? model.metadata : undefined,
|
|
106
|
+
...(rawFallbacks !== undefined ? { fallbackModels } : {})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanitizeModelFallbackReferences(providers) {
|
|
111
|
+
const validQualifiedModels = new Set();
|
|
112
|
+
for (const provider of (providers || [])) {
|
|
113
|
+
if (provider.enabled === false) continue;
|
|
114
|
+
for (const model of (provider.models || [])) {
|
|
115
|
+
if (model.enabled === false) continue;
|
|
116
|
+
validQualifiedModels.add(`${provider.id}/${model.id}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (providers || []).map((provider) => ({
|
|
121
|
+
...provider,
|
|
122
|
+
models: (provider.models || []).map((model) => {
|
|
123
|
+
const selfId = `${provider.id}/${model.id}`;
|
|
124
|
+
const nextFallbacks = dedupeStrings(model.fallbackModels || [])
|
|
125
|
+
.filter((item) => item !== selfId)
|
|
126
|
+
.filter((item) => validQualifiedModels.has(item));
|
|
127
|
+
|
|
128
|
+
if (nextFallbacks.length > 0 || Object.prototype.hasOwnProperty.call(model, "fallbackModels")) {
|
|
129
|
+
return {
|
|
130
|
+
...model,
|
|
131
|
+
fallbackModels: nextFallbacks
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return model;
|
|
136
|
+
})
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeBaseUrlByFormat(value) {
|
|
141
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
142
|
+
const out = {};
|
|
143
|
+
const openai = typeof value.openai === "string" ? sanitizeEndpointUrl(value.openai) : "";
|
|
144
|
+
const claude =
|
|
145
|
+
typeof value.claude === "string" ? sanitizeEndpointUrl(value.claude)
|
|
146
|
+
: (typeof value.anthropic === "string" ? sanitizeEndpointUrl(value.anthropic) : "");
|
|
147
|
+
|
|
148
|
+
if (openai) out[FORMATS.OPENAI] = openai;
|
|
149
|
+
if (claude) out[FORMATS.CLAUDE] = claude;
|
|
150
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeProvider(provider, index = 0) {
|
|
154
|
+
if (!provider || typeof provider !== "object") return null;
|
|
155
|
+
|
|
156
|
+
const name = provider.name || provider.id || `provider-${index + 1}`;
|
|
157
|
+
const id = slugifyId(provider.id || provider.name || `provider-${index + 1}`);
|
|
158
|
+
const baseUrlByFormat = normalizeBaseUrlByFormat(
|
|
159
|
+
provider.baseUrlByFormat ||
|
|
160
|
+
provider["base-url-by-format"] ||
|
|
161
|
+
provider.endpointByFormat ||
|
|
162
|
+
provider["endpoint-by-format"] ||
|
|
163
|
+
provider.endpoints
|
|
164
|
+
);
|
|
165
|
+
const explicitBaseUrl = sanitizeEndpointUrl(provider.baseUrl || provider["base-url"] || provider.endpoint || "");
|
|
166
|
+
const rawFormat = provider.format || provider.responseFormat || provider["response-format"];
|
|
167
|
+
const preferredFormat = [FORMATS.OPENAI, FORMATS.CLAUDE].includes(rawFormat) ? rawFormat : undefined;
|
|
168
|
+
const endpointFormats = baseUrlByFormat ? Object.keys(baseUrlByFormat) : [];
|
|
169
|
+
const formats = dedupeStrings([
|
|
170
|
+
...toArray(provider.formats),
|
|
171
|
+
...endpointFormats,
|
|
172
|
+
...(preferredFormat ? [preferredFormat] : [])
|
|
173
|
+
]).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
174
|
+
const orderedFormats = preferredFormat
|
|
175
|
+
? dedupeStrings([preferredFormat, ...formats])
|
|
176
|
+
: formats;
|
|
177
|
+
const baseUrl = explicitBaseUrl
|
|
178
|
+
|| (preferredFormat && baseUrlByFormat?.[preferredFormat])
|
|
179
|
+
|| (baseUrlByFormat?.[orderedFormats[0]])
|
|
180
|
+
|| baseUrlByFormat?.[FORMATS.OPENAI]
|
|
181
|
+
|| baseUrlByFormat?.[FORMATS.CLAUDE]
|
|
182
|
+
|| "";
|
|
183
|
+
|
|
184
|
+
const normalizedModels = toArray(provider.models)
|
|
185
|
+
.map(normalizeModelEntry)
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.filter((item) => item.enabled !== false);
|
|
188
|
+
|
|
189
|
+
const auth = normalizeAuthConfig(provider.auth) || null;
|
|
190
|
+
const authByFormat = provider.authByFormat && typeof provider.authByFormat === "object"
|
|
191
|
+
? Object.fromEntries(
|
|
192
|
+
Object.entries(provider.authByFormat)
|
|
193
|
+
.map(([fmt, cfg]) => [fmt, normalizeAuthConfig(cfg)])
|
|
194
|
+
.filter(([, cfg]) => cfg)
|
|
195
|
+
)
|
|
196
|
+
: undefined;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
id,
|
|
200
|
+
name,
|
|
201
|
+
enabled: provider.enabled !== false,
|
|
202
|
+
baseUrl,
|
|
203
|
+
baseUrlByFormat,
|
|
204
|
+
apiKey: typeof provider.apiKey === "string" ? provider.apiKey : (typeof provider.credential === "string" ? provider.credential : undefined),
|
|
205
|
+
apiKeyEnv: typeof provider.apiKeyEnv === "string" ? provider.apiKeyEnv : undefined,
|
|
206
|
+
format: preferredFormat || orderedFormats[0],
|
|
207
|
+
formats: orderedFormats,
|
|
208
|
+
auth,
|
|
209
|
+
authByFormat,
|
|
210
|
+
headers: provider.headers && typeof provider.headers === "object" ? provider.headers : {},
|
|
211
|
+
anthropicVersion: provider.anthropicVersion || provider["anthropic-version"] || undefined,
|
|
212
|
+
anthropicBeta: provider.anthropicBeta || provider["anthropic-beta"] || undefined,
|
|
213
|
+
models: normalizedModels,
|
|
214
|
+
lastProbe: provider.lastProbe && typeof provider.lastProbe === "object" ? provider.lastProbe : undefined
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function normalizeRuntimeConfig(rawConfig) {
|
|
219
|
+
const raw = rawConfig && typeof rawConfig === "object" ? rawConfig : {};
|
|
220
|
+
const providers = sanitizeModelFallbackReferences(
|
|
221
|
+
toArray(raw.providers)
|
|
222
|
+
.map(normalizeProvider)
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.filter((provider) => provider.enabled !== false)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const masterKey = typeof raw.masterKey === "string"
|
|
228
|
+
? raw.masterKey
|
|
229
|
+
: (typeof raw["master-key"] === "string" ? raw["master-key"] : undefined);
|
|
230
|
+
|
|
231
|
+
const defaultModel = typeof raw.defaultModel === "string"
|
|
232
|
+
? raw.defaultModel
|
|
233
|
+
: (typeof raw["default-model"] === "string" ? raw["default-model"] : undefined);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
version: Number.isFinite(raw.version) ? Number(raw.version) : CONFIG_VERSION,
|
|
237
|
+
masterKey,
|
|
238
|
+
defaultModel,
|
|
239
|
+
providers,
|
|
240
|
+
metadata: raw.metadata && typeof raw.metadata === "object" ? raw.metadata : {}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function parseRuntimeConfigJson(json) {
|
|
245
|
+
return normalizeRuntimeConfig(JSON.parse(json));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function configHasProvider(config) {
|
|
249
|
+
return Array.isArray(config?.providers) && config.providers.some((provider) => provider.enabled !== false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function validateRuntimeConfig(config, { requireMasterKey = false, requireProvider = false } = {}) {
|
|
253
|
+
const errors = [];
|
|
254
|
+
|
|
255
|
+
if (!config || typeof config !== "object") {
|
|
256
|
+
errors.push("Config is missing or invalid.");
|
|
257
|
+
return errors;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!Array.isArray(config.providers)) {
|
|
261
|
+
errors.push("Config.providers must be an array.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (requireProvider && !configHasProvider(config)) {
|
|
265
|
+
errors.push("At least one enabled provider is required.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const provider of (config.providers || [])) {
|
|
269
|
+
if (!provider.id) errors.push("Provider missing id.");
|
|
270
|
+
if (provider.id && !PROVIDER_ID_PATTERN.test(provider.id)) {
|
|
271
|
+
errors.push(`Provider id '${provider.id}' is invalid. Use slug/camelCase (e.g. openrouter or myProvider).`);
|
|
272
|
+
}
|
|
273
|
+
if (!provider.baseUrl) errors.push(`Provider ${provider.id || "(unknown)"} missing baseUrl.`);
|
|
274
|
+
if (!provider.format && (!provider.formats || provider.formats.length === 0)) {
|
|
275
|
+
errors.push(`Provider ${provider.id || "(unknown)"} missing detected format.`);
|
|
276
|
+
}
|
|
277
|
+
if (!Array.isArray(provider.models) || provider.models.length === 0) {
|
|
278
|
+
errors.push(`Provider ${provider.id || "(unknown)"} must define at least one model.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (requireMasterKey && !config.masterKey) {
|
|
283
|
+
errors.push("masterKey is required for worker deployment/export.");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return errors;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function resolveProviderApiKey(provider, env = undefined) {
|
|
290
|
+
if (provider?.apiKey) return provider.apiKey;
|
|
291
|
+
if (provider?.apiKeyEnv && env && provider.apiKeyEnv in env) {
|
|
292
|
+
return env[provider.apiKeyEnv];
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function resolveProviderFormat(provider, sourceFormat = undefined) {
|
|
298
|
+
const supported = dedupeStrings([...(provider?.formats || []), provider?.format]);
|
|
299
|
+
if (sourceFormat && supported.includes(sourceFormat)) return sourceFormat;
|
|
300
|
+
if (supported.includes(FORMATS.CLAUDE) && sourceFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE;
|
|
301
|
+
if (provider?.format) return provider.format;
|
|
302
|
+
if (supported.length > 0) return supported[0];
|
|
303
|
+
return FORMATS.OPENAI;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function resolveProviderUrl(provider, targetFormat) {
|
|
307
|
+
const baseUrl = sanitizeEndpointUrl(provider?.baseUrlByFormat?.[targetFormat] || provider?.baseUrl || "").replace(/\/+$/, "");
|
|
308
|
+
if (!baseUrl) return "";
|
|
309
|
+
const isVersionedApiRoot = /\/v\d+(?:\.\d+)?$/i.test(baseUrl);
|
|
310
|
+
|
|
311
|
+
if (targetFormat === FORMATS.OPENAI) {
|
|
312
|
+
if (baseUrl.endsWith("/chat/completions")) return baseUrl;
|
|
313
|
+
if (baseUrl.endsWith("/v1") || isVersionedApiRoot) return `${baseUrl}/chat/completions`;
|
|
314
|
+
return `${baseUrl}/v1/chat/completions`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (targetFormat === FORMATS.CLAUDE) {
|
|
318
|
+
if (baseUrl.endsWith("/v1/messages") || baseUrl.endsWith("/messages")) return baseUrl;
|
|
319
|
+
if (baseUrl.endsWith("/v1") || isVersionedApiRoot) return `${baseUrl}/messages`;
|
|
320
|
+
return `${baseUrl}/v1/messages`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return baseUrl;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function pickProviderAuth(provider, targetFormat) {
|
|
327
|
+
if (provider?.authByFormat && provider.authByFormat[targetFormat]) {
|
|
328
|
+
return provider.authByFormat[targetFormat];
|
|
329
|
+
}
|
|
330
|
+
if (provider?.auth) return provider.auth;
|
|
331
|
+
if (targetFormat === FORMATS.CLAUDE) {
|
|
332
|
+
return { type: "x-api-key" };
|
|
333
|
+
}
|
|
334
|
+
return { type: "bearer" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function hasHeaderName(headers, name) {
|
|
338
|
+
const lower = String(name).toLowerCase();
|
|
339
|
+
return Object.keys(headers || {}).some((key) => key.toLowerCase() === lower);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeCustomHeaders(rawHeaders) {
|
|
343
|
+
const out = {};
|
|
344
|
+
let userAgentExplicitlyDisabled = false;
|
|
345
|
+
const blockedHeaders = new Set([
|
|
346
|
+
"connection",
|
|
347
|
+
"content-length",
|
|
348
|
+
"host",
|
|
349
|
+
"proxy-authenticate",
|
|
350
|
+
"proxy-authorization",
|
|
351
|
+
"te",
|
|
352
|
+
"trailer",
|
|
353
|
+
"transfer-encoding",
|
|
354
|
+
"upgrade"
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
if (!rawHeaders || typeof rawHeaders !== "object" || Array.isArray(rawHeaders)) {
|
|
358
|
+
return { headers: out, userAgentExplicitlyDisabled };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const [name, value] of Object.entries(rawHeaders)) {
|
|
362
|
+
if (typeof name !== "string" || !name.trim()) continue;
|
|
363
|
+
if (/[\r\n]/.test(name)) continue;
|
|
364
|
+
const lower = name.toLowerCase();
|
|
365
|
+
if (blockedHeaders.has(lower)) continue;
|
|
366
|
+
const isUserAgent = lower === "user-agent";
|
|
367
|
+
|
|
368
|
+
if (value === undefined || value === null || value === false) {
|
|
369
|
+
if (isUserAgent) userAgentExplicitlyDisabled = true;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const text = String(value);
|
|
374
|
+
if (/[\r\n]/.test(text)) continue;
|
|
375
|
+
if (!text && isUserAgent) {
|
|
376
|
+
userAgentExplicitlyDisabled = true;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!text) continue;
|
|
380
|
+
out[name] = text;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { headers: out, userAgentExplicitlyDisabled };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function buildProviderHeaders(provider, env = undefined, targetFormat = undefined) {
|
|
387
|
+
const format = targetFormat || resolveProviderFormat(provider);
|
|
388
|
+
const { headers: customHeaders, userAgentExplicitlyDisabled } = normalizeCustomHeaders(provider?.headers);
|
|
389
|
+
const headers = {
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
...customHeaders
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (!userAgentExplicitlyDisabled && !hasHeaderName(headers, "user-agent")) {
|
|
395
|
+
headers["User-Agent"] = DEFAULT_PROVIDER_USER_AGENT;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const apiKey = resolveProviderApiKey(provider, env);
|
|
399
|
+
const auth = pickProviderAuth(provider, format);
|
|
400
|
+
|
|
401
|
+
if (apiKey) {
|
|
402
|
+
if (auth?.type === "x-api-key") {
|
|
403
|
+
headers["x-api-key"] = apiKey;
|
|
404
|
+
} else if (auth?.type === "header" && auth.headerName) {
|
|
405
|
+
headers[auth.headerName] = `${auth.prefix || ""}${apiKey}`;
|
|
406
|
+
} else if (auth?.type !== "none") {
|
|
407
|
+
headers["Authorization"] = `${auth?.prefix || "Bearer "}${apiKey}`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (format === FORMATS.CLAUDE) {
|
|
412
|
+
if (!headers["anthropic-version"] && !headers["Anthropic-Version"]) {
|
|
413
|
+
headers["anthropic-version"] = provider?.anthropicVersion || DEFAULT_ANTHROPIC_VERSION;
|
|
414
|
+
}
|
|
415
|
+
if (provider?.anthropicBeta && !headers["anthropic-beta"] && !headers["Anthropic-Beta"]) {
|
|
416
|
+
headers["anthropic-beta"] = provider.anthropicBeta;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return headers;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function maskSecret(value) {
|
|
424
|
+
if (!value || typeof value !== "string") return "";
|
|
425
|
+
if (value.length <= 8) return "*".repeat(value.length);
|
|
426
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function sanitizeConfigForDisplay(config) {
|
|
430
|
+
return {
|
|
431
|
+
...config,
|
|
432
|
+
masterKey: config.masterKey ? maskSecret(config.masterKey) : undefined,
|
|
433
|
+
providers: (config.providers || []).map((provider) => ({
|
|
434
|
+
...provider,
|
|
435
|
+
apiKey: provider.apiKey ? maskSecret(provider.apiKey) : undefined
|
|
436
|
+
}))
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function buildTargetCandidate(provider, model, sourceFormat) {
|
|
441
|
+
const providerFormats = dedupeStrings([...(provider?.formats || []), provider?.format])
|
|
442
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
443
|
+
const modelFormats = dedupeStrings([...(model?.formats || []), model?.format])
|
|
444
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
445
|
+
const supportedFormats = modelFormats.length > 0
|
|
446
|
+
? providerFormats.filter((fmt) => modelFormats.includes(fmt))
|
|
447
|
+
: providerFormats;
|
|
448
|
+
|
|
449
|
+
let targetFormat = sourceFormat && supportedFormats.includes(sourceFormat)
|
|
450
|
+
? sourceFormat
|
|
451
|
+
: undefined;
|
|
452
|
+
|
|
453
|
+
if (!targetFormat && supportedFormats.length > 0) {
|
|
454
|
+
if (sourceFormat === FORMATS.CLAUDE && supportedFormats.includes(FORMATS.CLAUDE)) {
|
|
455
|
+
targetFormat = FORMATS.CLAUDE;
|
|
456
|
+
} else if (sourceFormat === FORMATS.OPENAI && supportedFormats.includes(FORMATS.OPENAI)) {
|
|
457
|
+
targetFormat = FORMATS.OPENAI;
|
|
458
|
+
} else {
|
|
459
|
+
targetFormat = supportedFormats[0];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!targetFormat) {
|
|
464
|
+
targetFormat = resolveProviderFormat(provider, sourceFormat);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
providerId: provider.id,
|
|
469
|
+
providerName: provider.name,
|
|
470
|
+
provider,
|
|
471
|
+
modelId: model.id,
|
|
472
|
+
model,
|
|
473
|
+
backend: model.id,
|
|
474
|
+
targetFormat,
|
|
475
|
+
requestModelId: `${provider.id}/${model.id}`
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function modelSupportsProviderFormat(provider, model) {
|
|
480
|
+
const providerFormats = dedupeStrings([...(provider.formats || []), provider.format])
|
|
481
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
482
|
+
const modelFormats = dedupeStrings([...(model.formats || []), model.format])
|
|
483
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
484
|
+
if (modelFormats.length === 0) return true;
|
|
485
|
+
return providerFormats.some((fmt) => modelFormats.includes(fmt));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function findModelById(provider, modelId) {
|
|
489
|
+
return (provider.models || []).find((model) => model.id === modelId || (model.aliases || []).includes(modelId));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function resolveQualifiedModel(config, qualifiedModel) {
|
|
493
|
+
const value = String(qualifiedModel || "").trim();
|
|
494
|
+
const slashIndex = value.indexOf("/");
|
|
495
|
+
if (slashIndex <= 0 || slashIndex === value.length - 1) return null;
|
|
496
|
+
|
|
497
|
+
const providerId = value.slice(0, slashIndex);
|
|
498
|
+
const modelId = value.slice(slashIndex + 1);
|
|
499
|
+
const provider = (config.providers || []).find((item) => item.id === providerId && item.enabled !== false);
|
|
500
|
+
if (!provider) return null;
|
|
501
|
+
|
|
502
|
+
const model = findModelById(provider, modelId);
|
|
503
|
+
if (!model) return null;
|
|
504
|
+
|
|
505
|
+
return { provider, model };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function resolveRequestModel(config, requestedModel, sourceFormat = FORMATS.CLAUDE) {
|
|
509
|
+
const normalizedRequested = typeof requestedModel === "string" && requestedModel.trim()
|
|
510
|
+
? requestedModel.trim()
|
|
511
|
+
: "smart";
|
|
512
|
+
|
|
513
|
+
const defaultModel = config.defaultModel || "smart";
|
|
514
|
+
const effectiveRequested = normalizedRequested === "smart" ? defaultModel : normalizedRequested;
|
|
515
|
+
|
|
516
|
+
// Provider-qualified model syntax is required: provider/model
|
|
517
|
+
const slashIndex = effectiveRequested.indexOf("/");
|
|
518
|
+
if (slashIndex <= 0 || slashIndex === effectiveRequested.length - 1) {
|
|
519
|
+
return {
|
|
520
|
+
requestedModel: normalizedRequested,
|
|
521
|
+
resolvedModel: null,
|
|
522
|
+
primary: null,
|
|
523
|
+
fallbacks: [],
|
|
524
|
+
error: "Model must use the 'provider/model' convention."
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const providerId = effectiveRequested.slice(0, slashIndex);
|
|
529
|
+
const modelName = effectiveRequested.slice(slashIndex + 1);
|
|
530
|
+
const provider = (config.providers || []).find((item) => item.id === providerId && item.enabled !== false);
|
|
531
|
+
|
|
532
|
+
if (!provider) {
|
|
533
|
+
return {
|
|
534
|
+
requestedModel: normalizedRequested,
|
|
535
|
+
resolvedModel: null,
|
|
536
|
+
primary: null,
|
|
537
|
+
fallbacks: [],
|
|
538
|
+
error: `Provider '${providerId}' not found.`
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const model = findModelById(provider, modelName);
|
|
543
|
+
if (!model) {
|
|
544
|
+
return {
|
|
545
|
+
requestedModel: normalizedRequested,
|
|
546
|
+
resolvedModel: null,
|
|
547
|
+
primary: null,
|
|
548
|
+
fallbacks: [],
|
|
549
|
+
error: `Model '${modelName}' is not configured under provider '${providerId}'.`
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!modelSupportsProviderFormat(provider, model)) {
|
|
554
|
+
return {
|
|
555
|
+
requestedModel: normalizedRequested,
|
|
556
|
+
resolvedModel: null,
|
|
557
|
+
primary: null,
|
|
558
|
+
fallbacks: [],
|
|
559
|
+
error: `Model '${modelName}' is configured for unsupported endpoint formats under provider '${providerId}'.`
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const primary = buildTargetCandidate(provider, model, sourceFormat);
|
|
564
|
+
const fallbackCandidates = [];
|
|
565
|
+
const seen = new Set([primary.requestModelId]);
|
|
566
|
+
|
|
567
|
+
for (const fallbackEntry of (model.fallbackModels || [])) {
|
|
568
|
+
const resolvedFallback = resolveQualifiedModel(config, fallbackEntry);
|
|
569
|
+
if (!resolvedFallback) continue;
|
|
570
|
+
if (!modelSupportsProviderFormat(resolvedFallback.provider, resolvedFallback.model)) continue;
|
|
571
|
+
|
|
572
|
+
const fallbackCandidate = buildTargetCandidate(resolvedFallback.provider, resolvedFallback.model, sourceFormat);
|
|
573
|
+
if (seen.has(fallbackCandidate.requestModelId)) continue;
|
|
574
|
+
seen.add(fallbackCandidate.requestModelId);
|
|
575
|
+
fallbackCandidates.push(fallbackCandidate);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
requestedModel: normalizedRequested,
|
|
580
|
+
resolvedModel: `${provider.id}/${model.id}`,
|
|
581
|
+
primary,
|
|
582
|
+
fallbacks: fallbackCandidates
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function listConfiguredModels(config, { endpointFormat } = {}) {
|
|
587
|
+
const rows = [];
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
|
|
590
|
+
for (const provider of (config.providers || [])) {
|
|
591
|
+
if (provider.enabled === false) continue;
|
|
592
|
+
|
|
593
|
+
for (const model of (provider.models || [])) {
|
|
594
|
+
if (model.enabled === false) continue;
|
|
595
|
+
|
|
596
|
+
rows.push({
|
|
597
|
+
id: `${provider.id}/${model.id}`,
|
|
598
|
+
object: "model",
|
|
599
|
+
created: now,
|
|
600
|
+
owned_by: provider.id,
|
|
601
|
+
provider_id: provider.id,
|
|
602
|
+
provider_name: provider.name,
|
|
603
|
+
formats: (model.formats && model.formats.length > 0) ? model.formats : (provider.formats || []),
|
|
604
|
+
endpoint_format_supported: endpointFormat
|
|
605
|
+
? ((model.formats && model.formats.length > 0) ? model.formats.includes(endpointFormat) : (provider.formats || []).includes(endpointFormat))
|
|
606
|
+
: undefined,
|
|
607
|
+
context_window: model.contextWindow,
|
|
608
|
+
cost: model.cost,
|
|
609
|
+
model_formats: model.formats || [],
|
|
610
|
+
fallback_models: model.fallbackModels || []
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return rows;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function runtimeConfigFromEnv(env = {}) {
|
|
619
|
+
const rawJson =
|
|
620
|
+
env.LLM_ROUTER_CONFIG_JSON ||
|
|
621
|
+
env.ROUTE_CONFIG_JSON ||
|
|
622
|
+
env.LLM_ROUTER_JSON;
|
|
623
|
+
const overrideMasterKey = typeof env.LLM_ROUTER_MASTER_KEY === "string"
|
|
624
|
+
? env.LLM_ROUTER_MASTER_KEY
|
|
625
|
+
: "";
|
|
626
|
+
|
|
627
|
+
if (!runtimeEnvCache || runtimeEnvCache.rawJson !== rawJson) {
|
|
628
|
+
runtimeEnvCache = {
|
|
629
|
+
rawJson,
|
|
630
|
+
parsed: rawJson ? parseRuntimeConfigJson(rawJson) : normalizeRuntimeConfig({}),
|
|
631
|
+
overrideMasterKey: null,
|
|
632
|
+
resolved: null
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (
|
|
637
|
+
runtimeEnvCache.resolved &&
|
|
638
|
+
runtimeEnvCache.overrideMasterKey === overrideMasterKey
|
|
639
|
+
) {
|
|
640
|
+
return runtimeEnvCache.resolved;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (!overrideMasterKey) {
|
|
644
|
+
runtimeEnvCache.overrideMasterKey = overrideMasterKey;
|
|
645
|
+
runtimeEnvCache.resolved = runtimeEnvCache.parsed;
|
|
646
|
+
return runtimeEnvCache.resolved;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
runtimeEnvCache.overrideMasterKey = overrideMasterKey;
|
|
650
|
+
runtimeEnvCache.resolved = {
|
|
651
|
+
...runtimeEnvCache.parsed,
|
|
652
|
+
masterKey: overrideMasterKey
|
|
653
|
+
};
|
|
654
|
+
return runtimeEnvCache.resolved;
|
|
655
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function parseAuthToken(request) {
|
|
2
|
+
const authHeader = request.headers.get("Authorization");
|
|
3
|
+
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7).trim();
|
|
4
|
+
if (authHeader && !authHeader.startsWith("Bearer ")) return authHeader.trim();
|
|
5
|
+
const apiKey = request.headers.get("x-api-key");
|
|
6
|
+
return apiKey ? apiKey.trim() : "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function timingSafeStringEqual(left, right) {
|
|
10
|
+
const a = String(left || "");
|
|
11
|
+
const b = String(right || "");
|
|
12
|
+
const max = Math.max(a.length, b.length);
|
|
13
|
+
let diff = a.length ^ b.length;
|
|
14
|
+
for (let index = 0; index < max; index += 1) {
|
|
15
|
+
const aCode = index < a.length ? a.charCodeAt(index) : 0;
|
|
16
|
+
const bCode = index < b.length ? b.charCodeAt(index) : 0;
|
|
17
|
+
diff |= aCode ^ bCode;
|
|
18
|
+
}
|
|
19
|
+
return diff === 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateAuth(request, config, options = {}) {
|
|
23
|
+
if (options.ignoreAuth === true) return true;
|
|
24
|
+
const requiredToken = config.masterKey;
|
|
25
|
+
if (!requiredToken) return true;
|
|
26
|
+
const providedToken = parseAuthToken(request);
|
|
27
|
+
return timingSafeStringEqual(providedToken, requiredToken);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shouldEnforceWorkerAuth(options = {}) {
|
|
31
|
+
return options.ignoreAuth !== true;
|
|
32
|
+
}
|