llm-cli-gateway 1.15.3 → 1.16.1

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.
@@ -7,112 +7,16 @@ const DEFAULT_SESSION_DESCRIPTIONS = {
7
7
  mistral: "Mistral Session",
8
8
  };
9
9
  /**
10
- * PostgreSQL-backed session manager with Redis caching
10
+ * PostgreSQL-backed session manager. PostgreSQL is the source of truth and
11
+ * the only required service for this backend.
11
12
  */
12
13
  export class PostgreSQLSessionManager {
13
14
  pool;
14
- redis;
15
- cacheTtl;
16
- logger;
17
- constructor(pool, redis, cacheTtl, logger) {
15
+ constructor(pool) {
18
16
  this.pool = pool;
19
- this.redis = redis;
20
- this.cacheTtl = cacheTtl;
21
- this.logger = logger;
22
17
  }
23
18
  /**
24
- * Acquire distributed lock using Redis SET NX EX
25
- * Returns [success, lockValue] tuple
26
- */
27
- async acquireLock(key, ttlSeconds) {
28
- const lockKey = `lock:${key}`;
29
- const lockValue = randomUUID();
30
- // SET NX EX atomic operation
31
- const result = await this.redis.set(lockKey, lockValue, "EX", ttlSeconds, "NX");
32
- return [result === "OK", lockValue];
33
- }
34
- async sleep(ms) {
35
- await new Promise(resolve => setTimeout(resolve, ms));
36
- }
37
- /**
38
- * Acquire a distributed lock with bounded retries to smooth contention spikes.
39
- */
40
- async acquireLockWithRetry(key, ttlSeconds, errorLabel, maxWaitMs = 6000) {
41
- const deadline = Date.now() + maxWaitMs;
42
- while (true) {
43
- const [lockAcquired, lockValue] = await this.acquireLock(key, ttlSeconds);
44
- if (lockAcquired) {
45
- return lockValue;
46
- }
47
- if (Date.now() >= deadline) {
48
- throw new Error(`Failed to acquire lock for ${errorLabel}`);
49
- }
50
- // Small jitter avoids lock-step retries from concurrent callers.
51
- await this.sleep(25 + Math.floor(Math.random() * 25));
52
- }
53
- }
54
- /**
55
- * Release distributed lock with optimistic Redis transaction semantics.
56
- * Only releases if lockValue matches, which prevents releasing another
57
- * process's lock after expiry/reacquire.
58
- */
59
- async releaseLock(key, lockValue) {
60
- const lockKey = `lock:${key}`;
61
- await this.redis.watch(lockKey);
62
- try {
63
- const currentValue = await this.redis.get(lockKey);
64
- if (currentValue !== lockValue) {
65
- await this.redis.unwatch();
66
- return;
67
- }
68
- await this.redis.multi().del(lockKey).exec();
69
- }
70
- catch (error) {
71
- await this.redis.unwatch().catch(() => undefined);
72
- throw error;
73
- }
74
- }
75
- /**
76
- * Invalidate session cache
77
- */
78
- async invalidateCache(sessionId) {
79
- try {
80
- await this.redis.del(`session:${sessionId}`);
81
- }
82
- catch (error) {
83
- // Graceful degradation - log but don't fail
84
- this.logger.error(`Cache invalidation failed for session ${sessionId}`, { error, sessionId });
85
- }
86
- }
87
- /**
88
- * Invalidate session list cache using SCAN (non-blocking)
89
- */
90
- async invalidateListCache(cli) {
91
- try {
92
- if (cli) {
93
- await this.redis.del(`session_list:${cli}`);
94
- }
95
- else {
96
- // Use SCAN instead of KEYS to avoid blocking Redis
97
- const keys = [];
98
- let cursor = "0";
99
- do {
100
- const [nextCursor, matchedKeys] = await this.redis.scan(cursor, "MATCH", "session_list:*", "COUNT", 100);
101
- cursor = nextCursor;
102
- keys.push(...matchedKeys);
103
- } while (cursor !== "0");
104
- // Delete in batches to avoid overwhelming Redis
105
- if (keys.length > 0) {
106
- await this.redis.del(...keys);
107
- }
108
- }
109
- }
110
- catch (error) {
111
- this.logger.error("List cache invalidation failed", { error });
112
- }
113
- }
114
- /**
115
- * Create a new session
19
+ * Create a new session.
116
20
  */
117
21
  async createSession(cli, description, sessionId) {
118
22
  const id = sessionId || randomUUID();
@@ -121,32 +25,19 @@ export class PostgreSQLSessionManager {
121
25
  const client = await this.pool.connect();
122
26
  try {
123
27
  await client.query("BEGIN");
124
- // Insert session
125
28
  await client.query(`INSERT INTO sessions (id, cli, description, created_at, last_used_at)
126
29
  VALUES ($1, $2, $3, $4, $5)`, [id, cli, sessionDescription, now, now]);
127
- // Set as active if none exists
128
30
  await client.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
129
31
  VALUES ($1, $2, $3)
130
32
  ON CONFLICT (cli) DO NOTHING`, [cli, id, now]);
131
33
  await client.query("COMMIT");
132
- const session = {
34
+ return {
133
35
  id,
134
36
  cli,
135
37
  createdAt: now,
136
38
  lastUsedAt: now,
137
39
  description: sessionDescription,
138
40
  };
139
- // Write-through to cache
140
- try {
141
- await this.redis.setex(`session:${id}`, this.cacheTtl.session, JSON.stringify(session));
142
- }
143
- catch (error) {
144
- // Graceful degradation
145
- this.logger.error("Cache write failed", { error });
146
- }
147
- // Invalidate list cache
148
- await this.invalidateListCache(cli);
149
- return session;
150
41
  }
151
42
  catch (error) {
152
43
  await client.query("ROLLBACK");
@@ -157,55 +48,18 @@ export class PostgreSQLSessionManager {
157
48
  }
158
49
  }
159
50
  /**
160
- * Get session by ID (cache-aside pattern)
51
+ * Get session by ID.
161
52
  */
162
53
  async getSession(sessionId) {
163
- // Try cache first
164
- try {
165
- const cached = await this.redis.get(`session:${sessionId}`);
166
- if (cached) {
167
- return JSON.parse(cached);
168
- }
169
- }
170
- catch (error) {
171
- // Graceful degradation - fallback to DB
172
- this.logger.error("Cache read failed", { error });
173
- }
174
- // Cache miss - query database
175
54
  const result = await this.pool.query(`SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
176
55
  FROM sessions
177
56
  WHERE id = $1`, [sessionId]);
178
- if (result.rows.length === 0) {
179
- return null;
180
- }
181
- const session = result.rows[0];
182
- // Populate cache
183
- try {
184
- await this.redis.setex(`session:${sessionId}`, this.cacheTtl.session, JSON.stringify(session));
185
- }
186
- catch (error) {
187
- this.logger.error("Cache write failed", { error });
188
- }
189
- return session;
57
+ return result.rows[0] ?? null;
190
58
  }
191
59
  /**
192
- * List all sessions, optionally filtered by CLI
60
+ * List all sessions, optionally filtered by CLI.
193
61
  */
194
62
  async listSessions(cli) {
195
- // Try cache for CLI-specific lists
196
- const cacheKey = cli ? `session_list:${cli}` : null;
197
- if (cacheKey) {
198
- try {
199
- const cached = await this.redis.get(cacheKey);
200
- if (cached) {
201
- return JSON.parse(cached);
202
- }
203
- }
204
- catch (error) {
205
- this.logger.error("Cache read failed", { error });
206
- }
207
- }
208
- // Query database
209
63
  const query = cli
210
64
  ? `SELECT id, cli, description, metadata, created_at AS "createdAt", last_used_at AS "lastUsedAt"
211
65
  FROM sessions
@@ -217,176 +71,70 @@ export class PostgreSQLSessionManager {
217
71
  const result = cli
218
72
  ? await this.pool.query(query, [cli])
219
73
  : await this.pool.query(query);
220
- const sessions = result.rows;
221
- // Cache CLI-specific lists
222
- if (cacheKey) {
223
- try {
224
- await this.redis.setex(cacheKey, this.cacheTtl.sessionList, JSON.stringify(sessions));
225
- }
226
- catch (error) {
227
- this.logger.error("Cache write failed", { error });
228
- }
229
- }
230
- return sessions;
74
+ return result.rows;
231
75
  }
232
76
  /**
233
- * Delete a session
77
+ * Delete a session.
234
78
  */
235
79
  async deleteSession(sessionId) {
236
- // Get session to find CLI type
237
80
  const session = await this.getSession(sessionId);
238
81
  if (!session) {
239
82
  return false;
240
83
  }
241
- // Delete from database (CASCADE will handle active_sessions)
242
84
  const result = await this.pool.query("DELETE FROM sessions WHERE id = $1", [sessionId]);
243
- if (result.rowCount === 0) {
244
- return false;
245
- }
246
- // Invalidate caches (session, active session for this CLI, and list)
247
- await this.invalidateCache(sessionId);
248
- try {
249
- await this.redis.del(`active_session:${session.cli}`);
250
- }
251
- catch (error) {
252
- this.logger.error(`Failed to invalidate active session cache for ${session.cli}`, { error });
253
- }
254
- await this.invalidateListCache(session.cli);
255
- return true;
85
+ return result.rowCount !== 0;
256
86
  }
257
87
  /**
258
- * Set active session for a CLI (with distributed locking)
88
+ * Set active session for a CLI. The row-level update is serialized by
89
+ * PostgreSQL and the session FK keeps stale IDs from being recorded.
259
90
  */
260
91
  async setActiveSession(cli, sessionId) {
261
- // Validate session exists if not null
262
92
  if (sessionId !== null) {
263
93
  const session = await this.getSession(sessionId);
264
94
  if (!session || session.cli !== cli) {
265
95
  return false;
266
96
  }
267
97
  }
268
- // Acquire lock with bounded retries to avoid failing benign concurrent updates.
269
- const lockValue = await this.acquireLockWithRetry(`active_session:${cli}`, 5, `active session ${cli}`);
270
- try {
271
- // UPSERT active session
272
- const now = new Date().toISOString();
273
- await this.pool.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
274
- VALUES ($1, $2, $3)
275
- ON CONFLICT (cli) DO UPDATE SET session_id = $2, updated_at = $3`, [cli, sessionId, now]);
276
- // Update cache
277
- try {
278
- if (sessionId) {
279
- await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
280
- }
281
- else {
282
- await this.redis.del(`active_session:${cli}`);
283
- }
284
- }
285
- catch (error) {
286
- this.logger.error("Cache update failed", { error });
287
- }
288
- return true;
289
- }
290
- finally {
291
- // Release lock with ownership verification
292
- try {
293
- await this.releaseLock(`active_session:${cli}`, lockValue);
294
- }
295
- catch (error) {
296
- this.logger.error(`Failed to release lock for active session ${cli}`, { error, cli });
297
- }
298
- }
98
+ const now = new Date().toISOString();
99
+ await this.pool.query(`INSERT INTO active_sessions (cli, session_id, updated_at)
100
+ VALUES ($1, $2, $3)
101
+ ON CONFLICT (cli) DO UPDATE SET session_id = $2, updated_at = $3`, [cli, sessionId, now]);
102
+ return true;
299
103
  }
300
104
  /**
301
- * Get active session for a CLI
105
+ * Get active session for a CLI.
302
106
  */
303
107
  async getActiveSession(cli) {
304
- // Try cache first
305
- try {
306
- const cachedId = await this.redis.get(`active_session:${cli}`);
307
- if (cachedId) {
308
- return await this.getSession(cachedId);
309
- }
310
- }
311
- catch (error) {
312
- this.logger.error("Cache read failed", { error });
313
- }
314
- // Query database
315
108
  const result = await this.pool.query("SELECT session_id FROM active_sessions WHERE cli = $1", [cli]);
316
- if (result.rows.length === 0 || !result.rows[0].session_id) {
109
+ const sessionId = result.rows[0]?.session_id;
110
+ if (!sessionId) {
317
111
  return null;
318
112
  }
319
- const sessionId = result.rows[0].session_id;
320
- // Populate cache
321
- try {
322
- await this.redis.setex(`active_session:${cli}`, this.cacheTtl.activeSession, sessionId);
323
- }
324
- catch (error) {
325
- this.logger.error("Cache write failed", { error });
326
- }
327
113
  return await this.getSession(sessionId);
328
114
  }
329
115
  /**
330
- * Update session usage timestamp
116
+ * Update session usage timestamp.
331
117
  */
332
118
  async updateSessionUsage(sessionId) {
333
119
  const now = new Date().toISOString();
334
120
  await this.pool.query("UPDATE sessions SET last_used_at = $1 WHERE id = $2", [now, sessionId]);
335
- // Invalidate cache to force refresh
336
- await this.invalidateCache(sessionId);
337
121
  }
338
122
  /**
339
- * Update session metadata (atomic JSONB merge)
123
+ * Update session metadata using PostgreSQL's atomic JSONB merge.
340
124
  */
341
125
  async updateSessionMetadata(sessionId, metadata) {
342
- // Use PostgreSQL JSONB || operator for atomic merge (prevents race conditions)
343
126
  const result = await this.pool.query(`UPDATE sessions
344
127
  SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb
345
128
  WHERE id = $2
346
129
  RETURNING id`, [JSON.stringify(metadata), sessionId]);
347
- if (result.rowCount === 0) {
348
- return false;
349
- }
350
- // Invalidate cache
351
- await this.invalidateCache(sessionId);
352
- return true;
130
+ return result.rowCount !== 0;
353
131
  }
354
132
  /**
355
- * Clear all sessions, optionally filtered by CLI
356
- * Invalidates all related caches (session, active, list)
133
+ * Clear all sessions, optionally filtered by CLI.
357
134
  */
358
135
  async clearAllSessions(cli) {
359
- // First get all sessions to invalidate their caches
360
- const sessions = await this.listSessions(cli);
361
- // Delete from database
362
136
  const query = cli ? "DELETE FROM sessions WHERE cli = $1" : "DELETE FROM sessions";
363
137
  const result = cli ? await this.pool.query(query, [cli]) : await this.pool.query(query);
364
- // Invalidate individual session caches (concurrent — each has its own try/catch)
365
- await Promise.all(sessions.map(session => this.invalidateCache(session.id)));
366
- // Invalidate active session caches
367
- if (cli) {
368
- try {
369
- await this.redis.del(`active_session:${cli}`);
370
- }
371
- catch (error) {
372
- this.logger.error(`Failed to invalidate active session cache for ${cli}`, { error, cli });
373
- }
374
- }
375
- else {
376
- // Invalidate all active session caches
377
- try {
378
- await Promise.all([
379
- this.redis.del("active_session:claude"),
380
- this.redis.del("active_session:codex"),
381
- this.redis.del("active_session:gemini"),
382
- ]);
383
- }
384
- catch (error) {
385
- this.logger.error("Failed to invalidate active session caches", { error });
386
- }
387
- }
388
- // Invalidate list caches
389
- await this.invalidateListCache(cli);
390
138
  return result.rowCount || 0;
391
139
  }
392
140
  }
@@ -230,15 +230,15 @@ export const SessionManager = FileSessionManager;
230
230
  * @param logger - Logger instance for structured logging
231
231
  */
232
232
  export async function createSessionManager(config, db, logger, opts) {
233
- if (config?.database && config?.redis) {
234
- // Import dynamically to avoid loading pg/ioredis if not needed
233
+ if (config?.database) {
234
+ // Import dynamically to avoid loading pg if not needed.
235
235
  const { PostgreSQLSessionManager } = await import("./session-manager-pg.js");
236
236
  // Use provided db connection or create new one
237
237
  if (!db) {
238
238
  const { createDatabaseConnection } = await import("./db.js");
239
239
  db = await createDatabaseConnection(config, logger);
240
240
  }
241
- return new PostgreSQLSessionManager(db.getPool(), db.getRedis(), config.cacheTtl, logger ?? noopLogger);
241
+ return new PostgreSQLSessionManager(db.getPool());
242
242
  }
243
243
  else {
244
244
  // Use file-based storage with TTL from config
@@ -604,15 +604,16 @@ export const UPSTREAM_CLI_CONTRACTS = {
604
604
  // Phase 4 slice δ
605
605
  "maxTurns",
606
606
  "maxPrice",
607
+ "maxTokens",
607
608
  // Phase 4 slice ζ
608
609
  "workingDir",
609
610
  "addDir",
610
611
  ],
611
612
  flags: {
612
613
  "-p": { arity: "one", description: "Prompt text" },
613
- "--output-format": {
614
+ "--output": {
614
615
  arity: "one",
615
- values: ["plain", "json", "stream-json"],
616
+ values: ["text", "json", "streaming"],
616
617
  description: "Output format",
617
618
  },
618
619
  "--agent": {
@@ -641,6 +642,11 @@ export const UPSTREAM_CLI_CONTRACTS = {
641
642
  pattern: /^(0|[1-9][0-9]*)(\.[0-9]+)?$/,
642
643
  description: "Cumulative cost cap in USD (Phase 4 slice δ, programmatic mode only)",
643
644
  },
645
+ "--max-tokens": {
646
+ arity: "one",
647
+ pattern: /^[1-9][0-9]*$/,
648
+ description: "Cumulative prompt + completion token cap (Vibe 2.x programmatic mode)",
649
+ },
644
650
  "--workdir": {
645
651
  arity: "one",
646
652
  description: "Working directory for the invocation (Phase 4 slice ζ)",
@@ -686,6 +692,22 @@ export const UPSTREAM_CLI_CONTRACTS = {
686
692
  env: { VIBE_ACTIVE_MODEL: "mistral-medium-3.5" },
687
693
  expect: "pass",
688
694
  },
695
+ {
696
+ id: "mistral-output-streaming-and-max-tokens",
697
+ description: "Vibe 2.x: --output streaming and --max-tokens are accepted",
698
+ args: [
699
+ "-p",
700
+ "hello",
701
+ "--agent",
702
+ "auto-approve",
703
+ "--output",
704
+ "streaming",
705
+ "--max-tokens",
706
+ "1000",
707
+ ],
708
+ env: { VIBE_ACTIVE_MODEL: "mistral-medium-3.5" },
709
+ expect: "pass",
710
+ },
689
711
  {
690
712
  id: "mistral-max-price-scientific-notation",
691
713
  description: "Phase 4 slice δ: scientific-notation --max-price is rejected by contract pattern (matches MAX_PRICE_SCHEMA bounds)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "1.15.3",
3
+ "version": "1.16.1",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
6
6
  "license": "MIT",
@@ -90,13 +90,9 @@
90
90
  "zod": "^3.23.0"
91
91
  },
92
92
  "peerDependencies": {
93
- "ioredis": "^5.4.1",
94
93
  "pg": "^8.12.0"
95
94
  },
96
95
  "peerDependenciesMeta": {
97
- "ioredis": {
98
- "optional": true
99
- },
100
96
  "pg": {
101
97
  "optional": true
102
98
  }
@@ -111,7 +107,6 @@
111
107
  "eslint": "^8.57.1",
112
108
  "eslint-config-prettier": "^9.0.0",
113
109
  "eslint-plugin-security": "^3.0.1",
114
- "ioredis": "5.9.2",
115
110
  "pg": "^8.12.0",
116
111
  "prettier": "^3.0.0",
117
112
  "typescript": "^5.0.0",