llm-cli-gateway 1.4.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.
- package/CHANGELOG.md +67 -1
- package/README.md +111 -8
- package/dist/approval-manager.d.ts +1 -1
- package/dist/async-job-manager.d.ts +24 -2
- package/dist/async-job-manager.js +71 -7
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +46 -0
- package/dist/cli-updater.d.ts +19 -2
- package/dist/cli-updater.js +110 -7
- package/dist/codex-json-parser.d.ts +34 -0
- package/dist/codex-json-parser.js +105 -0
- package/dist/doctor.d.ts +110 -0
- package/dist/doctor.js +280 -0
- package/dist/endpoint-exposure.d.ts +22 -0
- package/dist/endpoint-exposure.js +231 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -2
- package/dist/flight-recorder.d.ts +3 -1
- package/dist/flight-recorder.js +31 -2
- package/dist/gateway-server.d.ts +2 -0
- package/dist/gateway-server.js +1 -0
- package/dist/gemini-json-parser.d.ts +21 -0
- package/dist/gemini-json-parser.js +47 -0
- package/dist/health.d.ts +7 -0
- package/dist/health.js +22 -0
- package/dist/http-transport.d.ts +22 -0
- package/dist/http-transport.js +164 -0
- package/dist/index.d.ts +183 -2
- package/dist/index.js +2629 -1411
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +14 -0
- package/dist/model-registry.js +40 -6
- package/dist/provider-login-guidance.d.ts +21 -0
- package/dist/provider-login-guidance.js +98 -0
- package/dist/provider-status.d.ts +41 -0
- package/dist/provider-status.js +203 -0
- package/dist/request-helpers.d.ts +484 -4
- package/dist/request-helpers.js +613 -0
- package/dist/resources.js +44 -0
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +1 -1
- package/dist/session-manager.js +2 -1
- package/dist/validation-normalizer.d.ts +23 -0
- package/dist/validation-normalizer.js +79 -0
- package/dist/validation-orchestrator.d.ts +47 -0
- package/dist/validation-orchestrator.js +145 -0
- package/dist/validation-prompts.d.ts +15 -0
- package/dist/validation-prompts.js +52 -0
- package/dist/validation-report.d.ts +57 -0
- package/dist/validation-report.js +129 -0
- package/dist/validation-tools.d.ts +7 -0
- package/dist/validation-tools.js +198 -0
- package/package.json +15 -5
- 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
|
+
}
|
package/dist/executor.d.ts
CHANGED
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" | "grok";
|
|
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;
|
package/dist/flight-recorder.js
CHANGED
|
@@ -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 @@
|
|
|
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;
|