@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.
Files changed (42) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +272 -0
  3. package/dist/adapters/base.js +2 -0
  4. package/dist/adapters/deepseek.js +78 -0
  5. package/dist/adapters/index.js +20 -0
  6. package/dist/adapters/ollama.js +182 -0
  7. package/dist/adapters/openaiCompatible.js +50 -0
  8. package/dist/admin/auth.js +37 -0
  9. package/dist/admin/configStore.js +80 -0
  10. package/dist/admin/envStore.js +149 -0
  11. package/dist/admin/routes.js +360 -0
  12. package/dist/cli/bin.js +10 -0
  13. package/dist/cli/commands/config.js +31 -0
  14. package/dist/cli/commands/doctor.js +107 -0
  15. package/dist/cli/commands/init.js +68 -0
  16. package/dist/cli/commands/start.js +38 -0
  17. package/dist/cli/commands/status.js +23 -0
  18. package/dist/cli/index.js +22 -0
  19. package/dist/config/defaultModelsFile.js +16 -0
  20. package/dist/config/load.js +221 -0
  21. package/dist/config/mergeHeaders.js +33 -0
  22. package/dist/config/paths.js +45 -0
  23. package/dist/config/schema.js +59 -0
  24. package/dist/config.js +25 -0
  25. package/dist/http.js +69 -0
  26. package/dist/index.js +30 -0
  27. package/dist/observability/metrics.js +102 -0
  28. package/dist/observability/modelMessageDebugStore.js +69 -0
  29. package/dist/observability/modelRequestStore.js +52 -0
  30. package/dist/observability/requestId.js +21 -0
  31. package/dist/observability/requestRecorder.js +48 -0
  32. package/dist/observability/summary.js +56 -0
  33. package/dist/observability/tokenUsage.js +46 -0
  34. package/dist/server.js +442 -0
  35. package/dist/startupLog.js +114 -0
  36. package/dist/types.js +2 -0
  37. package/dist/upstreamProbe.js +53 -0
  38. package/dist/version.js +19 -0
  39. package/package.json +73 -0
  40. package/ui/dist/assets/index-CDUAKry5.css +1 -0
  41. package/ui/dist/assets/index-Dq3YzAqp.js +13 -0
  42. 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
+ }