@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 +3 -0
- package/package.json +1 -1
- package/src/cli/router-module.js +105 -22
- package/src/cli-entry.js +2 -0
- package/src/node/config-workflows.js +36 -2
- package/src/node/provider-probe.js +31 -7
- package/src/node/start-command.js +166 -7
- package/src/node/startup-manager.js +26 -8
- package/src/runtime/config.js +38 -5
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
package/src/cli/router-module.js
CHANGED
|
@@ -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:
|
|
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
|
|
256
|
-
manualFormat = await context
|
|
307
|
+
if (!probe) {
|
|
308
|
+
manualFormat = await promptProviderFormat(context, {
|
|
257
309
|
message: "Primary provider format",
|
|
258
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
269
|
+
resolveDone();
|
|
168
270
|
};
|
|
169
271
|
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
package/src/runtime/config.js
CHANGED
|
@@ -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
|
|
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
|
-
...
|
|
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:
|
|
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
|
|