llm-cli-gateway 1.1.0 → 1.5.4

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 (57) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +226 -9
  3. package/dist/approval-manager.d.ts +1 -1
  4. package/dist/async-job-manager.d.ts +75 -4
  5. package/dist/async-job-manager.js +303 -19
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.js +46 -0
  8. package/dist/cli-updater.d.ts +55 -0
  9. package/dist/cli-updater.js +248 -0
  10. package/dist/codex-json-parser.d.ts +34 -0
  11. package/dist/codex-json-parser.js +105 -0
  12. package/dist/doctor.d.ts +110 -0
  13. package/dist/doctor.js +280 -0
  14. package/dist/endpoint-exposure.d.ts +22 -0
  15. package/dist/endpoint-exposure.js +231 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -2
  18. package/dist/flight-recorder.d.ts +3 -1
  19. package/dist/flight-recorder.js +31 -2
  20. package/dist/gateway-server.d.ts +2 -0
  21. package/dist/gateway-server.js +1 -0
  22. package/dist/gemini-json-parser.d.ts +21 -0
  23. package/dist/gemini-json-parser.js +47 -0
  24. package/dist/health.d.ts +7 -0
  25. package/dist/health.js +22 -0
  26. package/dist/http-transport.d.ts +22 -0
  27. package/dist/http-transport.js +164 -0
  28. package/dist/index.d.ts +210 -2
  29. package/dist/index.js +2880 -1037
  30. package/dist/job-store.d.ts +84 -0
  31. package/dist/job-store.js +251 -0
  32. package/dist/logger.d.ts +9 -0
  33. package/dist/logger.js +14 -0
  34. package/dist/model-registry.d.ts +14 -0
  35. package/dist/model-registry.js +478 -134
  36. package/dist/provider-login-guidance.d.ts +21 -0
  37. package/dist/provider-login-guidance.js +98 -0
  38. package/dist/provider-status.d.ts +41 -0
  39. package/dist/provider-status.js +203 -0
  40. package/dist/request-helpers.d.ts +525 -4
  41. package/dist/request-helpers.js +653 -0
  42. package/dist/resources.js +88 -0
  43. package/dist/session-manager-pg.js +2 -0
  44. package/dist/session-manager.d.ts +1 -1
  45. package/dist/session-manager.js +3 -1
  46. package/dist/validation-normalizer.d.ts +23 -0
  47. package/dist/validation-normalizer.js +79 -0
  48. package/dist/validation-orchestrator.d.ts +47 -0
  49. package/dist/validation-orchestrator.js +145 -0
  50. package/dist/validation-prompts.d.ts +15 -0
  51. package/dist/validation-prompts.js +52 -0
  52. package/dist/validation-report.d.ts +57 -0
  53. package/dist/validation-report.js +129 -0
  54. package/dist/validation-tools.d.ts +7 -0
  55. package/dist/validation-tools.js +198 -0
  56. package/package.json +16 -6
  57. package/setup/status.schema.json +271 -0
@@ -0,0 +1,84 @@
1
+ import type { Logger } from "./logger.js";
2
+ export type JobStoreStatus = "running" | "completed" | "failed" | "canceled" | "orphaned";
3
+ export interface JobRecord {
4
+ id: string;
5
+ correlationId: string;
6
+ requestKey: string;
7
+ cli: string;
8
+ argsJson: string;
9
+ outputFormat?: string | null;
10
+ status: JobStoreStatus;
11
+ exitCode: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ outputTruncated: boolean;
15
+ error: string | null;
16
+ startedAt: string;
17
+ finishedAt: string | null;
18
+ pid: number | null;
19
+ expiresAt: string;
20
+ }
21
+ export declare function resolveJobStoreDbPath(): string | null;
22
+ export declare function resolveJobRetentionMs(): number;
23
+ export declare function resolveDedupWindowMs(): number;
24
+ export declare function computeRequestKey(cli: string, args: string[], extra?: string): string;
25
+ export declare class JobStore {
26
+ private logger;
27
+ private db;
28
+ private retentionMs;
29
+ private dedupWindowMs;
30
+ private insertStmt;
31
+ private updateOutputStmt;
32
+ private updateCompleteStmt;
33
+ private getByIdStmt;
34
+ private findByRequestKeyStmt;
35
+ private markOrphanedStmt;
36
+ private deleteExpiredStmt;
37
+ constructor(dbPath: string, logger?: Logger);
38
+ /**
39
+ * Insert a new running job row. Caller has already computed requestKey.
40
+ */
41
+ recordStart(input: {
42
+ id: string;
43
+ correlationId: string;
44
+ requestKey: string;
45
+ cli: string;
46
+ args: string[];
47
+ outputFormat?: string;
48
+ startedAt: string;
49
+ pid: number | null;
50
+ }): void;
51
+ /**
52
+ * Batched output flush. Cheap to call repeatedly; better-sqlite3 is sync.
53
+ */
54
+ recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
55
+ /**
56
+ * Mark a job as completed/failed/canceled. Sets expires_at = now + retention.
57
+ */
58
+ recordComplete(input: {
59
+ id: string;
60
+ status: Exclude<JobStoreStatus, "running">;
61
+ exitCode: number | null;
62
+ stdout: string;
63
+ stderr: string;
64
+ outputTruncated: boolean;
65
+ error: string | null;
66
+ finishedAt: string;
67
+ }): void;
68
+ getById(id: string): JobRecord | null;
69
+ /**
70
+ * Returns the most recent matching job within the dedup window, if any.
71
+ * Caller pre-filters out forceRefresh requests.
72
+ */
73
+ findByRequestKey(requestKey: string): JobRecord | null;
74
+ /**
75
+ * On gateway boot, flip any jobs that were 'running' to 'orphaned'.
76
+ * The child processes were detached but can't be reattached to in this process.
77
+ */
78
+ markOrphanedOnStartup(): number;
79
+ /**
80
+ * Delete rows whose expires_at has passed. Returns number of rows deleted.
81
+ */
82
+ evictExpired(): number;
83
+ close(): void;
84
+ }
@@ -0,0 +1,251 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ import { createRequire } from "module";
6
+ import { noopLogger } from "./logger.js";
7
+ export function resolveJobStoreDbPath() {
8
+ const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
9
+ if (configured !== undefined) {
10
+ const normalized = configured.trim().toLowerCase();
11
+ if (!normalized || normalized === "none") {
12
+ return null;
13
+ }
14
+ return configured.trim();
15
+ }
16
+ return path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
17
+ }
18
+ const DEFAULT_RETENTION_DAYS = 30;
19
+ const FAR_FUTURE_ISO = "9999-12-31T23:59:59.999Z";
20
+ export function resolveJobRetentionMs() {
21
+ const raw = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
22
+ const days = raw ? Number(raw) : DEFAULT_RETENTION_DAYS;
23
+ if (!Number.isFinite(days) || days <= 0) {
24
+ return DEFAULT_RETENTION_DAYS * 24 * 60 * 60 * 1000;
25
+ }
26
+ return days * 24 * 60 * 60 * 1000;
27
+ }
28
+ const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
29
+ export function resolveDedupWindowMs() {
30
+ const raw = process.env.LLM_GATEWAY_DEDUP_WINDOW_MS;
31
+ if (raw === undefined)
32
+ return DEFAULT_DEDUP_WINDOW_MS;
33
+ const n = Number(raw);
34
+ if (!Number.isFinite(n) || n < 0)
35
+ return DEFAULT_DEDUP_WINDOW_MS;
36
+ return n;
37
+ }
38
+ export function computeRequestKey(cli, args, extra) {
39
+ const payload = JSON.stringify({ cli, args, extra: extra ?? "" });
40
+ return createHash("sha256").update(payload).digest("hex");
41
+ }
42
+ function rowToRecord(row) {
43
+ return {
44
+ id: row.id,
45
+ correlationId: row.correlation_id,
46
+ requestKey: row.request_key,
47
+ cli: row.cli,
48
+ argsJson: row.args_json,
49
+ outputFormat: row.output_format ?? null,
50
+ status: row.status,
51
+ exitCode: row.exit_code,
52
+ stdout: row.stdout ?? "",
53
+ stderr: row.stderr ?? "",
54
+ outputTruncated: Boolean(row.output_truncated),
55
+ error: row.error ?? null,
56
+ startedAt: row.started_at,
57
+ finishedAt: row.finished_at,
58
+ pid: row.pid,
59
+ expiresAt: row.expires_at,
60
+ };
61
+ }
62
+ export class JobStore {
63
+ logger;
64
+ db;
65
+ retentionMs;
66
+ dedupWindowMs;
67
+ insertStmt;
68
+ updateOutputStmt;
69
+ updateCompleteStmt;
70
+ getByIdStmt;
71
+ findByRequestKeyStmt;
72
+ markOrphanedStmt;
73
+ deleteExpiredStmt;
74
+ constructor(dbPath, logger = noopLogger) {
75
+ this.logger = logger;
76
+ const require = createRequire(import.meta.url);
77
+ const BetterSqlite3 = require("better-sqlite3");
78
+ const directory = path.dirname(dbPath);
79
+ if (!existsSync(directory)) {
80
+ mkdirSync(directory, { recursive: true });
81
+ }
82
+ this.db = new BetterSqlite3(dbPath);
83
+ this.db.pragma("journal_mode = WAL");
84
+ this.db.pragma("synchronous = NORMAL");
85
+ this.db.exec(`
86
+ CREATE TABLE IF NOT EXISTS jobs (
87
+ id TEXT PRIMARY KEY,
88
+ correlation_id TEXT NOT NULL,
89
+ request_key TEXT NOT NULL,
90
+ cli TEXT NOT NULL,
91
+ args_json TEXT NOT NULL,
92
+ output_format TEXT,
93
+ status TEXT NOT NULL,
94
+ exit_code INTEGER,
95
+ stdout TEXT,
96
+ stderr TEXT,
97
+ output_truncated INTEGER NOT NULL DEFAULT 0,
98
+ error TEXT,
99
+ started_at TEXT NOT NULL,
100
+ finished_at TEXT,
101
+ pid INTEGER,
102
+ expires_at TEXT NOT NULL
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
105
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
106
+ CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
107
+ CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
108
+ `);
109
+ if (process.platform !== "win32") {
110
+ try {
111
+ chmodSync(dbPath, 0o600);
112
+ }
113
+ catch {
114
+ // Best effort permissions hardening.
115
+ }
116
+ }
117
+ this.retentionMs = resolveJobRetentionMs();
118
+ this.dedupWindowMs = resolveDedupWindowMs();
119
+ this.insertStmt = this.db.prepare(`
120
+ INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
121
+ status, exit_code, stdout, stderr, output_truncated, error,
122
+ started_at, finished_at, pid, expires_at)
123
+ VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
124
+ @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
125
+ @started_at, @finished_at, @pid, @expires_at)
126
+ `);
127
+ this.updateOutputStmt = this.db.prepare(`
128
+ UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
129
+ WHERE id = @id
130
+ `);
131
+ this.updateCompleteStmt = this.db.prepare(`
132
+ UPDATE jobs SET status = @status, exit_code = @exit_code, stdout = @stdout, stderr = @stderr,
133
+ output_truncated = @output_truncated, error = @error,
134
+ finished_at = @finished_at, expires_at = @expires_at
135
+ WHERE id = @id
136
+ `);
137
+ this.getByIdStmt = this.db.prepare(`SELECT * FROM jobs WHERE id = ?`);
138
+ // Dedup query: most recent non-orphaned job with matching request_key, started within window.
139
+ // Exclude orphaned/canceled/failed-with-error from dedup so a broken run isn't reused.
140
+ this.findByRequestKeyStmt = this.db.prepare(`
141
+ SELECT * FROM jobs
142
+ WHERE request_key = ?
143
+ AND started_at >= ?
144
+ AND status IN ('running', 'completed')
145
+ ORDER BY started_at DESC
146
+ LIMIT 1
147
+ `);
148
+ this.markOrphanedStmt = this.db.prepare(`
149
+ UPDATE jobs
150
+ SET status = 'orphaned',
151
+ error = COALESCE(error, 'Gateway restarted while job was running'),
152
+ finished_at = COALESCE(finished_at, ?),
153
+ expires_at = ?
154
+ WHERE status = 'running'
155
+ `);
156
+ this.deleteExpiredStmt = this.db.prepare(`DELETE FROM jobs WHERE expires_at < ?`);
157
+ }
158
+ /**
159
+ * Insert a new running job row. Caller has already computed requestKey.
160
+ */
161
+ recordStart(input) {
162
+ this.insertStmt.run({
163
+ id: input.id,
164
+ correlation_id: input.correlationId,
165
+ request_key: input.requestKey,
166
+ cli: input.cli,
167
+ args_json: JSON.stringify(input.args),
168
+ output_format: input.outputFormat ?? null,
169
+ status: "running",
170
+ exit_code: null,
171
+ stdout: "",
172
+ stderr: "",
173
+ output_truncated: 0,
174
+ error: null,
175
+ started_at: input.startedAt,
176
+ finished_at: null,
177
+ pid: input.pid,
178
+ // Running jobs never expire — only completed/failed/canceled do.
179
+ expires_at: FAR_FUTURE_ISO,
180
+ });
181
+ }
182
+ /**
183
+ * Batched output flush. Cheap to call repeatedly; better-sqlite3 is sync.
184
+ */
185
+ recordOutput(id, stdout, stderr, outputTruncated) {
186
+ this.updateOutputStmt.run({
187
+ id,
188
+ stdout,
189
+ stderr,
190
+ output_truncated: outputTruncated ? 1 : 0,
191
+ });
192
+ }
193
+ /**
194
+ * Mark a job as completed/failed/canceled. Sets expires_at = now + retention.
195
+ */
196
+ recordComplete(input) {
197
+ const expiresAt = new Date(Date.parse(input.finishedAt) + this.retentionMs).toISOString();
198
+ this.updateCompleteStmt.run({
199
+ id: input.id,
200
+ status: input.status,
201
+ exit_code: input.exitCode,
202
+ stdout: input.stdout,
203
+ stderr: input.stderr,
204
+ output_truncated: input.outputTruncated ? 1 : 0,
205
+ error: input.error,
206
+ finished_at: input.finishedAt,
207
+ expires_at: expiresAt,
208
+ });
209
+ }
210
+ getById(id) {
211
+ const row = this.getByIdStmt.get(id);
212
+ return row ? rowToRecord(row) : null;
213
+ }
214
+ /**
215
+ * Returns the most recent matching job within the dedup window, if any.
216
+ * Caller pre-filters out forceRefresh requests.
217
+ */
218
+ findByRequestKey(requestKey) {
219
+ const cutoff = new Date(Date.now() - this.dedupWindowMs).toISOString();
220
+ const row = this.findByRequestKeyStmt.get(requestKey, cutoff);
221
+ return row ? rowToRecord(row) : null;
222
+ }
223
+ /**
224
+ * On gateway boot, flip any jobs that were 'running' to 'orphaned'.
225
+ * The child processes were detached but can't be reattached to in this process.
226
+ */
227
+ markOrphanedOnStartup() {
228
+ const now = new Date().toISOString();
229
+ // Orphaned jobs retain a short window so callers can fetch the partial output,
230
+ // then evict. Reuse the standard retention.
231
+ const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
232
+ const result = this.markOrphanedStmt.run(now, expiresAt);
233
+ return result?.changes ?? 0;
234
+ }
235
+ /**
236
+ * Delete rows whose expires_at has passed. Returns number of rows deleted.
237
+ */
238
+ evictExpired() {
239
+ const now = new Date().toISOString();
240
+ const result = this.deleteExpiredStmt.run(now);
241
+ return result?.changes ?? 0;
242
+ }
243
+ close() {
244
+ try {
245
+ this.db.close();
246
+ }
247
+ catch (err) {
248
+ this.logger.error("JobStore close failed", err);
249
+ }
250
+ }
251
+ }
package/dist/logger.d.ts CHANGED
@@ -2,5 +2,14 @@ export interface Logger {
2
2
  info(message: string, meta?: unknown): void;
3
3
  error(message: string, meta?: unknown): void;
4
4
  debug(message: string, meta?: unknown): void;
5
+ /** Optional: callers that want explicit WARN routing can implement this. */
6
+ warn?(message: string, meta?: unknown): void;
5
7
  }
6
8
  export declare const noopLogger: Logger;
9
+ /**
10
+ * Emit a warning through whichever logger surface is available. Some Logger
11
+ * implementations (legacy) only provide `info`/`error`/`debug`; in that case
12
+ * the message is prefixed with `[WARN]` and routed through `info` so it still
13
+ * reaches stderr.
14
+ */
15
+ export declare function logWarn(logger: Logger, message: string, meta?: unknown): void;
package/dist/logger.js CHANGED
@@ -2,4 +2,18 @@ export const noopLogger = {
2
2
  info: () => { },
3
3
  error: () => { },
4
4
  debug: () => { },
5
+ warn: () => { },
5
6
  };
7
+ /**
8
+ * Emit a warning through whichever logger surface is available. Some Logger
9
+ * implementations (legacy) only provide `info`/`error`/`debug`; in that case
10
+ * the message is prefixed with `[WARN]` and routed through `info` so it still
11
+ * reaches stderr.
12
+ */
13
+ export function logWarn(logger, message, meta) {
14
+ if (typeof logger.warn === "function") {
15
+ logger.warn(message, meta);
16
+ return;
17
+ }
18
+ logger.info(`[WARN] ${message}`, meta);
19
+ }
@@ -1,10 +1,24 @@
1
1
  import type { CliType } from "./session-manager.js";
2
+ type ModelSource = "fallback" | "observed" | "config" | "env";
3
+ type ModelConfidence = "low" | "medium" | "high";
4
+ export interface ModelMetadata {
5
+ source: ModelSource;
6
+ sourceDetail: string;
7
+ confidence: ModelConfidence;
8
+ lastSeen?: string;
9
+ }
2
10
  export interface CliInfo {
3
11
  description: string;
4
12
  models: Record<string, string>;
5
13
  defaultModel?: string;
14
+ defaultModelSource?: string;
6
15
  modelOrder?: string[];
16
+ aliases?: Record<string, string>;
17
+ modelMetadata?: Record<string, ModelMetadata>;
18
+ warnings?: string[];
7
19
  }
8
20
  export type CliInfoMap = Record<CliType, CliInfo>;
9
21
  export declare function getCliInfo(forceRefresh?: boolean): CliInfoMap;
22
+ export declare function clearModelRegistryCache(): void;
10
23
  export declare function resolveModelAlias(cli: CliType, model: string | undefined, info: CliInfoMap): string | undefined;
24
+ export {};