llm-cli-gateway 2.4.0 → 2.6.0

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/dist/doctor.d.ts CHANGED
@@ -69,6 +69,21 @@ export interface DoctorReport {
69
69
  required: boolean;
70
70
  token_configured: boolean;
71
71
  source: string;
72
+ oauth: {
73
+ enabled: boolean;
74
+ registration_policy: string;
75
+ clients_configured: number;
76
+ shared_secret_enabled: boolean;
77
+ pkce_required: boolean;
78
+ issuer: string | null;
79
+ };
80
+ };
81
+ workspaces: {
82
+ enabled: boolean;
83
+ default: string | null;
84
+ repo_count: number;
85
+ allowed_root_count: number;
86
+ gateway_app_dir_is_workspace: boolean;
72
87
  };
73
88
  providers: Record<"claude" | "codex" | "gemini" | "grok" | "mistral", {
74
89
  cli_available: boolean;
package/dist/doctor.js CHANGED
@@ -6,7 +6,8 @@ import { loadAuthConfig } from "./auth.js";
6
6
  import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
7
7
  import { listProviderRuntimeStatuses, } from "./provider-status.js";
8
8
  import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
9
- import { loadCacheAwarenessConfig } from "./config.js";
9
+ import { loadCacheAwarenessConfig, loadRemoteOAuthConfig, } from "./config.js";
10
+ import { loadWorkspaceRegistry } from "./workspace-registry.js";
10
11
  import { computeGlobalCacheStats } from "./cache-stats.js";
11
12
  import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
12
13
  import { buildUpstreamContractReport } from "./upstream-contracts.js";
@@ -168,16 +169,7 @@ function chatGPTConnectorUrl(env, rawPublicUrl) {
168
169
  .find(value => value.startsWith("/") && !value.includes("?") && !value.includes("#"));
169
170
  if (!rawPublicUrl || !path)
170
171
  return null;
171
- try {
172
- const url = new URL(rawPublicUrl);
173
- url.pathname = path;
174
- url.search = "";
175
- url.hash = "";
176
- return redactDiagnosticUrl(url.toString());
177
- }
178
- catch {
179
- return null;
180
- }
172
+ return "<redacted>";
181
173
  }
182
174
  function buildCacheAwarenessReport(opts) {
183
175
  const enabled = [];
@@ -240,6 +232,8 @@ export function createDoctorReport(envOrOptions = process.env) {
240
232
  : { env: envOrOptions };
241
233
  const env = opts.env ?? process.env;
242
234
  const auth = loadAuthConfig(env);
235
+ const oauth = loadRemoteOAuthConfig(undefined, env);
236
+ const workspaceRegistry = loadWorkspaceRegistry();
243
237
  const transport = defaultTransport(env);
244
238
  const rawPublicUrl = env.LLM_GATEWAY_PUBLIC_URL || null;
245
239
  const publicUrl = redactDiagnosticUrl(rawPublicUrl);
@@ -294,6 +288,23 @@ export function createDoctorReport(envOrOptions = process.env) {
294
288
  required: auth.required,
295
289
  token_configured: auth.tokenConfigured,
296
290
  source: auth.source,
291
+ oauth: {
292
+ enabled: oauth.enabled,
293
+ registration_policy: oauth.registrationPolicy,
294
+ clients_configured: oauth.clients.length,
295
+ shared_secret_enabled: Boolean(oauth.sharedSecret?.enabled),
296
+ pkce_required: oauth.requirePkce,
297
+ issuer: oauth.issuer === "auto"
298
+ ? publicUrl
299
+ : redactDiagnosticUrl(oauth.issuer === "auto" ? null : oauth.issuer),
300
+ },
301
+ },
302
+ workspaces: {
303
+ enabled: workspaceRegistry.enabled,
304
+ default: workspaceRegistry.defaultAlias,
305
+ repo_count: workspaceRegistry.repos.length,
306
+ allowed_root_count: workspaceRegistry.allowedRoots.length,
307
+ gateway_app_dir_is_workspace: workspaceRegistry.repos.some(repo => repo.path === join(homedir(), ".llm-cli-gateway")),
297
308
  },
298
309
  providers: {
299
310
  claude: doctorProviderStatus(providerStatuses.claude),
@@ -13,6 +13,7 @@ export interface ExecuteResult {
13
13
  stderr: string;
14
14
  code: number;
15
15
  }
16
+ export declare function providerCommandName(command: string): string;
16
17
  export declare function buildExtendedPath(env?: NodeJS.ProcessEnv, home?: string, nodePath?: string, platform?: NodeJS.Platform): string;
17
18
  export declare function getExtendedPath(): string;
18
19
  export declare function envWithExtendedPath(baseEnv?: NodeJS.ProcessEnv, extendedPath?: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
package/dist/executor.js CHANGED
@@ -3,6 +3,13 @@ import { homedir } from "os";
3
3
  import { delimiter, join, dirname, extname, win32 } from "path";
4
4
  import { readdirSync, existsSync } from "fs";
5
5
  import { createCircuitBreaker, withRetry } from "./retry.js";
6
+ export function providerCommandName(command) {
7
+ if (command === "gemini")
8
+ return "agy";
9
+ if (command === "mistral")
10
+ return "vibe";
11
+ return command;
12
+ }
6
13
  const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
7
14
  const circuitBreakers = new Map();
8
15
  let cachedNvmPath;
@@ -3,31 +3,42 @@ import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { authorizeBearerRequest, getRequiredBearerToken, writeAuthFailure } from "./auth.js";
6
+ import { loadRemoteOAuthConfig } from "./config.js";
7
+ import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
8
+ import { runWithRequestContext } from "./request-context.js";
6
9
  const noopLogger = {
7
10
  info: (..._args) => { },
8
11
  error: (..._args) => { },
9
12
  debug: (..._args) => { },
10
13
  };
11
- function readBody(req) {
14
+ function firstHeader(value) {
15
+ return Array.isArray(value) ? value[0] : value;
16
+ }
17
+ function readRawBody(req) {
12
18
  return new Promise((resolve, reject) => {
13
19
  const chunks = [];
14
20
  req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
15
21
  req.on("error", reject);
16
22
  req.on("end", () => {
17
23
  if (chunks.length === 0) {
18
- resolve(undefined);
24
+ resolve("");
19
25
  return;
20
26
  }
21
- const raw = Buffer.concat(chunks).toString("utf8");
22
- try {
23
- resolve(JSON.parse(raw));
24
- }
25
- catch (error) {
26
- reject(error);
27
- }
27
+ resolve(Buffer.concat(chunks).toString("utf8"));
28
28
  });
29
29
  });
30
30
  }
31
+ async function readBody(req) {
32
+ const raw = await readRawBody(req);
33
+ if (!raw)
34
+ return undefined;
35
+ try {
36
+ return JSON.parse(raw);
37
+ }
38
+ catch (error) {
39
+ throw error;
40
+ }
41
+ }
31
42
  function methodNotAllowed(res) {
32
43
  res.writeHead(405, { allow: "GET, POST, DELETE", "content-type": "application/json" });
33
44
  res.end(JSON.stringify({ error: "Method not allowed" }));
@@ -36,6 +47,10 @@ function jsonError(res, status, message) {
36
47
  res.writeHead(status, { "content-type": "application/json" });
37
48
  res.end(JSON.stringify({ error: message }));
38
49
  }
50
+ function jsonResponse(res, status, body) {
51
+ res.writeHead(status, { "content-type": "application/json" });
52
+ res.end(JSON.stringify(body));
53
+ }
39
54
  function parseNoAuthPaths(raw, protectedPath) {
40
55
  const paths = new Set();
41
56
  for (const value of (raw ?? "").split(/[,;\s]+/)) {
@@ -51,6 +66,25 @@ function parseNoAuthPaths(raw, protectedPath) {
51
66
  }
52
67
  return paths;
53
68
  }
69
+ function isLocalHost(host) {
70
+ const hostname = host.split(":")[0]?.toLowerCase() ?? "";
71
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
72
+ }
73
+ function requestBaseUrl(req) {
74
+ const configured = process.env.LLM_GATEWAY_PUBLIC_URL;
75
+ if (configured) {
76
+ try {
77
+ return new URL(configured).origin;
78
+ }
79
+ catch {
80
+ }
81
+ }
82
+ const host = firstHeader(req.headers.host) ?? "127.0.0.1:3333";
83
+ const forwardedProto = firstHeader(req.headers["x-forwarded-proto"]);
84
+ const proto = forwardedProto ??
85
+ (host.startsWith("127.0.0.1") || host.startsWith("localhost") ? "http" : "https");
86
+ return `${proto}://${host}`;
87
+ }
54
88
  export async function startHttpGateway(options) {
55
89
  const host = options.host ?? process.env.LLM_GATEWAY_HTTP_HOST ?? "127.0.0.1";
56
90
  const port = options.port ?? Number(process.env.LLM_GATEWAY_HTTP_PORT ?? 3333);
@@ -59,6 +93,10 @@ export async function startHttpGateway(options) {
59
93
  const logger = options.logger ?? noopLogger;
60
94
  const sessions = new Map();
61
95
  const token = getRequiredBearerToken();
96
+ const oauthConfig = loadRemoteOAuthConfig(logger);
97
+ const oauthServer = oauthConfig.enabled
98
+ ? new OAuthServer({ protectedPath: path, config: oauthConfig, logger })
99
+ : null;
62
100
  async function closeSession(sessionId) {
63
101
  const entry = sessions.get(sessionId);
64
102
  if (!entry)
@@ -89,22 +127,46 @@ export async function startHttpGateway(options) {
89
127
  const httpServer = createServer(async (req, res) => {
90
128
  try {
91
129
  const url = new URL(req.url || "/", `http://${req.headers.host || `${host}:${port}`}`);
130
+ const baseUrl = requestBaseUrl(req);
131
+ const oauthOrigin = oauthServer ? oauthBaseUrlFromRequest(req, oauthConfig) : null;
132
+ const effectiveOAuthBaseUrl = oauthOrigin ?? baseUrl;
133
+ const resourceMetadataUrl = oauthServer && oauthOrigin ? oauthServer.resourceMetadataUrl(oauthOrigin) : undefined;
92
134
  if (url.pathname === "/healthz") {
93
135
  res.writeHead(200, { "content-type": "application/json" });
94
136
  res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
95
137
  return;
96
138
  }
139
+ if (oauthServer) {
140
+ if (oauthServer.isOAuthPath(url.pathname) && !oauthOrigin) {
141
+ jsonError(res, 503, "LLM_GATEWAY_PUBLIC_URL is required for public OAuth issuer metadata");
142
+ return;
143
+ }
144
+ if (await oauthServer.handle({
145
+ req,
146
+ res,
147
+ url,
148
+ baseUrl: effectiveOAuthBaseUrl,
149
+ })) {
150
+ return;
151
+ }
152
+ }
97
153
  const noAuthPath = noAuthPaths.has(url.pathname);
98
154
  if (url.pathname !== path && !noAuthPath) {
99
155
  jsonError(res, 404, "Not found");
100
156
  return;
101
157
  }
158
+ let requestContext = { authScopes: [] };
102
159
  if (!noAuthPath) {
103
160
  const auth = authorizeBearerRequest(req, token);
104
161
  if (!auth.ok) {
105
- writeAuthFailure(res, auth);
162
+ writeAuthFailure(res, auth, resourceMetadataUrl ? { resourceMetadataUrl } : {});
106
163
  return;
107
164
  }
165
+ requestContext = {
166
+ authKind: auth.kind,
167
+ authScopes: auth.scopes ?? [],
168
+ authClientId: auth.clientId,
169
+ };
108
170
  }
109
171
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
110
172
  methodNotAllowed(res);
@@ -129,7 +191,7 @@ export async function startHttpGateway(options) {
129
191
  return;
130
192
  }
131
193
  const body = req.method === "POST" ? await readBody(req) : undefined;
132
- await entry.transport.handleRequest(req, res, body);
194
+ await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
133
195
  return;
134
196
  }
135
197
  if (req.method !== "POST") {
@@ -146,7 +208,7 @@ export async function startHttpGateway(options) {
146
208
  return;
147
209
  }
148
210
  const entry = await createSession();
149
- await entry.transport.handleRequest(req, res, body);
211
+ await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
150
212
  }
151
213
  catch (error) {
152
214
  logger.error("HTTP transport request failed", error);
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ import { ClaudeMcpConfigResult, ClaudeMcpServerName } from "./claude-mcp-config.
14
14
  import { type MistralAgentMode, type ClaudePermissionMode, type CodexSandboxMode, type CodexAskForApproval, type ClaudeEffortLevel } from "./request-helpers.js";
15
15
  import { FlightRecorderLike } from "./flight-recorder.js";
16
16
  import { type PromptParts } from "./prompt-parts.js";
17
+ import { type WorkspaceRegistry } from "./workspace-registry.js";
17
18
  export interface WarningEntry {
18
19
  code: string;
19
20
  message?: string;
@@ -59,6 +60,7 @@ export declare const WORKTREE_SCHEMA: z.ZodUnion<[z.ZodBoolean, z.ZodObject<{
59
60
  name?: string | undefined;
60
61
  ref?: string | undefined;
61
62
  }>]>;
63
+ export declare const WORKSPACE_ALIAS_SCHEMA: z.ZodString;
62
64
  export declare const SESSION_PROVIDER_VALUES: readonly ["claude", "codex", "gemini", "grok", "mistral", "grok-api"];
63
65
  export declare const SESSION_PROVIDER_ENUM: z.ZodEnum<["claude", "codex", "gemini", "grok", "mistral", "grok-api"]>;
64
66
  export type SessionProvider = ProviderType;
@@ -74,6 +76,7 @@ export interface GatewayServerDeps {
74
76
  persistence?: PersistenceConfig;
75
77
  cacheAwareness?: CacheAwarenessConfig;
76
78
  providers?: ProvidersConfig;
79
+ workspaces?: WorkspaceRegistry;
77
80
  }
78
81
  export interface GatewayServerRuntime {
79
82
  sessionManager: ISessionManager;
@@ -87,6 +90,7 @@ export interface GatewayServerRuntime {
87
90
  persistence: PersistenceConfig;
88
91
  cacheAwareness: CacheAwarenessConfig;
89
92
  providers: ProvidersConfig;
93
+ workspaces: WorkspaceRegistry;
90
94
  }
91
95
  export declare function resolveGatewayServerRuntime(deps?: GatewayServerDeps, options?: {
92
96
  isolateState?: boolean;
@@ -95,11 +99,17 @@ export declare function shouldRegisterGrokApiTools(providers: ProvidersConfig):
95
99
  export interface ResolvedWorktree {
96
100
  cwd?: string;
97
101
  worktreePath?: string;
102
+ workspaceAlias?: string;
103
+ workspaceRoot?: string;
98
104
  }
99
105
  export declare function resolveWorktreeForRequest(worktreeOpt: boolean | {
100
106
  name?: string;
101
107
  ref?: string;
102
- } | undefined, sessionId: string | undefined, runtime: GatewayServerRuntime): Promise<ResolvedWorktree>;
108
+ } | undefined, sessionId: string | undefined, runtime: GatewayServerRuntime, options?: {
109
+ repoRoot?: string;
110
+ workspaceAlias?: string;
111
+ workspaceRoot?: string;
112
+ }): Promise<ResolvedWorktree>;
103
113
  export declare function formatWorktreePrefix(worktreePath?: string): string;
104
114
  export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral", output: string, outputFormat?: string, ctx?: {
105
115
  sessionId?: string;
@@ -330,6 +340,7 @@ export interface GeminiRequestParams {
330
340
  attachments?: string[];
331
341
  skipTrust?: boolean;
332
342
  yolo?: boolean;
343
+ workspace?: string;
333
344
  worktree?: boolean | {
334
345
  name?: string;
335
346
  ref?: string;
@@ -343,6 +354,7 @@ export interface HandlerDeps {
343
354
  error: (...args: any[]) => void;
344
355
  debug: (...args: any[]) => void;
345
356
  };
357
+ workspaces?: WorkspaceRegistry;
346
358
  runtime?: GatewayServerRuntime;
347
359
  }
348
360
  export interface AsyncHandlerDeps extends HandlerDeps {
@@ -400,6 +412,7 @@ export interface GrokRequestParams {
400
412
  restoreCode?: boolean;
401
413
  leaderSocket?: string;
402
414
  nativeWorktree?: boolean | string;
415
+ workspace?: string;
403
416
  worktree?: boolean | {
404
417
  name?: string;
405
418
  ref?: string;
@@ -432,6 +445,7 @@ export interface MistralRequestParams {
432
445
  maxTokens?: number;
433
446
  workingDir?: string;
434
447
  addDir?: string[];
448
+ workspace?: string;
435
449
  worktree?: boolean | {
436
450
  name?: string;
437
451
  ref?: string;
@@ -469,6 +483,7 @@ export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params:
469
483
  ignoreRules?: boolean;
470
484
  workingDir?: string;
471
485
  addDir?: string[];
486
+ workspace?: string;
472
487
  worktree?: boolean | {
473
488
  name?: string;
474
489
  ref?: string;