@archimonde12/llm-proxy 1.0.0
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/LICENSE +20 -0
- package/README.md +272 -0
- package/dist/adapters/base.js +2 -0
- package/dist/adapters/deepseek.js +78 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/ollama.js +182 -0
- package/dist/adapters/openaiCompatible.js +50 -0
- package/dist/admin/auth.js +37 -0
- package/dist/admin/configStore.js +80 -0
- package/dist/admin/envStore.js +149 -0
- package/dist/admin/routes.js +360 -0
- package/dist/cli/bin.js +10 -0
- package/dist/cli/commands/config.js +31 -0
- package/dist/cli/commands/doctor.js +107 -0
- package/dist/cli/commands/init.js +68 -0
- package/dist/cli/commands/start.js +38 -0
- package/dist/cli/commands/status.js +23 -0
- package/dist/cli/index.js +22 -0
- package/dist/config/defaultModelsFile.js +16 -0
- package/dist/config/load.js +221 -0
- package/dist/config/mergeHeaders.js +33 -0
- package/dist/config/paths.js +45 -0
- package/dist/config/schema.js +59 -0
- package/dist/config.js +25 -0
- package/dist/http.js +69 -0
- package/dist/index.js +30 -0
- package/dist/observability/metrics.js +102 -0
- package/dist/observability/modelMessageDebugStore.js +69 -0
- package/dist/observability/modelRequestStore.js +52 -0
- package/dist/observability/requestId.js +21 -0
- package/dist/observability/requestRecorder.js +48 -0
- package/dist/observability/summary.js +56 -0
- package/dist/observability/tokenUsage.js +46 -0
- package/dist/server.js +442 -0
- package/dist/startupLog.js +114 -0
- package/dist/types.js +2 -0
- package/dist/upstreamProbe.js +53 -0
- package/dist/version.js +19 -0
- package/package.json +73 -0
- package/ui/dist/assets/index-CDUAKry5.css +1 -0
- package/ui/dist/assets/index-Dq3YzAqp.js +13 -0
- package/ui/dist/index.html +16 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.doctorCommand = doctorCommand;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const load_1 = require("../../config/load");
|
|
9
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
10
|
+
async function checkPortFree(host, port) {
|
|
11
|
+
return await new Promise((resolve) => {
|
|
12
|
+
const srv = node_net_1.default
|
|
13
|
+
.createServer()
|
|
14
|
+
.once("error", () => resolve(false))
|
|
15
|
+
.once("listening", () => srv.close(() => resolve(true)))
|
|
16
|
+
.listen(port, host);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function pingUrl(url, timeoutMs) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(url, { method: "GET", signal: controller.signal });
|
|
24
|
+
return { ok: res.ok, status: res.status };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { ok: false, status: 0, error: err?.message ?? String(err) };
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(t);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function checkUpstreams(modelsFile) {
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const m of modelsFile.models) {
|
|
36
|
+
const base = m.baseUrl.replace(/\/+$/, "");
|
|
37
|
+
const r = await pingUrl(base, Math.min(m.timeoutMs ?? 1500, 5000));
|
|
38
|
+
results.push({
|
|
39
|
+
id: m.id,
|
|
40
|
+
baseUrl: base,
|
|
41
|
+
ok: r.ok,
|
|
42
|
+
detail: r.ok ? `HTTP ${r.status}` : r.error ? `ERROR ${r.error}` : `HTTP ${r.status}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
function doctorCommand() {
|
|
48
|
+
const cmd = new commander_1.Command("doctor");
|
|
49
|
+
cmd
|
|
50
|
+
.description("Run quick diagnostics (config parse, port check, upstream reachability).")
|
|
51
|
+
.option("--models <path>", "Path to models.json")
|
|
52
|
+
.option("--host <host>", "Host to bind for port check (default: 127.0.0.1)")
|
|
53
|
+
.option("--port <port>", "Port to check (default: 8787)")
|
|
54
|
+
.option("--deep", "Ping upstream baseUrl for each model", false)
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
const host = String(opts.host ?? "127.0.0.1");
|
|
57
|
+
const port = Number(opts.port ?? 8787);
|
|
58
|
+
let modelsFile;
|
|
59
|
+
let sourcePath;
|
|
60
|
+
try {
|
|
61
|
+
const loaded = await (0, load_1.loadModelsFile)({
|
|
62
|
+
cliFlagPath: opts.models,
|
|
63
|
+
envPath: process.env.MODELS_PATH,
|
|
64
|
+
});
|
|
65
|
+
modelsFile = loaded.modelsFile;
|
|
66
|
+
sourcePath = loaded.source.path;
|
|
67
|
+
if (loaded.createdDefaultFile) {
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.log(`note: created starter models.json (example Ollama model) at ${sourcePath}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.error((0, load_1.formatConfigError)(err));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.log(`config: OK (${sourcePath})`);
|
|
80
|
+
const portFree = await checkPortFree(host, port);
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.log(`port: ${host}:${port} ${portFree ? "available" : "IN USE"}`);
|
|
83
|
+
const warnings = [];
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
for (const m of modelsFile.models) {
|
|
86
|
+
if (ids.has(m.id))
|
|
87
|
+
warnings.push(`duplicate id: ${m.id}`);
|
|
88
|
+
ids.add(m.id);
|
|
89
|
+
if (!/^https?:\/\//.test(m.baseUrl))
|
|
90
|
+
warnings.push(`baseUrl missing protocol for ${m.id}: ${m.baseUrl}`);
|
|
91
|
+
}
|
|
92
|
+
for (const w of warnings) {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn(`warn: ${w}`);
|
|
95
|
+
}
|
|
96
|
+
if (opts.deep) {
|
|
97
|
+
const upstreams = await checkUpstreams(modelsFile);
|
|
98
|
+
for (const u of upstreams) {
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.log(`upstream: ${u.id} ${u.baseUrl} ${u.ok ? "OK" : "FAIL"} (${u.detail})`);
|
|
101
|
+
}
|
|
102
|
+
if (upstreams.some((u) => !u.ok))
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return cmd;
|
|
107
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.initCommand = initCommand;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const prompts_1 = __importDefault(require("prompts"));
|
|
9
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const defaultModelsFile_1 = require("../../config/defaultModelsFile");
|
|
12
|
+
const paths_1 = require("../../config/paths");
|
|
13
|
+
function defaultModelsTemplate(args) {
|
|
14
|
+
const file = (0, defaultModelsFile_1.createDefaultModelsFile)();
|
|
15
|
+
if (args.includeOpenAICompatible) {
|
|
16
|
+
file.models.push({
|
|
17
|
+
id: "openai-compatible",
|
|
18
|
+
adapter: "openai_compatible",
|
|
19
|
+
baseUrl: "http://localhost:8000",
|
|
20
|
+
model: "your-model-name",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return file;
|
|
24
|
+
}
|
|
25
|
+
function initCommand() {
|
|
26
|
+
const cmd = new commander_1.Command("init");
|
|
27
|
+
cmd
|
|
28
|
+
.description("Create a starter models.json config (wizard if interactive).")
|
|
29
|
+
.option("--file <path>", "Where to write the config")
|
|
30
|
+
.option("-y, --yes", "Non-interactive (use defaults)", false)
|
|
31
|
+
.action(async (opts) => {
|
|
32
|
+
const isInteractive = Boolean(process.stdin.isTTY && !opts.yes);
|
|
33
|
+
const defaultPath = (await (0, paths_1.fileExists)((0, paths_1.defaultProjectModelsPath)()))
|
|
34
|
+
? (0, paths_1.defaultUserModelsPath)()
|
|
35
|
+
: (0, paths_1.defaultProjectModelsPath)();
|
|
36
|
+
const targetPath = node_path_1.default.resolve(opts.file ?? defaultPath);
|
|
37
|
+
let includeOpenAICompatible = true;
|
|
38
|
+
if (isInteractive) {
|
|
39
|
+
const answers = await (0, prompts_1.default)([
|
|
40
|
+
{
|
|
41
|
+
type: "confirm",
|
|
42
|
+
name: "includeOpenAICompatible",
|
|
43
|
+
message: "Include an OpenAI-compatible upstream example?",
|
|
44
|
+
initial: true,
|
|
45
|
+
},
|
|
46
|
+
], {
|
|
47
|
+
onCancel: () => {
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
throw new Error("Cancelled");
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
includeOpenAICompatible = Boolean(answers.includeOpenAICompatible);
|
|
53
|
+
}
|
|
54
|
+
const content = JSON.stringify(defaultModelsTemplate({ includeOpenAICompatible }), null, 2);
|
|
55
|
+
await (0, paths_1.ensureParentDir)(targetPath);
|
|
56
|
+
await promises_1.default.writeFile(targetPath, content + "\n", { encoding: "utf8", flag: "wx" }).catch(async (err) => {
|
|
57
|
+
if (err?.code === "EEXIST") {
|
|
58
|
+
throw new Error(`Refusing to overwrite existing file: ${targetPath}`);
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
});
|
|
62
|
+
// eslint-disable-next-line no-console
|
|
63
|
+
console.log(`Wrote config to ${targetPath}`);
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.log(`Next: llm-proxy start --models "${targetPath}"`);
|
|
66
|
+
});
|
|
67
|
+
return cmd;
|
|
68
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startCommand = startCommand;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const server_1 = require("../../server");
|
|
6
|
+
const load_1 = require("../../config/load");
|
|
7
|
+
const startupLog_1 = require("../../startupLog");
|
|
8
|
+
function startCommand() {
|
|
9
|
+
const cmd = new commander_1.Command("start");
|
|
10
|
+
cmd
|
|
11
|
+
.description("Start the llm-proxy server.")
|
|
12
|
+
.option("--models <path>", "Path to models.json")
|
|
13
|
+
.option("--port <port>", "Port to listen on (default: 8787)")
|
|
14
|
+
.option("--host <host>", "Host to bind (default: 127.0.0.1)")
|
|
15
|
+
.action(async (opts) => {
|
|
16
|
+
const port = Number(opts.port ?? process.env.PORT ?? 8787);
|
|
17
|
+
const host = String(opts.host ?? process.env.HOST ?? "127.0.0.1");
|
|
18
|
+
const loaded = await (0, load_1.loadModelsFile)({
|
|
19
|
+
cliFlagPath: opts.models,
|
|
20
|
+
envPath: process.env.MODELS_PATH,
|
|
21
|
+
}).catch((err) => {
|
|
22
|
+
throw new Error((0, load_1.formatConfigError)(err));
|
|
23
|
+
});
|
|
24
|
+
(0, startupLog_1.logStartupPreamble)({
|
|
25
|
+
nodeVersion: process.version,
|
|
26
|
+
nodeEnv: process.env.NODE_ENV ?? "development",
|
|
27
|
+
host,
|
|
28
|
+
port,
|
|
29
|
+
modelsPath: loaded.source.path,
|
|
30
|
+
modelsSourceKind: loaded.source.kind,
|
|
31
|
+
createdDefaultModelsFile: loaded.createdDefaultFile,
|
|
32
|
+
});
|
|
33
|
+
const app = await (0, server_1.buildServer)({ bindHost: host, initial: loaded });
|
|
34
|
+
const address = await app.listen({ port, host });
|
|
35
|
+
(0, startupLog_1.logListenBanner)({ address: String(address), port });
|
|
36
|
+
});
|
|
37
|
+
return cmd;
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.statusCommand = statusCommand;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
function statusCommand() {
|
|
6
|
+
const cmd = new commander_1.Command("status");
|
|
7
|
+
cmd
|
|
8
|
+
.description("Check server health (GET /healthz).")
|
|
9
|
+
.option("--url <baseUrl>", "Base URL of llm-proxy (default: http://127.0.0.1:8787)")
|
|
10
|
+
.action(async (opts) => {
|
|
11
|
+
const baseUrl = (opts.url ?? "http://127.0.0.1:8787").replace(/\/+$/, "");
|
|
12
|
+
const res = await fetch(`${baseUrl}/healthz`, {
|
|
13
|
+
method: "GET",
|
|
14
|
+
headers: { accept: "application/json" },
|
|
15
|
+
});
|
|
16
|
+
const body = await res.text();
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.log(`status=${res.status} url=${baseUrl}/healthz`);
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
20
|
+
console.log(body);
|
|
21
|
+
});
|
|
22
|
+
return cmd;
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCli = runCli;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const init_1 = require("./commands/init");
|
|
6
|
+
const start_1 = require("./commands/start");
|
|
7
|
+
const status_1 = require("./commands/status");
|
|
8
|
+
const doctor_1 = require("./commands/doctor");
|
|
9
|
+
const config_1 = require("./commands/config");
|
|
10
|
+
async function runCli(argv = process.argv) {
|
|
11
|
+
const program = new commander_1.Command();
|
|
12
|
+
program
|
|
13
|
+
.name("llm-proxy")
|
|
14
|
+
.description("A lightweight proxy to route requests to local LLMs via an OpenAI-compatible API.")
|
|
15
|
+
.version(process.env.npm_package_version ?? "0.0.0");
|
|
16
|
+
program.addCommand((0, init_1.initCommand)());
|
|
17
|
+
program.addCommand((0, start_1.startCommand)());
|
|
18
|
+
program.addCommand((0, status_1.statusCommand)());
|
|
19
|
+
program.addCommand((0, doctor_1.doctorCommand)());
|
|
20
|
+
program.addCommand((0, config_1.configCommand)());
|
|
21
|
+
await program.parseAsync(argv);
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDefaultModelsFile = createDefaultModelsFile;
|
|
4
|
+
/** Minimal valid config used by `llm-proxy init -y` and auto-create on first start. */
|
|
5
|
+
function createDefaultModelsFile() {
|
|
6
|
+
return {
|
|
7
|
+
models: [
|
|
8
|
+
{
|
|
9
|
+
id: "ollama-llama3",
|
|
10
|
+
adapter: "ollama",
|
|
11
|
+
baseUrl: "http://localhost:11434",
|
|
12
|
+
model: "llama3",
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ConfigValidationError = void 0;
|
|
7
|
+
exports.resolveModelsPath = resolveModelsPath;
|
|
8
|
+
exports.resolveExistingModelsPath = resolveExistingModelsPath;
|
|
9
|
+
exports.loadModelsFileFromPath = loadModelsFileFromPath;
|
|
10
|
+
exports.loadModelsFileFromPathAsStored = loadModelsFileFromPathAsStored;
|
|
11
|
+
exports.loadModelsFile = loadModelsFile;
|
|
12
|
+
exports.isConfigValidationError = isConfigValidationError;
|
|
13
|
+
exports.formatConfigError = formatConfigError;
|
|
14
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const defaultModelsFile_1 = require("./defaultModelsFile");
|
|
17
|
+
const paths_1 = require("./paths");
|
|
18
|
+
const schema_1 = require("./schema");
|
|
19
|
+
function resolveModelsPath(input) {
|
|
20
|
+
const cliFlagPath = input?.cliFlagPath?.trim();
|
|
21
|
+
if (cliFlagPath) {
|
|
22
|
+
return { kind: "cli_flag", path: node_path_1.default.resolve(cliFlagPath) };
|
|
23
|
+
}
|
|
24
|
+
const envPath = input?.envPath?.trim();
|
|
25
|
+
if (envPath) {
|
|
26
|
+
return { kind: "env", path: node_path_1.default.resolve(envPath) };
|
|
27
|
+
}
|
|
28
|
+
const project = (0, paths_1.defaultProjectModelsPath)(input?.cwd);
|
|
29
|
+
return { kind: "project_default", path: project };
|
|
30
|
+
}
|
|
31
|
+
async function resolveExistingModelsPath(input) {
|
|
32
|
+
const resolved = resolveModelsPath(input);
|
|
33
|
+
if (resolved.kind === "project_default") {
|
|
34
|
+
if (await (0, paths_1.fileExists)(resolved.path))
|
|
35
|
+
return resolved;
|
|
36
|
+
// Check canonical user path first, then legacy (backward compat)
|
|
37
|
+
const canonical = (0, paths_1.canonicalUserModelsPath)();
|
|
38
|
+
if (await (0, paths_1.fileExists)(canonical))
|
|
39
|
+
return { kind: "user_default", path: canonical };
|
|
40
|
+
const legacy = (0, paths_1.legacyUserModelsPath)();
|
|
41
|
+
if (await (0, paths_1.fileExists)(legacy))
|
|
42
|
+
return { kind: "user_default", path: legacy };
|
|
43
|
+
return { kind: "user_default", path: canonical };
|
|
44
|
+
}
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
class ConfigValidationError extends Error {
|
|
48
|
+
issues;
|
|
49
|
+
filePath;
|
|
50
|
+
constructor(args) {
|
|
51
|
+
super(`Invalid config at ${args.filePath}\n` +
|
|
52
|
+
args.issues.map((i) => `- ${i.path}: ${i.message}`).join("\n"));
|
|
53
|
+
this.name = "ConfigValidationError";
|
|
54
|
+
this.issues = args.issues;
|
|
55
|
+
this.filePath = args.filePath;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.ConfigValidationError = ConfigValidationError;
|
|
59
|
+
function zodPathToString(p) {
|
|
60
|
+
if (p.length === 0)
|
|
61
|
+
return "<root>";
|
|
62
|
+
let out = "";
|
|
63
|
+
for (const seg of p) {
|
|
64
|
+
if (typeof seg === "number")
|
|
65
|
+
out += `[${seg}]`;
|
|
66
|
+
else
|
|
67
|
+
out += out ? `.${seg}` : seg;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
async function loadModelsFileFromPath(filePath) {
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await promises_1.default.readFile(filePath, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const e = new Error(`Failed to read models file at ${filePath}: ${err?.message ?? String(err)}`);
|
|
78
|
+
e.statusCode = 500;
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new ConfigValidationError({
|
|
87
|
+
filePath,
|
|
88
|
+
issues: [{ path: "<file>", message: `Invalid JSON: ${err?.message ?? String(err)}` }],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Allow secrets to be referenced as ${ENV_VAR} in config without committing raw keys.
|
|
92
|
+
// If the env var is missing, the field is removed so validation can still succeed.
|
|
93
|
+
if (parsed && typeof parsed === "object") {
|
|
94
|
+
const maybeModels = parsed.models;
|
|
95
|
+
if (Array.isArray(maybeModels)) {
|
|
96
|
+
for (const m of maybeModels) {
|
|
97
|
+
if (!m || typeof m !== "object")
|
|
98
|
+
continue;
|
|
99
|
+
const ak = m.apiKey;
|
|
100
|
+
if (typeof ak === "string") {
|
|
101
|
+
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(ak.trim());
|
|
102
|
+
if (match) {
|
|
103
|
+
const envName = match[1];
|
|
104
|
+
const envVal = process.env[envName];
|
|
105
|
+
if (typeof envVal === "string" && envVal.trim())
|
|
106
|
+
m.apiKey = envVal.trim();
|
|
107
|
+
else
|
|
108
|
+
delete m.apiKey;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const res = schema_1.ModelsFileSchema.safeParse(parsed);
|
|
115
|
+
if (!res.success) {
|
|
116
|
+
throw new ConfigValidationError({
|
|
117
|
+
filePath,
|
|
118
|
+
issues: res.error.issues.map((i) => ({
|
|
119
|
+
path: zodPathToString(i.path.map((seg) => typeof seg === "symbol" ? String(seg) : seg)),
|
|
120
|
+
message: i.message,
|
|
121
|
+
})),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return res.data;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Same as {@link loadModelsFileFromPath} but does not substitute `${ENV_VAR}` in apiKey.
|
|
128
|
+
* Use for admin UI so secrets stay referenced as in models.json.
|
|
129
|
+
*/
|
|
130
|
+
async function loadModelsFileFromPathAsStored(filePath) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = await promises_1.default.readFile(filePath, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const e = new Error(`Failed to read models file at ${filePath}: ${err?.message ?? String(err)}`);
|
|
137
|
+
e.statusCode = 500;
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(raw);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
throw new ConfigValidationError({
|
|
146
|
+
filePath,
|
|
147
|
+
issues: [{ path: "<file>", message: `Invalid JSON: ${err?.message ?? String(err)}` }],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const res = schema_1.ModelsFileSchema.safeParse(parsed);
|
|
151
|
+
if (!res.success) {
|
|
152
|
+
throw new ConfigValidationError({
|
|
153
|
+
filePath,
|
|
154
|
+
issues: res.error.issues.map((i) => ({
|
|
155
|
+
path: zodPathToString(i.path.map((seg) => typeof seg === "symbol" ? String(seg) : seg)),
|
|
156
|
+
message: i.message,
|
|
157
|
+
})),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return res.data;
|
|
161
|
+
}
|
|
162
|
+
async function ensureMockModelsFileAtPath(filePath) {
|
|
163
|
+
if (await (0, paths_1.fileExists)(filePath))
|
|
164
|
+
return false;
|
|
165
|
+
await (0, paths_1.ensureParentDir)(filePath);
|
|
166
|
+
const body = JSON.stringify((0, defaultModelsFile_1.createDefaultModelsFile)(), null, 2) + "\n";
|
|
167
|
+
await promises_1.default.writeFile(filePath, body, "utf8");
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
async function loadModelsFile(input) {
|
|
171
|
+
const cwd = input?.cwd ?? process.cwd();
|
|
172
|
+
const cliFlagPath = input?.cliFlagPath?.trim();
|
|
173
|
+
const envPath = input?.envPath?.trim();
|
|
174
|
+
let source;
|
|
175
|
+
let createdDefaultFile = false;
|
|
176
|
+
if (cliFlagPath) {
|
|
177
|
+
const p = node_path_1.default.resolve(cliFlagPath);
|
|
178
|
+
createdDefaultFile = await ensureMockModelsFileAtPath(p);
|
|
179
|
+
source = { kind: "cli_flag", path: p };
|
|
180
|
+
}
|
|
181
|
+
else if (envPath) {
|
|
182
|
+
const p = node_path_1.default.resolve(envPath);
|
|
183
|
+
createdDefaultFile = await ensureMockModelsFileAtPath(p);
|
|
184
|
+
source = { kind: "env", path: p };
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const project = (0, paths_1.defaultProjectModelsPath)(cwd);
|
|
188
|
+
if (await (0, paths_1.fileExists)(project)) {
|
|
189
|
+
source = { kind: "project_default", path: project };
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// Resolve order: canonical user path → legacy user path → create at project path
|
|
193
|
+
const canonical = (0, paths_1.canonicalUserModelsPath)();
|
|
194
|
+
if (await (0, paths_1.fileExists)(canonical)) {
|
|
195
|
+
source = { kind: "user_default", path: canonical };
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const legacy = (0, paths_1.legacyUserModelsPath)();
|
|
199
|
+
if (await (0, paths_1.fileExists)(legacy)) {
|
|
200
|
+
source = { kind: "user_default", path: legacy };
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
createdDefaultFile = await ensureMockModelsFileAtPath(project);
|
|
204
|
+
source = { kind: "project_default", path: project };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const modelsFile = await loadModelsFileFromPath(source.path);
|
|
210
|
+
return { modelsFile, source, createdDefaultFile };
|
|
211
|
+
}
|
|
212
|
+
function isConfigValidationError(err) {
|
|
213
|
+
return err instanceof ConfigValidationError;
|
|
214
|
+
}
|
|
215
|
+
function formatConfigError(err) {
|
|
216
|
+
if (isConfigValidationError(err))
|
|
217
|
+
return err.message;
|
|
218
|
+
if (err && typeof err === "object" && "message" in err)
|
|
219
|
+
return String(err.message);
|
|
220
|
+
return String(err);
|
|
221
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Explanation of this code:
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.mergeModelOutboundHeaders = mergeModelOutboundHeaders;
|
|
5
|
+
/**
|
|
6
|
+
* This function merges outbound headers for upstream model API calls.
|
|
7
|
+
*
|
|
8
|
+
* - The input is a config object with: apiKey (an optional token string),
|
|
9
|
+
* apiKeyHeader (the header name to use for the key, if custom), and headers (an object of additional custom headers).
|
|
10
|
+
* - If apiKey is present:
|
|
11
|
+
* + If apiKeyHeader is unset, or is "authorization" (case-insensitive), it sets the "Authorization" header as "Bearer <apiKey>".
|
|
12
|
+
* + Otherwise, it sets a header with the name specified in apiKeyHeader (lowercased), with its value exactly apiKey (no "Bearer" prefix).
|
|
13
|
+
* - Then, any additional key-value pairs from config.headers (if present) are merged in, overwriting previous values for the same key.
|
|
14
|
+
* - Returns the merged headers object, or undefined if there are no headers to send.
|
|
15
|
+
*/
|
|
16
|
+
function mergeModelOutboundHeaders(cfg) {
|
|
17
|
+
const out = {};
|
|
18
|
+
// Handle apiKey (if present) and determine the authentication header to add
|
|
19
|
+
const key = typeof cfg.apiKey === "string" ? cfg.apiKey.trim() : "";
|
|
20
|
+
if (key) {
|
|
21
|
+
const rawName = typeof cfg.apiKeyHeader === "string" ? cfg.apiKeyHeader.trim() : "";
|
|
22
|
+
if (!rawName || /^authorization$/i.test(rawName)) {
|
|
23
|
+
out.authorization = `Bearer ${key}`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
out[rawName.toLowerCase()] = key;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Merge in any custom headers from config.headers (if present)
|
|
30
|
+
const merged = { ...out, ...(cfg.headers ?? {}) };
|
|
31
|
+
// If any headers are set, return the object; otherwise, return undefined
|
|
32
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.defaultProjectModelsPath = defaultProjectModelsPath;
|
|
7
|
+
exports.canonicalUserModelsPath = canonicalUserModelsPath;
|
|
8
|
+
exports.legacyUserModelsPath = legacyUserModelsPath;
|
|
9
|
+
exports.defaultUserModelsPath = defaultUserModelsPath;
|
|
10
|
+
exports.fileExists = fileExists;
|
|
11
|
+
exports.ensureParentDir = ensureParentDir;
|
|
12
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
15
|
+
function defaultProjectModelsPath(cwd = process.cwd()) {
|
|
16
|
+
return node_path_1.default.resolve(cwd, "models.json");
|
|
17
|
+
}
|
|
18
|
+
/** Canonical user-level config path: ~/.config/llm-proxy/models.json */
|
|
19
|
+
function canonicalUserModelsPath() {
|
|
20
|
+
return node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "llm-proxy", "models.json");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Legacy user-level config path kept for backward compatibility with
|
|
24
|
+
* installs that used the old package name "llm-open-gateway".
|
|
25
|
+
* ~/.config/llm-open-gateway/models.json
|
|
26
|
+
*/
|
|
27
|
+
function legacyUserModelsPath() {
|
|
28
|
+
return node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "llm-open-gateway", "models.json");
|
|
29
|
+
}
|
|
30
|
+
/** @deprecated Use {@link canonicalUserModelsPath} instead. */
|
|
31
|
+
function defaultUserModelsPath() {
|
|
32
|
+
return canonicalUserModelsPath();
|
|
33
|
+
}
|
|
34
|
+
async function fileExists(p) {
|
|
35
|
+
try {
|
|
36
|
+
await promises_1.default.access(p);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function ensureParentDir(filePath) {
|
|
44
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(filePath), { recursive: true });
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ModelsFileSchema = exports.ModelConfigSchema = exports.ModelAdapterTypeSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
exports.ModelAdapterTypeSchema = zod_1.z.enum(["ollama", "openai_compatible", "deepseek"]);
|
|
6
|
+
exports.ModelConfigSchema = zod_1.z.object({
|
|
7
|
+
id: zod_1.z.string().min(1, "Required"),
|
|
8
|
+
adapter: exports.ModelAdapterTypeSchema,
|
|
9
|
+
baseUrl: zod_1.z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1, "Required")
|
|
12
|
+
.refine((v) => {
|
|
13
|
+
try {
|
|
14
|
+
// eslint-disable-next-line no-new
|
|
15
|
+
new URL(v);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}, { message: "Invalid URL (must include protocol, e.g. http://localhost:11434)" }),
|
|
22
|
+
model: zod_1.z.string().min(1, "Required"),
|
|
23
|
+
apiKey: zod_1.z.string().min(1).optional(),
|
|
24
|
+
apiKeyHeader: zod_1.z.string().min(1).optional(),
|
|
25
|
+
headers: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
26
|
+
timeoutMs: zod_1.z.number().int().positive().optional(),
|
|
27
|
+
})
|
|
28
|
+
.superRefine((val, ctx) => {
|
|
29
|
+
if (val.apiKeyHeader && !val.apiKey) {
|
|
30
|
+
ctx.addIssue({
|
|
31
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
32
|
+
path: ["apiKey"],
|
|
33
|
+
message: "Required when apiKeyHeader is set",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
exports.ModelsFileSchema = zod_1.z
|
|
38
|
+
.object({
|
|
39
|
+
models: zod_1.z.array(exports.ModelConfigSchema).min(1, "Must include at least 1 model"),
|
|
40
|
+
})
|
|
41
|
+
.superRefine((val, ctx) => {
|
|
42
|
+
const seen = new Map();
|
|
43
|
+
for (let i = 0; i < val.models.length; i++) {
|
|
44
|
+
const id = val.models[i]?.id;
|
|
45
|
+
if (!id)
|
|
46
|
+
continue;
|
|
47
|
+
const prev = seen.get(id);
|
|
48
|
+
if (prev !== undefined) {
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
51
|
+
path: ["models", i, "id"],
|
|
52
|
+
message: `Duplicate id '${id}' (also at models[${prev}].id)`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
seen.set(id, i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadModelsFile = loadModelsFile;
|
|
7
|
+
exports.resolveModelConfig = resolveModelConfig;
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const load_1 = require("./config/load");
|
|
10
|
+
async function loadModelsFile(modelsPath) {
|
|
11
|
+
const resolved = modelsPath
|
|
12
|
+
? node_path_1.default.resolve(modelsPath)
|
|
13
|
+
: node_path_1.default.resolve(process.cwd(), "models.json");
|
|
14
|
+
return (await (0, load_1.loadModelsFileFromPath)(resolved));
|
|
15
|
+
}
|
|
16
|
+
function resolveModelConfig(modelsFile, requestedModelId) {
|
|
17
|
+
const match = modelsFile.models.find((m) => m.id === requestedModelId);
|
|
18
|
+
if (!match) {
|
|
19
|
+
const available = modelsFile.models.map((m) => m.id).sort();
|
|
20
|
+
const err = new Error(`Unknown model '${requestedModelId}'. Available: ${available.join(", ")}`);
|
|
21
|
+
err.statusCode = 400;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
return match;
|
|
25
|
+
}
|