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
package/dist/doctor.js ADDED
@@ -0,0 +1,280 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir, platform, arch, release } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadAuthConfig } from "./auth.js";
6
+ import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
7
+ import { listProviderRuntimeStatuses, } from "./provider-status.js";
8
+ import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
9
+ /**
10
+ * Probe ~/.vibe/config.toml to see whether session_logging is enabled. Vibe
11
+ * persists session logs (which sessionId/--continue depends on) only when
12
+ * `[session_logging] enabled = true` is set. The probe is read-only: the
13
+ * gateway never mutates this file.
14
+ */
15
+ export function checkVibeSessionLogging(home = homedir()) {
16
+ const configPath = join(home, ".vibe", "config.toml");
17
+ if (!existsSync(configPath)) {
18
+ return {
19
+ config_path: configPath,
20
+ config_present: false,
21
+ session_logging_enabled: false,
22
+ note: "~/.vibe/config.toml not found. Run `vibe config set session_logging.enabled true` or create the file with a [session_logging]\\nenabled = true block.",
23
+ };
24
+ }
25
+ try {
26
+ const text = readFileSync(configPath, "utf8");
27
+ const enabled = parseVibeSessionLoggingEnabled(text);
28
+ if (enabled) {
29
+ return {
30
+ config_path: configPath,
31
+ config_present: true,
32
+ session_logging_enabled: true,
33
+ note: "session_logging.enabled is true; --continue/--resume will work for mistral_request.",
34
+ };
35
+ }
36
+ return {
37
+ config_path: configPath,
38
+ config_present: true,
39
+ session_logging_enabled: false,
40
+ note: "[session_logging] enabled = false (or missing). Run `vibe config set session_logging.enabled true` or edit ~/.vibe/config.toml so mistral_request --resume / --continue can persist sessions.",
41
+ };
42
+ }
43
+ catch (err) {
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ return {
46
+ config_path: configPath,
47
+ config_present: true,
48
+ session_logging_enabled: false,
49
+ note: `Could not parse ~/.vibe/config.toml: ${message}. Verify the file is valid TOML.`,
50
+ };
51
+ }
52
+ }
53
+ /**
54
+ * Tiny TOML probe focused on `[session_logging] enabled = true`. Avoids pulling
55
+ * in the full `toml` parser when only one boolean is needed.
56
+ */
57
+ function parseVibeSessionLoggingEnabled(text) {
58
+ const lines = text.split(/\r?\n/);
59
+ let inSection = false;
60
+ for (let raw of lines) {
61
+ const line = raw.replace(/#.*$/, "").trim();
62
+ if (!line)
63
+ continue;
64
+ const sectionMatch = line.match(/^\[\s*([A-Za-z0-9_.-]+)\s*\]$/);
65
+ if (sectionMatch) {
66
+ inSection = sectionMatch[1] === "session_logging";
67
+ continue;
68
+ }
69
+ if (inSection) {
70
+ const kv = line.match(/^enabled\s*=\s*(.+)$/);
71
+ if (kv) {
72
+ const value = kv[1].trim().toLowerCase();
73
+ return value === "true";
74
+ }
75
+ }
76
+ else {
77
+ // Allow dotted form: session_logging.enabled = true
78
+ const dotted = line.match(/^session_logging\.enabled\s*=\s*(.+)$/);
79
+ if (dotted) {
80
+ const value = dotted[1].trim().toLowerCase();
81
+ return value === "true";
82
+ }
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ /**
88
+ * U27: Probe Gemini's project/user config locations.
89
+ *
90
+ * - `./GEMINI.md` (gateway cwd) and `~/.gemini/GEMINI.md` are documented
91
+ * "context" surfaces. Missing both means Gemini has no project-specific
92
+ * guidance.
93
+ * - `~/.gemini/settings.json` defines registered MCP servers (`mcpServers`
94
+ * block). The gateway tracks its own whitelist (`CLAUDE_MCP_SERVER_NAMES`)
95
+ * and surfaces a reconciliation warning for each whitelisted server not
96
+ * present in settings.json so callers don't ship requests for unregistered
97
+ * servers.
98
+ */
99
+ export function checkGeminiConfig(cwd = process.cwd(), home = homedir(), whitelist = CLAUDE_MCP_SERVER_NAMES) {
100
+ const projectGeminiMd = join(cwd, "GEMINI.md");
101
+ const userGeminiMd = join(home, ".gemini", "GEMINI.md");
102
+ const settingsPath = join(home, ".gemini", "settings.json");
103
+ const projectGeminiMdPresent = existsSync(projectGeminiMd);
104
+ const userGeminiMdPresent = existsSync(userGeminiMd);
105
+ const settingsPresent = existsSync(settingsPath);
106
+ let mcpServersRegistered = [];
107
+ if (settingsPresent) {
108
+ try {
109
+ const raw = readFileSync(settingsPath, "utf8");
110
+ const parsed = JSON.parse(raw);
111
+ if (parsed && typeof parsed.mcpServers === "object" && parsed.mcpServers) {
112
+ mcpServersRegistered = Object.keys(parsed.mcpServers);
113
+ }
114
+ }
115
+ catch {
116
+ // Best-effort: leave list empty so the next_action surfaces the gap.
117
+ }
118
+ }
119
+ const missingFromSettings = whitelist.filter(name => !mcpServersRegistered.includes(name));
120
+ const nextActions = [];
121
+ if (!projectGeminiMdPresent && !userGeminiMdPresent) {
122
+ nextActions.push(`Create ${projectGeminiMd} to give Gemini project-specific context (or ${userGeminiMd} for a user-wide default).`);
123
+ }
124
+ if (!settingsPresent) {
125
+ nextActions.push(`Create ${settingsPath} to register MCP servers (mcpServers block). Run \`gemini mcp add <name>\` for each gateway-whitelisted server.`);
126
+ }
127
+ for (const name of missingFromSettings) {
128
+ nextActions.push(`MCP server \`${name}\` is whitelisted by the gateway but not registered in ${settingsPath}. Run \`gemini mcp add ${name}\` to register it.`);
129
+ }
130
+ return {
131
+ project_gemini_md_present: projectGeminiMdPresent,
132
+ project_gemini_md_path: projectGeminiMd,
133
+ user_gemini_md_present: userGeminiMdPresent,
134
+ user_gemini_md_path: userGeminiMd,
135
+ settings_json_present: settingsPresent,
136
+ settings_json_path: settingsPath,
137
+ mcp_servers_registered: mcpServersRegistered,
138
+ mcp_reconciliation: {
139
+ whitelisted: [...whitelist],
140
+ missing_from_settings: missingFromSettings,
141
+ },
142
+ next_actions: nextActions,
143
+ };
144
+ }
145
+ function packageVersion() {
146
+ const here = dirname(fileURLToPath(import.meta.url));
147
+ const candidates = [join(here, "..", "package.json"), join(here, "..", "..", "package.json")];
148
+ for (const candidate of candidates) {
149
+ try {
150
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
151
+ return parsed.version || "unknown";
152
+ }
153
+ catch {
154
+ // Try next candidate.
155
+ }
156
+ }
157
+ return "unknown";
158
+ }
159
+ function clientConfigStatus(home = homedir()) {
160
+ return {
161
+ claude_desktop_config_present: existsSync(join(home, "Library/Application Support/Claude/claude_desktop_config.json")) ||
162
+ existsSync(join(home, ".config", "Claude", "claude_desktop_config.json")),
163
+ codex_config_present: existsSync(join(home, ".codex", "config.toml")),
164
+ gemini_settings_present: existsSync(join(home, ".gemini", "settings.json")) ||
165
+ existsSync(join(home, ".config", "gemini", "settings.json")),
166
+ gemini_config: checkGeminiConfig(process.cwd(), home),
167
+ vibe_session_logging: checkVibeSessionLogging(home),
168
+ };
169
+ }
170
+ function defaultTransport(env) {
171
+ if (env.LLM_GATEWAY_TRANSPORT === "http" || env.MCP_TRANSPORT === "http")
172
+ return "http";
173
+ return "stdio";
174
+ }
175
+ export function createDoctorReport(env = process.env) {
176
+ const auth = loadAuthConfig(env);
177
+ const transport = defaultTransport(env);
178
+ const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
179
+ const publicUrl = redactDiagnosticUrl(rawPublicUrl);
180
+ const endpointExposure = createEndpointExposureReport(env, publicUrl);
181
+ const providerStatuses = listProviderRuntimeStatuses();
182
+ const report = {
183
+ schema_version: "1.0",
184
+ ok: true,
185
+ generated_at: new Date().toISOString(),
186
+ system: {
187
+ os: platform(),
188
+ arch: arch(),
189
+ release: release(),
190
+ node_version: process.version,
191
+ },
192
+ gateway: {
193
+ name: "llm-cli-gateway",
194
+ version: packageVersion(),
195
+ },
196
+ transport: {
197
+ default: transport,
198
+ http: {
199
+ enabled: transport === "http",
200
+ host: env.LLM_GATEWAY_HTTP_HOST || "127.0.0.1",
201
+ port: Number(env.LLM_GATEWAY_HTTP_PORT || 3333),
202
+ path: env.LLM_GATEWAY_HTTP_PATH || "/mcp",
203
+ public_url_configured: Boolean(publicUrl),
204
+ public_url: publicUrl,
205
+ },
206
+ },
207
+ auth: {
208
+ required: auth.required,
209
+ token_configured: auth.tokenConfigured,
210
+ source: auth.source,
211
+ },
212
+ providers: {
213
+ claude: doctorProviderStatus(providerStatuses.claude),
214
+ codex: doctorProviderStatus(providerStatuses.codex),
215
+ gemini: doctorProviderStatus(providerStatuses.gemini),
216
+ grok: doctorProviderStatus(providerStatuses.grok),
217
+ mistral: doctorProviderStatus(providerStatuses.mistral),
218
+ },
219
+ endpoint_exposure: endpointExposure,
220
+ client_config: clientConfigStatus(),
221
+ next_actions: [],
222
+ };
223
+ if (transport === "http" && auth.required && !auth.tokenConfigured) {
224
+ report.ok = false;
225
+ report.next_actions.push("Set LLM_GATEWAY_AUTH_TOKEN before starting HTTP transport.");
226
+ }
227
+ report.next_actions.push(...endpointExposure.next_actions);
228
+ for (const [name, provider] of Object.entries(report.providers)) {
229
+ if (!provider.cli_available) {
230
+ report.next_actions.push(provider.install_guidance.summary);
231
+ }
232
+ else if (provider.login_status !== "authenticated") {
233
+ report.next_actions.push(`${name}: ${provider.login_guidance.summary}`);
234
+ }
235
+ }
236
+ // Mistral-specific: surface the session_logging toggle BEFORE a --continue/--resume
237
+ // request fails opaquely. The check is read-only; the gateway never mutates the file.
238
+ const vibeStatus = report.client_config.vibe_session_logging;
239
+ if (report.providers.mistral.cli_available && !vibeStatus.session_logging_enabled) {
240
+ report.next_actions.push(`mistral: ${vibeStatus.note}`);
241
+ }
242
+ // U27: surface Gemini config gaps (missing GEMINI.md, missing settings.json,
243
+ // MCP-server whitelist drift) only when Gemini CLI is actually installed.
244
+ if (report.providers.gemini.cli_available) {
245
+ for (const action of report.client_config.gemini_config.next_actions) {
246
+ report.next_actions.push(`gemini: ${action}`);
247
+ }
248
+ }
249
+ if (report.next_actions.length === 0) {
250
+ report.next_actions.push("Run a client setup guide and verify with doctor --json after each step.");
251
+ }
252
+ return report;
253
+ }
254
+ export function printDoctorJson() {
255
+ process.stdout.write(`${JSON.stringify(createDoctorReport(), null, 2)}\n`);
256
+ }
257
+ function doctorProviderStatus(provider) {
258
+ return {
259
+ cli_available: provider.installed,
260
+ version: provider.version,
261
+ login_status: provider.loginStatus,
262
+ version_command: provider.versionCommand,
263
+ login_check: {
264
+ method: provider.loginCheck.method,
265
+ command: provider.loginCheck.command,
266
+ credential_store: provider.loginCheck.credentialStore,
267
+ detail: provider.loginCheck.detail,
268
+ },
269
+ install_guidance: {
270
+ summary: provider.guidance.install.summary,
271
+ commands: provider.guidance.install.commands,
272
+ documentation_url: provider.guidance.install.documentationUrl,
273
+ },
274
+ login_guidance: {
275
+ summary: provider.guidance.login.summary,
276
+ commands: provider.guidance.login.commands,
277
+ credential_handling: provider.guidance.login.credentialHandling,
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,22 @@
1
+ export type EndpointExposureMode = "local_only" | "lan" | "tunnel" | "byo_reverse_proxy" | "misconfigured";
2
+ export type EndpointReachability = "not_checked" | "reachable" | "unreachable";
3
+ export interface EndpointExposureReport {
4
+ mode: EndpointExposureMode;
5
+ local_url: string;
6
+ public_url_configured: boolean;
7
+ public_url: string | null;
8
+ https_required_for_web: boolean;
9
+ https_configured: boolean;
10
+ web_clients_supported: boolean;
11
+ tunnel_provider: string | null;
12
+ reachable_from_web: EndpointReachability;
13
+ verification: {
14
+ method: "not_checked" | "http_head";
15
+ checked_url: string | null;
16
+ status_code: number | null;
17
+ error: string | null;
18
+ };
19
+ next_actions: string[];
20
+ }
21
+ export declare function createEndpointExposureReport(env: NodeJS.ProcessEnv, redactedPublicUrl: string | null): EndpointExposureReport;
22
+ export declare function redactDiagnosticUrl(rawUrl: string | null): string | null;
@@ -0,0 +1,231 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const TUNNEL_HOST_PATTERNS = [
3
+ /cloudflare/i,
4
+ /trycloudflare\.com$/i,
5
+ /ngrok(?:-free)?\.app$/i,
6
+ /ngrok\.io$/i,
7
+ /ts\.net$/i,
8
+ /tailscale/i,
9
+ ];
10
+ export function createEndpointExposureReport(env, redactedPublicUrl) {
11
+ const host = env.LLM_GATEWAY_HTTP_HOST || "127.0.0.1";
12
+ const port = Number(env.LLM_GATEWAY_HTTP_PORT || 3333);
13
+ const path = env.LLM_GATEWAY_HTTP_PATH || "/mcp";
14
+ const localHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
15
+ const localUrl = `http://${localHost}:${port}${path}`;
16
+ const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || "";
17
+ const tunnelProvider = env.LLM_GATEWAY_TUNNEL_PROVIDER || inferTunnelProvider(rawPublicUrl);
18
+ const httpsConfigured = rawPublicUrl.startsWith("https://");
19
+ const publicConfigured = Boolean(redactedPublicUrl);
20
+ const mode = classifyEndpointMode({ host, rawPublicUrl, tunnelProvider, httpsConfigured });
21
+ const verification = maybeVerifyEndpoint(env, rawPublicUrl, mode);
22
+ const webClientsSupported = publicConfigured &&
23
+ httpsConfigured &&
24
+ mode !== "local_only" &&
25
+ mode !== "lan" &&
26
+ verification.reachable_from_web === "reachable";
27
+ const nextActions = [];
28
+ if (!publicConfigured) {
29
+ nextActions.push("Keep using local stdio/CLI clients, or configure an HTTPS tunnel before web-client setup.");
30
+ }
31
+ else if (mode === "local_only" || mode === "lan") {
32
+ nextActions.push("Set LLM_GATEWAY_PUBLIC_URL to a public HTTPS tunnel or reverse-proxy URL, not localhost or a LAN address.");
33
+ }
34
+ else if (!httpsConfigured) {
35
+ nextActions.push("Use an HTTPS public URL before configuring ChatGPT, Claude web, or Grok web connectors.");
36
+ }
37
+ if (publicConfigured && mode !== "local_only" && mode !== "lan") {
38
+ if (verification.method === "not_checked") {
39
+ nextActions.push("Set LLM_GATEWAY_VERIFY_PUBLIC_URL=1 to have doctor check public endpoint reachability.");
40
+ }
41
+ else if (verification.reachable_from_web === "unreachable") {
42
+ nextActions.push("Fix tunnel/proxy routing until the public MCP URL is reachable, then rerun doctor --json.");
43
+ }
44
+ }
45
+ return {
46
+ mode,
47
+ local_url: localUrl,
48
+ public_url_configured: publicConfigured,
49
+ public_url: redactedPublicUrl,
50
+ https_required_for_web: true,
51
+ https_configured: httpsConfigured,
52
+ web_clients_supported: webClientsSupported,
53
+ tunnel_provider: tunnelProvider || null,
54
+ reachable_from_web: verification.reachable_from_web,
55
+ verification: {
56
+ method: verification.method,
57
+ checked_url: verification.checked_url ? redactDiagnosticUrl(verification.checked_url) : null,
58
+ status_code: verification.status_code,
59
+ error: verification.error,
60
+ },
61
+ next_actions: nextActions,
62
+ };
63
+ }
64
+ export function redactDiagnosticUrl(rawUrl) {
65
+ if (!rawUrl)
66
+ return null;
67
+ const sensitiveKeyPattern = /auth|bearer|token|secret|credential|password|authorization|signature|api[_-]?key|access[_-]?key|jwt|cookie|session/i;
68
+ const redactSensitivePairs = (value) => value.replace(new RegExp(`((${sensitiveKeyPattern.source})=)[^&\\s#]+`, "gi"), "$1<redacted>");
69
+ try {
70
+ const url = new URL(rawUrl);
71
+ if (url.username)
72
+ url.username = "<redacted>";
73
+ if (url.password)
74
+ url.password = "<redacted>";
75
+ for (const key of Array.from(url.searchParams.keys())) {
76
+ if (sensitiveKeyPattern.test(key)) {
77
+ url.searchParams.set(key, "<redacted>");
78
+ }
79
+ }
80
+ url.hash = redactSensitivePairs(url.hash);
81
+ return url.toString().replace(/%3Credacted%3E/gi, "<redacted>");
82
+ }
83
+ catch {
84
+ return redactSensitivePairs(rawUrl.replace(/(https?:\/\/)[^/@]+@/gi, "$1<redacted>@"));
85
+ }
86
+ }
87
+ function classifyEndpointMode(input) {
88
+ if (!input.rawPublicUrl) {
89
+ if (input.host === "0.0.0.0" || input.host === "::" || isLanHost(input.host))
90
+ return "lan";
91
+ return "local_only";
92
+ }
93
+ const publicHost = publicUrlHost(input.rawPublicUrl);
94
+ if (!publicHost)
95
+ return "misconfigured";
96
+ if (isLoopbackHost(publicHost))
97
+ return "local_only";
98
+ if (isLanHost(publicHost))
99
+ return "lan";
100
+ if (!input.httpsConfigured)
101
+ return "misconfigured";
102
+ if (input.tunnelProvider)
103
+ return "tunnel";
104
+ return "byo_reverse_proxy";
105
+ }
106
+ function inferTunnelProvider(rawUrl) {
107
+ if (!rawUrl)
108
+ return "";
109
+ try {
110
+ const host = new URL(rawUrl).hostname;
111
+ if (TUNNEL_HOST_PATTERNS.some(pattern => pattern.test(host)))
112
+ return host;
113
+ }
114
+ catch {
115
+ // Treat unparsable URLs as not classified.
116
+ }
117
+ return "";
118
+ }
119
+ function isLanHost(host) {
120
+ const normalized = normalizeHost(host);
121
+ const mappedIpv4 = ipv4FromMappedIpv6(normalized);
122
+ if (mappedIpv4)
123
+ return isLanHost(mappedIpv4);
124
+ return (/^10\./.test(normalized) ||
125
+ /^192\.168\./.test(normalized) ||
126
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) ||
127
+ /^f[cd][0-9a-f]{2}:/i.test(normalized) ||
128
+ /^fe80:/i.test(normalized));
129
+ }
130
+ function isLoopbackHost(host) {
131
+ const normalized = normalizeHost(host);
132
+ const mappedIpv4 = ipv4FromMappedIpv6(normalized);
133
+ if (mappedIpv4)
134
+ return isLoopbackHost(mappedIpv4);
135
+ return (normalized === "localhost" ||
136
+ normalized === "0.0.0.0" ||
137
+ normalized === "::" ||
138
+ normalized === "::1" ||
139
+ /^127\./.test(normalized));
140
+ }
141
+ function publicUrlHost(rawUrl) {
142
+ try {
143
+ return normalizeHost(new URL(rawUrl).hostname);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ function normalizeHost(host) {
150
+ return host.toLowerCase().replace(/^\[|\]$/g, "");
151
+ }
152
+ function ipv4FromMappedIpv6(host) {
153
+ const dotted = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
154
+ if (dotted)
155
+ return dotted[1];
156
+ const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
157
+ if (!hex)
158
+ return null;
159
+ const high = Number.parseInt(hex[1], 16);
160
+ const low = Number.parseInt(hex[2], 16);
161
+ if (!Number.isFinite(high) || !Number.isFinite(low))
162
+ return null;
163
+ return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
164
+ }
165
+ function maybeVerifyEndpoint(env, rawPublicUrl, mode) {
166
+ if (!rawPublicUrl || env.LLM_GATEWAY_VERIFY_PUBLIC_URL !== "1") {
167
+ return {
168
+ method: "not_checked",
169
+ checked_url: rawPublicUrl || null,
170
+ status_code: null,
171
+ error: null,
172
+ reachable_from_web: "not_checked",
173
+ };
174
+ }
175
+ if (mode === "local_only" || mode === "lan") {
176
+ return {
177
+ method: "not_checked",
178
+ checked_url: rawPublicUrl,
179
+ status_code: null,
180
+ error: "Public URL points to localhost or a private LAN address.",
181
+ reachable_from_web: "unreachable",
182
+ };
183
+ }
184
+ const result = verifyEndpointSync(rawPublicUrl, 3_000);
185
+ return {
186
+ method: "http_head",
187
+ checked_url: rawPublicUrl,
188
+ status_code: result.statusCode,
189
+ error: result.error,
190
+ reachable_from_web: result.ok ? "reachable" : "unreachable",
191
+ };
192
+ }
193
+ function verifyEndpointSync(url, timeoutMs) {
194
+ const script = `
195
+ const { request: http } = require("node:http");
196
+ const { request: https } = require("node:https");
197
+ const target = new URL(process.argv[1]);
198
+ const timeout = Number(process.argv[2]);
199
+ const requester = target.protocol === "https:" ? https : http;
200
+ const req = requester(target, { method: "HEAD", timeout, headers: { accept: "application/json, text/event-stream" } }, res => {
201
+ res.resume();
202
+ const statusCode = res.statusCode || 0;
203
+ const endpointFound = statusCode < 500 && statusCode !== 404;
204
+ console.log(JSON.stringify({ ok: endpointFound, statusCode: res.statusCode, error: null }));
205
+ });
206
+ req.on("timeout", () => {
207
+ req.destroy(new Error("Endpoint verification timed out."));
208
+ });
209
+ req.on("error", err => {
210
+ console.log(JSON.stringify({ ok: false, statusCode: null, error: err.message }));
211
+ });
212
+ req.end();
213
+ `;
214
+ const result = spawnSync(process.execPath, ["-e", script, url, String(timeoutMs)], {
215
+ encoding: "utf8",
216
+ timeout: timeoutMs + 1_000,
217
+ });
218
+ if (result.error) {
219
+ return {
220
+ ok: false,
221
+ statusCode: null,
222
+ error: result.error.message,
223
+ };
224
+ }
225
+ try {
226
+ return JSON.parse(result.stdout.trim());
227
+ }
228
+ catch {
229
+ return { ok: false, statusCode: null, error: "Endpoint verification failed." };
230
+ }
231
+ }
@@ -5,6 +5,8 @@ export interface ExecuteOptions {
5
5
  idleTimeout?: number;
6
6
  cwd?: string;
7
7
  logger?: Logger;
8
+ /** Extra environment variables to inject; merged after PATH. */
9
+ env?: NodeJS.ProcessEnv;
8
10
  }
9
11
  export interface ExecuteResult {
10
12
  stdout: string;
package/dist/executor.js CHANGED
@@ -125,7 +125,7 @@ export function killProcessGroup(proc, signal) {
125
125
  }
126
126
  }
127
127
  export async function executeCli(command, args, options = {}) {
128
- const { timeout, idleTimeout, cwd } = options;
128
+ const { timeout, idleTimeout, cwd, env: extraEnv } = options;
129
129
  const extendedPath = getExtendedPath();
130
130
  const circuitBreaker = getCircuitBreaker(command);
131
131
  const runOnce = () => new Promise((resolve, reject) => {
@@ -133,7 +133,7 @@ export async function executeCli(command, args, options = {}) {
133
133
  cwd,
134
134
  detached: true,
135
135
  stdio: ["ignore", "pipe", "pipe"],
136
- env: { ...process.env, PATH: extendedPath },
136
+ env: { ...process.env, PATH: extendedPath, ...(extraEnv ?? {}) },
137
137
  });
138
138
  if (proc.pid)
139
139
  registerProcessGroup(proc.pid);
@@ -1,6 +1,6 @@
1
1
  export interface FlightLogStart {
2
2
  correlationId: string;
3
- cli: "claude" | "codex" | "gemini";
3
+ cli: "claude" | "codex" | "gemini" | "grok" | "mistral";
4
4
  model: string;
5
5
  prompt: string;
6
6
  system?: string;
@@ -11,6 +11,8 @@ export interface FlightLogResult {
11
11
  response: string;
12
12
  inputTokens?: number;
13
13
  outputTokens?: number;
14
+ cacheReadTokens?: number;
15
+ cacheCreationTokens?: number;
14
16
  durationMs: number;
15
17
  retryCount: number;
16
18
  circuitBreakerState: string;
@@ -3,6 +3,21 @@ import os from "os";
3
3
  import path from "path";
4
4
  import { createRequire } from "module";
5
5
  const MAX_THINKING_BYTES = 1_000_000;
6
+ /**
7
+ * Idempotent migration: add `cache_read_tokens` / `cache_creation_tokens`
8
+ * columns to the `requests` table if a pre-U23 logs.db is opened. Existing
9
+ * rows keep NULL for the new columns; that is intentional.
10
+ */
11
+ function ensureRequestsCacheColumns(db) {
12
+ const rows = db.prepare("PRAGMA table_info(requests)").all?.() ?? [];
13
+ const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
14
+ if (!names.has("cache_read_tokens")) {
15
+ db.exec("ALTER TABLE requests ADD COLUMN cache_read_tokens INTEGER");
16
+ }
17
+ if (!names.has("cache_creation_tokens")) {
18
+ db.exec("ALTER TABLE requests ADD COLUMN cache_creation_tokens INTEGER");
19
+ }
20
+ }
6
21
  export function resolveFlightRecorderDbPath() {
7
22
  const configured = process.env.LLM_GATEWAY_LOGS_DB;
8
23
  if (configured !== undefined) {
@@ -80,7 +95,9 @@ export class FlightRecorder {
80
95
  duration_ms INTEGER,
81
96
  datetime_utc TEXT NOT NULL,
82
97
  input_tokens INTEGER,
83
- output_tokens INTEGER
98
+ output_tokens INTEGER,
99
+ cache_read_tokens INTEGER,
100
+ cache_creation_tokens INTEGER
84
101
  );
85
102
 
86
103
  CREATE TABLE IF NOT EXISTS gateway_metadata (
@@ -106,6 +123,14 @@ export class FlightRecorder {
106
123
  this.db
107
124
  .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(1, ?)")
108
125
  .run(new Date().toISOString());
126
+ // Migration v2: cache_read_tokens / cache_creation_tokens columns on
127
+ // pre-U23 logs.db files. ALTER TABLE ADD COLUMN is idempotent only via
128
+ // a prior PRAGMA table_info() check; better-sqlite3 has no native
129
+ // "IF NOT EXISTS" for ADD COLUMN.
130
+ ensureRequestsCacheColumns(this.db);
131
+ this.db
132
+ .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(2, ?)")
133
+ .run(new Date().toISOString());
109
134
  if (process.platform !== "win32") {
110
135
  try {
111
136
  chmodSync(dbPath, 0o600);
@@ -142,7 +167,9 @@ export class FlightRecorder {
142
167
  SET response = @response,
143
168
  duration_ms = @duration_ms,
144
169
  input_tokens = @input_tokens,
145
- output_tokens = @output_tokens
170
+ output_tokens = @output_tokens,
171
+ cache_read_tokens = @cache_read_tokens,
172
+ cache_creation_tokens = @cache_creation_tokens
146
173
  WHERE id = @id
147
174
  `);
148
175
  const updateMetadata = this.db.prepare(`
@@ -168,6 +195,8 @@ export class FlightRecorder {
168
195
  duration_ms: result.durationMs,
169
196
  input_tokens: result.inputTokens ?? null,
170
197
  output_tokens: result.outputTokens ?? null,
198
+ cache_read_tokens: result.cacheReadTokens ?? null,
199
+ cache_creation_tokens: result.cacheCreationTokens ?? null,
171
200
  });
172
201
  updateMetadata.run({
173
202
  id: correlationId,
@@ -0,0 +1,2 @@
1
+ export type { GatewayServerDeps } from "./index.js";
2
+ export { createGatewayServer } from "./index.js";
@@ -0,0 +1 @@
1
+ export { createGatewayServer } from "./index.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parser for Gemini CLI `-o json` output.
3
+ *
4
+ * Gemini emits a single JSON object with:
5
+ * - `response`: string final model output
6
+ * - `usageMetadata`: { promptTokenCount, candidatesTokenCount,
7
+ * cachedContentTokenCount?, totalTokenCount }
8
+ *
9
+ * Returns null when stdout is not parseable as JSON. Returns an object with
10
+ * only `response` when usageMetadata is missing.
11
+ */
12
+ export interface GeminiUsage {
13
+ input_tokens: number;
14
+ output_tokens: number;
15
+ cache_read_tokens?: number;
16
+ }
17
+ export interface GeminiJsonParseResult {
18
+ usage?: GeminiUsage;
19
+ response?: string;
20
+ }
21
+ export declare function parseGeminiJson(stdout: string): GeminiJsonParseResult | null;