llm-cli-gateway 1.5.4 → 1.5.13
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/CHANGELOG.md +68 -0
- package/README.md +249 -9
- package/dist/async-job-manager.d.ts +8 -0
- package/dist/async-job-manager.js +31 -10
- package/dist/config.d.ts +30 -0
- package/dist/config.js +167 -0
- package/dist/entrypoint-url.d.ts +1 -0
- package/dist/entrypoint-url.js +5 -0
- package/dist/executor.d.ts +7 -1
- package/dist/executor.js +50 -15
- package/dist/index.d.ts +3 -0
- package/dist/index.js +764 -675
- package/dist/job-store.d.ts +118 -2
- package/dist/job-store.js +176 -5
- package/dist/upstream-contracts.d.ts +62 -0
- package/dist/upstream-contracts.js +620 -0
- package/package.json +11 -6
package/dist/config.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createRequire } from "module";
|
|
1
5
|
import { z } from "zod";
|
|
6
|
+
import { logWarn, noopLogger } from "./logger.js";
|
|
2
7
|
// Zod schemas for configuration validation
|
|
3
8
|
const DatabaseUrlSchema = z
|
|
4
9
|
.string()
|
|
@@ -60,3 +65,165 @@ export function loadConfig() {
|
|
|
60
65
|
sessionTtl,
|
|
61
66
|
};
|
|
62
67
|
}
|
|
68
|
+
//──────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Persistence configuration
|
|
70
|
+
//
|
|
71
|
+
// The async job store is now driven by a typed config (TOML file +
|
|
72
|
+
// validated env-var overrides) instead of a single LLM_GATEWAY_LOGS_DB env
|
|
73
|
+
// var. The structural invariant: `*_request_async` tools are only registered
|
|
74
|
+
// when a real durable store is attached, so silent in-memory loss after the
|
|
75
|
+
// 1h TTL becomes impossible.
|
|
76
|
+
//
|
|
77
|
+
// Backends:
|
|
78
|
+
// - "sqlite": durable on disk (default).
|
|
79
|
+
// - "postgres": durable in Postgres (interface only — impl not yet shipped).
|
|
80
|
+
// - "memory": in-process MemoryJobStore. Process-lifetime durability only.
|
|
81
|
+
// Requires acknowledgeEphemeral=true to register async tools.
|
|
82
|
+
// - "none": no store. Async tools are NOT registered.
|
|
83
|
+
//──────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
export const PERSISTENCE_BACKENDS = ["sqlite", "postgres", "memory", "none"];
|
|
85
|
+
export const DEFAULT_JOB_RETENTION_DAYS = 30;
|
|
86
|
+
export const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
87
|
+
const PersistenceSchema = z
|
|
88
|
+
.object({
|
|
89
|
+
backend: z.enum(PERSISTENCE_BACKENDS).default("sqlite"),
|
|
90
|
+
path: z.string().optional(),
|
|
91
|
+
dsn: z.string().optional(),
|
|
92
|
+
retentionDays: z.number().positive().default(DEFAULT_JOB_RETENTION_DAYS),
|
|
93
|
+
dedupWindowMs: z.number().int().nonnegative().default(DEFAULT_DEDUP_WINDOW_MS),
|
|
94
|
+
acknowledgeEphemeral: z.boolean().default(false),
|
|
95
|
+
})
|
|
96
|
+
.strict();
|
|
97
|
+
const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
|
|
98
|
+
function defaultPersistenceConfigPath() {
|
|
99
|
+
return (process.env.LLM_GATEWAY_CONFIG ?? path.join(os.homedir(), ".llm-cli-gateway", "config.toml"));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read and parse the optional TOML config file. Returns the raw `[persistence]`
|
|
103
|
+
* table (if present) and the file path. Missing file is fine — defaults apply.
|
|
104
|
+
*/
|
|
105
|
+
function readPersistenceFile(configPath, logger) {
|
|
106
|
+
if (!existsSync(configPath)) {
|
|
107
|
+
return { raw: undefined, sourcePath: null };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const require = createRequire(import.meta.url);
|
|
111
|
+
const TOML = require("toml");
|
|
112
|
+
const text = readFileSync(configPath, "utf-8");
|
|
113
|
+
const parsed = TOML.parse(text);
|
|
114
|
+
return { raw: parsed?.persistence, sourcePath: configPath };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.error(`Failed to parse gateway config at ${configPath}; using defaults`, err);
|
|
118
|
+
return { raw: undefined, sourcePath: null };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Apply legacy env-var overrides on top of the file/defaults. Each application
|
|
123
|
+
* appends a string to `sources.envOverrides` and emits a one-time deprecation
|
|
124
|
+
* warning so operators can migrate to the config file.
|
|
125
|
+
*/
|
|
126
|
+
function applyEnvOverrides(base, logger, sources) {
|
|
127
|
+
const out = { ...base };
|
|
128
|
+
const jobsDbEnv = process.env.LLM_GATEWAY_JOBS_DB;
|
|
129
|
+
const logsDbEnv = process.env.LLM_GATEWAY_LOGS_DB;
|
|
130
|
+
// Empty string is treated as "not set" — only an explicitly non-empty value
|
|
131
|
+
// (or the literal "none") overrides the file/defaults. This avoids the
|
|
132
|
+
// old footgun where `LLM_GATEWAY_LOGS_DB=` silently disabled persistence.
|
|
133
|
+
const dbEnvRaw = jobsDbEnv && jobsDbEnv.length > 0
|
|
134
|
+
? jobsDbEnv
|
|
135
|
+
: logsDbEnv && logsDbEnv.length > 0
|
|
136
|
+
? logsDbEnv
|
|
137
|
+
: undefined;
|
|
138
|
+
if (dbEnvRaw !== undefined) {
|
|
139
|
+
const normalized = dbEnvRaw.trim().toLowerCase();
|
|
140
|
+
if (normalized === "none") {
|
|
141
|
+
out.backend = "none";
|
|
142
|
+
out.path = undefined;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
out.backend = "sqlite";
|
|
146
|
+
out.path = dbEnvRaw.trim();
|
|
147
|
+
}
|
|
148
|
+
const which = jobsDbEnv && jobsDbEnv.length > 0 ? "LLM_GATEWAY_JOBS_DB" : "LLM_GATEWAY_LOGS_DB";
|
|
149
|
+
sources.envOverrides.push(which);
|
|
150
|
+
logWarn(logger, `${which} is deprecated; migrate to [persistence] in ~/.llm-cli-gateway/config.toml`, { backend: out.backend, path: out.path ?? null });
|
|
151
|
+
}
|
|
152
|
+
const retEnv = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
|
|
153
|
+
if (retEnv !== undefined) {
|
|
154
|
+
const n = Number(retEnv);
|
|
155
|
+
if (Number.isFinite(n) && n > 0) {
|
|
156
|
+
out.retentionDays = n;
|
|
157
|
+
sources.envOverrides.push("LLM_GATEWAY_JOB_RETENTION_DAYS");
|
|
158
|
+
logWarn(logger, "LLM_GATEWAY_JOB_RETENTION_DAYS is deprecated; set [persistence].retentionDays in config.toml", { retentionDays: n });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const dedupEnv = process.env.LLM_GATEWAY_DEDUP_WINDOW_MS;
|
|
162
|
+
if (dedupEnv !== undefined) {
|
|
163
|
+
const n = Number(dedupEnv);
|
|
164
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
165
|
+
out.dedupWindowMs = n;
|
|
166
|
+
sources.envOverrides.push("LLM_GATEWAY_DEDUP_WINDOW_MS");
|
|
167
|
+
logWarn(logger, "LLM_GATEWAY_DEDUP_WINDOW_MS is deprecated; set [persistence].dedupWindowMs in config.toml", { dedupWindowMs: n });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const ackEnv = process.env.LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL;
|
|
171
|
+
if (ackEnv && ackEnv.length > 0) {
|
|
172
|
+
out.acknowledgeEphemeral = /^(1|true|yes)$/i.test(ackEnv.trim());
|
|
173
|
+
sources.envOverrides.push("LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL");
|
|
174
|
+
logWarn(logger, "LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL is deprecated; set [persistence].acknowledgeEphemeral in config.toml", { acknowledgeEphemeral: out.acknowledgeEphemeral });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function expandHome(p) {
|
|
179
|
+
return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Load and validate the persistence config from (in order, last-write-wins):
|
|
183
|
+
* 1. Built-in defaults (backend=sqlite, default retention/dedup).
|
|
184
|
+
* 2. ~/.llm-cli-gateway/config.toml (or $LLM_GATEWAY_CONFIG).
|
|
185
|
+
* 3. Legacy env vars (with deprecation warning).
|
|
186
|
+
*
|
|
187
|
+
* Throws on incoherent configs (memory/none + asyncJobsEnabled without ack).
|
|
188
|
+
*/
|
|
189
|
+
export function loadPersistenceConfig(logger = noopLogger) {
|
|
190
|
+
const configPath = defaultPersistenceConfigPath();
|
|
191
|
+
const { raw, sourcePath } = readPersistenceFile(configPath, logger);
|
|
192
|
+
const sources = {
|
|
193
|
+
configFile: sourcePath,
|
|
194
|
+
envOverrides: [],
|
|
195
|
+
};
|
|
196
|
+
const merged = applyEnvOverrides(raw ?? {}, logger, sources);
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = PersistenceSchema.parse(merged);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
throw new Error(`Invalid [persistence] config: ${err instanceof Error ? err.message : String(err)}`);
|
|
203
|
+
}
|
|
204
|
+
const backend = parsed.backend;
|
|
205
|
+
const resolvedPath = backend === "sqlite" ? expandHome(parsed.path ?? DEFAULT_SQLITE_PATH) : null;
|
|
206
|
+
const dsn = backend === "postgres" ? (parsed.dsn ?? null) : null;
|
|
207
|
+
if (backend === "postgres" && !dsn) {
|
|
208
|
+
throw new Error("[persistence].backend = 'postgres' requires a non-empty 'dsn' (e.g. postgresql://user:pw@host/db)");
|
|
209
|
+
}
|
|
210
|
+
if (backend === "memory" && !parsed.acknowledgeEphemeral) {
|
|
211
|
+
throw new Error("[persistence].backend = 'memory' is ephemeral — async job results are lost on gateway exit. " +
|
|
212
|
+
"Set [persistence].acknowledgeEphemeral = true (or LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL=1) to confirm this is intentional.");
|
|
213
|
+
}
|
|
214
|
+
const asyncJobsEnabled = backend === "sqlite" || backend === "postgres" || backend === "memory";
|
|
215
|
+
if (backend === "none") {
|
|
216
|
+
logWarn(logger, "Async job persistence is DISABLED (backend = 'none'). " +
|
|
217
|
+
"*_request_async tools will NOT be registered on this gateway.");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
backend,
|
|
221
|
+
path: resolvedPath,
|
|
222
|
+
dsn,
|
|
223
|
+
retentionDays: parsed.retentionDays,
|
|
224
|
+
dedupWindowMs: parsed.dedupWindowMs,
|
|
225
|
+
acknowledgeEphemeral: parsed.acknowledgeEphemeral,
|
|
226
|
+
asyncJobsEnabled,
|
|
227
|
+
sources,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function entrypointFileURL(path: string | undefined): string;
|
package/dist/executor.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChildProcess } from "child_process";
|
|
1
|
+
import { ChildProcess, type SpawnOptions } from "child_process";
|
|
2
2
|
import type { Logger } from "./logger.js";
|
|
3
3
|
export interface ExecuteOptions {
|
|
4
4
|
timeout?: number;
|
|
@@ -14,6 +14,7 @@ export interface ExecuteResult {
|
|
|
14
14
|
code: number;
|
|
15
15
|
}
|
|
16
16
|
export declare function getExtendedPath(): string;
|
|
17
|
+
export declare function shouldDetachProviderProcess(platform?: NodeJS.Platform): boolean;
|
|
17
18
|
export declare function registerProcessGroup(pid: number): void;
|
|
18
19
|
export declare function unregisterProcessGroup(pid: number): void;
|
|
19
20
|
/**
|
|
@@ -29,4 +30,9 @@ export declare function killAllProcessGroups(): Promise<void>;
|
|
|
29
30
|
* if the group kill fails (e.g., pid not yet assigned).
|
|
30
31
|
*/
|
|
31
32
|
export declare function killProcessGroup(proc: ChildProcess, signal: NodeJS.Signals): boolean;
|
|
33
|
+
export declare function spawnCliProcess(command: string, args: string[], options: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
stdio: SpawnOptions["stdio"];
|
|
37
|
+
}): ChildProcess;
|
|
32
38
|
export declare function executeCli(command: string, args: string[], options?: ExecuteOptions): Promise<ExecuteResult>;
|
package/dist/executor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
1
|
+
import { spawn, spawnSync } from "child_process";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { readdirSync, existsSync } from "fs";
|
|
@@ -55,6 +55,12 @@ export function getExtendedPath() {
|
|
|
55
55
|
}
|
|
56
56
|
/** Registry of active detached process groups for shutdown cleanup. */
|
|
57
57
|
const activeProcessGroups = new Set();
|
|
58
|
+
export function shouldDetachProviderProcess(platform = process.platform) {
|
|
59
|
+
// On Windows, detached console children can flash visible cmd/conhost windows
|
|
60
|
+
// when provider CLIs are native console apps or .cmd shims. Keep them in the
|
|
61
|
+
// gateway process tree and rely on hidden-window spawn plus taskkill cleanup.
|
|
62
|
+
return platform !== "win32";
|
|
63
|
+
}
|
|
58
64
|
export function registerProcessGroup(pid) {
|
|
59
65
|
activeProcessGroups.add(pid);
|
|
60
66
|
}
|
|
@@ -72,21 +78,31 @@ export function killAllProcessGroups() {
|
|
|
72
78
|
if (activeProcessGroups.size === 0)
|
|
73
79
|
return Promise.resolve();
|
|
74
80
|
for (const pid of activeProcessGroups) {
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
killWindowsProcessTree(pid);
|
|
77
83
|
}
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-pid, "SIGTERM");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* ESRCH ok */
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
93
|
return new Promise(resolve => {
|
|
83
94
|
setTimeout(() => {
|
|
84
95
|
for (const pid of activeProcessGroups) {
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
killWindowsProcessTree(pid);
|
|
87
98
|
}
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pid, "SIGKILL");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* ESRCH ok */
|
|
105
|
+
}
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
activeProcessGroups.clear();
|
|
@@ -100,6 +116,9 @@ export function killAllProcessGroups() {
|
|
|
100
116
|
*/
|
|
101
117
|
export function killProcessGroup(proc, signal) {
|
|
102
118
|
if (proc.pid) {
|
|
119
|
+
if (process.platform === "win32") {
|
|
120
|
+
return killWindowsProcessTree(proc.pid);
|
|
121
|
+
}
|
|
103
122
|
try {
|
|
104
123
|
process.kill(-proc.pid, signal);
|
|
105
124
|
return true;
|
|
@@ -124,21 +143,37 @@ export function killProcessGroup(proc, signal) {
|
|
|
124
143
|
return false;
|
|
125
144
|
}
|
|
126
145
|
}
|
|
146
|
+
function killWindowsProcessTree(pid) {
|
|
147
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
|
148
|
+
stdio: "ignore",
|
|
149
|
+
windowsHide: true,
|
|
150
|
+
});
|
|
151
|
+
return result.status === 0;
|
|
152
|
+
}
|
|
153
|
+
export function spawnCliProcess(command, args, options) {
|
|
154
|
+
const detached = shouldDetachProviderProcess();
|
|
155
|
+
const proc = spawn(command, args, {
|
|
156
|
+
cwd: options.cwd,
|
|
157
|
+
detached,
|
|
158
|
+
windowsHide: true,
|
|
159
|
+
stdio: options.stdio,
|
|
160
|
+
env: options.env,
|
|
161
|
+
});
|
|
162
|
+
if (proc.pid)
|
|
163
|
+
registerProcessGroup(proc.pid);
|
|
164
|
+
proc.unref();
|
|
165
|
+
return proc;
|
|
166
|
+
}
|
|
127
167
|
export async function executeCli(command, args, options = {}) {
|
|
128
168
|
const { timeout, idleTimeout, cwd, env: extraEnv } = options;
|
|
129
169
|
const extendedPath = getExtendedPath();
|
|
130
170
|
const circuitBreaker = getCircuitBreaker(command);
|
|
131
171
|
const runOnce = () => new Promise((resolve, reject) => {
|
|
132
|
-
const proc =
|
|
172
|
+
const proc = spawnCliProcess(command, args, {
|
|
133
173
|
cwd,
|
|
134
|
-
detached: true,
|
|
135
174
|
stdio: ["ignore", "pipe", "pipe"],
|
|
136
175
|
env: { ...process.env, PATH: extendedPath, ...(extraEnv ?? {}) },
|
|
137
176
|
});
|
|
138
|
-
if (proc.pid)
|
|
139
|
-
registerProcessGroup(proc.pid);
|
|
140
|
-
// Prevent detached process from keeping parent alive when not needed
|
|
141
|
-
proc.unref();
|
|
142
177
|
let stdout = "";
|
|
143
178
|
let stderr = "";
|
|
144
179
|
let timedOut = false;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { ISessionManager } from "./session-manager.js";
|
|
5
5
|
import { ResourceProvider } from "./resources.js";
|
|
6
6
|
import { PerformanceMetrics } from "./metrics.js";
|
|
7
|
+
import { type PersistenceConfig } from "./config.js";
|
|
7
8
|
import { DatabaseConnection } from "./db.js";
|
|
8
9
|
import { AsyncJobManager } from "./async-job-manager.js";
|
|
9
10
|
import { ApprovalManager, ApprovalRecord } from "./approval-manager.js";
|
|
@@ -47,6 +48,7 @@ export interface GatewayServerDeps {
|
|
|
47
48
|
approvalManager?: ApprovalManager;
|
|
48
49
|
flightRecorder?: FlightRecorderLike;
|
|
49
50
|
logger?: GatewayLogger;
|
|
51
|
+
persistence?: PersistenceConfig;
|
|
50
52
|
}
|
|
51
53
|
interface GatewayServerRuntime {
|
|
52
54
|
sessionManager: ISessionManager;
|
|
@@ -57,6 +59,7 @@ interface GatewayServerRuntime {
|
|
|
57
59
|
approvalManager: ApprovalManager;
|
|
58
60
|
flightRecorder: FlightRecorderLike;
|
|
59
61
|
logger: GatewayLogger;
|
|
62
|
+
persistence: PersistenceConfig;
|
|
60
63
|
}
|
|
61
64
|
interface CliRequestPrep {
|
|
62
65
|
corrId: string;
|