llm-cli-gateway 1.4.0 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/README.md +358 -15
  3. package/dist/approval-manager.d.ts +1 -1
  4. package/dist/async-job-manager.d.ts +32 -2
  5. package/dist/async-job-manager.js +101 -16
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.js +46 -0
  8. package/dist/cli-updater.d.ts +19 -2
  9. package/dist/cli-updater.js +110 -7
  10. package/dist/codex-json-parser.d.ts +34 -0
  11. package/dist/codex-json-parser.js +105 -0
  12. package/dist/config.d.ts +30 -0
  13. package/dist/config.js +167 -0
  14. package/dist/doctor.d.ts +110 -0
  15. package/dist/doctor.js +280 -0
  16. package/dist/endpoint-exposure.d.ts +22 -0
  17. package/dist/endpoint-exposure.js +231 -0
  18. package/dist/entrypoint-url.d.ts +1 -0
  19. package/dist/entrypoint-url.js +5 -0
  20. package/dist/executor.d.ts +9 -1
  21. package/dist/executor.js +52 -17
  22. package/dist/flight-recorder.d.ts +3 -1
  23. package/dist/flight-recorder.js +31 -2
  24. package/dist/gateway-server.d.ts +2 -0
  25. package/dist/gateway-server.js +1 -0
  26. package/dist/gemini-json-parser.d.ts +21 -0
  27. package/dist/gemini-json-parser.js +47 -0
  28. package/dist/health.d.ts +7 -0
  29. package/dist/health.js +22 -0
  30. package/dist/http-transport.d.ts +22 -0
  31. package/dist/http-transport.js +164 -0
  32. package/dist/index.d.ts +186 -2
  33. package/dist/index.js +2761 -1454
  34. package/dist/job-store.d.ts +118 -2
  35. package/dist/job-store.js +176 -5
  36. package/dist/logger.d.ts +9 -0
  37. package/dist/logger.js +14 -0
  38. package/dist/model-registry.js +40 -6
  39. package/dist/provider-login-guidance.d.ts +21 -0
  40. package/dist/provider-login-guidance.js +98 -0
  41. package/dist/provider-status.d.ts +41 -0
  42. package/dist/provider-status.js +203 -0
  43. package/dist/request-helpers.d.ts +484 -4
  44. package/dist/request-helpers.js +613 -0
  45. package/dist/resources.js +44 -0
  46. package/dist/session-manager-pg.js +1 -0
  47. package/dist/session-manager.d.ts +1 -1
  48. package/dist/session-manager.js +2 -1
  49. package/dist/upstream-contracts.d.ts +62 -0
  50. package/dist/upstream-contracts.js +620 -0
  51. package/dist/validation-normalizer.d.ts +23 -0
  52. package/dist/validation-normalizer.js +79 -0
  53. package/dist/validation-orchestrator.d.ts +47 -0
  54. package/dist/validation-orchestrator.js +145 -0
  55. package/dist/validation-prompts.d.ts +15 -0
  56. package/dist/validation-prompts.js +52 -0
  57. package/dist/validation-report.d.ts +57 -0
  58. package/dist/validation-report.js +129 -0
  59. package/dist/validation-tools.d.ts +7 -0
  60. package/dist/validation-tools.js +198 -0
  61. package/package.json +25 -10
  62. package/setup/status.schema.json +271 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Parser for Codex CLI `--json` JSONL event stream.
3
+ *
4
+ * Codex emits one JSON object per line, e.g.:
5
+ * {"type":"thread.started","thread_id":"t-abc"}
6
+ * {"type":"turn.started","turn_id":"u-001"}
7
+ * {"type":"item.started","item":{...}}
8
+ * {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
9
+ * {"type":"turn.completed","usage":{"input_tokens":...,"output_tokens":...,...}}
10
+ * {"type":"turn.failed","error":{...}}
11
+ * {"type":"error","message":"..."}
12
+ *
13
+ * This parser is lenient: malformed lines are skipped, partial streams are
14
+ * tolerated (usage is `undefined` if no turn.completed event arrived), and
15
+ * error events are surfaced.
16
+ *
17
+ * Cost is intentionally NOT computed here — Codex does not price client-side
18
+ * and U23 only plumbs tokens. A future unit can compute cost from the model
19
+ * registry.
20
+ */
21
+ export function parseCodexJsonStream(stdout) {
22
+ const lines = stdout.split("\n").filter(line => line.trim().length > 0);
23
+ const result = {};
24
+ let lastAgentMessage;
25
+ for (const line of lines) {
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(line);
29
+ }
30
+ catch {
31
+ // Skip preamble/garbage lines that aren't valid JSON.
32
+ continue;
33
+ }
34
+ if (!parsed || typeof parsed !== "object") {
35
+ continue;
36
+ }
37
+ switch (parsed.type) {
38
+ case "thread.started":
39
+ if (typeof parsed.thread_id === "string") {
40
+ result.threadId = parsed.thread_id;
41
+ }
42
+ break;
43
+ case "turn.completed": {
44
+ const u = parsed.usage;
45
+ if (u && typeof u === "object") {
46
+ const usage = {
47
+ input_tokens: typeof u.input_tokens === "number" ? u.input_tokens : 0,
48
+ output_tokens: typeof u.output_tokens === "number" ? u.output_tokens : 0,
49
+ };
50
+ if (typeof u.cache_read_input_tokens === "number") {
51
+ usage.cache_read_tokens = u.cache_read_input_tokens;
52
+ }
53
+ else if (typeof u.cache_read_tokens === "number") {
54
+ usage.cache_read_tokens = u.cache_read_tokens;
55
+ }
56
+ if (typeof u.cache_creation_input_tokens === "number") {
57
+ usage.cache_creation_tokens = u.cache_creation_input_tokens;
58
+ }
59
+ else if (typeof u.cache_creation_tokens === "number") {
60
+ usage.cache_creation_tokens = u.cache_creation_tokens;
61
+ }
62
+ if (typeof u.cost_usd === "number") {
63
+ usage.cost_usd = u.cost_usd;
64
+ }
65
+ result.usage = usage;
66
+ }
67
+ break;
68
+ }
69
+ case "turn.failed": {
70
+ const err = parsed.error;
71
+ if (typeof err === "string") {
72
+ result.error = err;
73
+ }
74
+ else if (err && typeof err === "object" && typeof err.message === "string") {
75
+ result.error = err.message;
76
+ }
77
+ else {
78
+ result.error = "turn failed";
79
+ }
80
+ break;
81
+ }
82
+ case "error":
83
+ if (typeof parsed.message === "string") {
84
+ result.error = parsed.message;
85
+ }
86
+ break;
87
+ case "item.completed": {
88
+ const item = parsed.item;
89
+ if (item &&
90
+ typeof item === "object" &&
91
+ item.type === "agent_message" &&
92
+ typeof item.text === "string") {
93
+ lastAgentMessage = item.text;
94
+ }
95
+ break;
96
+ }
97
+ default:
98
+ break;
99
+ }
100
+ }
101
+ if (lastAgentMessage !== undefined) {
102
+ result.finalMessage = lastAgentMessage;
103
+ }
104
+ return result;
105
+ }
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "./logger.js";
1
2
  export interface CacheTtl {
2
3
  session: number;
3
4
  activeSession: number;
@@ -33,3 +34,32 @@ export interface Config {
33
34
  * Database and Redis fields are populated only when both env vars are set.
34
35
  */
35
36
  export declare function loadConfig(): Config;
37
+ export declare const PERSISTENCE_BACKENDS: readonly ["sqlite", "postgres", "memory", "none"];
38
+ export type PersistenceBackend = (typeof PERSISTENCE_BACKENDS)[number];
39
+ export declare const DEFAULT_JOB_RETENTION_DAYS = 30;
40
+ export declare const DEFAULT_DEDUP_WINDOW_MS: number;
41
+ export interface PersistenceConfig {
42
+ backend: PersistenceBackend;
43
+ path: string | null;
44
+ dsn: string | null;
45
+ retentionDays: number;
46
+ dedupWindowMs: number;
47
+ acknowledgeEphemeral: boolean;
48
+ /** True iff async-job tools should be registered on the MCP server. */
49
+ asyncJobsEnabled: boolean;
50
+ /** Audit trail: which inputs (file, env vars) contributed to the resolved config. */
51
+ sources: PersistenceConfigSources;
52
+ }
53
+ export interface PersistenceConfigSources {
54
+ configFile: string | null;
55
+ envOverrides: string[];
56
+ }
57
+ /**
58
+ * Load and validate the persistence config from (in order, last-write-wins):
59
+ * 1. Built-in defaults (backend=sqlite, default retention/dedup).
60
+ * 2. ~/.llm-cli-gateway/config.toml (or $LLM_GATEWAY_CONFIG).
61
+ * 3. Legacy env vars (with deprecation warning).
62
+ *
63
+ * Throws on incoherent configs (memory/none + asyncJobsEnabled without ack).
64
+ */
65
+ export declare function loadPersistenceConfig(logger?: Logger): PersistenceConfig;
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,110 @@
1
+ import { type EndpointExposureReport } from "./endpoint-exposure.js";
2
+ import { type ProviderLoginStatus } from "./provider-status.js";
3
+ export interface VibeSessionLoggingStatus {
4
+ config_path: string;
5
+ config_present: boolean;
6
+ session_logging_enabled: boolean;
7
+ note: string;
8
+ }
9
+ export interface GeminiConfigStatus {
10
+ /** Presence of a project-local `GEMINI.md` in the gateway's cwd. */
11
+ project_gemini_md_present: boolean;
12
+ project_gemini_md_path: string;
13
+ /** Presence of `~/.gemini/GEMINI.md`. */
14
+ user_gemini_md_present: boolean;
15
+ user_gemini_md_path: string;
16
+ /** Presence and contents of `~/.gemini/settings.json` `mcpServers` block. */
17
+ settings_json_present: boolean;
18
+ settings_json_path: string;
19
+ mcp_servers_registered: string[];
20
+ /** Per-server reconciliation against the gateway's `--allowed-mcp-server-names` whitelist. */
21
+ mcp_reconciliation: {
22
+ whitelisted: string[];
23
+ missing_from_settings: string[];
24
+ };
25
+ next_actions: string[];
26
+ }
27
+ /**
28
+ * Probe ~/.vibe/config.toml to see whether session_logging is enabled. Vibe
29
+ * persists session logs (which sessionId/--continue depends on) only when
30
+ * `[session_logging] enabled = true` is set. The probe is read-only: the
31
+ * gateway never mutates this file.
32
+ */
33
+ export declare function checkVibeSessionLogging(home?: string): VibeSessionLoggingStatus;
34
+ /**
35
+ * U27: Probe Gemini's project/user config locations.
36
+ *
37
+ * - `./GEMINI.md` (gateway cwd) and `~/.gemini/GEMINI.md` are documented
38
+ * "context" surfaces. Missing both means Gemini has no project-specific
39
+ * guidance.
40
+ * - `~/.gemini/settings.json` defines registered MCP servers (`mcpServers`
41
+ * block). The gateway tracks its own whitelist (`CLAUDE_MCP_SERVER_NAMES`)
42
+ * and surfaces a reconciliation warning for each whitelisted server not
43
+ * present in settings.json so callers don't ship requests for unregistered
44
+ * servers.
45
+ */
46
+ export declare function checkGeminiConfig(cwd?: string, home?: string, whitelist?: readonly string[]): GeminiConfigStatus;
47
+ export interface DoctorReport {
48
+ schema_version: "1.0";
49
+ ok: boolean;
50
+ generated_at: string;
51
+ system: {
52
+ os: NodeJS.Platform;
53
+ arch: string;
54
+ release: string;
55
+ node_version: string;
56
+ };
57
+ gateway: {
58
+ name: string;
59
+ version: string;
60
+ };
61
+ transport: {
62
+ default: "stdio" | "http";
63
+ http: {
64
+ enabled: boolean;
65
+ host: string;
66
+ port: number;
67
+ path: string;
68
+ public_url_configured: boolean;
69
+ public_url: string | null;
70
+ };
71
+ };
72
+ auth: {
73
+ required: boolean;
74
+ token_configured: boolean;
75
+ source: string;
76
+ };
77
+ providers: Record<"claude" | "codex" | "gemini" | "grok" | "mistral", {
78
+ cli_available: boolean;
79
+ version: string | null;
80
+ login_status: ProviderLoginStatus;
81
+ version_command: string[];
82
+ login_check: {
83
+ method: "cli" | "credential_store" | "not_checked";
84
+ command: string[] | null;
85
+ credential_store: "present" | "not_found" | "not_checked";
86
+ detail: string;
87
+ };
88
+ install_guidance: {
89
+ summary: string;
90
+ commands: string[];
91
+ documentation_url?: string;
92
+ };
93
+ login_guidance: {
94
+ summary: string;
95
+ commands: string[];
96
+ credential_handling: string;
97
+ };
98
+ }>;
99
+ endpoint_exposure: EndpointExposureReport;
100
+ client_config: {
101
+ claude_desktop_config_present: boolean;
102
+ codex_config_present: boolean;
103
+ gemini_settings_present: boolean;
104
+ gemini_config: GeminiConfigStatus;
105
+ vibe_session_logging: VibeSessionLoggingStatus;
106
+ };
107
+ next_actions: string[];
108
+ }
109
+ export declare function createDoctorReport(env?: NodeJS.ProcessEnv): DoctorReport;
110
+ export declare function printDoctorJson(): void;