llm-cli-gateway 1.0.1 → 1.4.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/dist/config.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { z } from "zod";
2
2
  // Zod schemas for configuration validation
3
- const DatabaseUrlSchema = z.string().url().refine((url) => url.startsWith("postgresql://") || url.startsWith("postgres://"), { message: "Database URL must start with postgresql:// or postgres://" });
3
+ const DatabaseUrlSchema = z
4
+ .string()
5
+ .url()
6
+ .refine(url => url.startsWith("postgresql://") || url.startsWith("postgres://"), {
7
+ message: "Database URL must start with postgresql:// or postgres://",
8
+ });
4
9
  const RedisUrlSchema = z.string().url().startsWith("redis://");
5
10
  export const DEFAULT_SESSION_TTL_SECONDS = 2592000; // 30 days
6
11
  /**
@@ -15,11 +20,12 @@ export function loadConfig() {
15
20
  const cacheTtl = {
16
21
  session: 3600, // 1 hour
17
22
  activeSession: 1800, // 30 minutes
18
- sessionList: 120 // 2 minutes
23
+ sessionList: 120, // 2 minutes
19
24
  };
20
25
  const rawSessionTtl = parseInt(process.env.SESSION_TTL || String(DEFAULT_SESSION_TTL_SECONDS), 10);
21
- const sessionTtl = (Number.isFinite(rawSessionTtl) && rawSessionTtl > 0)
22
- ? rawSessionTtl : DEFAULT_SESSION_TTL_SECONDS;
26
+ const sessionTtl = Number.isFinite(rawSessionTtl) && rawSessionTtl > 0
27
+ ? rawSessionTtl
28
+ : DEFAULT_SESSION_TTL_SECONDS;
23
29
  // If no database config, return base config (file-based storage)
24
30
  if (!databaseUrl || !redisUrl) {
25
31
  return { cacheTtl, sessionTtl };
@@ -39,18 +45,18 @@ export function loadConfig() {
39
45
  max: 10,
40
46
  idleTimeoutMillis: 30000,
41
47
  connectionTimeoutMillis: 5000,
42
- statementTimeout: 10000
43
- }
48
+ statementTimeout: 10000,
49
+ },
44
50
  },
45
51
  redis: {
46
52
  url: redisUrl,
47
53
  retryStrategy: {
48
54
  maxRetries: 3,
49
55
  initialDelay: 50,
50
- maxDelay: 2000
51
- }
56
+ maxDelay: 2000,
57
+ },
52
58
  },
53
59
  cacheTtl,
54
- sessionTtl
60
+ sessionTtl,
55
61
  };
56
62
  }
package/dist/db.js CHANGED
@@ -26,7 +26,7 @@ export class DatabaseConnection {
26
26
  max: this.config.database.pool.max,
27
27
  idleTimeoutMillis: this.config.database.pool.idleTimeoutMillis,
28
28
  connectionTimeoutMillis: this.config.database.pool.connectionTimeoutMillis,
29
- statement_timeout: this.config.database.pool.statementTimeout
29
+ statement_timeout: this.config.database.pool.statementTimeout,
30
30
  };
31
31
  this.pool = new Pool(poolConfig);
32
32
  // Test PostgreSQL connection
@@ -54,7 +54,7 @@ export class DatabaseConnection {
54
54
  // Reconnect on READONLY and ECONNRESET errors
55
55
  const targetErrors = ["READONLY", "ECONNRESET"];
56
56
  return targetErrors.some(targetError => err.message.includes(targetError));
57
- }
57
+ },
58
58
  };
59
59
  this.redis = new Redis(this.config.redis.url, redisOptions);
60
60
  // Test Redis connection
@@ -101,7 +101,7 @@ export class DatabaseConnection {
101
101
  async healthCheck() {
102
102
  const result = {
103
103
  postgres: { connected: false, latency: 0 },
104
- redis: { connected: false, latency: 0 }
104
+ redis: { connected: false, latency: 0 },
105
105
  };
106
106
  // Check PostgreSQL
107
107
  if (this.pool) {
@@ -137,7 +137,7 @@ export class DatabaseConnection {
137
137
  }
138
138
  this.logger.debug("Health check completed", {
139
139
  postgres: result.postgres.connected,
140
- redis: result.redis.connected
140
+ redis: result.redis.connected,
141
141
  });
142
142
  return result;
143
143
  }
package/dist/executor.js CHANGED
@@ -28,7 +28,7 @@ function getNvmPath() {
28
28
  try {
29
29
  const versions = readdirSync(nvmVersionsDir);
30
30
  cachedNvmPath = versions.length
31
- ? versions.map((version) => join(nvmVersionsDir, version, "bin")).join(":")
31
+ ? versions.map(version => join(nvmVersionsDir, version, "bin")).join(":")
32
32
  : null;
33
33
  }
34
34
  catch {
@@ -43,7 +43,7 @@ export function getExtendedPath() {
43
43
  join(home, ".local/bin"),
44
44
  dirname(process.execPath), // Current node's bin directory
45
45
  "/usr/local/bin",
46
- "/usr/bin"
46
+ "/usr/bin",
47
47
  ];
48
48
  // Add all nvm node version bin directories
49
49
  const nvmPath = getNvmPath();
@@ -75,7 +75,9 @@ export function killAllProcessGroups() {
75
75
  try {
76
76
  process.kill(-pid, "SIGTERM");
77
77
  }
78
- catch { /* ESRCH ok */ }
78
+ catch {
79
+ /* ESRCH ok */
80
+ }
79
81
  }
80
82
  return new Promise(resolve => {
81
83
  setTimeout(() => {
@@ -83,7 +85,9 @@ export function killAllProcessGroups() {
83
85
  try {
84
86
  process.kill(-pid, "SIGKILL");
85
87
  }
86
- catch { /* ESRCH ok */ }
88
+ catch {
89
+ /* ESRCH ok */
90
+ }
87
91
  }
88
92
  activeProcessGroups.clear();
89
93
  resolve();
@@ -129,7 +133,7 @@ export async function executeCli(command, args, options = {}) {
129
133
  cwd,
130
134
  detached: true,
131
135
  stdio: ["ignore", "pipe", "pipe"],
132
- env: { ...process.env, PATH: extendedPath }
136
+ env: { ...process.env, PATH: extendedPath },
133
137
  });
134
138
  if (proc.pid)
135
139
  registerProcessGroup(proc.pid);
@@ -152,7 +156,9 @@ export async function executeCli(command, args, options = {}) {
152
156
  if (proc.pid)
153
157
  unregisterProcessGroup(proc.pid);
154
158
  };
155
- const timeoutMs = typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0 ? timeout : undefined;
159
+ const timeoutMs = typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
160
+ ? timeout
161
+ : undefined;
156
162
  const timeoutId = timeoutMs
157
163
  ? setTimeout(() => {
158
164
  timedOut = true;
@@ -166,7 +172,8 @@ export async function executeCli(command, args, options = {}) {
166
172
  : undefined;
167
173
  // Idle timeout: kill process if no stdout/stderr activity for idleMs
168
174
  const idleMs = typeof idleTimeout === "number" && Number.isFinite(idleTimeout) && idleTimeout > 0
169
- ? idleTimeout : undefined;
175
+ ? idleTimeout
176
+ : undefined;
170
177
  let idleTimerId;
171
178
  const resetIdleTimer = () => {
172
179
  if (!idleMs)
@@ -222,19 +229,19 @@ export async function executeCli(command, args, options = {}) {
222
229
  stderr += text;
223
230
  }
224
231
  };
225
- proc.stdout.on("data", (data) => {
232
+ proc.stdout.on("data", data => {
226
233
  if (settled) {
227
234
  return;
228
235
  }
229
236
  handleOutputChunk(data, "stdout");
230
237
  });
231
- proc.stderr.on("data", (data) => {
238
+ proc.stderr.on("data", data => {
232
239
  if (settled) {
233
240
  return;
234
241
  }
235
242
  handleOutputChunk(data, "stderr");
236
243
  });
237
- proc.on("close", (code) => {
244
+ proc.on("close", code => {
238
245
  exited = true;
239
246
  if (idleTimerId) {
240
247
  clearTimeout(idleTimerId);
@@ -253,7 +260,7 @@ export async function executeCli(command, args, options = {}) {
253
260
  const result = {
254
261
  stdout,
255
262
  stderr: stderr + `\nProcess timed out after ${timeoutMs}ms`,
256
- code: 124 // Standard timeout exit code
263
+ code: 124, // Standard timeout exit code
257
264
  };
258
265
  const error = new Error(result.stderr);
259
266
  error.code = 124;
@@ -265,7 +272,7 @@ export async function executeCli(command, args, options = {}) {
265
272
  const result = {
266
273
  stdout,
267
274
  stderr: stderr + `\nProcess killed after ${idleMs}ms of inactivity`,
268
- code: 125
275
+ code: 125,
269
276
  };
270
277
  const error = new Error(result.stderr);
271
278
  error.code = 125;
@@ -283,7 +290,7 @@ export async function executeCli(command, args, options = {}) {
283
290
  }
284
291
  resolve(result);
285
292
  });
286
- proc.on("error", (err) => {
293
+ proc.on("error", err => {
287
294
  exited = true;
288
295
  if (idleTimerId) {
289
296
  clearTimeout(idleTimerId);
@@ -0,0 +1,48 @@
1
+ export interface FlightLogStart {
2
+ correlationId: string;
3
+ cli: "claude" | "codex" | "gemini" | "grok";
4
+ model: string;
5
+ prompt: string;
6
+ system?: string;
7
+ sessionId?: string;
8
+ asyncJobId?: string;
9
+ }
10
+ export interface FlightLogResult {
11
+ response: string;
12
+ inputTokens?: number;
13
+ outputTokens?: number;
14
+ durationMs: number;
15
+ retryCount: number;
16
+ circuitBreakerState: string;
17
+ costUsd?: number;
18
+ approvalDecision?: string;
19
+ optimizationApplied: boolean;
20
+ thinkingBlocks?: string[];
21
+ exitCode: number;
22
+ errorMessage?: string;
23
+ status: "completed" | "failed";
24
+ }
25
+ interface LoggerLike {
26
+ info: (message: string, ...args: any[]) => void;
27
+ error: (message: string, ...args: any[]) => void;
28
+ }
29
+ export declare function resolveFlightRecorderDbPath(): string | null;
30
+ export declare class FlightRecorder {
31
+ private db;
32
+ private insertStartTxn;
33
+ private updateCompleteTxn;
34
+ constructor(dbPath: string);
35
+ logStart(entry: FlightLogStart): void;
36
+ logComplete(correlationId: string, result: FlightLogResult): void;
37
+ flush(): void;
38
+ close(): void;
39
+ }
40
+ export declare class NoopFlightRecorder {
41
+ logStart(_entry: FlightLogStart): void;
42
+ logComplete(_correlationId: string, _result: FlightLogResult): void;
43
+ flush(): void;
44
+ close(): void;
45
+ }
46
+ export type FlightRecorderLike = FlightRecorder | NoopFlightRecorder;
47
+ export declare function createFlightRecorder(logger: LoggerLike): FlightRecorderLike;
48
+ export {};
@@ -0,0 +1,220 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createRequire } from "module";
5
+ const MAX_THINKING_BYTES = 1_000_000;
6
+ export function resolveFlightRecorderDbPath() {
7
+ const configured = process.env.LLM_GATEWAY_LOGS_DB;
8
+ if (configured !== undefined) {
9
+ const normalized = configured.trim().toLowerCase();
10
+ if (!normalized || normalized === "none") {
11
+ return null;
12
+ }
13
+ return configured.trim();
14
+ }
15
+ return path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
16
+ }
17
+ const TRUNCATION_SUFFIX = "[TRUNCATED]";
18
+ const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
19
+ function truncateThinkingBlocks(blocks) {
20
+ const result = [];
21
+ let used = 0;
22
+ for (const block of blocks) {
23
+ const bytes = Buffer.byteLength(block, "utf8");
24
+ if (used + bytes <= MAX_THINKING_BYTES) {
25
+ result.push(block);
26
+ used += bytes;
27
+ continue;
28
+ }
29
+ // Reserve space for the suffix so total stays within budget
30
+ const budget = Math.max(0, MAX_THINKING_BYTES - used - TRUNCATION_SUFFIX_BYTES);
31
+ if (budget > 0) {
32
+ // Truncate on code point boundaries by using string iteration
33
+ let charBytes = 0;
34
+ let safeEnd = 0;
35
+ for (const char of block) {
36
+ const charSize = Buffer.byteLength(char, "utf8");
37
+ if (charBytes + charSize > budget)
38
+ break;
39
+ charBytes += charSize;
40
+ safeEnd += char.length; // char.length handles surrogate pairs
41
+ }
42
+ const sliced = block.slice(0, safeEnd);
43
+ result.push(sliced ? `${sliced}${TRUNCATION_SUFFIX}` : TRUNCATION_SUFFIX);
44
+ }
45
+ else {
46
+ result.push(TRUNCATION_SUFFIX);
47
+ }
48
+ break;
49
+ }
50
+ return result;
51
+ }
52
+ export class FlightRecorder {
53
+ db;
54
+ insertStartTxn;
55
+ updateCompleteTxn;
56
+ constructor(dbPath) {
57
+ const require = createRequire(import.meta.url);
58
+ const BetterSqlite3 = require("better-sqlite3");
59
+ const directory = path.dirname(dbPath);
60
+ if (!existsSync(directory)) {
61
+ mkdirSync(directory, { recursive: true });
62
+ }
63
+ this.db = new BetterSqlite3(dbPath);
64
+ this.db.pragma("journal_mode = WAL");
65
+ this.db.pragma("foreign_keys = ON");
66
+ this.db.exec(`
67
+ CREATE TABLE IF NOT EXISTS _migrations (
68
+ version INTEGER PRIMARY KEY,
69
+ applied_at TEXT NOT NULL
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS requests (
73
+ id TEXT PRIMARY KEY,
74
+ cli TEXT NOT NULL,
75
+ model TEXT NOT NULL,
76
+ prompt TEXT NOT NULL,
77
+ system TEXT,
78
+ response TEXT,
79
+ session_id TEXT,
80
+ duration_ms INTEGER,
81
+ datetime_utc TEXT NOT NULL,
82
+ input_tokens INTEGER,
83
+ output_tokens INTEGER
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS gateway_metadata (
87
+ request_id TEXT PRIMARY KEY REFERENCES requests(id),
88
+ retry_count INTEGER DEFAULT 0,
89
+ circuit_breaker_state TEXT,
90
+ cost_usd REAL,
91
+ approval_decision TEXT,
92
+ optimization_applied INTEGER DEFAULT 0,
93
+ thinking_blocks TEXT,
94
+ exit_code INTEGER,
95
+ error_message TEXT,
96
+ async_job_id TEXT,
97
+ status TEXT NOT NULL DEFAULT 'started'
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_requests_datetime ON requests(datetime_utc);
101
+ CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model);
102
+ CREATE INDEX IF NOT EXISTS idx_requests_cli ON requests(cli);
103
+ CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);
104
+ CREATE INDEX IF NOT EXISTS idx_metadata_status ON gateway_metadata(status);
105
+ `);
106
+ this.db
107
+ .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(1, ?)")
108
+ .run(new Date().toISOString());
109
+ if (process.platform !== "win32") {
110
+ try {
111
+ chmodSync(dbPath, 0o600);
112
+ }
113
+ catch {
114
+ // Best effort permissions hardening.
115
+ }
116
+ }
117
+ const insertRequest = this.db.prepare(`
118
+ INSERT INTO requests (id, cli, model, prompt, system, session_id, datetime_utc)
119
+ VALUES (@id, @cli, @model, @prompt, @system, @session_id, @datetime_utc)
120
+ `);
121
+ const insertMetadata = this.db.prepare(`
122
+ INSERT INTO gateway_metadata (request_id, async_job_id, status)
123
+ VALUES (@request_id, @async_job_id, 'started')
124
+ `);
125
+ this.insertStartTxn = this.db.transaction((entry) => {
126
+ insertRequest.run({
127
+ id: entry.correlationId,
128
+ cli: entry.cli,
129
+ model: entry.model,
130
+ prompt: entry.prompt,
131
+ system: entry.system || null,
132
+ session_id: entry.sessionId || null,
133
+ datetime_utc: new Date().toISOString(),
134
+ });
135
+ insertMetadata.run({
136
+ request_id: entry.correlationId,
137
+ async_job_id: entry.asyncJobId || null,
138
+ });
139
+ });
140
+ const updateRequests = this.db.prepare(`
141
+ UPDATE requests
142
+ SET response = @response,
143
+ duration_ms = @duration_ms,
144
+ input_tokens = @input_tokens,
145
+ output_tokens = @output_tokens
146
+ WHERE id = @id
147
+ `);
148
+ const updateMetadata = this.db.prepare(`
149
+ UPDATE gateway_metadata
150
+ SET retry_count = @retry_count,
151
+ circuit_breaker_state = @circuit_breaker_state,
152
+ cost_usd = @cost_usd,
153
+ approval_decision = @approval_decision,
154
+ optimization_applied = @optimization_applied,
155
+ thinking_blocks = @thinking_blocks,
156
+ exit_code = @exit_code,
157
+ error_message = @error_message,
158
+ status = @status
159
+ WHERE request_id = @id AND status = 'started'
160
+ `);
161
+ this.updateCompleteTxn = this.db.transaction((correlationId, result) => {
162
+ const thinkingBlocks = result.thinkingBlocks && result.thinkingBlocks.length > 0
163
+ ? JSON.stringify(truncateThinkingBlocks(result.thinkingBlocks))
164
+ : null;
165
+ updateRequests.run({
166
+ id: correlationId,
167
+ response: result.response,
168
+ duration_ms: result.durationMs,
169
+ input_tokens: result.inputTokens ?? null,
170
+ output_tokens: result.outputTokens ?? null,
171
+ });
172
+ updateMetadata.run({
173
+ id: correlationId,
174
+ retry_count: result.retryCount,
175
+ circuit_breaker_state: result.circuitBreakerState,
176
+ cost_usd: result.costUsd ?? null,
177
+ approval_decision: result.approvalDecision ?? null,
178
+ optimization_applied: result.optimizationApplied ? 1 : 0,
179
+ thinking_blocks: thinkingBlocks,
180
+ exit_code: result.exitCode,
181
+ error_message: result.errorMessage ?? null,
182
+ status: result.status,
183
+ });
184
+ });
185
+ }
186
+ logStart(entry) {
187
+ this.insertStartTxn(entry);
188
+ }
189
+ logComplete(correlationId, result) {
190
+ this.updateCompleteTxn(correlationId, result);
191
+ }
192
+ flush() {
193
+ // No-op: better-sqlite3 writes synchronously.
194
+ }
195
+ close() {
196
+ this.db.close();
197
+ }
198
+ }
199
+ export class NoopFlightRecorder {
200
+ logStart(_entry) { }
201
+ logComplete(_correlationId, _result) { }
202
+ flush() { }
203
+ close() { }
204
+ }
205
+ export function createFlightRecorder(logger) {
206
+ const dbPath = resolveFlightRecorderDbPath();
207
+ if (!dbPath) {
208
+ logger.info("Flight recorder disabled (LLM_GATEWAY_LOGS_DB=none)");
209
+ return new NoopFlightRecorder();
210
+ }
211
+ try {
212
+ const recorder = new FlightRecorder(dbPath);
213
+ logger.info(`Flight recorder enabled at ${dbPath}`);
214
+ return recorder;
215
+ }
216
+ catch (error) {
217
+ logger.error("Flight recorder unavailable; continuing without SQLite logging", error);
218
+ return new NoopFlightRecorder();
219
+ }
220
+ }
package/dist/health.js CHANGED
@@ -10,13 +10,13 @@ export async function checkHealth(db) {
10
10
  status: "unhealthy",
11
11
  postgres: {
12
12
  status: result.postgres.connected ? "up" : "down",
13
- latency: result.postgres.latency
13
+ latency: result.postgres.latency,
14
14
  },
15
15
  redis: {
16
16
  status: result.redis.connected ? "up" : "down",
17
- latency: result.redis.latency
17
+ latency: result.redis.latency,
18
18
  },
19
- timestamp: new Date().toISOString()
19
+ timestamp: new Date().toISOString(),
20
20
  };
21
21
  // Determine overall health status
22
22
  if (result.postgres.connected && result.redis.connected) {
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ type ExtendedToolResponse = {
12
12
  isError?: boolean;
13
13
  sessionId?: string;
14
14
  resumable?: boolean;
15
+ structuredContent?: Record<string, unknown>;
15
16
  approval?: ApprovalRecord | null;
16
17
  mcpServers?: {
17
18
  requested: ClaudeMcpServerName[];
@@ -36,6 +37,7 @@ export interface GeminiRequestParams {
36
37
  optimizePrompt: boolean;
37
38
  optimizeResponse?: boolean;
38
39
  idleTimeoutMs?: number;
40
+ forceRefresh?: boolean;
39
41
  }
40
42
  export interface HandlerDeps {
41
43
  sessionManager: ISessionManager;
@@ -50,6 +52,30 @@ export interface AsyncHandlerDeps extends HandlerDeps {
50
52
  }
51
53
  export declare function handleGeminiRequest(deps: HandlerDeps, params: GeminiRequestParams): Promise<ExtendedToolResponse>;
52
54
  export declare function handleGeminiRequestAsync(deps: AsyncHandlerDeps, params: Omit<GeminiRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
55
+ export interface GrokRequestParams {
56
+ prompt: string;
57
+ model?: string;
58
+ outputFormat?: string;
59
+ sessionId?: string;
60
+ resumeLatest: boolean;
61
+ createNewSession: boolean;
62
+ alwaysApprove?: boolean;
63
+ permissionMode?: string;
64
+ effort?: string;
65
+ reasoningEffort?: string;
66
+ approvalStrategy: "legacy" | "mcp_managed";
67
+ approvalPolicy?: string;
68
+ mcpServers?: ClaudeMcpServerName[];
69
+ allowedTools?: string[];
70
+ disallowedTools?: string[];
71
+ correlationId?: string;
72
+ optimizePrompt: boolean;
73
+ optimizeResponse?: boolean;
74
+ idleTimeoutMs?: number;
75
+ forceRefresh?: boolean;
76
+ }
77
+ export declare function handleGrokRequest(deps: HandlerDeps, params: GrokRequestParams): Promise<ExtendedToolResponse>;
78
+ export declare function handleGrokRequestAsync(deps: AsyncHandlerDeps, params: Omit<GrokRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
53
79
  export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params: {
54
80
  prompt: string;
55
81
  model?: string;
@@ -59,9 +85,11 @@ export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params:
59
85
  approvalPolicy?: string;
60
86
  mcpServers?: ClaudeMcpServerName[];
61
87
  sessionId?: string;
88
+ resumeLatest?: boolean;
62
89
  createNewSession: boolean;
63
90
  correlationId?: string;
64
91
  optimizePrompt: boolean;
65
92
  idleTimeoutMs?: number;
93
+ forceRefresh?: boolean;
66
94
  }): Promise<ExtendedToolResponse>;
67
95
  export {};