llm-cli-gateway 2.13.1 → 2.14.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +53 -26
  3. package/dist/acp/client.d.ts +29 -1
  4. package/dist/acp/client.js +78 -4
  5. package/dist/acp/errors.d.ts +9 -1
  6. package/dist/acp/errors.js +19 -0
  7. package/dist/acp/event-normalizer.d.ts +12 -0
  8. package/dist/acp/event-normalizer.js +16 -0
  9. package/dist/acp/flight-redaction.d.ts +3 -0
  10. package/dist/acp/flight-redaction.js +3 -0
  11. package/dist/acp/permission-bridge.js +11 -5
  12. package/dist/acp/process-manager.d.ts +2 -1
  13. package/dist/acp/process-manager.js +43 -4
  14. package/dist/acp/provider-registry.js +19 -11
  15. package/dist/acp/runtime.d.ts +8 -0
  16. package/dist/acp/runtime.js +47 -4
  17. package/dist/acp/types.d.ts +3083 -55
  18. package/dist/acp/types.js +242 -5
  19. package/dist/async-job-manager.d.ts +2 -0
  20. package/dist/async-job-manager.js +11 -0
  21. package/dist/codex-json-parser.d.ts +1 -0
  22. package/dist/codex-json-parser.js +6 -0
  23. package/dist/config.d.ts +16 -0
  24. package/dist/config.js +105 -19
  25. package/dist/connector-setup.d.ts +39 -0
  26. package/dist/connector-setup.js +86 -0
  27. package/dist/doctor.d.ts +39 -1
  28. package/dist/doctor.js +182 -3
  29. package/dist/flight-recorder.d.ts +2 -0
  30. package/dist/flight-recorder.js +20 -0
  31. package/dist/gemini-json-parser.d.ts +2 -0
  32. package/dist/gemini-json-parser.js +45 -8
  33. package/dist/grok-json-parser.d.ts +14 -0
  34. package/dist/grok-json-parser.js +156 -0
  35. package/dist/http-transport.js +27 -3
  36. package/dist/index.d.ts +48 -2
  37. package/dist/index.js +633 -144
  38. package/dist/job-store.d.ts +45 -7
  39. package/dist/job-store.js +138 -17
  40. package/dist/model-registry.d.ts +1 -0
  41. package/dist/model-registry.js +46 -0
  42. package/dist/oauth.js +17 -16
  43. package/dist/postgres-job-store-worker.d.ts +1 -0
  44. package/dist/postgres-job-store-worker.js +327 -0
  45. package/dist/pricing.d.ts +3 -2
  46. package/dist/provider-acp-capabilities.d.ts +52 -0
  47. package/dist/provider-acp-capabilities.js +101 -0
  48. package/dist/provider-admin-tools.d.ts +100 -0
  49. package/dist/provider-admin-tools.js +572 -0
  50. package/dist/provider-capability-cache.d.ts +46 -0
  51. package/dist/provider-capability-cache.js +248 -0
  52. package/dist/provider-capability-discovery.d.ts +85 -0
  53. package/dist/provider-capability-discovery.js +461 -0
  54. package/dist/provider-capability-resolver.d.ts +29 -0
  55. package/dist/provider-capability-resolver.js +92 -0
  56. package/dist/provider-definition-assertions.d.ts +9 -0
  57. package/dist/provider-definition-assertions.js +147 -0
  58. package/dist/provider-definitions.d.ts +127 -0
  59. package/dist/provider-definitions.js +758 -0
  60. package/dist/provider-help-parser.d.ts +34 -0
  61. package/dist/provider-help-parser.js +203 -0
  62. package/dist/provider-model-discovery.d.ts +30 -0
  63. package/dist/provider-model-discovery.js +229 -0
  64. package/dist/provider-output-metadata.d.ts +7 -0
  65. package/dist/provider-output-metadata.js +68 -0
  66. package/dist/provider-schema-builder.d.ts +22 -0
  67. package/dist/provider-schema-builder.js +55 -0
  68. package/dist/provider-surface-generator.d.ts +98 -0
  69. package/dist/provider-surface-generator.js +140 -0
  70. package/dist/provider-tool-capabilities.d.ts +3 -2
  71. package/dist/provider-tool-capabilities.js +77 -62
  72. package/dist/remote-url.d.ts +28 -0
  73. package/dist/remote-url.js +61 -0
  74. package/dist/request-helpers.d.ts +37 -4
  75. package/dist/request-helpers.js +134 -0
  76. package/dist/resources.d.ts +6 -1
  77. package/dist/resources.js +67 -193
  78. package/dist/session-manager.d.ts +2 -0
  79. package/dist/session-manager.js +34 -8
  80. package/dist/stream-json-parser.d.ts +1 -0
  81. package/dist/stream-json-parser.js +3 -0
  82. package/dist/upstream-contracts.d.ts +17 -1
  83. package/dist/upstream-contracts.js +209 -36
  84. package/dist/workspace-registry.d.ts +9 -0
  85. package/dist/workspace-registry.js +24 -4
  86. package/npm-shrinkwrap.json +2 -2
  87. package/package.json +8 -3
  88. package/setup/status.schema.json +75 -0
@@ -0,0 +1,86 @@
1
+ import { gatherRemoteHttpOAuthReadiness } from "./doctor.js";
2
+ import { diagnoseRemoteOAuthConfig } from "./config.js";
3
+ import { joinBaseAndPath, resolveConfiguredRemoteOrigin } from "./remote-url.js";
4
+ export const CONNECTOR_SETUP_SECRET_WARNING = "Never paste gateway bearer tokens, OAuth client secrets, OAuth access tokens, consent/shared secrets, tunnel tokens, or provider credentials into a remote chat transcript. Only the fields in this packet are safe to paste into a connector UI.";
5
+ export function buildConnectorSetupPacket(input) {
6
+ const { readiness, oauth } = input;
7
+ const options = input.options ?? {};
8
+ const configuredClientId = oauth.clients[0]?.clientId ?? null;
9
+ const clientId = options.clientId ?? configuredClientId;
10
+ const clientSecretRequired = oauth.enabled && !oauth.allowPublicClients;
11
+ const clientSecretSource = clientSecretRequired
12
+ ? "Run `llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once` (or `oauth client rotate <client-id> --print-once`) and paste the printed secret once into the connector UI."
13
+ : null;
14
+ const packet = {
15
+ ok: true,
16
+ schema: "remote-connector-setup.v1",
17
+ ready: readiness.ready,
18
+ stage: readiness.stage,
19
+ auth_mode: readiness.auth_mode,
20
+ connector: {
21
+ mcp_url: readiness.mcp_url,
22
+ authorization_url: readiness.oauth.authorization_url,
23
+ token_url: readiness.oauth.token_url,
24
+ client_id: clientId,
25
+ client_secret_required: clientSecretRequired,
26
+ client_secret_source: clientSecretSource,
27
+ },
28
+ workspace: readiness.workspace,
29
+ next_actions: readiness.next_actions,
30
+ warnings: [CONNECTOR_SETUP_SECRET_WARNING],
31
+ };
32
+ if (options.includeLegacyNoAuth) {
33
+ packet.legacy_no_auth = {
34
+ deprecated: true,
35
+ connector_url: input.legacyNoAuthUrl ?? null,
36
+ note: "Deprecated no-auth connector URL. It bypasses authentication; prefer OAuth. Do not share this URL.",
37
+ };
38
+ }
39
+ return packet;
40
+ }
41
+ export function legacyNoAuthConnectorUrl(env = process.env) {
42
+ const noAuthPath = (env.LLM_GATEWAY_NO_AUTH_PATHS || "")
43
+ .split(/[,;\s]+/)
44
+ .map(value => value.trim())
45
+ .find(value => value.startsWith("/") && !value.includes("?") && !value.includes("#"));
46
+ if (!noAuthPath)
47
+ return null;
48
+ const baseOrigin = resolveConfiguredRemoteOrigin({
49
+ publicUrl: env.LLM_GATEWAY_PUBLIC_URL ?? null,
50
+ });
51
+ if (!baseOrigin)
52
+ return null;
53
+ return joinBaseAndPath(baseOrigin, noAuthPath);
54
+ }
55
+ export function gatherConnectorSetupPacket(options = {}, env = process.env) {
56
+ const readiness = gatherRemoteHttpOAuthReadiness(env);
57
+ const oauth = diagnoseRemoteOAuthConfig(undefined, env).config;
58
+ const legacyNoAuthUrl = options.includeLegacyNoAuth ? legacyNoAuthConnectorUrl(env) : null;
59
+ return buildConnectorSetupPacket({ readiness, oauth, options, legacyNoAuthUrl });
60
+ }
61
+ export function renderConnectorSetupSummary(packet) {
62
+ const lines = [];
63
+ lines.push(`Remote connector readiness: ${packet.stage}${packet.ready ? " (ready)" : ""}`);
64
+ lines.push(`Authentication mode: ${packet.auth_mode}`);
65
+ if (packet.connector.mcp_url)
66
+ lines.push(`MCP URL: ${packet.connector.mcp_url}`);
67
+ if (packet.connector.authorization_url)
68
+ lines.push(`Authorization URL: ${packet.connector.authorization_url}`);
69
+ if (packet.connector.token_url)
70
+ lines.push(`Token URL: ${packet.connector.token_url}`);
71
+ if (packet.connector.client_id)
72
+ lines.push(`Client ID: ${packet.connector.client_id}`);
73
+ if (packet.connector.client_secret_required && packet.connector.client_secret_source) {
74
+ lines.push(`Client secret: ${packet.connector.client_secret_source}`);
75
+ }
76
+ lines.push(`Workspace: ${packet.workspace.ready
77
+ ? `ready (default: ${packet.workspace.default ?? "none, pass a registered alias"})`
78
+ : "not ready"}`);
79
+ if (packet.next_actions.length > 0) {
80
+ lines.push("Next actions:");
81
+ for (const action of packet.next_actions)
82
+ lines.push(` - ${action}`);
83
+ }
84
+ lines.push(packet.warnings[0] ?? CONNECTOR_SETUP_SECRET_WARNING);
85
+ return lines.join("\n");
86
+ }
package/dist/doctor.d.ts CHANGED
@@ -2,7 +2,9 @@ import { type EndpointExposureReport } from "./endpoint-exposure.js";
2
2
  import { type ProviderLoginStatus } from "./provider-status.js";
3
3
  import { type ApiProviderLoginGuidance } from "./provider-login-guidance.js";
4
4
  import type { FlightRecorderQuery } from "./flight-recorder.js";
5
- import { type CacheAwarenessConfig, type ProvidersConfig } from "./config.js";
5
+ import { type CacheAwarenessConfig, type ProvidersConfig, type RemoteOAuthConfigDiagnostics } from "./config.js";
6
+ import type { AuthConfig } from "./auth.js";
7
+ import { type RemoteSafeWorkspaceSummary } from "./workspace-registry.js";
6
8
  import { type ProviderCapabilityId, type ProviderKind } from "./provider-tool-capabilities.js";
7
9
  import { type CliType } from "./session-manager.js";
8
10
  export interface CacheAwarenessReport {
@@ -146,6 +148,7 @@ export interface DoctorReport {
146
148
  };
147
149
  }>;
148
150
  endpoint_exposure: EndpointExposureReport;
151
+ remote_http_oauth: RemoteHttpOAuthReadiness;
149
152
  client_config: {
150
153
  claude_desktop_config_present: boolean;
151
154
  codex_config_present: boolean;
@@ -167,6 +170,41 @@ export interface DoctorReport {
167
170
  };
168
171
  next_actions: string[];
169
172
  }
173
+ export type RemoteHttpOAuthStage = "not_started" | "missing_public_url" | "endpoint_unreachable" | "oauth_disabled" | "unsafe_oauth_config" | "missing_oauth_client" | "missing_workspace" | "ready";
174
+ export declare const REMOTE_HTTP_OAUTH_STAGES: readonly RemoteHttpOAuthStage[];
175
+ export interface RemoteHttpOAuthReadiness {
176
+ ready: boolean;
177
+ stage: RemoteHttpOAuthStage;
178
+ public_url: string | null;
179
+ mcp_url: string | null;
180
+ auth_mode: "oauth" | "bearer_token" | "none";
181
+ oauth: {
182
+ enabled: boolean;
183
+ issuer: string | null;
184
+ authorization_url: string | null;
185
+ token_url: string | null;
186
+ registration_policy: string;
187
+ clients_configured: number;
188
+ consent_required: boolean;
189
+ };
190
+ workspace: {
191
+ ready: boolean;
192
+ default: string | null;
193
+ aliases: string[];
194
+ };
195
+ next_actions: string[];
196
+ }
197
+ export interface RemoteHttpOAuthReadinessInput {
198
+ oauthDiag: Pick<RemoteOAuthConfigDiagnostics, "status" | "config" | "issues">;
199
+ workspace: RemoteSafeWorkspaceSummary;
200
+ auth: AuthConfig;
201
+ transport: "stdio" | "http";
202
+ publicUrl: string | null;
203
+ endpoint: EndpointExposureReport;
204
+ mcpPath: string;
205
+ }
206
+ export declare function buildRemoteHttpOAuthReadiness(input: RemoteHttpOAuthReadinessInput): RemoteHttpOAuthReadiness;
207
+ export declare function gatherRemoteHttpOAuthReadiness(env?: NodeJS.ProcessEnv): RemoteHttpOAuthReadiness;
170
208
  export interface CreateDoctorReportOptions {
171
209
  env?: NodeJS.ProcessEnv;
172
210
  flightRecorder?: FlightRecorderQuery;
package/dist/doctor.js CHANGED
@@ -7,8 +7,9 @@ import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-e
7
7
  import { getApiProviderStatus, listProviderRuntimeStatuses, } from "./provider-status.js";
8
8
  import { getApiProviderLoginGuidance, } from "./provider-login-guidance.js";
9
9
  import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
10
- import { enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, loadRemoteOAuthConfig, } from "./config.js";
11
- import { loadWorkspaceRegistry } from "./workspace-registry.js";
10
+ import { diagnoseRemoteOAuthConfig, enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, } from "./config.js";
11
+ import { loadWorkspaceRegistry, remoteSafeWorkspaceSummary, } from "./workspace-registry.js";
12
+ import { buildRemoteConnectorUrls, resolveConfiguredRemoteOrigin } from "./remote-url.js";
12
13
  import { computeGlobalCacheStats } from "./cache-stats.js";
13
14
  import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
14
15
  import { buildUpstreamContractReport } from "./upstream-contracts.js";
@@ -136,6 +137,174 @@ export function checkGeminiConfig(cwd = process.cwd(), home = homedir(), whiteli
136
137
  next_actions: nextActions,
137
138
  };
138
139
  }
140
+ export const REMOTE_HTTP_OAUTH_STAGES = [
141
+ "not_started",
142
+ "missing_public_url",
143
+ "endpoint_unreachable",
144
+ "oauth_disabled",
145
+ "unsafe_oauth_config",
146
+ "missing_oauth_client",
147
+ "missing_workspace",
148
+ "ready",
149
+ ];
150
+ export function buildRemoteHttpOAuthReadiness(input) {
151
+ const { config: oauth, status, issues } = input.oauthDiag;
152
+ const e = input.endpoint;
153
+ const baseOrigin = resolveConfiguredRemoteOrigin({
154
+ issuer: oauth.issuer === "auto" ? null : redactDiagnosticUrl(oauth.issuer),
155
+ publicUrl: input.publicUrl,
156
+ });
157
+ const urls = buildRemoteConnectorUrls({
158
+ baseOrigin,
159
+ mcpPath: input.mcpPath,
160
+ oauthEnabled: oauth.enabled,
161
+ });
162
+ const hasValidPublicHttpsUrl = e.public_url_configured &&
163
+ e.https_configured &&
164
+ (e.mode === "tunnel" || e.mode === "byo_reverse_proxy");
165
+ const oauthNotEnabled = status === "absent" || status === "disabled";
166
+ const oauthUnsafeForRemote = status === "malformed" ||
167
+ (oauth.enabled && (oauth.allowPublicClients || oauth.registrationPolicy === "open_dev"));
168
+ let stage;
169
+ if (!e.public_url_configured && input.transport !== "http" && status === "absent") {
170
+ stage = "not_started";
171
+ }
172
+ else if (!hasValidPublicHttpsUrl) {
173
+ stage = "missing_public_url";
174
+ }
175
+ else if (e.reachable_from_web === "unreachable") {
176
+ stage = "endpoint_unreachable";
177
+ }
178
+ else if (oauthNotEnabled) {
179
+ stage = "oauth_disabled";
180
+ }
181
+ else if (oauthUnsafeForRemote) {
182
+ stage = "unsafe_oauth_config";
183
+ }
184
+ else if (oauth.registrationPolicy === "static_clients" && oauth.clients.length === 0) {
185
+ stage = "missing_oauth_client";
186
+ }
187
+ else if (!input.workspace.ready) {
188
+ stage = "missing_workspace";
189
+ }
190
+ else {
191
+ stage = "ready";
192
+ }
193
+ const authMode = oauth.enabled
194
+ ? "oauth"
195
+ : input.auth.required && input.auth.tokenConfigured
196
+ ? "bearer_token"
197
+ : "none";
198
+ return {
199
+ ready: stage === "ready",
200
+ stage,
201
+ public_url: input.publicUrl,
202
+ mcp_url: urls.mcpUrl,
203
+ auth_mode: authMode,
204
+ oauth: {
205
+ enabled: oauth.enabled,
206
+ issuer: urls.issuer,
207
+ authorization_url: urls.authorizationUrl,
208
+ token_url: urls.tokenUrl,
209
+ registration_policy: oauth.registrationPolicy,
210
+ clients_configured: oauth.clients.length,
211
+ consent_required: oauth.requireConsent,
212
+ },
213
+ workspace: {
214
+ ready: input.workspace.ready,
215
+ default: input.workspace.default,
216
+ aliases: input.workspace.aliases,
217
+ },
218
+ next_actions: remoteReadinessNextActions(stage, {
219
+ oauth,
220
+ status,
221
+ issues,
222
+ workspace: input.workspace,
223
+ endpointReachability: e.reachable_from_web,
224
+ }),
225
+ };
226
+ }
227
+ function remoteReadinessNextActions(stage, ctx) {
228
+ switch (stage) {
229
+ case "not_started":
230
+ return [
231
+ "Remote HTTP + OAuth setup has not started. Start an HTTPS tunnel or reverse proxy and set LLM_GATEWAY_PUBLIC_URL to the public https URL.",
232
+ "Then register an OAuth client: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
233
+ ];
234
+ case "missing_public_url":
235
+ return [
236
+ "Set LLM_GATEWAY_PUBLIC_URL to a public https URL (tunnel or reverse proxy), not localhost or a LAN address.",
237
+ "Re-run: llm-cli-gateway doctor --json",
238
+ ];
239
+ case "endpoint_unreachable":
240
+ return [
241
+ "The public MCP URL is configured but not reachable from the web. Fix tunnel/reverse-proxy routing, then re-run: llm-cli-gateway doctor --json",
242
+ ];
243
+ case "oauth_disabled":
244
+ return [
245
+ "OAuth is the recommended remote connector authentication mode. Enable it by registering a client: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
246
+ ];
247
+ case "unsafe_oauth_config": {
248
+ const reasons = [];
249
+ if (ctx.status === "malformed") {
250
+ reasons.push(...(ctx.issues.length
251
+ ? ctx.issues
252
+ : ["The [http.oauth] config is invalid; fix it and re-run doctor --json."]));
253
+ }
254
+ else {
255
+ if (ctx.oauth.allowPublicClients) {
256
+ reasons.push("OAuth allow_public_clients is enabled on a public endpoint. Use confidential clients: registration_policy=static_clients with a client secret.");
257
+ }
258
+ if (ctx.oauth.registrationPolicy === "open_dev") {
259
+ reasons.push("OAuth registration_policy=open_dev is unsafe on a public endpoint. Switch to static_clients with confidential client secrets.");
260
+ }
261
+ }
262
+ reasons.push("Re-run: llm-cli-gateway doctor --json");
263
+ return reasons;
264
+ }
265
+ case "missing_oauth_client":
266
+ return [
267
+ "OAuth is enabled but no client is registered. Add one: llm-cli-gateway oauth client add <client-id> --redirect-uri <connector-callback> --print-once",
268
+ ];
269
+ case "missing_workspace":
270
+ return [
271
+ "No workspace is available for remote provider execution. Register a repo and set it as the default: add a [[workspaces.repos]] entry with [workspaces].default in ~/.llm-cli-gateway/config.toml, or run `llm-cli-gateway workspace add <alias> <absolute-repo-path> --default` when an allowed root is configured.",
272
+ "Remote clients then select the workspace by alias; local absolute paths are never accepted from remote clients.",
273
+ ];
274
+ case "ready": {
275
+ const readyActions = [
276
+ ctx.workspace.default
277
+ ? `Remote connector is ready. Remote provider calls use the default workspace "${ctx.workspace.default}" unless a registered alias is supplied.`
278
+ : "Remote connector is ready. Remote provider calls must supply a registered workspace alias; set a default with `llm-cli-gateway workspace add <alias> <path> --default` to make one implicit.",
279
+ "Paste only copy-safe connector fields (MCP URL, authorization URL, token URL, client id) into the remote connector UI.",
280
+ ];
281
+ if (ctx.endpointReachability === "not_checked") {
282
+ readyActions.push("Config is complete but public reachability was not verified. Set LLM_GATEWAY_VERIFY_PUBLIC_URL=1 and re-run doctor --json to confirm the public URL is reachable from the web.");
283
+ }
284
+ return readyActions;
285
+ }
286
+ default:
287
+ return [];
288
+ }
289
+ }
290
+ export function gatherRemoteHttpOAuthReadiness(env = process.env) {
291
+ const oauthDiag = diagnoseRemoteOAuthConfig(undefined, env);
292
+ const workspace = remoteSafeWorkspaceSummary(loadWorkspaceRegistry());
293
+ const auth = loadAuthConfig(env);
294
+ const transport = defaultTransport(env);
295
+ const publicUrl = redactDiagnosticUrl(env.LLM_GATEWAY_PUBLIC_URL || null);
296
+ const endpoint = createEndpointExposureReport(env, publicUrl);
297
+ const mcpPath = env.LLM_GATEWAY_HTTP_PATH || "/mcp";
298
+ return buildRemoteHttpOAuthReadiness({
299
+ oauthDiag,
300
+ workspace,
301
+ auth,
302
+ transport,
303
+ publicUrl,
304
+ endpoint,
305
+ mcpPath,
306
+ });
307
+ }
139
308
  function packageVersion() {
140
309
  const here = dirname(fileURLToPath(import.meta.url));
141
310
  const candidates = [join(here, "..", "package.json"), join(here, "..", "..", "package.json")];
@@ -306,7 +475,8 @@ export function createDoctorReport(envOrOptions = process.env) {
306
475
  : { env: envOrOptions };
307
476
  const env = opts.env ?? process.env;
308
477
  const auth = loadAuthConfig(env);
309
- const oauth = loadRemoteOAuthConfig(undefined, env);
478
+ const oauthDiag = diagnoseRemoteOAuthConfig(undefined, env);
479
+ const oauth = oauthDiag.config;
310
480
  const workspaceRegistry = loadWorkspaceRegistry();
311
481
  const transport = defaultTransport(env);
312
482
  const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
@@ -382,6 +552,15 @@ export function createDoctorReport(envOrOptions = process.env) {
382
552
  },
383
553
  providers: Object.fromEntries(CLI_TYPES.map(provider => [provider, doctorProviderStatus(providerStatuses[provider])])),
384
554
  endpoint_exposure: endpointExposure,
555
+ remote_http_oauth: buildRemoteHttpOAuthReadiness({
556
+ oauthDiag,
557
+ workspace: remoteSafeWorkspaceSummary(workspaceRegistry),
558
+ auth,
559
+ transport,
560
+ publicUrl,
561
+ endpoint: endpointExposure,
562
+ mcpPath: env.LLM_GATEWAY_HTTP_PATH || "/mcp",
563
+ }),
385
564
  client_config: clientConfigStatus(),
386
565
  cache_awareness: buildCacheAwarenessReport(opts),
387
566
  provider_capabilities: buildProviderCapabilitySummary(providerStatuses),
@@ -30,6 +30,8 @@ export interface FlightLogResult {
30
30
  httpStatus?: number;
31
31
  errorMessage?: string;
32
32
  status: "completed" | "failed";
33
+ providerSessionId?: string;
34
+ stopReason?: string;
33
35
  }
34
36
  interface LoggerLike {
35
37
  info: (message: string, ...args: any[]) => void;
@@ -54,6 +54,16 @@ function ensureCacheControlTtlSecondsColumn(db) {
54
54
  db.exec("ALTER TABLE requests ADD COLUMN cache_control_ttl_seconds INTEGER");
55
55
  }
56
56
  }
57
+ function ensureMetadataProviderSessionColumns(db) {
58
+ const rows = db.prepare("PRAGMA table_info(gateway_metadata)").all();
59
+ const names = new Set(rows.map((row) => (row && typeof row.name === "string" ? row.name : "")));
60
+ if (!names.has("provider_session_id")) {
61
+ db.exec("ALTER TABLE gateway_metadata ADD COLUMN provider_session_id TEXT");
62
+ }
63
+ if (!names.has("stop_reason")) {
64
+ db.exec("ALTER TABLE gateway_metadata ADD COLUMN stop_reason TEXT");
65
+ }
66
+ }
57
67
  export function resolveFlightRecorderDbPath() {
58
68
  const configured = process.env.LLM_GATEWAY_LOGS_DB;
59
69
  if (configured !== undefined) {
@@ -147,6 +157,8 @@ export class FlightRecorder {
147
157
  http_status INTEGER,
148
158
  error_message TEXT,
149
159
  async_job_id TEXT,
160
+ provider_session_id TEXT,
161
+ stop_reason TEXT,
150
162
  status TEXT NOT NULL DEFAULT 'started'
151
163
  );
152
164
 
@@ -180,6 +192,10 @@ export class FlightRecorder {
180
192
  this.db
181
193
  .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(6, ?)")
182
194
  .run(new Date().toISOString());
195
+ ensureMetadataProviderSessionColumns(this.db);
196
+ this.db
197
+ .prepare("INSERT OR IGNORE INTO _migrations(version, applied_at) VALUES(7, ?)")
198
+ .run(new Date().toISOString());
183
199
  if (process.platform !== "win32") {
184
200
  try {
185
201
  chmodSync(dbPath, 0o600);
@@ -240,6 +256,8 @@ export class FlightRecorder {
240
256
  exit_code = @exit_code,
241
257
  http_status = @http_status,
242
258
  error_message = @error_message,
259
+ provider_session_id = @provider_session_id,
260
+ stop_reason = @stop_reason,
243
261
  status = @status
244
262
  WHERE request_id = @id AND status = 'started'
245
263
  `);
@@ -267,6 +285,8 @@ export class FlightRecorder {
267
285
  exit_code: result.exitCode,
268
286
  http_status: result.httpStatus ?? null,
269
287
  error_message: result.errorMessage ?? null,
288
+ provider_session_id: result.providerSessionId ?? null,
289
+ stop_reason: result.stopReason ?? null,
270
290
  status: result.status,
271
291
  });
272
292
  });
@@ -6,6 +6,8 @@ export interface GeminiUsage {
6
6
  export interface GeminiJsonParseResult {
7
7
  usage?: GeminiUsage;
8
8
  response?: string;
9
+ sessionId?: string;
10
+ stopReason?: string;
9
11
  }
10
12
  export declare function parseGeminiJson(stdout: string): GeminiJsonParseResult | null;
11
13
  export declare function parseGeminiStreamJson(stdout: string): GeminiJsonParseResult | null;
@@ -1,22 +1,47 @@
1
- export function parseGeminiJson(stdout) {
2
- const trimmed = stdout.trim();
3
- if (!trimmed) {
4
- return null;
5
- }
6
- let parsed;
1
+ function parseTolerantJsonObject(text) {
7
2
  try {
8
- parsed = JSON.parse(trimmed);
3
+ const v = JSON.parse(text);
4
+ if (v && typeof v === "object")
5
+ return v;
9
6
  }
10
7
  catch {
8
+ }
9
+ const start = text.indexOf("{");
10
+ const end = text.lastIndexOf("}");
11
+ if (start >= 0 && end > start) {
12
+ try {
13
+ const v = JSON.parse(text.slice(start, end + 1));
14
+ if (v && typeof v === "object")
15
+ return v;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+ export function parseGeminiJson(stdout) {
24
+ const trimmed = stdout.trim();
25
+ if (!trimmed) {
11
26
  return null;
12
27
  }
13
- if (!parsed || typeof parsed !== "object") {
28
+ const parsed = parseTolerantJsonObject(trimmed);
29
+ if (!parsed) {
14
30
  return null;
15
31
  }
16
32
  const result = {};
17
33
  if (typeof parsed.response === "string") {
18
34
  result.response = parsed.response;
19
35
  }
36
+ if (typeof parsed.session_id === "string") {
37
+ result.sessionId = parsed.session_id;
38
+ }
39
+ else if (typeof parsed.sessionId === "string") {
40
+ result.sessionId = parsed.sessionId;
41
+ }
42
+ if (typeof parsed.status === "string") {
43
+ result.stopReason = parsed.status;
44
+ }
20
45
  const meta = parsed.usageMetadata;
21
46
  if (meta && typeof meta === "object") {
22
47
  const input = typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : undefined;
@@ -56,12 +81,24 @@ export function parseGeminiStreamJson(stdout) {
56
81
  if (!event || typeof event !== "object")
57
82
  continue;
58
83
  sawAnyLine = true;
84
+ if (event.type === "init" && typeof event.session_id === "string") {
85
+ result.sessionId = event.session_id;
86
+ continue;
87
+ }
59
88
  if (event.type === "message" &&
60
89
  event.role === "assistant" &&
61
90
  typeof event.content === "string") {
91
+ if (event.delta === false || event.is_delta === false) {
92
+ assistantChunks.length = 0;
93
+ }
62
94
  assistantChunks.push(event.content);
63
95
  continue;
64
96
  }
97
+ if (event.type === "result") {
98
+ if (typeof event.status === "string") {
99
+ result.stopReason = event.status;
100
+ }
101
+ }
65
102
  if (event.type === "result" && event.stats && typeof event.stats === "object") {
66
103
  const stats = event.stats;
67
104
  const input = typeof stats.input_tokens === "number" ? stats.input_tokens : undefined;
@@ -0,0 +1,14 @@
1
+ export interface GrokJsonParseResult {
2
+ text?: string;
3
+ stopReason?: string;
4
+ sessionId?: string;
5
+ requestId?: string;
6
+ thought?: string;
7
+ error?: string;
8
+ sawEvent?: boolean;
9
+ usageAbsent: true;
10
+ }
11
+ export declare function parseGrokJson(stdout: string): GrokJsonParseResult | null;
12
+ export declare function parseGrokStreamingJson(stdout: string): GrokJsonParseResult | null;
13
+ export declare function parseGrokOutput(outputFormat: string | undefined, stdout: string): GrokJsonParseResult | null;
14
+ export declare function grokDisplayText(outputFormat: string | undefined, stdout: string): string;
@@ -0,0 +1,156 @@
1
+ function str(value) {
2
+ return typeof value === "string" ? value : undefined;
3
+ }
4
+ function deltaText(event) {
5
+ return str(event.data) ?? str(event.text) ?? str(event.content) ?? str(event.delta);
6
+ }
7
+ function deltaThought(event) {
8
+ return (str(event.data) ??
9
+ str(event.thought) ??
10
+ str(event.text) ??
11
+ str(event.content) ??
12
+ str(event.delta));
13
+ }
14
+ function parseTolerantGrokObject(text) {
15
+ try {
16
+ const v = JSON.parse(text);
17
+ if (v && typeof v === "object" && !Array.isArray(v))
18
+ return v;
19
+ }
20
+ catch {
21
+ }
22
+ const start = text.indexOf("{");
23
+ const end = text.lastIndexOf("}");
24
+ if (start >= 0 && end > start) {
25
+ try {
26
+ const v = JSON.parse(text.slice(start, end + 1));
27
+ if (v && typeof v === "object" && !Array.isArray(v))
28
+ return v;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ export function parseGrokJson(stdout) {
37
+ const trimmed = stdout.trim();
38
+ if (!trimmed) {
39
+ return null;
40
+ }
41
+ const parsed = parseTolerantGrokObject(trimmed);
42
+ if (!parsed) {
43
+ return null;
44
+ }
45
+ const result = { sawEvent: true, usageAbsent: true };
46
+ const text = str(parsed.text);
47
+ if (text !== undefined)
48
+ result.text = text;
49
+ const stopReason = str(parsed.stopReason);
50
+ if (stopReason !== undefined)
51
+ result.stopReason = stopReason;
52
+ const sessionId = str(parsed.sessionId);
53
+ if (sessionId !== undefined)
54
+ result.sessionId = sessionId;
55
+ const requestId = str(parsed.requestId);
56
+ if (requestId !== undefined)
57
+ result.requestId = requestId;
58
+ const thought = str(parsed.thought);
59
+ if (thought !== undefined)
60
+ result.thought = thought;
61
+ const error = str(parsed.error);
62
+ if (error !== undefined)
63
+ result.error = error;
64
+ return result;
65
+ }
66
+ export function parseGrokStreamingJson(stdout) {
67
+ if (!stdout) {
68
+ return null;
69
+ }
70
+ const lines = stdout.split(/\r?\n/);
71
+ const result = { usageAbsent: true };
72
+ const textChunks = [];
73
+ const thoughtChunks = [];
74
+ let sawAnyLine = false;
75
+ for (const line of lines) {
76
+ const trimmed = line.trim();
77
+ if (!trimmed)
78
+ continue;
79
+ let event;
80
+ try {
81
+ event = JSON.parse(trimmed);
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ if (!event || typeof event !== "object" || Array.isArray(event))
87
+ continue;
88
+ sawAnyLine = true;
89
+ switch (str(event.type)) {
90
+ case "text": {
91
+ const t = deltaText(event);
92
+ if (t !== undefined)
93
+ textChunks.push(t);
94
+ break;
95
+ }
96
+ case "thought": {
97
+ const t = deltaThought(event);
98
+ if (t !== undefined)
99
+ thoughtChunks.push(t);
100
+ break;
101
+ }
102
+ case "end": {
103
+ const stopReason = str(event.stopReason);
104
+ if (stopReason !== undefined)
105
+ result.stopReason = stopReason;
106
+ const sessionId = str(event.sessionId);
107
+ if (sessionId !== undefined)
108
+ result.sessionId = sessionId;
109
+ const requestId = str(event.requestId);
110
+ if (requestId !== undefined)
111
+ result.requestId = requestId;
112
+ break;
113
+ }
114
+ case "error": {
115
+ const error = str(event.error) ?? str(event.message);
116
+ if (error !== undefined)
117
+ result.error = error;
118
+ break;
119
+ }
120
+ default:
121
+ break;
122
+ }
123
+ }
124
+ if (!sawAnyLine) {
125
+ return null;
126
+ }
127
+ result.sawEvent = true;
128
+ if (textChunks.length > 0)
129
+ result.text = textChunks.join("");
130
+ if (thoughtChunks.length > 0)
131
+ result.thought = thoughtChunks.join("");
132
+ return result;
133
+ }
134
+ export function parseGrokOutput(outputFormat, stdout) {
135
+ if (outputFormat === "json")
136
+ return parseGrokJson(stdout);
137
+ if (outputFormat === "streaming-json")
138
+ return parseGrokStreamingJson(stdout);
139
+ return null;
140
+ }
141
+ export function grokDisplayText(outputFormat, stdout) {
142
+ if (outputFormat === "json") {
143
+ return stdout;
144
+ }
145
+ if (outputFormat === "streaming-json") {
146
+ const parsed = parseGrokStreamingJson(stdout);
147
+ if (!parsed)
148
+ return stdout;
149
+ if (parsed.text !== undefined)
150
+ return parsed.text;
151
+ if (parsed.error !== undefined)
152
+ return parsed.error;
153
+ return "";
154
+ }
155
+ return stdout;
156
+ }