@khanglvm/ai-router 1.0.1 → 1.0.2

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
@@ -10,6 +10,7 @@ Generic API proxy for AI providers with:
10
10
  - `/openai` (OpenAI-compatible responses)
11
11
  - OS startup install/uninstall/status (macOS/Linux)
12
12
  - Auto-restart local server when config file changes
13
+ - Auto-handle ai-router upgrades for running instances
13
14
 
14
15
  ## Install / Run
15
16
 
@@ -64,6 +65,7 @@ ai-router start --port=8787
64
65
  - prints proxy URLs and provider/model summary
65
66
  - exits with guidance if config/providers are missing
66
67
  - watches `~/.ai-router.json` and auto-restarts on changes (default enabled)
68
+ - watches ai-router binary updates and relaunches latest version automatically (default enabled)
67
69
 
68
70
  ```bash
69
71
  ai-router start --port=8787 --watch-config=true
@@ -105,6 +107,7 @@ ai-router setup --operation=startup-uninstall
105
107
  ```
106
108
 
107
109
  On macOS this installs a LaunchAgent, on Linux a `systemd --user` service. The startup service runs `ai-router start` with config-watch enabled.
110
+ With `--watch-binary=true` (default), startup-managed instances also self-exit on binary updates so launchd/systemd relaunches the new version.
108
111
 
109
112
  ## Provider Smoke Test Suite (Real-Usage Simulation)
110
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/ai-router",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Generic AI Router Proxy (local + Cloudflare Worker)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -68,6 +68,24 @@ function parseJsonObjectArg(value, fieldName) {
68
68
  }
69
69
  }
70
70
 
71
+ async function promptProviderFormat(context, {
72
+ message = "Primary provider format",
73
+ initialFormat = ""
74
+ } = {}) {
75
+ const preferred = initialFormat === "claude" ? "claude" : (initialFormat === "openai" ? "openai" : "");
76
+ const options = preferred === "claude"
77
+ ? [
78
+ { value: "claude", label: "Anthropic-compatible" },
79
+ { value: "openai", label: "OpenAI-compatible" }
80
+ ]
81
+ : [
82
+ { value: "openai", label: "OpenAI-compatible" },
83
+ { value: "claude", label: "Anthropic-compatible" }
84
+ ];
85
+
86
+ return context.prompts.select({ message, options });
87
+ }
88
+
71
89
  function slugifyId(value, fallback = "provider") {
72
90
  const slug = String(value || fallback)
73
91
  .trim()
@@ -95,6 +113,12 @@ function summarizeConfig(config, configPath, { includeSecrets = false } = {}) {
95
113
  for (const provider of target.providers) {
96
114
  lines.push(`- ${provider.id} (${provider.name})`);
97
115
  lines.push(` baseUrl=${provider.baseUrl}`);
116
+ if (provider.baseUrlByFormat?.openai) {
117
+ lines.push(` openaiBaseUrl=${provider.baseUrlByFormat.openai}`);
118
+ }
119
+ if (provider.baseUrlByFormat?.claude) {
120
+ lines.push(` claudeBaseUrl=${provider.baseUrlByFormat.claude}`);
121
+ }
98
122
  lines.push(` formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`);
99
123
  lines.push(` apiKey=${provider.apiKey || "(from env/hidden)"}`);
100
124
  lines.push(` models=${(provider.models || []).map((m) => m.id).join(", ") || "(none)"}`);
@@ -191,6 +215,16 @@ async function resolveUpsertInput(context, existingConfig) {
191
215
  const baseProviderId = argProviderId || selectedExisting?.id || "";
192
216
  const baseName = String(readArg(args, ["name"], selectedExisting?.name || "") || "");
193
217
  const baseUrl = String(readArg(args, ["base-url", "baseUrl"], selectedExisting?.baseUrl || "") || "");
218
+ const baseOpenAIBaseUrl = String(readArg(
219
+ args,
220
+ ["openai-base-url", "openaiBaseUrl"],
221
+ selectedExisting?.baseUrlByFormat?.openai || ""
222
+ ) || "");
223
+ const baseClaudeBaseUrl = String(readArg(
224
+ args,
225
+ ["claude-base-url", "claudeBaseUrl", "anthropic-base-url", "anthropicBaseUrl"],
226
+ selectedExisting?.baseUrlByFormat?.claude || ""
227
+ ) || "");
194
228
  const baseApiKey = String(readArg(args, ["api-key", "apiKey"], "") || "");
195
229
  const baseModels = String(readArg(args, ["models"], (selectedExisting?.models || []).map((m) => m.id).join(",")) || "");
196
230
  const baseFormat = String(readArg(args, ["format"], selectedExisting?.format || "") || "");
@@ -207,6 +241,8 @@ async function resolveUpsertInput(context, existingConfig) {
207
241
  providerId: baseProviderId || slugifyId(baseName || "provider"),
208
242
  name: baseName,
209
243
  baseUrl,
244
+ openaiBaseUrl: baseOpenAIBaseUrl,
245
+ claudeBaseUrl: baseClaudeBaseUrl,
210
246
  apiKey: baseApiKey || selectedExisting?.apiKey || "",
211
247
  models: parseModelListInput(baseModels),
212
248
  format: baseFormat,
@@ -231,11 +267,21 @@ async function resolveUpsertInput(context, existingConfig) {
231
267
  });
232
268
 
233
269
  const url = baseUrl || await context.prompts.text({
234
- message: "Provider base URL",
235
- required: true,
270
+ message: "Provider base URL (shared fallback, optional)",
271
+ required: false,
236
272
  placeholder: "https://api.example.com/v1"
237
273
  });
238
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
+
239
285
  const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
240
286
  message: "Replace saved API key?",
241
287
  initialValue: false
@@ -246,19 +292,22 @@ async function resolveUpsertInput(context, existingConfig) {
246
292
  required: true
247
293
  });
248
294
 
295
+ const headersInput = await context.prompts.text({
296
+ message: "Custom headers JSON (optional)",
297
+ initialValue: String(baseHeaders || "")
298
+ });
299
+ const interactiveHeaders = parseJsonObjectArg(headersInput, "Custom headers");
300
+
249
301
  const probe = await context.prompts.confirm({
250
302
  message: "Auto-detect format and models via live probe?",
251
303
  initialValue: shouldProbe
252
304
  });
253
305
 
254
306
  let manualFormat = baseFormat;
255
- if (!probe && !manualFormat) {
256
- manualFormat = await context.prompts.select({
307
+ if (!probe) {
308
+ manualFormat = await promptProviderFormat(context, {
257
309
  message: "Primary provider format",
258
- options: [
259
- { value: "openai", label: "OpenAI-compatible" },
260
- { value: "claude", label: "Anthropic-compatible" }
261
- ]
310
+ initialFormat: manualFormat
262
311
  });
263
312
  }
264
313
 
@@ -267,12 +316,6 @@ async function resolveUpsertInput(context, existingConfig) {
267
316
  initialValue: baseModels
268
317
  });
269
318
 
270
- const headersInput = await context.prompts.text({
271
- message: "Custom headers JSON (optional)",
272
- initialValue: String(baseHeaders || "")
273
- });
274
- const interactiveHeaders = parseJsonObjectArg(headersInput, "Custom headers");
275
-
276
319
  const setMasterKey = setMasterKeyFlag || await context.prompts.confirm({
277
320
  message: "Set/update worker master key?",
278
321
  initialValue: false
@@ -290,9 +333,11 @@ async function resolveUpsertInput(context, existingConfig) {
290
333
  providerId,
291
334
  name,
292
335
  baseUrl: url,
336
+ openaiBaseUrl,
337
+ claudeBaseUrl,
293
338
  apiKey,
294
339
  models: parseModelListInput(modelsInput),
295
- format: manualFormat,
340
+ format: probe ? "" : manualFormat,
296
341
  formats: baseFormats,
297
342
  headers: interactiveHeaders,
298
343
  shouldProbe: probe,
@@ -306,14 +351,16 @@ async function doUpsertProvider(context) {
306
351
  const existingConfig = await readConfigFile(configPath);
307
352
  const input = await resolveUpsertInput(context, existingConfig);
308
353
 
309
- if (!input.name || !input.baseUrl || !input.apiKey) {
354
+ const hasAnyEndpoint = Boolean(input.baseUrl || input.openaiBaseUrl || input.claudeBaseUrl);
355
+ if (!input.name || !hasAnyEndpoint || !input.apiKey) {
310
356
  return {
311
357
  ok: false,
312
358
  mode: context.mode,
313
359
  exitCode: EXIT_VALIDATION,
314
- errorMessage: "Missing provider inputs: provider-id, name, base-url, api-key."
360
+ errorMessage: "Missing provider inputs: provider-id, name, api-key, and at least one endpoint (base-url/openai-base-url/claude-base-url)."
315
361
  };
316
362
  }
363
+
317
364
  if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
318
365
  return {
319
366
  ok: false,
@@ -324,8 +371,18 @@ async function doUpsertProvider(context) {
324
371
  }
325
372
 
326
373
  let probe = null;
374
+ let selectedFormat = String(input.format || "").trim();
327
375
  if (input.shouldProbe) {
328
- probe = await probeProvider({ baseUrl: input.baseUrl, apiKey: input.apiKey });
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
+ });
329
386
  if (!probe.ok) {
330
387
  if (canPrompt()) {
331
388
  const continueWithoutProbe = await context.prompts.confirm({
@@ -340,6 +397,11 @@ async function doUpsertProvider(context) {
340
397
  errorMessage: "Setup cancelled because provider probe failed."
341
398
  };
342
399
  }
400
+
401
+ selectedFormat = await promptProviderFormat(context, {
402
+ message: "Probe could not confirm a working format. Choose primary provider format",
403
+ initialFormat: selectedFormat
404
+ });
343
405
  } else {
344
406
  return {
345
407
  ok: false,
@@ -348,16 +410,22 @@ async function doUpsertProvider(context) {
348
410
  errorMessage: "Provider probe failed. Use --skip-probe=true to force save."
349
411
  };
350
412
  }
413
+ } else {
414
+ selectedFormat = probe.preferredFormat || selectedFormat;
351
415
  }
352
416
  }
353
417
 
418
+ const effectiveFormat = selectedFormat || (input.shouldProbe ? "" : "openai");
419
+
354
420
  const provider = buildProviderFromSetupInput({
355
421
  providerId: input.providerId,
356
422
  name: input.name,
357
423
  baseUrl: input.baseUrl,
424
+ openaiBaseUrl: input.openaiBaseUrl,
425
+ claudeBaseUrl: input.claudeBaseUrl,
358
426
  apiKey: input.apiKey,
359
427
  models: input.models,
360
- format: input.format || "openai",
428
+ format: effectiveFormat,
361
429
  formats: input.formats,
362
430
  headers: input.headers,
363
431
  probe
@@ -385,6 +453,9 @@ async function doUpsertProvider(context) {
385
453
  exitCode: EXIT_SUCCESS,
386
454
  data: [
387
455
  `Saved provider '${provider.id}' to ${input.configPath}`,
456
+ probe
457
+ ? `probe preferred=${probe.preferredFormat || "(none)"} working=${(probe.workingFormats || []).join(",") || "(none)"}`
458
+ : "probe=skipped",
388
459
  `formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`,
389
460
  `models=${provider.models.map((m) => m.id).join(", ")}`,
390
461
  `masterKey=${nextConfig.masterKey ? maskSecret(nextConfig.masterKey) : "(not set)"}`
@@ -544,6 +615,7 @@ async function doStartupInstall(context) {
544
615
  const host = String(readArg(context.args, ["host"], "127.0.0.1"));
545
616
  const port = toNumber(readArg(context.args, ["port"]), 8787);
546
617
  const watchConfig = toBoolean(readArg(context.args, ["watch-config", "watchConfig"], true), true);
618
+ const watchBinary = toBoolean(readArg(context.args, ["watch-binary", "watchBinary"], true), true);
547
619
  const requireAuth = toBoolean(readArg(context.args, ["require-auth", "requireAuth"], false), false);
548
620
 
549
621
  if (!(await configFileExists(configPath))) {
@@ -583,7 +655,7 @@ async function doStartupInstall(context) {
583
655
  }
584
656
  }
585
657
 
586
- const result = await installStartup({ configPath, host, port, watchConfig, requireAuth });
658
+ const result = await installStartup({ configPath, host, port, watchConfig, watchBinary, requireAuth });
587
659
  return {
588
660
  ok: true,
589
661
  mode: context.mode,
@@ -593,6 +665,7 @@ async function doStartupInstall(context) {
593
665
  `service=${result.serviceId}`,
594
666
  `file=${result.filePath}`,
595
667
  `start target=http://${host}:${port}`,
668
+ `binary watch=${watchBinary ? "enabled" : "disabled"}`,
596
669
  `local auth=${requireAuth ? "required (masterKey)" : "disabled"}`
597
670
  ].join("\n")
598
671
  };
@@ -701,7 +774,9 @@ async function runStartAction(context) {
701
774
  host: String(readArg(args, ["host"], "127.0.0.1")),
702
775
  port: toNumber(readArg(args, ["port"]), 8787),
703
776
  watchConfig: toBoolean(readArg(args, ["watch-config", "watchConfig"], true), true),
777
+ watchBinary: toBoolean(readArg(args, ["watch-binary", "watchBinary"], true), true),
704
778
  requireAuth: toBoolean(readArg(args, ["require-auth", "requireAuth"], false), false),
779
+ cliPathForWatch: process.argv[1],
705
780
  onLine: (line) => context.terminal.line(line),
706
781
  onError: (line) => context.terminal.error(line)
707
782
  });
@@ -966,14 +1041,15 @@ const routerModule = {
966
1041
  actionId: "start",
967
1042
  description: "Start local ai-router proxy.",
968
1043
  tui: { steps: ["start-server"] },
969
- commandline: { requiredArgs: [], optionalArgs: ["host", "port", "config", "watch-config", "require-auth"] },
1044
+ commandline: { requiredArgs: [], optionalArgs: ["host", "port", "config", "watch-config", "watch-binary", "require-auth"] },
970
1045
  help: {
971
- summary: "Start local ai-router on localhost. Auto-restarts when config file changes.",
1046
+ summary: "Start local ai-router on localhost. Auto-restarts on config changes and auto-relaunches after ai-router upgrades.",
972
1047
  args: [
973
1048
  { name: "host", required: false, description: "Listen host.", example: "--host=127.0.0.1" },
974
1049
  { name: "port", required: false, description: "Listen port.", example: "--port=8787" },
975
1050
  { name: "config", required: false, description: "Path to config file.", example: "--config=~/.ai-router.json" },
976
1051
  { name: "watch-config", required: false, description: "Auto-restart on config changes.", example: "--watch-config=true" },
1052
+ { name: "watch-binary", required: false, description: "Watch for ai-router upgrades and relaunch the latest version.", example: "--watch-binary=true" },
977
1053
  { name: "require-auth", required: false, description: "Require local API auth using config.masterKey.", example: "--require-auth=true" }
978
1054
  ],
979
1055
  examples: ["ai-router", "ai-router start --port=8787", "ai-router start --require-auth=true"],
@@ -1001,6 +1077,9 @@ const routerModule = {
1001
1077
  "provider-id",
1002
1078
  "name",
1003
1079
  "base-url",
1080
+ "openai-base-url",
1081
+ "claude-base-url",
1082
+ "anthropic-base-url",
1004
1083
  "api-key",
1005
1084
  "models",
1006
1085
  "format",
@@ -1013,6 +1092,7 @@ const routerModule = {
1013
1092
  "host",
1014
1093
  "port",
1015
1094
  "watch-config",
1095
+ "watch-binary",
1016
1096
  "require-auth"
1017
1097
  ]
1018
1098
  },
@@ -1023,6 +1103,8 @@ const routerModule = {
1023
1103
  { name: "provider-id", required: false, description: "Provider id (slug/camelCase).", example: "--provider-id=openrouter" },
1024
1104
  { name: "name", required: false, description: "Provider display name.", example: "--name=OpenRouter" },
1025
1105
  { name: "base-url", required: false, description: "Provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
1106
+ { name: "openai-base-url", required: false, description: "OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
1107
+ { name: "claude-base-url", required: false, description: "Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
1026
1108
  { name: "api-key", required: false, description: "Provider API key.", example: "--api-key=sk-..." },
1027
1109
  { name: "models", required: false, description: "Comma-separated model list.", example: "--models=gpt-4o,claude-3-5-sonnet-latest" },
1028
1110
  { name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
@@ -1030,6 +1112,7 @@ const routerModule = {
1030
1112
  { name: "headers", required: false, description: "Custom provider headers as JSON object.", example: "--headers={\"User-Agent\":\"Mozilla/5.0\"}" },
1031
1113
  { name: "skip-probe", required: false, description: "Skip live provider probe.", example: "--skip-probe=true" },
1032
1114
  { name: "master-key", required: false, description: "Worker auth token.", example: "--master-key=my-token" },
1115
+ { name: "watch-binary", required: false, description: "For startup-install: detect ai-router upgrades and auto-relaunch under OS startup.", example: "--watch-binary=true" },
1033
1116
  { name: "require-auth", required: false, description: "Require masterKey auth for local start/startup-install.", example: "--require-auth=true" },
1034
1117
  { name: "config", required: false, description: "Path to config file.", example: "--config=~/.ai-router.json" }
1035
1118
  ],
package/src/cli-entry.js CHANGED
@@ -63,7 +63,9 @@ async function runStartFastPath(args) {
63
63
  host: args.host || "127.0.0.1",
64
64
  port: parseNumber(args.port, 8787),
65
65
  watchConfig: parseBoolean(args["watch-config"] ?? args.watchConfig, true),
66
+ watchBinary: parseBoolean(args["watch-binary"] ?? args.watchBinary, true),
66
67
  requireAuth: parseBoolean(args["require-auth"] ?? args.requireAuth, false),
68
+ cliPathForWatch: process.argv[1],
67
69
  onLine: (line) => console.log(line),
68
70
  onError: (line) => console.error(line)
69
71
  });
@@ -8,6 +8,32 @@ function dedupe(values) {
8
8
  return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
9
9
  }
10
10
 
11
+ function normalizeBaseUrlByFormatInput(input) {
12
+ const source = input?.baseUrlByFormat && typeof input.baseUrlByFormat === "object"
13
+ ? input.baseUrlByFormat
14
+ : {};
15
+ const openai = String(
16
+ source.openai ||
17
+ input?.openaiBaseUrl ||
18
+ input?.["openai-base-url"] ||
19
+ ""
20
+ ).trim();
21
+ const claude = String(
22
+ source.claude ||
23
+ source.anthropic ||
24
+ input?.claudeBaseUrl ||
25
+ input?.anthropicBaseUrl ||
26
+ input?.["claude-base-url"] ||
27
+ input?.["anthropic-base-url"] ||
28
+ ""
29
+ ).trim();
30
+
31
+ const out = {};
32
+ if (openai) out.openai = openai;
33
+ if (claude) out.claude = claude;
34
+ return Object.keys(out).length > 0 ? out : undefined;
35
+ }
36
+
11
37
  export function parseModelListInput(raw) {
12
38
  if (!raw) return [];
13
39
  if (Array.isArray(raw)) return dedupe(raw);
@@ -20,22 +46,31 @@ function normalizeModelArray(models) {
20
46
 
21
47
  export function buildProviderFromSetupInput(input) {
22
48
  const providerId = input.providerId || input.id || input.name;
49
+ const baseUrlByFormat = normalizeBaseUrlByFormatInput(input);
23
50
  const explicitModels = normalizeModelArray(parseModelListInput(input.models));
24
51
  const probeModels = normalizeModelArray(input.probe?.models || []);
25
52
  const mergedModels = explicitModels.length > 0 ? explicitModels : probeModels;
53
+ const endpointFormats = baseUrlByFormat ? Object.keys(baseUrlByFormat) : [];
26
54
 
27
55
  const preferredFormat = input.probe?.preferredFormat || input.format;
28
56
  const supportedFormats = dedupe([
29
57
  ...(input.probe?.formats || []),
58
+ ...endpointFormats,
30
59
  ...(input.formats || []),
31
60
  ...(preferredFormat ? [preferredFormat] : [])
32
61
  ]);
62
+ const baseUrl = String(input.baseUrl || "").trim()
63
+ || (preferredFormat ? baseUrlByFormat?.[preferredFormat] : "")
64
+ || baseUrlByFormat?.openai
65
+ || baseUrlByFormat?.claude
66
+ || "";
33
67
 
34
68
  return normalizeRuntimeConfig({
35
69
  providers: [{
36
70
  id: providerId,
37
71
  name: input.name || providerId,
38
- baseUrl: input.baseUrl,
72
+ baseUrl,
73
+ baseUrlByFormat,
39
74
  apiKey: input.apiKey,
40
75
  format: preferredFormat,
41
76
  formats: supportedFormats,
@@ -136,4 +171,3 @@ export function buildWorkerConfigPayload(config, { masterKey } = {}) {
136
171
 
137
172
  return payload;
138
173
  }
139
-
@@ -16,6 +16,18 @@ function makeProviderShell(baseUrl) {
16
16
  };
17
17
  }
18
18
 
19
+ function normalizeProbeBaseUrlByFormat(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
21
+ const openai = typeof value.openai === "string" ? value.openai.trim() : "";
22
+ const claude = typeof value.claude === "string"
23
+ ? value.claude.trim()
24
+ : (typeof value.anthropic === "string" ? value.anthropic.trim() : "");
25
+ const out = {};
26
+ if (openai) out[FORMATS.OPENAI] = openai;
27
+ if (claude) out[FORMATS.CLAUDE] = claude;
28
+ return Object.keys(out).length > 0 ? out : undefined;
29
+ }
30
+
19
31
  function cloneHeaders(headers) {
20
32
  return { ...(headers || {}) };
21
33
  }
@@ -159,7 +171,7 @@ function extractModelIds(result) {
159
171
  return [...new Set(ids)];
160
172
  }
161
173
 
162
- async function probeOpenAI(baseUrl, apiKey, timeoutMs) {
174
+ async function probeOpenAI(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
163
175
  const authVariants = makeAuthVariants(FORMATS.OPENAI, apiKey);
164
176
  const modelsUrl = resolveModelsUrl(baseUrl, FORMATS.OPENAI);
165
177
  const messagesUrl = resolveProviderUrl(makeProviderShell(baseUrl), FORMATS.OPENAI);
@@ -174,7 +186,7 @@ async function probeOpenAI(baseUrl, apiKey, timeoutMs) {
174
186
  };
175
187
 
176
188
  for (const variant of authVariants) {
177
- const commonHeaders = { "Content-Type": "application/json", ...variant.headers };
189
+ const commonHeaders = { "Content-Type": "application/json", ...extraHeaders, ...variant.headers };
178
190
 
179
191
  const modelsResult = await safeFetchJson(modelsUrl, {
180
192
  method: "GET",
@@ -210,7 +222,7 @@ async function probeOpenAI(baseUrl, apiKey, timeoutMs) {
210
222
  return details;
211
223
  }
212
224
 
213
- async function probeClaude(baseUrl, apiKey, timeoutMs) {
225
+ async function probeClaude(baseUrl, apiKey, timeoutMs, extraHeaders = {}) {
214
226
  const authVariants = makeAuthVariants(FORMATS.CLAUDE, apiKey);
215
227
  const modelsUrl = resolveModelsUrl(baseUrl, FORMATS.CLAUDE);
216
228
  const messagesUrl = resolveProviderUrl(makeProviderShell(baseUrl), FORMATS.CLAUDE);
@@ -228,6 +240,7 @@ async function probeClaude(baseUrl, apiKey, timeoutMs) {
228
240
  const commonHeaders = {
229
241
  "Content-Type": "application/json",
230
242
  "anthropic-version": "2023-06-01",
243
+ ...extraHeaders,
231
244
  ...variant.headers
232
245
  };
233
246
 
@@ -266,10 +279,16 @@ async function probeClaude(baseUrl, apiKey, timeoutMs) {
266
279
 
267
280
  export async function probeProvider(options) {
268
281
  const baseUrl = String(options?.baseUrl || "").trim();
282
+ const baseUrlByFormat = normalizeProbeBaseUrlByFormat(options?.baseUrlByFormat);
269
283
  const apiKey = String(options?.apiKey || "").trim();
270
284
  const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
285
+ const extraHeaders = options?.headers && typeof options.headers === "object" && !Array.isArray(options.headers)
286
+ ? options.headers
287
+ : {};
288
+ const openaiProbeBaseUrl = String(baseUrlByFormat?.[FORMATS.OPENAI] || baseUrl || "").trim();
289
+ const claudeProbeBaseUrl = String(baseUrlByFormat?.[FORMATS.CLAUDE] || baseUrl || "").trim();
271
290
 
272
- if (!baseUrl) {
291
+ if (!openaiProbeBaseUrl && !claudeProbeBaseUrl) {
273
292
  throw new Error("Provider baseUrl is required for probing.");
274
293
  }
275
294
  if (!apiKey) {
@@ -277,8 +296,12 @@ export async function probeProvider(options) {
277
296
  }
278
297
 
279
298
  const [openai, claude] = await Promise.all([
280
- probeOpenAI(baseUrl, apiKey, timeoutMs),
281
- probeClaude(baseUrl, apiKey, timeoutMs)
299
+ openaiProbeBaseUrl
300
+ ? probeOpenAI(openaiProbeBaseUrl, apiKey, timeoutMs, extraHeaders)
301
+ : Promise.resolve({ format: FORMATS.OPENAI, supported: false, working: false, models: [], auth: null, checks: [] }),
302
+ claudeProbeBaseUrl
303
+ ? probeClaude(claudeProbeBaseUrl, apiKey, timeoutMs, extraHeaders)
304
+ : Promise.resolve({ format: FORMATS.CLAUDE, supported: false, working: false, models: [], auth: null, checks: [] })
282
305
  ]);
283
306
 
284
307
  const supportedFormats = [claude, openai]
@@ -304,7 +327,8 @@ export async function probeProvider(options) {
304
327
 
305
328
  return {
306
329
  ok: workingFormats.length > 0,
307
- baseUrl,
330
+ baseUrl: baseUrl || openaiProbeBaseUrl || claudeProbeBaseUrl,
331
+ baseUrlByFormat,
308
332
  formats: supportedFormats,
309
333
  workingFormats,
310
334
  preferredFormat,
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { watch as fsWatch } from "node:fs";
2
+ import { watch as fsWatch, existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
3
4
  import { configFileExists, getDefaultConfigPath, readConfigFile } from "./config-store.js";
4
5
  import { startLocalProxyServer } from "./local-server.js";
5
6
  import { configHasProvider, sanitizeConfigForDisplay } from "../runtime/config.js";
@@ -20,6 +21,12 @@ function summarizeConfig(config, configPath) {
20
21
  for (const provider of target.providers) {
21
22
  lines.push(`- ${provider.id} (${provider.name})`);
22
23
  lines.push(` baseUrl=${provider.baseUrl}`);
24
+ if (provider.baseUrlByFormat?.openai) {
25
+ lines.push(` openaiBaseUrl=${provider.baseUrlByFormat.openai}`);
26
+ }
27
+ if (provider.baseUrlByFormat?.claude) {
28
+ lines.push(` claudeBaseUrl=${provider.baseUrlByFormat.claude}`);
29
+ }
23
30
  lines.push(` formats=${(provider.formats || []).join(", ") || provider.format || "unknown"}`);
24
31
  lines.push(` apiKey=${provider.apiKey || "(from env/hidden)"}`);
25
32
  lines.push(` models=${(provider.models || []).map((m) => m.id).join(", ") || "(none)"}`);
@@ -43,12 +50,97 @@ function toNumber(value, fallback) {
43
50
  return Number.isFinite(parsed) ? parsed : fallback;
44
51
  }
45
52
 
53
+ function safeRealpath(filePath) {
54
+ if (!filePath) return "";
55
+ try {
56
+ return realpathSync(filePath);
57
+ } catch {
58
+ return path.resolve(filePath);
59
+ }
60
+ }
61
+
62
+ function resolvePackageJsonPathFromCliPath(cliPath) {
63
+ if (!cliPath) return "";
64
+ let dir = path.dirname(cliPath);
65
+ for (let i = 0; i < 8; i += 1) {
66
+ const candidate = path.join(dir, "package.json");
67
+ if (existsSync(candidate)) return candidate;
68
+ const next = path.dirname(dir);
69
+ if (next === dir) break;
70
+ dir = next;
71
+ }
72
+ return "";
73
+ }
74
+
75
+ function readPackageVersion(packageJsonPath) {
76
+ if (!packageJsonPath || !existsSync(packageJsonPath)) return "";
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
79
+ return typeof parsed?.version === "string" ? parsed.version : "";
80
+ } catch {
81
+ return "";
82
+ }
83
+ }
84
+
85
+ function snapshotCliVersionState(cliPath) {
86
+ const realpath = safeRealpath(cliPath);
87
+ const packageJsonPath = resolvePackageJsonPathFromCliPath(realpath);
88
+ const version = readPackageVersion(packageJsonPath);
89
+ return { cliPath, realpath, packageJsonPath, version };
90
+ }
91
+
92
+ function buildStartArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
93
+ return [
94
+ "start",
95
+ `--config=${configPath}`,
96
+ `--host=${host}`,
97
+ `--port=${port}`,
98
+ `--watch-config=${watchConfig ? "true" : "false"}`,
99
+ `--watch-binary=${watchBinary ? "true" : "false"}`,
100
+ `--require-auth=${requireAuth ? "true" : "false"}`
101
+ ];
102
+ }
103
+
104
+ function spawnReplacementCli({ cliPath, startArgs }) {
105
+ return new Promise((resolve) => {
106
+ try {
107
+ const env = { ...process.env };
108
+ env.AI_ROUTER_CLI_PATH = cliPath;
109
+ delete env.AI_ROUTER_MANAGED_BY_STARTUP;
110
+
111
+ const child = spawn(process.execPath, [cliPath, ...startArgs], {
112
+ stdio: "inherit",
113
+ env
114
+ });
115
+
116
+ let settled = false;
117
+ const finish = (result) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ resolve(result);
121
+ };
122
+
123
+ child.once("spawn", () => finish({ ok: true, pid: child.pid }));
124
+ child.once("error", (error) => finish({ ok: false, error }));
125
+ } catch (error) {
126
+ resolve({ ok: false, error });
127
+ }
128
+ });
129
+ }
130
+
46
131
  export async function runStartCommand(options = {}) {
47
132
  const configPath = options.configPath || getDefaultConfigPath();
48
133
  const host = options.host || "127.0.0.1";
49
134
  const port = toNumber(options.port, 8787);
50
135
  const watchConfig = toBoolean(options.watchConfig, true);
136
+ const watchBinary = toBoolean(options.watchBinary, true);
137
+ const binaryWatchIntervalMs = Math.max(
138
+ 1000,
139
+ toNumber(options.binaryWatchIntervalMs ?? process.env.AI_ROUTER_BINARY_WATCH_INTERVAL_MS, 15000)
140
+ );
51
141
  const requireAuth = toBoolean(options.requireAuth, false);
142
+ const managedByStartup = options.managedByStartup === true || process.env.AI_ROUTER_MANAGED_BY_STARTUP === "1";
143
+ const cliPathForWatch = String(options.cliPathForWatch || process.env.AI_ROUTER_CLI_PATH || process.argv[1] || "");
52
144
  const line = typeof options.onLine === "function" ? options.onLine : console.log;
53
145
  const error = typeof options.onError === "function" ? options.onError : console.error;
54
146
 
@@ -95,12 +187,17 @@ export async function runStartCommand(options = {}) {
95
187
  }
96
188
  line(`Local auth: ${requireAuth ? "required (masterKey)" : "disabled"}`);
97
189
  line(`Config watch auto-restart: ${watchConfig ? "enabled" : "disabled"}`);
190
+ line(`Binary update watch: ${watchBinary ? "enabled" : "disabled"}${managedByStartup ? " (startup-managed auto-restart)" : ""}`);
98
191
  line("Press Ctrl+C to stop.");
99
192
 
100
193
  let shuttingDown = false;
101
194
  let restarting = false;
102
195
  let debounceTimer = null;
103
196
  let watcher = null;
197
+ let binaryWatchTimer = null;
198
+ let binaryState = watchBinary && cliPathForWatch ? snapshotCliVersionState(cliPathForWatch) : null;
199
+ let binaryNoticeSent = false;
200
+ let binaryRelaunching = false;
104
201
 
105
202
  const closeServer = async () => {
106
203
  if (!server) return;
@@ -153,23 +250,85 @@ export async function runStartCommand(options = {}) {
153
250
  }
154
251
  }
155
252
 
156
- await new Promise((resolve) => {
157
- const shutdown = async () => {
253
+ let resolveDone;
254
+ const donePromise = new Promise((resolve) => {
255
+ resolveDone = resolve;
256
+ });
257
+
258
+ const shutdown = async () => {
158
259
  if (shuttingDown) return;
159
260
  shuttingDown = true;
160
261
  try {
161
262
  if (debounceTimer) clearTimeout(debounceTimer);
162
263
  watcher?.close?.();
264
+ if (binaryWatchTimer) clearInterval(binaryWatchTimer);
163
265
  } catch {
164
266
  // ignore
165
267
  }
166
268
  await closeServer();
167
- resolve();
269
+ resolveDone();
168
270
  };
169
271
 
170
- process.once("SIGINT", () => { void shutdown(); });
171
- process.once("SIGTERM", () => { void shutdown(); });
172
- });
272
+ if (watchBinary && binaryState) {
273
+ binaryWatchTimer = setInterval(() => {
274
+ if (shuttingDown || restarting || binaryRelaunching) return;
275
+ const nextState = snapshotCliVersionState(binaryState.cliPath);
276
+ const changed =
277
+ nextState.realpath !== binaryState.realpath ||
278
+ (nextState.version && binaryState.version && nextState.version !== binaryState.version);
279
+
280
+ if (!changed) return;
281
+
282
+ const from = binaryState.version || binaryState.realpath || "(unknown)";
283
+ const to = nextState.version || nextState.realpath || "(unknown)";
284
+ binaryState = nextState;
285
+
286
+ if (managedByStartup) {
287
+ line(`Detected ai-router update (${from} -> ${to}). Exiting for startup manager to relaunch latest version.`);
288
+ void shutdown().then(() => {
289
+ process.exit(0);
290
+ });
291
+ return;
292
+ }
293
+
294
+ const cliPath = nextState.cliPath || cliPathForWatch || process.argv[1];
295
+ if (!cliPath) {
296
+ if (!binaryNoticeSent) {
297
+ binaryNoticeSent = true;
298
+ line(`Detected ai-router update (${from} -> ${to}). Restart this process to run the new version.`);
299
+ }
300
+ return;
301
+ }
302
+
303
+ binaryRelaunching = true;
304
+ void (async () => {
305
+ try {
306
+ line(`Detected ai-router update (${from} -> ${to}). Relaunching latest version...`);
307
+ await shutdown();
308
+ const launch = await spawnReplacementCli({
309
+ cliPath,
310
+ startArgs: buildStartArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth })
311
+ });
312
+ if (!launch.ok) {
313
+ error(`Failed to relaunch updated ai-router: ${launch.error instanceof Error ? launch.error.message : String(launch.error)}`);
314
+ process.exit(1);
315
+ return;
316
+ }
317
+
318
+ line(`Started updated ai-router process (pid ${launch.pid || "unknown"}).`);
319
+ process.exit(0);
320
+ } catch (relaunchError) {
321
+ error(`Failed during ai-router auto-relaunch: ${relaunchError instanceof Error ? relaunchError.message : String(relaunchError)}`);
322
+ process.exit(1);
323
+ }
324
+ })();
325
+ }, binaryWatchIntervalMs);
326
+ }
327
+
328
+ process.once("SIGINT", () => { void shutdown(); });
329
+ process.once("SIGTERM", () => { void shutdown(); });
330
+
331
+ await donePromise;
173
332
 
174
333
  return {
175
334
  ok: true,
@@ -5,7 +5,7 @@
5
5
 
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
- import { promises as fs } from "node:fs";
8
+ import { promises as fs, existsSync } from "node:fs";
9
9
  import { spawnSync } from "node:child_process";
10
10
 
11
11
  const SERVICE_NAME = "ai-router";
@@ -37,26 +37,32 @@ function runCommand(command, args, { cwd } = {}) {
37
37
 
38
38
  function resolveCliEntryPath() {
39
39
  if (process.env.AI_ROUTER_CLI_PATH) return process.env.AI_ROUTER_CLI_PATH;
40
+ const nodeBinDir = path.dirname(process.execPath);
41
+ for (const binName of ["ai-router", "ai-router-proxy"]) {
42
+ const candidate = path.join(nodeBinDir, binName);
43
+ if (existsSync(candidate)) return candidate;
44
+ }
40
45
  if (process.argv[1]) return path.resolve(process.argv[1]);
41
46
  throw new Error("Unable to resolve ai-router CLI entry path.");
42
47
  }
43
48
 
44
- function makeExecArgs({ configPath, host, port, watchConfig, requireAuth }) {
49
+ function makeExecArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
45
50
  return [
46
51
  "start",
47
52
  `--config=${configPath}`,
48
53
  `--host=${host}`,
49
54
  `--port=${port}`,
50
55
  `--watch-config=${watchConfig ? "true" : "false"}`,
56
+ `--watch-binary=${watchBinary ? "true" : "false"}`,
51
57
  `--require-auth=${requireAuth ? "true" : "false"}`
52
58
  ];
53
59
  }
54
60
 
55
- function buildLaunchAgentPlist({ nodePath, cliPath, configPath, host, port, watchConfig, requireAuth }) {
61
+ function buildLaunchAgentPlist({ nodePath, cliPath, configPath, host, port, watchConfig, watchBinary, requireAuth }) {
56
62
  const logDir = path.join(os.homedir(), "Library", "Logs");
57
63
  const stdoutPath = path.join(logDir, "ai-router.out.log");
58
64
  const stderrPath = path.join(logDir, "ai-router.err.log");
59
- const args = [nodePath, cliPath, ...makeExecArgs({ configPath, host, port, watchConfig, requireAuth })];
65
+ const args = [nodePath, cliPath, ...makeExecArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth })];
60
66
 
61
67
  const xmlArgs = args.map((arg) => ` <string>${arg}</string>`).join("\n");
62
68
 
@@ -74,6 +80,13 @@ ${xmlArgs}
74
80
  <true/>
75
81
  <key>KeepAlive</key>
76
82
  <true/>
83
+ <key>EnvironmentVariables</key>
84
+ <dict>
85
+ <key>AI_ROUTER_MANAGED_BY_STARTUP</key>
86
+ <string>1</string>
87
+ <key>AI_ROUTER_CLI_PATH</key>
88
+ <string>${cliPath}</string>
89
+ </dict>
77
90
  <key>StandardOutPath</key>
78
91
  <string>${stdoutPath}</string>
79
92
  <key>StandardErrorPath</key>
@@ -85,8 +98,8 @@ ${xmlArgs}
85
98
  `;
86
99
  }
87
100
 
88
- function buildSystemdService({ nodePath, cliPath, configPath, host, port, watchConfig, requireAuth }) {
89
- const execArgs = makeExecArgs({ configPath, host, port, watchConfig, requireAuth }).map(quoteArg).join(" ");
101
+ function buildSystemdService({ nodePath, cliPath, configPath, host, port, watchConfig, watchBinary, requireAuth }) {
102
+ const execArgs = makeExecArgs({ configPath, host, port, watchConfig, watchBinary, requireAuth }).map(quoteArg).join(" ");
90
103
  const execStart = `${quoteArg(nodePath)} ${quoteArg(cliPath)} ${execArgs}`;
91
104
 
92
105
  return `[Unit]
@@ -99,6 +112,8 @@ ExecStart=${execStart}
99
112
  Restart=always
100
113
  RestartSec=2
101
114
  Environment=NODE_ENV=production
115
+ Environment=AI_ROUTER_MANAGED_BY_STARTUP=1
116
+ Environment=AI_ROUTER_CLI_PATH=${cliPath}
102
117
  WorkingDirectory=${process.cwd()}
103
118
 
104
119
  [Install]
@@ -106,7 +121,7 @@ WantedBy=default.target
106
121
  `;
107
122
  }
108
123
 
109
- async function installDarwin({ configPath, host, port, watchConfig, requireAuth }) {
124
+ async function installDarwin({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
110
125
  const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
111
126
  const plistPath = path.join(launchAgentsDir, `${LAUNCH_AGENT_ID}.plist`);
112
127
  const nodePath = process.execPath;
@@ -122,6 +137,7 @@ async function installDarwin({ configPath, host, port, watchConfig, requireAuth
122
137
  host,
123
138
  port,
124
139
  watchConfig,
140
+ watchBinary,
125
141
  requireAuth
126
142
  });
127
143
 
@@ -191,7 +207,7 @@ async function statusDarwin() {
191
207
  };
192
208
  }
193
209
 
194
- async function installLinux({ configPath, host, port, watchConfig, requireAuth }) {
210
+ async function installLinux({ configPath, host, port, watchConfig, watchBinary, requireAuth }) {
195
211
  const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
196
212
  const servicePath = path.join(systemdDir, `${SERVICE_NAME}.service`);
197
213
  const nodePath = process.execPath;
@@ -205,6 +221,7 @@ async function installLinux({ configPath, host, port, watchConfig, requireAuth }
205
221
  host,
206
222
  port,
207
223
  watchConfig,
224
+ watchBinary,
208
225
  requireAuth
209
226
  });
210
227
  await fs.writeFile(servicePath, content, "utf8");
@@ -271,6 +288,7 @@ export async function installStartup(options) {
271
288
  host: options.host || "127.0.0.1",
272
289
  port: options.port || 8787,
273
290
  watchConfig: options.watchConfig !== false,
291
+ watchBinary: options.watchBinary !== false,
274
292
  requireAuth: options.requireAuth === true
275
293
  };
276
294
 
@@ -68,17 +68,49 @@ function normalizeModelEntry(model) {
68
68
  };
69
69
  }
70
70
 
71
+ function normalizeBaseUrlByFormat(value) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
73
+ const out = {};
74
+ const openai = typeof value.openai === "string" ? value.openai.trim() : "";
75
+ const claude =
76
+ typeof value.claude === "string" ? value.claude.trim()
77
+ : (typeof value.anthropic === "string" ? value.anthropic.trim() : "");
78
+
79
+ if (openai) out[FORMATS.OPENAI] = openai;
80
+ if (claude) out[FORMATS.CLAUDE] = claude;
81
+ return Object.keys(out).length > 0 ? out : undefined;
82
+ }
83
+
71
84
  function normalizeProvider(provider, index = 0) {
72
85
  if (!provider || typeof provider !== "object") return null;
73
86
 
74
87
  const name = provider.name || provider.id || `provider-${index + 1}`;
75
88
  const id = slugifyId(provider.id || provider.name || `provider-${index + 1}`);
76
- const baseUrl = String(provider.baseUrl || provider["base-url"] || provider.endpoint || "").trim();
89
+ const baseUrlByFormat = normalizeBaseUrlByFormat(
90
+ provider.baseUrlByFormat ||
91
+ provider["base-url-by-format"] ||
92
+ provider.endpointByFormat ||
93
+ provider["endpoint-by-format"] ||
94
+ provider.endpoints
95
+ );
96
+ const explicitBaseUrl = String(provider.baseUrl || provider["base-url"] || provider.endpoint || "").trim();
77
97
  const rawFormat = provider.format || provider.responseFormat || provider["response-format"];
98
+ const preferredFormat = [FORMATS.OPENAI, FORMATS.CLAUDE].includes(rawFormat) ? rawFormat : undefined;
99
+ const endpointFormats = baseUrlByFormat ? Object.keys(baseUrlByFormat) : [];
78
100
  const formats = dedupeStrings([
79
101
  ...toArray(provider.formats),
80
- ...(rawFormat ? [rawFormat] : [])
102
+ ...endpointFormats,
103
+ ...(preferredFormat ? [preferredFormat] : [])
81
104
  ]).filter((value) => value === FORMATS.OPENAI || value === FORMATS.CLAUDE);
105
+ const orderedFormats = preferredFormat
106
+ ? dedupeStrings([preferredFormat, ...formats])
107
+ : formats;
108
+ const baseUrl = explicitBaseUrl
109
+ || (preferredFormat && baseUrlByFormat?.[preferredFormat])
110
+ || (baseUrlByFormat?.[orderedFormats[0]])
111
+ || baseUrlByFormat?.[FORMATS.OPENAI]
112
+ || baseUrlByFormat?.[FORMATS.CLAUDE]
113
+ || "";
82
114
 
83
115
  const normalizedModels = toArray(provider.models)
84
116
  .map(normalizeModelEntry)
@@ -99,10 +131,11 @@ function normalizeProvider(provider, index = 0) {
99
131
  name,
100
132
  enabled: provider.enabled !== false,
101
133
  baseUrl,
134
+ baseUrlByFormat,
102
135
  apiKey: typeof provider.apiKey === "string" ? provider.apiKey : (typeof provider.credential === "string" ? provider.credential : undefined),
103
136
  apiKeyEnv: typeof provider.apiKeyEnv === "string" ? provider.apiKeyEnv : undefined,
104
- format: formats[0],
105
- formats,
137
+ format: preferredFormat || orderedFormats[0],
138
+ formats: orderedFormats,
106
139
  auth,
107
140
  authByFormat,
108
141
  headers: provider.headers && typeof provider.headers === "object" ? provider.headers : {},
@@ -200,7 +233,7 @@ export function resolveProviderFormat(provider, sourceFormat = undefined) {
200
233
  }
201
234
 
202
235
  export function resolveProviderUrl(provider, targetFormat) {
203
- const baseUrl = String(provider?.baseUrl || "").trim().replace(/\/+$/, "");
236
+ const baseUrl = String(provider?.baseUrlByFormat?.[targetFormat] || provider?.baseUrl || "").trim().replace(/\/+$/, "");
204
237
  if (!baseUrl) return "";
205
238
  const isVersionedApiRoot = /\/v\d+(?:\.\d+)?$/i.test(baseUrl);
206
239