@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 CHANGED
@@ -32,11 +32,10 @@ ai-router setup
32
32
  ```
33
33
 
34
34
  This command:
35
- - asks for provider name/base URL/API key
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 the provider with live requests
38
- - auto-detects supported format(s): `openai` and/or `claude`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/ai-router",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Generic AI Router Proxy (local + Cloudflare Worker)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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 = parseJsonObjectArg(baseHeaders, "--headers");
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: String(baseHeaders || "")
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 format and models via live probe?",
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: url,
336
- openaiBaseUrl,
337
- claudeBaseUrl,
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 hasAnyEndpoint = Boolean(input.baseUrl || input.openaiBaseUrl || input.claudeBaseUrl);
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 (base-url/openai-base-url/claude-base-url)."
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 probeBaseUrlByFormat = {};
377
- if (input.openaiBaseUrl) probeBaseUrlByFormat.openai = input.openaiBaseUrl;
378
- if (input.claudeBaseUrl) probeBaseUrlByFormat.claude = input.claudeBaseUrl;
379
-
380
- probe = await probeProvider({
381
- baseUrl: input.baseUrl,
382
- baseUrlByFormat: Object.keys(probeBaseUrlByFormat).length > 0 ? probeBaseUrlByFormat : undefined,
383
- apiKey: input.apiKey,
384
- headers: input.headers
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 a working format. Save provider anyway?",
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. Use --skip-probe=true to force save."
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: input.baseUrl,
424
- openaiBaseUrl: input.openaiBaseUrl,
425
- claudeBaseUrl: input.claudeBaseUrl,
580
+ baseUrl: effectiveBaseUrl,
581
+ openaiBaseUrl: effectiveOpenAIBaseUrl,
582
+ claudeBaseUrl: effectiveClaudeBaseUrl,
426
583
  apiKey: input.apiKey,
427
- models: input.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 provider probe.", example: "--skip-probe=true" },
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=openrouter --name=OpenRouter --base-url=https://openrouter.ai/api/v1 --api-key=sk-...",
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
- return dedupe(models).map((id) => ({ id }));
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 explicitModels = normalizeModelArray(parseModelListInput(input.models));
51
- const probeModels = normalizeModelArray(input.probe?.models || []);
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
+ }
@@ -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
- ...(provider?.headers || {})
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 targetFormat = resolveProviderFormat(provider, sourceFormat);
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
  }