@khanglvm/ai-router 1.0.2 → 1.0.4
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 +6 -5
- package/package.json +1 -1
- package/src/cli/router-module.js +212 -51
- package/src/node/config-workflows.js +53 -4
- package/src/node/provider-probe.js +465 -0
- package/src/runtime/config.js +87 -5
package/README.md
CHANGED
|
@@ -32,11 +32,10 @@ ai-router setup
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
This command:
|
|
35
|
-
- asks for provider name
|
|
35
|
+
- asks for provider name + API key, then endpoint candidates + model list
|
|
36
36
|
- requires a provider id (slug/camelCase, e.g. `openrouter` or `myProvider`)
|
|
37
|
-
- probes
|
|
38
|
-
- auto-detects supported format(s)
|
|
39
|
-
- tries to discover model list
|
|
37
|
+
- probes endpoint(s) x model(s) with live requests
|
|
38
|
+
- auto-detects supported format(s) per endpoint and model support per format
|
|
40
39
|
- saves config to `~/.ai-router.json`
|
|
41
40
|
|
|
42
41
|
### Non-interactive setup
|
|
@@ -46,12 +45,14 @@ npx ai-router-proxy setup \
|
|
|
46
45
|
--operation=upsert-provider \
|
|
47
46
|
--provider-id=openrouter \
|
|
48
47
|
--name=OpenRouter \
|
|
49
|
-
--base-url=https://openrouter.ai/api/v1 \
|
|
50
48
|
--api-key=sk-... \
|
|
49
|
+
--endpoints=https://openrouter.ai/api/v1 \
|
|
51
50
|
--models=gpt-4o,claude-3-5-sonnet-latest \
|
|
52
51
|
--headers='{"User-Agent":"Mozilla/5.0"}'
|
|
53
52
|
```
|
|
54
53
|
|
|
54
|
+
If `--headers` is omitted, setup saves a default `User-Agent` header to reduce provider compatibility issues. To remove it explicitly, set `--headers='{\"User-Agent\":null}'`.
|
|
55
|
+
|
|
55
56
|
### Start local proxy (default command)
|
|
56
57
|
|
|
57
58
|
```bash
|
package/package.json
CHANGED
package/src/cli/router-module.js
CHANGED
|
@@ -14,11 +14,12 @@ import {
|
|
|
14
14
|
removeProvider,
|
|
15
15
|
writeConfigFile
|
|
16
16
|
} from "../node/config-store.js";
|
|
17
|
-
import { probeProvider } from "../node/provider-probe.js";
|
|
17
|
+
import { probeProvider, probeProviderEndpointMatrix } from "../node/provider-probe.js";
|
|
18
18
|
import { runStartCommand } from "../node/start-command.js";
|
|
19
19
|
import { installStartup, startupStatus, uninstallStartup } from "../node/startup-manager.js";
|
|
20
20
|
import {
|
|
21
21
|
configHasProvider,
|
|
22
|
+
DEFAULT_PROVIDER_USER_AGENT,
|
|
22
23
|
maskSecret,
|
|
23
24
|
PROVIDER_ID_PATTERN,
|
|
24
25
|
sanitizeConfigForDisplay
|
|
@@ -68,6 +69,85 @@ function parseJsonObjectArg(value, fieldName) {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function hasHeaderName(headers, name) {
|
|
73
|
+
const lower = String(name).toLowerCase();
|
|
74
|
+
return Object.keys(headers || {}).some((key) => key.toLowerCase() === lower);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyDefaultHeaders(headers, { force = true } = {}) {
|
|
78
|
+
const source = headers && typeof headers === "object" && !Array.isArray(headers) ? headers : {};
|
|
79
|
+
const next = { ...source };
|
|
80
|
+
if (force && !hasHeaderName(next, "user-agent")) {
|
|
81
|
+
next["User-Agent"] = DEFAULT_PROVIDER_USER_AGENT;
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function providerEndpointsFromConfig(provider) {
|
|
87
|
+
const values = [
|
|
88
|
+
provider?.baseUrlByFormat?.openai,
|
|
89
|
+
provider?.baseUrlByFormat?.claude,
|
|
90
|
+
provider?.baseUrl
|
|
91
|
+
];
|
|
92
|
+
return parseModelListInput(values.filter(Boolean).join(","));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function probeProgressReporter(context) {
|
|
96
|
+
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
97
|
+
if (!line) return () => {};
|
|
98
|
+
|
|
99
|
+
let lastProgressPrinted = -1;
|
|
100
|
+
return (event) => {
|
|
101
|
+
if (!event || typeof event !== "object") return;
|
|
102
|
+
const phase = String(event.phase || "");
|
|
103
|
+
|
|
104
|
+
if (phase === "matrix-start") {
|
|
105
|
+
line(`Auto-discovery started: ${event.endpointCount || 0} endpoint(s) x ${event.modelCount || 0} model(s).`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (phase === "endpoint-start") {
|
|
109
|
+
line(`[discover] Endpoint ${event.endpointIndex || "?"}/${event.endpointCount || "?"}: ${event.endpoint}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (phase === "endpoint-formats") {
|
|
113
|
+
const formats = Array.isArray(event.formatsToTest) && event.formatsToTest.length > 0
|
|
114
|
+
? event.formatsToTest.join(", ")
|
|
115
|
+
: "(none)";
|
|
116
|
+
line(`[discover] Testing formats for ${event.endpoint}: ${formats}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (phase === "format-start") {
|
|
120
|
+
line(`[discover] ${event.endpoint} -> ${event.format} (${event.modelCount || 0} model checks)`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (phase === "model-check") {
|
|
124
|
+
const completed = Number(event.completedChecks || 0);
|
|
125
|
+
const total = Number(event.totalChecks || 0);
|
|
126
|
+
if (completed <= 0 || total <= 0) return;
|
|
127
|
+
if (completed === total || completed - lastProgressPrinted >= 3) {
|
|
128
|
+
lastProgressPrinted = completed;
|
|
129
|
+
line(`[discover] Progress ${completed}/${total} - ${event.model} on ${event.format} @ ${event.endpoint}: ${event.supported ? "ok" : "skip"}`);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (phase === "endpoint-done") {
|
|
134
|
+
const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
|
|
135
|
+
? event.workingFormats.join(", ")
|
|
136
|
+
: "(none)";
|
|
137
|
+
line(`[discover] Endpoint done: ${event.endpoint} working formats=${formats}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (phase === "matrix-done") {
|
|
141
|
+
const openaiBase = event.baseUrlByFormat?.openai || "(none)";
|
|
142
|
+
const claudeBase = event.baseUrlByFormat?.claude || "(none)";
|
|
143
|
+
const formats = Array.isArray(event.workingFormats) && event.workingFormats.length > 0
|
|
144
|
+
? event.workingFormats.join(", ")
|
|
145
|
+
: "(none)";
|
|
146
|
+
line(`Auto-discovery completed: working formats=${formats}, models=${event.supportedModelCount || 0}, openaiBase=${openaiBase}, claudeBase=${claudeBase}`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
71
151
|
async function promptProviderFormat(context, {
|
|
72
152
|
message = "Primary provider format",
|
|
73
153
|
initialFormat = ""
|
|
@@ -215,6 +295,11 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
215
295
|
const baseProviderId = argProviderId || selectedExisting?.id || "";
|
|
216
296
|
const baseName = String(readArg(args, ["name"], selectedExisting?.name || "") || "");
|
|
217
297
|
const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
|
|
298
|
+
const baseEndpoints = parseModelListInput(readArg(
|
|
299
|
+
args,
|
|
300
|
+
["endpoints"],
|
|
301
|
+
providerEndpointsFromConfig(selectedExisting).join(",")
|
|
302
|
+
));
|
|
218
303
|
const baseOpenAIBaseUrl = String(readArg(
|
|
219
304
|
args,
|
|
220
305
|
["openai-base-url", "openaiBaseUrl"],
|
|
@@ -229,11 +314,15 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
229
314
|
const baseModels = String(readArg(args, ["models"], (selectedExisting?.models || []).map((m) => m.id).join(",")) || "");
|
|
230
315
|
const baseFormat = String(readArg(args, ["format"], selectedExisting?.format || "") || "");
|
|
231
316
|
const baseFormats = parseModelListInput(readArg(args, ["formats"], (selectedExisting?.formats || []).join(",")));
|
|
317
|
+
const hasHeadersArg = args.headers !== undefined;
|
|
232
318
|
const baseHeaders = readArg(args, ["headers"], selectedExisting?.headers ? JSON.stringify(selectedExisting.headers) : "");
|
|
233
319
|
const shouldProbe = !toBoolean(readArg(args, ["skip-probe", "skipProbe"], false), false);
|
|
234
320
|
const setMasterKeyFlag = toBoolean(readArg(args, ["set-master-key", "setMasterKey"], false), false);
|
|
235
321
|
const providedMasterKey = String(readArg(args, ["master-key", "masterKey"], "") || "");
|
|
236
|
-
const parsedHeaders =
|
|
322
|
+
const parsedHeaders = applyDefaultHeaders(
|
|
323
|
+
parseJsonObjectArg(baseHeaders, "--headers"),
|
|
324
|
+
{ force: !hasHeadersArg }
|
|
325
|
+
);
|
|
237
326
|
|
|
238
327
|
if (!canPrompt()) {
|
|
239
328
|
return {
|
|
@@ -241,6 +330,7 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
241
330
|
providerId: baseProviderId || slugifyId(baseName || "provider"),
|
|
242
331
|
name: baseName,
|
|
243
332
|
baseUrl,
|
|
333
|
+
endpoints: baseEndpoints,
|
|
244
334
|
openaiBaseUrl: baseOpenAIBaseUrl,
|
|
245
335
|
claudeBaseUrl: baseClaudeBaseUrl,
|
|
246
336
|
apiKey: baseApiKey || selectedExisting?.apiKey || "",
|
|
@@ -266,22 +356,6 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
266
356
|
initialValue: slugifyId(name)
|
|
267
357
|
});
|
|
268
358
|
|
|
269
|
-
const url = baseUrl || await context.prompts.text({
|
|
270
|
-
message: "Provider base URL (shared fallback, optional)",
|
|
271
|
-
required: false,
|
|
272
|
-
placeholder: "https://api.example.com/v1"
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
const openaiBaseUrl = String(await context.prompts.text({
|
|
276
|
-
message: "OpenAI base URL override (optional)",
|
|
277
|
-
initialValue: baseOpenAIBaseUrl
|
|
278
|
-
}) || "");
|
|
279
|
-
|
|
280
|
-
const claudeBaseUrl = String(await context.prompts.text({
|
|
281
|
-
message: "Anthropic/Claude base URL override (optional)",
|
|
282
|
-
initialValue: baseClaudeBaseUrl
|
|
283
|
-
}) || "");
|
|
284
|
-
|
|
285
359
|
const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
|
|
286
360
|
message: "Replace saved API key?",
|
|
287
361
|
initialValue: false
|
|
@@ -292,14 +366,30 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
292
366
|
required: true
|
|
293
367
|
});
|
|
294
368
|
|
|
369
|
+
const endpointsInput = await context.prompts.text({
|
|
370
|
+
message: "Provider endpoints (comma separated)",
|
|
371
|
+
required: true,
|
|
372
|
+
initialValue: baseEndpoints.join(",")
|
|
373
|
+
});
|
|
374
|
+
const endpoints = parseModelListInput(endpointsInput);
|
|
375
|
+
|
|
376
|
+
const modelsInput = await context.prompts.text({
|
|
377
|
+
message: "Provider models (comma separated)",
|
|
378
|
+
required: true,
|
|
379
|
+
initialValue: baseModels
|
|
380
|
+
});
|
|
381
|
+
|
|
295
382
|
const headersInput = await context.prompts.text({
|
|
296
|
-
message: "Custom headers JSON (optional)",
|
|
297
|
-
initialValue:
|
|
383
|
+
message: "Custom headers JSON (optional; default User-Agent included)",
|
|
384
|
+
initialValue: JSON.stringify(applyDefaultHeaders(
|
|
385
|
+
parseJsonObjectArg(baseHeaders, "Custom headers"),
|
|
386
|
+
{ force: true }
|
|
387
|
+
))
|
|
298
388
|
});
|
|
299
389
|
const interactiveHeaders = parseJsonObjectArg(headersInput, "Custom headers");
|
|
300
390
|
|
|
301
391
|
const probe = await context.prompts.confirm({
|
|
302
|
-
message: "Auto-detect
|
|
392
|
+
message: "Auto-detect endpoint formats and model support via live probe?",
|
|
303
393
|
initialValue: shouldProbe
|
|
304
394
|
});
|
|
305
395
|
|
|
@@ -311,11 +401,6 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
311
401
|
});
|
|
312
402
|
}
|
|
313
403
|
|
|
314
|
-
const modelsInput = await context.prompts.text({
|
|
315
|
-
message: "Model list (comma separated, leave empty to use probe discovery)",
|
|
316
|
-
initialValue: baseModels
|
|
317
|
-
});
|
|
318
|
-
|
|
319
404
|
const setMasterKey = setMasterKeyFlag || await context.prompts.confirm({
|
|
320
405
|
message: "Set/update worker master key?",
|
|
321
406
|
initialValue: false
|
|
@@ -332,9 +417,10 @@ async function resolveUpsertInput(context, existingConfig) {
|
|
|
332
417
|
configPath,
|
|
333
418
|
providerId,
|
|
334
419
|
name,
|
|
335
|
-
baseUrl
|
|
336
|
-
|
|
337
|
-
|
|
420
|
+
baseUrl,
|
|
421
|
+
endpoints,
|
|
422
|
+
openaiBaseUrl: baseOpenAIBaseUrl,
|
|
423
|
+
claudeBaseUrl: baseClaudeBaseUrl,
|
|
338
424
|
apiKey,
|
|
339
425
|
models: parseModelListInput(modelsInput),
|
|
340
426
|
format: probe ? "" : manualFormat,
|
|
@@ -351,13 +437,19 @@ async function doUpsertProvider(context) {
|
|
|
351
437
|
const existingConfig = await readConfigFile(configPath);
|
|
352
438
|
const input = await resolveUpsertInput(context, existingConfig);
|
|
353
439
|
|
|
354
|
-
const
|
|
440
|
+
const endpointCandidates = parseModelListInput([
|
|
441
|
+
...(input.endpoints || []),
|
|
442
|
+
input.openaiBaseUrl,
|
|
443
|
+
input.claudeBaseUrl,
|
|
444
|
+
input.baseUrl
|
|
445
|
+
].filter(Boolean).join(","));
|
|
446
|
+
const hasAnyEndpoint = endpointCandidates.length > 0;
|
|
355
447
|
if (!input.name || !hasAnyEndpoint || !input.apiKey) {
|
|
356
448
|
return {
|
|
357
449
|
ok: false,
|
|
358
450
|
mode: context.mode,
|
|
359
451
|
exitCode: EXIT_VALIDATION,
|
|
360
|
-
errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint
|
|
452
|
+
errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint."
|
|
361
453
|
};
|
|
362
454
|
}
|
|
363
455
|
|
|
@@ -372,21 +464,68 @@ async function doUpsertProvider(context) {
|
|
|
372
464
|
|
|
373
465
|
let probe = null;
|
|
374
466
|
let selectedFormat = String(input.format || "").trim();
|
|
467
|
+
let effectiveBaseUrl = String(input.baseUrl || "").trim();
|
|
468
|
+
let effectiveOpenAIBaseUrl = String(input.openaiBaseUrl || "").trim();
|
|
469
|
+
let effectiveClaudeBaseUrl = String(input.claudeBaseUrl || "").trim();
|
|
470
|
+
let effectiveModels = [...(input.models || [])];
|
|
471
|
+
|
|
472
|
+
if (input.shouldProbe && endpointCandidates.length > 0 && effectiveModels.length === 0) {
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
mode: context.mode,
|
|
476
|
+
exitCode: EXIT_VALIDATION,
|
|
477
|
+
errorMessage: "Model list is required for endpoint-model probe. Provide --models=modelA,modelB."
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
375
481
|
if (input.shouldProbe) {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
482
|
+
const startedAt = Date.now();
|
|
483
|
+
const reportProgress = probeProgressReporter(context);
|
|
484
|
+
const canRunMatrixProbe = endpointCandidates.length > 0 && effectiveModels.length > 0;
|
|
485
|
+
if (canRunMatrixProbe) {
|
|
486
|
+
probe = await probeProviderEndpointMatrix({
|
|
487
|
+
endpoints: endpointCandidates,
|
|
488
|
+
models: effectiveModels,
|
|
489
|
+
apiKey: input.apiKey,
|
|
490
|
+
headers: input.headers,
|
|
491
|
+
onProgress: reportProgress
|
|
492
|
+
});
|
|
493
|
+
effectiveOpenAIBaseUrl = probe.baseUrlByFormat?.openai || effectiveOpenAIBaseUrl;
|
|
494
|
+
effectiveClaudeBaseUrl = probe.baseUrlByFormat?.claude || effectiveClaudeBaseUrl;
|
|
495
|
+
effectiveBaseUrl =
|
|
496
|
+
(probe.preferredFormat && probe.baseUrlByFormat?.[probe.preferredFormat]) ||
|
|
497
|
+
effectiveOpenAIBaseUrl ||
|
|
498
|
+
effectiveClaudeBaseUrl ||
|
|
499
|
+
endpointCandidates[0] ||
|
|
500
|
+
effectiveBaseUrl;
|
|
501
|
+
if ((probe.models || []).length > 0) {
|
|
502
|
+
effectiveModels = effectiveModels.length > 0
|
|
503
|
+
? effectiveModels.filter((model) => (probe.models || []).includes(model))
|
|
504
|
+
: [...probe.models];
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
const probeBaseUrlByFormat = {};
|
|
508
|
+
if (effectiveOpenAIBaseUrl) probeBaseUrlByFormat.openai = effectiveOpenAIBaseUrl;
|
|
509
|
+
if (effectiveClaudeBaseUrl) probeBaseUrlByFormat.claude = effectiveClaudeBaseUrl;
|
|
510
|
+
|
|
511
|
+
probe = await probeProvider({
|
|
512
|
+
baseUrl: effectiveBaseUrl || endpointCandidates[0],
|
|
513
|
+
baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
|
|
514
|
+
apiKey: input.apiKey,
|
|
515
|
+
headers: input.headers,
|
|
516
|
+
onProgress: reportProgress
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const line = typeof context?.terminal?.line === "function" ? context.terminal.line.bind(context.terminal) : null;
|
|
520
|
+
if (line) {
|
|
521
|
+
const tookMs = Date.now() - startedAt;
|
|
522
|
+
line(`Auto-discovery finished in ${(tookMs / 1000).toFixed(1)}s.`);
|
|
523
|
+
}
|
|
524
|
+
|
|
386
525
|
if (!probe.ok) {
|
|
387
526
|
if (canPrompt()) {
|
|
388
527
|
const continueWithoutProbe = await context.prompts.confirm({
|
|
389
|
-
message: "Probe failed to confirm
|
|
528
|
+
message: "Probe failed to confirm working endpoint/model support. Save provider anyway?",
|
|
390
529
|
initialValue: false
|
|
391
530
|
});
|
|
392
531
|
if (!continueWithoutProbe) {
|
|
@@ -407,7 +546,7 @@ async function doUpsertProvider(context) {
|
|
|
407
546
|
ok: false,
|
|
408
547
|
mode: context.mode,
|
|
409
548
|
exitCode: EXIT_FAILURE,
|
|
410
|
-
errorMessage: "Provider probe failed.
|
|
549
|
+
errorMessage: "Provider probe failed. Provide valid endpoints/models or use --skip-probe=true to force save."
|
|
411
550
|
};
|
|
412
551
|
}
|
|
413
552
|
} else {
|
|
@@ -415,16 +554,34 @@ async function doUpsertProvider(context) {
|
|
|
415
554
|
}
|
|
416
555
|
}
|
|
417
556
|
|
|
557
|
+
if (!input.shouldProbe) {
|
|
558
|
+
if (!effectiveBaseUrl && endpointCandidates.length > 0) {
|
|
559
|
+
effectiveBaseUrl = endpointCandidates[0];
|
|
560
|
+
}
|
|
561
|
+
if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length === 1 && selectedFormat) {
|
|
562
|
+
if (selectedFormat === "openai") effectiveOpenAIBaseUrl = endpointCandidates[0];
|
|
563
|
+
if (selectedFormat === "claude") effectiveClaudeBaseUrl = endpointCandidates[0];
|
|
564
|
+
}
|
|
565
|
+
if (!effectiveOpenAIBaseUrl && !effectiveClaudeBaseUrl && endpointCandidates.length > 1) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
mode: context.mode,
|
|
569
|
+
exitCode: EXIT_VALIDATION,
|
|
570
|
+
errorMessage: "Multiple endpoints require probe mode (recommended) or explicit --openai-base-url/--claude-base-url."
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
418
575
|
const effectiveFormat = selectedFormat || (input.shouldProbe ? "" : "openai");
|
|
419
576
|
|
|
420
577
|
const provider = buildProviderFromSetupInput({
|
|
421
578
|
providerId: input.providerId,
|
|
422
579
|
name: input.name,
|
|
423
|
-
baseUrl:
|
|
424
|
-
openaiBaseUrl:
|
|
425
|
-
claudeBaseUrl:
|
|
580
|
+
baseUrl: effectiveBaseUrl,
|
|
581
|
+
openaiBaseUrl: effectiveOpenAIBaseUrl,
|
|
582
|
+
claudeBaseUrl: effectiveClaudeBaseUrl,
|
|
426
583
|
apiKey: input.apiKey,
|
|
427
|
-
models:
|
|
584
|
+
models: effectiveModels,
|
|
428
585
|
format: effectiveFormat,
|
|
429
586
|
formats: input.formats,
|
|
430
587
|
headers: input.headers,
|
|
@@ -456,8 +613,10 @@ async function doUpsertProvider(context) {
|
|
|
456
613
|
probe
|
|
457
614
|
? `probe preferred=${probe.preferredFormat || "(none)"} working=${(probe.workingFormats || []).join(",") || "(none)"}`
|
|
458
615
|
: "probe=skipped",
|
|
616
|
+
provider.baseUrlByFormat?.openai ? `openaiBaseUrl=${provider.baseUrlByFormat.openai}` : "",
|
|
617
|
+
provider.baseUrlByFormat?.claude ? `claudeBaseUrl=${provider.baseUrlByFormat.claude}` : "",
|
|
459
618
|
`formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`,
|
|
460
|
-
`models=${provider.models.map((m) => m.id).join(", ")}`,
|
|
619
|
+
`models=${provider.models.map((m) => `${m.id}${m.formats?.length ? `[${m.formats.join("|")}]` : ""}`).join(", ")}`,
|
|
461
620
|
`masterKey=${nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"}`
|
|
462
621
|
].join("\n")
|
|
463
622
|
};
|
|
@@ -1076,6 +1235,7 @@ const routerModule = {
|
|
|
1076
1235
|
"config",
|
|
1077
1236
|
"provider-id",
|
|
1078
1237
|
"name",
|
|
1238
|
+
"endpoints",
|
|
1079
1239
|
"base-url",
|
|
1080
1240
|
"openai-base-url",
|
|
1081
1241
|
"claude-base-url",
|
|
@@ -1102,6 +1262,7 @@ const routerModule = {
|
|
|
1102
1262
|
{ name: "operation", required: false, description: "Setup operation (optional; prompts if omitted).", example: "--operation=upsert-provider" },
|
|
1103
1263
|
{ name: "provider-id", required: false, description: "Provider id (slug/camelCase).", example: "--provider-id=openrouter" },
|
|
1104
1264
|
{ name: "name", required: false, description: "Provider display name.", example: "--name=OpenRouter" },
|
|
1265
|
+
{ name: "endpoints", required: false, description: "Comma-separated provider endpoint candidates for auto-probe.", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
|
|
1105
1266
|
{ name: "base-url", required: false, description: "Provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
|
|
1106
1267
|
{ name: "openai-base-url", required: false, description: "OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
|
|
1107
1268
|
{ name: "claude-base-url", required: false, description: "Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
|
|
@@ -1109,8 +1270,8 @@ const routerModule = {
|
|
|
1109
1270
|
{ name: "models", required: false, description: "Comma-separated model list.", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
|
|
1110
1271
|
{ name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
|
|
1111
1272
|
{ name: "format", required: false, description: "Manual format if probe is skipped.", example: "--format=openai" },
|
|
1112
|
-
{ name: "headers", required: false, description: "Custom provider headers as JSON object.", example: "--headers={\"User-Agent\":\"Mozilla/5.0\"}" },
|
|
1113
|
-
{ name: "skip-probe", required: false, description: "Skip live
|
|
1273
|
+
{ name: "headers", required: false, description: "Custom provider headers as JSON object (default User-Agent applied when omitted).", example: "--headers={\"User-Agent\":\"Mozilla/5.0\"}" },
|
|
1274
|
+
{ name: "skip-probe", required: false, description: "Skip live endpoint/model probe.", example: "--skip-probe=true" },
|
|
1114
1275
|
{ name: "master-key", required: false, description: "Worker auth token.", example: "--master-key=my-token" },
|
|
1115
1276
|
{ name: "watch-binary", required: false, description: "For startup-install: detect ai-router upgrades and auto-relaunch under OS startup.", example: "--watch-binary=true" },
|
|
1116
1277
|
{ name: "require-auth", required: false, description: "Require masterKey auth for local start/startup-install.", example: "--require-auth=true" },
|
|
@@ -1118,7 +1279,7 @@ const routerModule = {
|
|
|
1118
1279
|
],
|
|
1119
1280
|
examples: [
|
|
1120
1281
|
"ai-router setup",
|
|
1121
|
-
"ai-router setup --operation=upsert-provider --provider-id=
|
|
1282
|
+
"ai-router setup --operation=upsert-provider --provider-id=ramclouds --name=RamClouds --api-key=sk-... --endpoints=https://ramclouds.me,https://ramclouds.me/v1 --models=claude-opus-4-6-thinking,gpt-5.3-codex",
|
|
1122
1283
|
"ai-router setup --operation=remove-model --provider-id=openrouter --model=gpt-4o",
|
|
1123
1284
|
"ai-router setup --operation=startup-install"
|
|
1124
1285
|
],
|
|
@@ -41,14 +41,59 @@ export function parseModelListInput(raw) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function normalizeModelArray(models) {
|
|
44
|
-
|
|
44
|
+
const rows = Array.isArray(models) ? models : dedupe(models).map((id) => ({ id }));
|
|
45
|
+
return rows
|
|
46
|
+
.map((entry) => {
|
|
47
|
+
if (typeof entry === "string") return { id: entry };
|
|
48
|
+
if (!entry || typeof entry !== "object") return null;
|
|
49
|
+
const id = String(entry.id || entry.name || "").trim();
|
|
50
|
+
if (!id) return null;
|
|
51
|
+
const formats = dedupe(entry.formats || entry.format || []).filter((value) => value === "openai" || value === "claude");
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
...(formats.length > 0 ? { formats } : {})
|
|
55
|
+
};
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildModelsWithPreferredFormat(modelIds, modelSupport = {}, modelPreferredFormat = {}) {
|
|
61
|
+
return normalizeModelArray(modelIds.map((id) => {
|
|
62
|
+
const preferred = modelPreferredFormat[id];
|
|
63
|
+
if (preferred) {
|
|
64
|
+
return { id, formats: [preferred] };
|
|
65
|
+
}
|
|
66
|
+
return { id, formats: modelSupport[id] || [] };
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summarizeEndpointMatrix(endpointMatrix) {
|
|
71
|
+
if (!Array.isArray(endpointMatrix)) return undefined;
|
|
72
|
+
return endpointMatrix.map((row) => ({
|
|
73
|
+
endpoint: row.endpoint,
|
|
74
|
+
supportedFormats: row.supportedFormats || [],
|
|
75
|
+
workingFormats: row.workingFormats || [],
|
|
76
|
+
modelsByFormat: row.modelsByFormat || {},
|
|
77
|
+
authByFormat: row.authByFormat || {}
|
|
78
|
+
}));
|
|
45
79
|
}
|
|
46
80
|
|
|
47
81
|
export function buildProviderFromSetupInput(input) {
|
|
48
82
|
const providerId = input.providerId || input.id || input.name;
|
|
49
83
|
const baseUrlByFormat = normalizeBaseUrlByFormatInput(input);
|
|
50
|
-
const
|
|
51
|
-
const
|
|
84
|
+
const explicitModelIds = parseModelListInput(input.models);
|
|
85
|
+
const probeModelSupport = input.probe?.modelSupport && typeof input.probe.modelSupport === "object"
|
|
86
|
+
? input.probe.modelSupport
|
|
87
|
+
: {};
|
|
88
|
+
const probeModelPreferredFormat = input.probe?.modelPreferredFormat && typeof input.probe.modelPreferredFormat === "object"
|
|
89
|
+
? input.probe.modelPreferredFormat
|
|
90
|
+
: {};
|
|
91
|
+
const explicitModels = explicitModelIds.length > 0
|
|
92
|
+
? buildModelsWithPreferredFormat(explicitModelIds, probeModelSupport, probeModelPreferredFormat)
|
|
93
|
+
: [];
|
|
94
|
+
const probeModels = input.probe?.models?.length
|
|
95
|
+
? buildModelsWithPreferredFormat(input.probe.models, probeModelSupport, probeModelPreferredFormat)
|
|
96
|
+
: [];
|
|
52
97
|
const mergedModels = explicitModels.length > 0 ? explicitModels : probeModels;
|
|
53
98
|
const endpointFormats = baseUrlByFormat ? Object.keys(baseUrlByFormat) : [];
|
|
54
99
|
|
|
@@ -86,7 +131,11 @@ export function buildProviderFromSetupInput(input) {
|
|
|
86
131
|
at: new Date().toISOString(),
|
|
87
132
|
formats: input.probe.formats || [],
|
|
88
133
|
workingFormats: input.probe.workingFormats || [],
|
|
89
|
-
models: input.probe.models || []
|
|
134
|
+
models: input.probe.models || [],
|
|
135
|
+
modelSupport: input.probe.modelSupport || undefined,
|
|
136
|
+
modelPreferredFormat: input.probe.modelPreferredFormat || undefined,
|
|
137
|
+
endpointMatrix: summarizeEndpointMatrix(input.probe.endpointMatrix),
|
|
138
|
+
warnings: input.probe.warnings || undefined
|
|
90
139
|
}
|
|
91
140
|
: undefined
|
|
92
141
|
}]
|
|
@@ -32,6 +32,17 @@ function cloneHeaders(headers) {
|
|
|
32
32
|
return { ...(headers || {}) };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function makeProgressEmitter(callback) {
|
|
36
|
+
if (typeof callback !== "function") return () => {};
|
|
37
|
+
return (event) => {
|
|
38
|
+
try {
|
|
39
|
+
callback(event);
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore probe progress callback failures
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
function makeAuthVariants(format, apiKey) {
|
|
36
47
|
if (!apiKey) return [];
|
|
37
48
|
|
|
@@ -171,6 +182,156 @@ function extractModelIds(result) {
|
|
|
171
182
|
return [...new Set(ids)];
|
|
172
183
|
}
|
|
173
184
|
|
|
185
|
+
function dedupeStrings(values) {
|
|
186
|
+
return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeUrlPathForScoring(endpoint) {
|
|
190
|
+
try {
|
|
191
|
+
return new URL(String(endpoint)).pathname.replace(/\/+$/, "") || "/";
|
|
192
|
+
} catch {
|
|
193
|
+
return String(endpoint || "").trim().replace(/^https?:\/\/[^/]+/i, "").replace(/\/+$/, "") || "/";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function orderAuthVariants(authVariants, preferredAuth) {
|
|
198
|
+
if (!preferredAuth || !Array.isArray(authVariants) || authVariants.length <= 1) return authVariants;
|
|
199
|
+
const normalized = String(preferredAuth).trim().toLowerCase();
|
|
200
|
+
const preferred = authVariants.find((item) => item.type === normalized);
|
|
201
|
+
if (!preferred) return authVariants;
|
|
202
|
+
return [preferred, ...authVariants.filter((item) => item !== preferred)];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getResultMessage(result) {
|
|
206
|
+
return String(getErrorMessage(result.json, result.text) || "").trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function truncateMessage(value, max = 220) {
|
|
210
|
+
const text = String(value || "").trim();
|
|
211
|
+
if (!text) return "";
|
|
212
|
+
if (text.length <= max) return text;
|
|
213
|
+
return `${text.slice(0, max - 3)}...`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isUnsupportedModelMessage(message) {
|
|
217
|
+
const text = String(message || "").toLowerCase();
|
|
218
|
+
if (!text) return false;
|
|
219
|
+
const patterns = [
|
|
220
|
+
/model .*not found/,
|
|
221
|
+
/unknown model/,
|
|
222
|
+
/unsupported model/,
|
|
223
|
+
/invalid model/,
|
|
224
|
+
/no such model/,
|
|
225
|
+
/model .*does not exist/,
|
|
226
|
+
/model .*not available/,
|
|
227
|
+
/unrecognized model/
|
|
228
|
+
];
|
|
229
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function looksExpectedFormat(format, result) {
|
|
233
|
+
if (format === FORMATS.CLAUDE) return looksClaude(result);
|
|
234
|
+
return looksOpenAI(result);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildProbeRequest(format, modelId) {
|
|
238
|
+
if (format === FORMATS.CLAUDE) {
|
|
239
|
+
return {
|
|
240
|
+
model: modelId,
|
|
241
|
+
max_tokens: 1,
|
|
242
|
+
stream: false,
|
|
243
|
+
messages: [{ role: "user", content: "ping" }]
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
model: modelId,
|
|
249
|
+
messages: [{ role: "user", content: "ping" }],
|
|
250
|
+
max_tokens: 1,
|
|
251
|
+
stream: false
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function makeProbeHeaders(format, extraHeaders, authHeaders = {}) {
|
|
256
|
+
const headers = {
|
|
257
|
+
"Content-Type": "application/json",
|
|
258
|
+
...extraHeaders,
|
|
259
|
+
...authHeaders
|
|
260
|
+
};
|
|
261
|
+
if (format === FORMATS.CLAUDE) {
|
|
262
|
+
if (!headers["anthropic-version"] && !headers["Anthropic-Version"]) {
|
|
263
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return headers;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function modelLooksSupported(format, result) {
|
|
270
|
+
if (result.ok) return true;
|
|
271
|
+
if (!looksExpectedFormat(format, result)) return false;
|
|
272
|
+
if (!authLooksValid(result)) return false;
|
|
273
|
+
|
|
274
|
+
const message = getResultMessage(result);
|
|
275
|
+
if (isUnsupportedModelMessage(message)) return false;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function probeModelForFormat({
|
|
280
|
+
baseUrl,
|
|
281
|
+
format,
|
|
282
|
+
apiKey,
|
|
283
|
+
modelId,
|
|
284
|
+
timeoutMs,
|
|
285
|
+
extraHeaders,
|
|
286
|
+
preferredAuthType
|
|
287
|
+
}) {
|
|
288
|
+
const url = resolveProviderUrl(makeProviderShell(baseUrl), format);
|
|
289
|
+
const authVariants = orderAuthVariants(makeAuthVariants(format, apiKey), preferredAuthType);
|
|
290
|
+
|
|
291
|
+
for (const variant of authVariants) {
|
|
292
|
+
const headers = makeProbeHeaders(format, extraHeaders, variant.headers);
|
|
293
|
+
const result = await safeFetchJson(url, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers,
|
|
296
|
+
body: JSON.stringify(buildProbeRequest(format, modelId))
|
|
297
|
+
}, timeoutMs);
|
|
298
|
+
|
|
299
|
+
if (modelLooksSupported(format, result)) {
|
|
300
|
+
return {
|
|
301
|
+
supported: true,
|
|
302
|
+
authType: variant.type,
|
|
303
|
+
status: result.status,
|
|
304
|
+
message: result.ok ? "ok" : truncateMessage(getResultMessage(result)),
|
|
305
|
+
error: result.error || null
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!authLooksValid(result)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Endpoint/format looks valid and auth seems valid, model is likely not available here.
|
|
314
|
+
const msg = getResultMessage(result);
|
|
315
|
+
if (isUnsupportedModelMessage(msg)) {
|
|
316
|
+
return {
|
|
317
|
+
supported: false,
|
|
318
|
+
authType: variant.type,
|
|
319
|
+
status: result.status,
|
|
320
|
+
message: truncateMessage(msg || "Model is not supported on this endpoint."),
|
|
321
|
+
error: result.error || null
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
supported: false,
|
|
328
|
+
authType: null,
|
|
329
|
+
status: 0,
|
|
330
|
+
message: "Could not validate model support for this endpoint/format.",
|
|
331
|
+
error: null
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
174
335
|
async function probeOpenAI(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
|
|
175
336
|
const authVariants = makeAuthVariants(FORMATS.OPENAI, apiKey);
|
|
176
337
|
const modelsUrl = resolveModelsUrl(baseUrl, FORMATS.OPENAI);
|
|
@@ -278,6 +439,7 @@ async function probeClaude(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
|
|
|
278
439
|
}
|
|
279
440
|
|
|
280
441
|
export async function probeProvider(options) {
|
|
442
|
+
const emitProgress = makeProgressEmitter(options?.onProgress);
|
|
281
443
|
const baseUrl = String(options?.baseUrl || "").trim();
|
|
282
444
|
const baseUrlByFormat = normalizeProbeBaseUrlByFormat(options?.baseUrlByFormat);
|
|
283
445
|
const apiKey = String(options?.apiKey || "").trim();
|
|
@@ -295,6 +457,8 @@ export async function probeProvider(options) {
|
|
|
295
457
|
throw new Error("Provider apiKey is required for probing.");
|
|
296
458
|
}
|
|
297
459
|
|
|
460
|
+
emitProgress({ phase: "provider-probe-start", baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl });
|
|
461
|
+
|
|
298
462
|
const [openai, claude] = await Promise.all([
|
|
299
463
|
openaiProbeBaseUrl
|
|
300
464
|
? probeOpenAI(openaiProbeBaseUrl, apiKey, timeoutMs, extraHeaders)
|
|
@@ -325,6 +489,13 @@ export async function probeProvider(options) {
|
|
|
325
489
|
|
|
326
490
|
const models = [...new Set([...(claude.models || []), ...(openai.models || [])])];
|
|
327
491
|
|
|
492
|
+
emitProgress({
|
|
493
|
+
phase: "provider-probe-done",
|
|
494
|
+
baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl,
|
|
495
|
+
supportedFormats,
|
|
496
|
+
workingFormats
|
|
497
|
+
});
|
|
498
|
+
|
|
328
499
|
return {
|
|
329
500
|
ok: workingFormats.length > 0,
|
|
330
501
|
baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl,
|
|
@@ -341,3 +512,297 @@ export async function probeProvider(options) {
|
|
|
341
512
|
}
|
|
342
513
|
};
|
|
343
514
|
}
|
|
515
|
+
|
|
516
|
+
function normalizeEndpointList(rawEndpoints, fallbackBaseUrl = "") {
|
|
517
|
+
const values = [];
|
|
518
|
+
if (Array.isArray(rawEndpoints)) {
|
|
519
|
+
values.push(...rawEndpoints);
|
|
520
|
+
} else if (typeof rawEndpoints === "string") {
|
|
521
|
+
values.push(...rawEndpoints.split(/[,\n]/g));
|
|
522
|
+
}
|
|
523
|
+
if (fallbackBaseUrl) values.push(fallbackBaseUrl);
|
|
524
|
+
return dedupeStrings(values);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function pickBestEndpointForFormat(endpointRows, format) {
|
|
528
|
+
const endpointPreferenceScore = (endpoint) => {
|
|
529
|
+
const path = normalizeUrlPathForScoring(endpoint);
|
|
530
|
+
const looksVersioned = /\/v\d+(?:\.\d+)?$/i.test(path);
|
|
531
|
+
const hasOpenAIHint = /\/openai(?:\/|$)/i.test(path);
|
|
532
|
+
const hasAnthropicHint = /\/anthropic(?:\/|$)|\/claude(?:\/|$)/i.test(path);
|
|
533
|
+
|
|
534
|
+
if (format === FORMATS.OPENAI) {
|
|
535
|
+
if (hasOpenAIHint) return 100;
|
|
536
|
+
if (looksVersioned) return 90;
|
|
537
|
+
if (path === "/" || path === "") return 10;
|
|
538
|
+
return 50;
|
|
539
|
+
}
|
|
540
|
+
if (format === FORMATS.CLAUDE) {
|
|
541
|
+
if (hasAnthropicHint) return 100;
|
|
542
|
+
if (path === "/" || path === "") return 90;
|
|
543
|
+
if (looksVersioned) return 10;
|
|
544
|
+
return 50;
|
|
545
|
+
}
|
|
546
|
+
return 0;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const candidates = endpointRows
|
|
550
|
+
.filter((row) => (row.workingFormats || []).includes(format))
|
|
551
|
+
.map((row) => ({
|
|
552
|
+
row,
|
|
553
|
+
score: (row.modelsByFormat?.[format] || []).length,
|
|
554
|
+
pref: endpointPreferenceScore(row.endpoint)
|
|
555
|
+
}))
|
|
556
|
+
.sort((a, b) => {
|
|
557
|
+
if (b.pref !== a.pref) return b.pref - a.pref;
|
|
558
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
559
|
+
return 0;
|
|
560
|
+
});
|
|
561
|
+
return candidates[0]?.row || null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function guessNativeModelFormat(modelId) {
|
|
565
|
+
const id = String(modelId || "").trim().toLowerCase();
|
|
566
|
+
if (!id) return null;
|
|
567
|
+
|
|
568
|
+
// High-confidence Anthropic family.
|
|
569
|
+
if (id.startsWith("claude")) return FORMATS.CLAUDE;
|
|
570
|
+
|
|
571
|
+
// Default most aggregator/coding endpoints expose native OpenAI-compatible format
|
|
572
|
+
// for many model families (gpt, gemini, glm, qwen, deepseek, etc).
|
|
573
|
+
return FORMATS.OPENAI;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function pickPreferredFormatForModel(modelId, formats, { providerPreferredFormat } = {}) {
|
|
577
|
+
const supported = dedupeStrings(formats).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
578
|
+
if (supported.length === 0) return null;
|
|
579
|
+
if (supported.length === 1) return supported[0];
|
|
580
|
+
|
|
581
|
+
const guessed = guessNativeModelFormat(modelId);
|
|
582
|
+
if (guessed && supported.includes(guessed)) return guessed;
|
|
583
|
+
if (providerPreferredFormat && supported.includes(providerPreferredFormat)) return providerPreferredFormat;
|
|
584
|
+
if (supported.includes(FORMATS.OPENAI)) return FORMATS.OPENAI;
|
|
585
|
+
return supported[0];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export async function probeProviderEndpointMatrix(options) {
|
|
589
|
+
const emitProgress = makeProgressEmitter(options?.onProgress);
|
|
590
|
+
const apiKey = String(options?.apiKey || "").trim();
|
|
591
|
+
const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
592
|
+
const extraHeaders = options?.headers && typeof options.headers === "object" && !Array.isArray(options.headers)
|
|
593
|
+
? options.headers
|
|
594
|
+
: {};
|
|
595
|
+
const endpoints = normalizeEndpointList(options?.endpoints, options?.baseUrl);
|
|
596
|
+
const models = dedupeStrings(options?.models || []);
|
|
597
|
+
|
|
598
|
+
if (!apiKey) throw new Error("Provider apiKey is required for probing.");
|
|
599
|
+
if (endpoints.length === 0) throw new Error("At least one endpoint is required for probing.");
|
|
600
|
+
if (models.length === 0) throw new Error("At least one model is required for endpoint-model probing.");
|
|
601
|
+
|
|
602
|
+
emitProgress({
|
|
603
|
+
phase: "matrix-start",
|
|
604
|
+
endpointCount: endpoints.length,
|
|
605
|
+
modelCount: models.length
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const endpointRows = [];
|
|
609
|
+
const modelFormatsMap = {};
|
|
610
|
+
const warnings = [];
|
|
611
|
+
|
|
612
|
+
let completedChecks = 0;
|
|
613
|
+
let totalChecks = 0;
|
|
614
|
+
for (const endpoint of endpoints) {
|
|
615
|
+
totalChecks += 2 * models.length;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex += 1) {
|
|
619
|
+
const endpoint = endpoints[endpointIndex];
|
|
620
|
+
emitProgress({
|
|
621
|
+
phase: "endpoint-start",
|
|
622
|
+
endpoint,
|
|
623
|
+
endpointIndex: endpointIndex + 1,
|
|
624
|
+
endpointCount: endpoints.length
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const endpointProbe = await probeProvider({
|
|
628
|
+
baseUrl: endpoint,
|
|
629
|
+
apiKey,
|
|
630
|
+
timeoutMs,
|
|
631
|
+
headers: extraHeaders,
|
|
632
|
+
onProgress: (event) => emitProgress({
|
|
633
|
+
...event,
|
|
634
|
+
endpoint,
|
|
635
|
+
endpointIndex: endpointIndex + 1,
|
|
636
|
+
endpointCount: endpoints.length
|
|
637
|
+
})
|
|
638
|
+
});
|
|
639
|
+
const rowAuthByFormat = { ...(endpointProbe.authByFormat || {}) };
|
|
640
|
+
const initialWorkingFormats = endpointProbe.workingFormats || [];
|
|
641
|
+
const initialSupportedFormats = endpointProbe.formats || [];
|
|
642
|
+
const formatsToTest = dedupeStrings([
|
|
643
|
+
FORMATS.OPENAI,
|
|
644
|
+
FORMATS.CLAUDE,
|
|
645
|
+
...initialWorkingFormats,
|
|
646
|
+
...initialSupportedFormats
|
|
647
|
+
]).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
648
|
+
const modelsByFormat = {};
|
|
649
|
+
const modelChecks = [];
|
|
650
|
+
|
|
651
|
+
if (formatsToTest.length === 0) {
|
|
652
|
+
warnings.push(`No supported format detected for endpoint ${endpoint}.`);
|
|
653
|
+
}
|
|
654
|
+
emitProgress({
|
|
655
|
+
phase: "endpoint-formats",
|
|
656
|
+
endpoint,
|
|
657
|
+
endpointIndex: endpointIndex + 1,
|
|
658
|
+
endpointCount: endpoints.length,
|
|
659
|
+
formatsToTest
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
for (const format of formatsToTest) {
|
|
663
|
+
const workingModels = [];
|
|
664
|
+
modelsByFormat[format] = workingModels;
|
|
665
|
+
const preferredAuthType = endpointProbe.authByFormat?.[format]?.type;
|
|
666
|
+
|
|
667
|
+
emitProgress({
|
|
668
|
+
phase: "format-start",
|
|
669
|
+
endpoint,
|
|
670
|
+
format,
|
|
671
|
+
endpointIndex: endpointIndex + 1,
|
|
672
|
+
endpointCount: endpoints.length,
|
|
673
|
+
modelCount: models.length
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
for (const modelId of models) {
|
|
677
|
+
const check = await probeModelForFormat({
|
|
678
|
+
baseUrl: endpoint,
|
|
679
|
+
format,
|
|
680
|
+
apiKey,
|
|
681
|
+
modelId,
|
|
682
|
+
timeoutMs,
|
|
683
|
+
extraHeaders,
|
|
684
|
+
preferredAuthType
|
|
685
|
+
});
|
|
686
|
+
modelChecks.push({
|
|
687
|
+
endpoint,
|
|
688
|
+
format,
|
|
689
|
+
model: modelId,
|
|
690
|
+
supported: check.supported,
|
|
691
|
+
status: check.status,
|
|
692
|
+
authType: check.authType,
|
|
693
|
+
message: check.message,
|
|
694
|
+
error: check.error
|
|
695
|
+
});
|
|
696
|
+
completedChecks += 1;
|
|
697
|
+
emitProgress({
|
|
698
|
+
phase: "model-check",
|
|
699
|
+
endpoint,
|
|
700
|
+
format,
|
|
701
|
+
model: modelId,
|
|
702
|
+
supported: check.supported,
|
|
703
|
+
status: check.status,
|
|
704
|
+
completedChecks,
|
|
705
|
+
totalChecks
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
if (!check.supported) continue;
|
|
709
|
+
workingModels.push(modelId);
|
|
710
|
+
if (!rowAuthByFormat[format] && check.authType) {
|
|
711
|
+
rowAuthByFormat[format] = { type: check.authType === "x-api-key" ? "x-api-key" : "bearer" };
|
|
712
|
+
}
|
|
713
|
+
if (!modelFormatsMap[modelId]) modelFormatsMap[modelId] = new Set();
|
|
714
|
+
modelFormatsMap[modelId].add(format);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const inferredWorkingFormats = dedupeStrings(formatsToTest.filter((format) => (modelsByFormat[format] || []).length > 0));
|
|
719
|
+
const inferredSupportedFormats = dedupeStrings([
|
|
720
|
+
...initialSupportedFormats,
|
|
721
|
+
...inferredWorkingFormats
|
|
722
|
+
]);
|
|
723
|
+
|
|
724
|
+
endpointRows.push({
|
|
725
|
+
endpoint,
|
|
726
|
+
supportedFormats: inferredSupportedFormats,
|
|
727
|
+
workingFormats: inferredWorkingFormats,
|
|
728
|
+
preferredFormat: endpointProbe.preferredFormat,
|
|
729
|
+
authByFormat: rowAuthByFormat,
|
|
730
|
+
modelsByFormat,
|
|
731
|
+
modelChecks,
|
|
732
|
+
details: endpointProbe.details
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
emitProgress({
|
|
736
|
+
phase: "endpoint-done",
|
|
737
|
+
endpoint,
|
|
738
|
+
endpointIndex: endpointIndex + 1,
|
|
739
|
+
endpointCount: endpoints.length,
|
|
740
|
+
workingFormats: inferredWorkingFormats,
|
|
741
|
+
modelsByFormat
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const openaiEndpoint = pickBestEndpointForFormat(endpointRows, FORMATS.OPENAI);
|
|
746
|
+
const claudeEndpoint = pickBestEndpointForFormat(endpointRows, FORMATS.CLAUDE);
|
|
747
|
+
|
|
748
|
+
const baseUrlByFormat = {};
|
|
749
|
+
if (openaiEndpoint) baseUrlByFormat[FORMATS.OPENAI] = openaiEndpoint.endpoint;
|
|
750
|
+
if (claudeEndpoint) baseUrlByFormat[FORMATS.CLAUDE] = claudeEndpoint.endpoint;
|
|
751
|
+
|
|
752
|
+
const authByFormat = {};
|
|
753
|
+
if (openaiEndpoint?.authByFormat?.[FORMATS.OPENAI]) {
|
|
754
|
+
authByFormat[FORMATS.OPENAI] = openaiEndpoint.authByFormat[FORMATS.OPENAI];
|
|
755
|
+
}
|
|
756
|
+
if (claudeEndpoint?.authByFormat?.[FORMATS.CLAUDE]) {
|
|
757
|
+
authByFormat[FORMATS.CLAUDE] = claudeEndpoint.authByFormat[FORMATS.CLAUDE];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const workingFormats = Object.keys(baseUrlByFormat);
|
|
761
|
+
const formats = dedupeStrings(endpointRows.flatMap((row) => row.supportedFormats || []));
|
|
762
|
+
const modelSupport = Object.fromEntries(
|
|
763
|
+
Object.entries(modelFormatsMap).map(([model, formatsSet]) => [model, [...formatsSet]])
|
|
764
|
+
);
|
|
765
|
+
const preferredFormat =
|
|
766
|
+
(workingFormats.includes(FORMATS.CLAUDE) && FORMATS.CLAUDE) ||
|
|
767
|
+
(workingFormats.includes(FORMATS.OPENAI) && FORMATS.OPENAI) ||
|
|
768
|
+
null;
|
|
769
|
+
const modelPreferredFormat = Object.fromEntries(
|
|
770
|
+
Object.entries(modelSupport)
|
|
771
|
+
.map(([modelId, supportedFormats]) => [
|
|
772
|
+
modelId,
|
|
773
|
+
pickPreferredFormatForModel(modelId, supportedFormats, { providerPreferredFormat: preferredFormat })
|
|
774
|
+
])
|
|
775
|
+
.filter(([, preferred]) => Boolean(preferred))
|
|
776
|
+
);
|
|
777
|
+
const supportedModels = dedupeStrings(Object.keys(modelSupport));
|
|
778
|
+
|
|
779
|
+
if (workingFormats.length === 0) {
|
|
780
|
+
warnings.push("No working endpoint format detected with provided API key.");
|
|
781
|
+
}
|
|
782
|
+
if (supportedModels.length === 0) {
|
|
783
|
+
warnings.push("No provided model was confirmed as working on the detected endpoints.");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
emitProgress({
|
|
787
|
+
phase: "matrix-done",
|
|
788
|
+
workingFormats,
|
|
789
|
+
baseUrlByFormat,
|
|
790
|
+
supportedModelCount: supportedModels.length
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
ok: workingFormats.length > 0 && supportedModels.length > 0,
|
|
795
|
+
endpoints,
|
|
796
|
+
formats,
|
|
797
|
+
workingFormats,
|
|
798
|
+
preferredFormat,
|
|
799
|
+
baseUrlByFormat,
|
|
800
|
+
authByFormat,
|
|
801
|
+
auth: preferredFormat ? authByFormat[preferredFormat] || null : null,
|
|
802
|
+
models: supportedModels,
|
|
803
|
+
modelSupport,
|
|
804
|
+
modelPreferredFormat,
|
|
805
|
+
endpointMatrix: endpointRows,
|
|
806
|
+
warnings
|
|
807
|
+
};
|
|
808
|
+
}
|
package/src/runtime/config.js
CHANGED
|
@@ -7,6 +7,7 @@ import { FORMATS } from "../translator/index.js";
|
|
|
7
7
|
|
|
8
8
|
export const CONFIG_VERSION = 1;
|
|
9
9
|
export const PROVIDER_ID_PATTERN = /^[a-z][a-zA-Z0-9-]*$/;
|
|
10
|
+
export const DEFAULT_PROVIDER_USER_AGENT = "ai-router (+https://github.com/khanglvm/ai-router)";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
|
|
12
13
|
let runtimeEnvCache = null;
|
|
@@ -61,6 +62,8 @@ function normalizeModelEntry(model) {
|
|
|
61
62
|
return {
|
|
62
63
|
id,
|
|
63
64
|
aliases: dedupeStrings(model.aliases || model.alias || []),
|
|
65
|
+
formats: dedupeStrings(model.formats || model.format || [])
|
|
66
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE),
|
|
64
67
|
enabled: model.enabled !== false,
|
|
65
68
|
contextWindow: Number.isFinite(model.contextWindow) ? Number(model.contextWindow) : undefined,
|
|
66
69
|
cost: model.cost,
|
|
@@ -263,13 +266,52 @@ function pickProviderAuth(provider, targetFormat) {
|
|
|
263
266
|
return { type: "bearer" };
|
|
264
267
|
}
|
|
265
268
|
|
|
269
|
+
function hasHeaderName(headers, name) {
|
|
270
|
+
const lower = String(name).toLowerCase();
|
|
271
|
+
return Object.keys(headers || {}).some((key) => key.toLowerCase() === lower);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeCustomHeaders(rawHeaders) {
|
|
275
|
+
const out = {};
|
|
276
|
+
let userAgentExplicitlyDisabled = false;
|
|
277
|
+
|
|
278
|
+
if (!rawHeaders || typeof rawHeaders !== "object" || Array.isArray(rawHeaders)) {
|
|
279
|
+
return { headers: out, userAgentExplicitlyDisabled };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const [name, value] of Object.entries(rawHeaders)) {
|
|
283
|
+
const lower = name.toLowerCase();
|
|
284
|
+
const isUserAgent = lower === "user-agent";
|
|
285
|
+
|
|
286
|
+
if (value === undefined || value === null || value === false) {
|
|
287
|
+
if (isUserAgent) userAgentExplicitlyDisabled = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const text = String(value);
|
|
292
|
+
if (!text && isUserAgent) {
|
|
293
|
+
userAgentExplicitlyDisabled = true;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!text) continue;
|
|
297
|
+
out[name] = text;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { headers: out, userAgentExplicitlyDisabled };
|
|
301
|
+
}
|
|
302
|
+
|
|
266
303
|
export function buildProviderHeaders(provider, env = undefined, targetFormat = undefined) {
|
|
267
304
|
const format = targetFormat || resolveProviderFormat(provider);
|
|
305
|
+
const { headers: customHeaders, userAgentExplicitlyDisabled } = normalizeCustomHeaders(provider?.headers);
|
|
268
306
|
const headers = {
|
|
269
307
|
"Content-Type": "application/json",
|
|
270
|
-
...
|
|
308
|
+
...customHeaders
|
|
271
309
|
};
|
|
272
310
|
|
|
311
|
+
if (!userAgentExplicitlyDisabled && !hasHeaderName(headers, "user-agent")) {
|
|
312
|
+
headers["User-Agent"] = DEFAULT_PROVIDER_USER_AGENT;
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
const apiKey = resolveProviderApiKey(provider, env);
|
|
274
316
|
const auth = pickProviderAuth(provider, format);
|
|
275
317
|
|
|
@@ -313,7 +355,32 @@ export function sanitizeConfigForDisplay(config) {
|
|
|
313
355
|
}
|
|
314
356
|
|
|
315
357
|
function buildTargetCandidate(provider, model, sourceFormat) {
|
|
316
|
-
const
|
|
358
|
+
const providerFormats = dedupeStrings([...(provider?.formats || []), provider?.format])
|
|
359
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
360
|
+
const modelFormats = dedupeStrings([...(model?.formats || []), model?.format])
|
|
361
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
362
|
+
const supportedFormats = modelFormats.length > 0
|
|
363
|
+
? providerFormats.filter((fmt) => modelFormats.includes(fmt))
|
|
364
|
+
: providerFormats;
|
|
365
|
+
|
|
366
|
+
let targetFormat = sourceFormat && supportedFormats.includes(sourceFormat)
|
|
367
|
+
? sourceFormat
|
|
368
|
+
: undefined;
|
|
369
|
+
|
|
370
|
+
if (!targetFormat && supportedFormats.length > 0) {
|
|
371
|
+
if (sourceFormat === FORMATS.CLAUDE && supportedFormats.includes(FORMATS.CLAUDE)) {
|
|
372
|
+
targetFormat = FORMATS.CLAUDE;
|
|
373
|
+
} else if (sourceFormat === FORMATS.OPENAI && supportedFormats.includes(FORMATS.OPENAI)) {
|
|
374
|
+
targetFormat = FORMATS.OPENAI;
|
|
375
|
+
} else {
|
|
376
|
+
targetFormat = supportedFormats[0];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!targetFormat) {
|
|
381
|
+
targetFormat = resolveProviderFormat(provider, sourceFormat);
|
|
382
|
+
}
|
|
383
|
+
|
|
317
384
|
return {
|
|
318
385
|
providerId: provider.id,
|
|
319
386
|
providerName: provider.name,
|
|
@@ -375,6 +442,20 @@ export function resolveRequestModel(config, requestedModel, sourceFormat = FORMA
|
|
|
375
442
|
};
|
|
376
443
|
}
|
|
377
444
|
|
|
445
|
+
const providerFormats = dedupeStrings([...(provider.formats || []), provider.format])
|
|
446
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
447
|
+
const modelFormats = dedupeStrings([...(model.formats || []), model.format])
|
|
448
|
+
.filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
|
|
449
|
+
if (modelFormats.length > 0 && !providerFormats.some((fmt) => modelFormats.includes(fmt))) {
|
|
450
|
+
return {
|
|
451
|
+
requestedModel: normalizedRequested,
|
|
452
|
+
resolvedModel: null,
|
|
453
|
+
primary: null,
|
|
454
|
+
fallbacks: [],
|
|
455
|
+
error: `Model '${modelName}' is configured for unsupported endpoint formats under provider '${providerId}'.`
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
378
459
|
const primary = buildTargetCandidate(provider, model, sourceFormat);
|
|
379
460
|
return {
|
|
380
461
|
requestedModel: normalizedRequested,
|
|
@@ -401,12 +482,13 @@ export function listConfiguredModels(config, { endpointFormat } = {}) {
|
|
|
401
482
|
owned_by: provider.id,
|
|
402
483
|
provider_id: provider.id,
|
|
403
484
|
provider_name: provider.name,
|
|
404
|
-
formats: provider.formats || [],
|
|
485
|
+
formats: (model.formats && model.formats.length > 0) ? model.formats : (provider.formats || []),
|
|
405
486
|
endpoint_format_supported: endpointFormat
|
|
406
|
-
? (provider.formats || []).includes(endpointFormat)
|
|
487
|
+
? ((model.formats && model.formats.length > 0) ? model.formats.includes(endpointFormat) : (provider.formats || []).includes(endpointFormat))
|
|
407
488
|
: undefined,
|
|
408
489
|
context_window: model.contextWindow,
|
|
409
|
-
cost: model.cost
|
|
490
|
+
cost: model.cost,
|
|
491
|
+
model_formats: model.formats || []
|
|
410
492
|
});
|
|
411
493
|
}
|
|
412
494
|
}
|