llm-cli-gateway 2.12.0 → 2.12.2
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 +29 -0
- package/README.md +132 -5
- package/dist/api-provider.d.ts +1 -0
- package/dist/api-provider.js +13 -0
- package/dist/async-job-manager.d.ts +34 -2
- package/dist/async-job-manager.js +344 -46
- package/dist/config.d.ts +34 -0
- package/dist/config.js +91 -0
- package/dist/doctor.d.ts +30 -2
- package/dist/doctor.js +78 -6
- package/dist/endpoint-exposure.js +15 -3
- package/dist/http-transport.d.ts +3 -0
- package/dist/http-transport.js +138 -8
- package/dist/index.d.ts +15 -0
- package/dist/index.js +221 -74
- package/dist/provider-login-guidance.d.ts +12 -0
- package/dist/provider-login-guidance.js +28 -0
- package/dist/provider-status.d.ts +12 -0
- package/dist/provider-status.js +14 -0
- package/dist/provider-tool-capabilities.d.ts +4 -1
- package/dist/provider-tool-capabilities.js +225 -11
- package/dist/resources.d.ts +6 -2
- package/dist/resources.js +72 -8
- package/dist/validation-normalizer.js +5 -4
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -1
- package/setup/status.schema.json +65 -0
package/dist/config.js
CHANGED
|
@@ -185,6 +185,93 @@ export function loadPersistenceConfig(logger = noopLogger) {
|
|
|
185
185
|
sources,
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
|
+
export const DEFAULT_HTTP_MAX_SESSIONS = 100;
|
|
189
|
+
export const DEFAULT_HTTP_SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
190
|
+
export const DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS = 60 * 1000;
|
|
191
|
+
export const DEFAULT_MAX_RUNNING_JOBS = 32;
|
|
192
|
+
export const DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER = 16;
|
|
193
|
+
export const DEFAULT_MAX_QUEUED_JOBS = 128;
|
|
194
|
+
export const DEFAULT_QUEUE_TIMEOUT_MS = 120000;
|
|
195
|
+
export const DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS = 60 * 60 * 1000;
|
|
196
|
+
export const DEFAULT_MAX_JOB_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
197
|
+
const HttpSessionLimitsSchema = z
|
|
198
|
+
.object({
|
|
199
|
+
max_sessions: z.number().int().positive().default(DEFAULT_HTTP_MAX_SESSIONS),
|
|
200
|
+
session_idle_ttl_ms: z.number().int().positive().default(DEFAULT_HTTP_SESSION_IDLE_TTL_MS),
|
|
201
|
+
session_reaper_interval_ms: z
|
|
202
|
+
.number()
|
|
203
|
+
.int()
|
|
204
|
+
.positive()
|
|
205
|
+
.default(DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS),
|
|
206
|
+
})
|
|
207
|
+
.passthrough();
|
|
208
|
+
const JobLimitsSchema = z
|
|
209
|
+
.object({
|
|
210
|
+
max_running_jobs: z.number().int().positive().default(DEFAULT_MAX_RUNNING_JOBS),
|
|
211
|
+
max_running_jobs_per_provider: z
|
|
212
|
+
.number()
|
|
213
|
+
.int()
|
|
214
|
+
.positive()
|
|
215
|
+
.default(DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER),
|
|
216
|
+
max_queued_jobs: z.number().int().positive().default(DEFAULT_MAX_QUEUED_JOBS),
|
|
217
|
+
queue_timeout_ms: z.number().int().positive().default(DEFAULT_QUEUE_TIMEOUT_MS),
|
|
218
|
+
completed_job_memory_ttl_ms: z
|
|
219
|
+
.number()
|
|
220
|
+
.int()
|
|
221
|
+
.positive()
|
|
222
|
+
.default(DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS),
|
|
223
|
+
max_job_output_bytes: z.number().int().positive().default(DEFAULT_MAX_JOB_OUTPUT_BYTES),
|
|
224
|
+
})
|
|
225
|
+
.strict();
|
|
226
|
+
export const DEFAULT_HTTP_SESSION_LIMITS = {
|
|
227
|
+
maxSessions: DEFAULT_HTTP_MAX_SESSIONS,
|
|
228
|
+
sessionIdleTtlMs: DEFAULT_HTTP_SESSION_IDLE_TTL_MS,
|
|
229
|
+
sessionReaperIntervalMs: DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS,
|
|
230
|
+
};
|
|
231
|
+
export const DEFAULT_JOB_LIMITS = {
|
|
232
|
+
maxRunningJobs: DEFAULT_MAX_RUNNING_JOBS,
|
|
233
|
+
maxRunningJobsPerProvider: DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER,
|
|
234
|
+
maxQueuedJobs: DEFAULT_MAX_QUEUED_JOBS,
|
|
235
|
+
queueTimeoutMs: DEFAULT_QUEUE_TIMEOUT_MS,
|
|
236
|
+
completedJobMemoryTtlMs: DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS,
|
|
237
|
+
maxJobOutputBytes: DEFAULT_MAX_JOB_OUTPUT_BYTES,
|
|
238
|
+
};
|
|
239
|
+
export function loadLimitsConfig(logger = noopLogger) {
|
|
240
|
+
const configPath = defaultGatewayConfigPath();
|
|
241
|
+
const { parsed, sourcePath } = readGatewayTomlFile(configPath, logger, "limits");
|
|
242
|
+
const rawHttp = parsed?.http ?? {};
|
|
243
|
+
const rawLimits = parsed?.limits ?? {};
|
|
244
|
+
let httpParsed;
|
|
245
|
+
try {
|
|
246
|
+
httpParsed = HttpSessionLimitsSchema.parse(rawHttp);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
throw new Error(`Invalid [http] session-limit config: ${err instanceof Error ? err.message : String(err)}`);
|
|
250
|
+
}
|
|
251
|
+
let limitsParsed;
|
|
252
|
+
try {
|
|
253
|
+
limitsParsed = JobLimitsSchema.parse(rawLimits);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
throw new Error(`Invalid [limits] config: ${err instanceof Error ? err.message : String(err)}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
http: {
|
|
260
|
+
maxSessions: httpParsed.max_sessions,
|
|
261
|
+
sessionIdleTtlMs: httpParsed.session_idle_ttl_ms,
|
|
262
|
+
sessionReaperIntervalMs: httpParsed.session_reaper_interval_ms,
|
|
263
|
+
},
|
|
264
|
+
jobs: {
|
|
265
|
+
maxRunningJobs: limitsParsed.max_running_jobs,
|
|
266
|
+
maxRunningJobsPerProvider: limitsParsed.max_running_jobs_per_provider,
|
|
267
|
+
maxQueuedJobs: limitsParsed.max_queued_jobs,
|
|
268
|
+
queueTimeoutMs: limitsParsed.queue_timeout_ms,
|
|
269
|
+
completedJobMemoryTtlMs: limitsParsed.completed_job_memory_ttl_ms,
|
|
270
|
+
maxJobOutputBytes: limitsParsed.max_job_output_bytes,
|
|
271
|
+
},
|
|
272
|
+
sources: { configFile: sourcePath },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
188
275
|
export const ANTHROPIC_TTL_SECONDS_VALUES = [300, 3600];
|
|
189
276
|
export const DEFAULT_MIN_STABLE_TOKENS_FOR_CACHE_CONTROL = {
|
|
190
277
|
sonnet: 1024,
|
|
@@ -386,6 +473,9 @@ export function isApiProviderEnabled(provider, env = process.env) {
|
|
|
386
473
|
return true;
|
|
387
474
|
return provider.kind === "openai-compatible" && isLoopbackUrl(provider.baseUrl);
|
|
388
475
|
}
|
|
476
|
+
export function apiProviderKeyPresent(provider, env = process.env) {
|
|
477
|
+
return resolveProviderKey(provider.apiKeyEnv, env) !== null;
|
|
478
|
+
}
|
|
389
479
|
export function enabledApiProviders(config, env = process.env) {
|
|
390
480
|
const runtimes = [];
|
|
391
481
|
for (const provider of Object.values(config.providers ?? {})) {
|
|
@@ -394,6 +484,7 @@ export function enabledApiProviders(config, env = process.env) {
|
|
|
394
484
|
runtimes.push({
|
|
395
485
|
name: provider.name,
|
|
396
486
|
kind: provider.kind,
|
|
487
|
+
apiKeyEnv: provider.apiKeyEnv,
|
|
397
488
|
baseUrl: provider.baseUrl,
|
|
398
489
|
defaultModel: provider.defaultModel,
|
|
399
490
|
models: provider.models,
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type EndpointExposureReport } from "./endpoint-exposure.js";
|
|
2
2
|
import { type ProviderLoginStatus } from "./provider-status.js";
|
|
3
|
+
import { type ApiProviderLoginGuidance } from "./provider-login-guidance.js";
|
|
3
4
|
import type { FlightRecorderQuery } from "./flight-recorder.js";
|
|
4
|
-
import { type CacheAwarenessConfig } from "./config.js";
|
|
5
|
+
import { type CacheAwarenessConfig, type ProvidersConfig } from "./config.js";
|
|
5
6
|
import { type ProviderCapabilityId, type ProviderKind } from "./provider-tool-capabilities.js";
|
|
6
7
|
export type CliType = "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin";
|
|
7
8
|
export interface CacheAwarenessReport {
|
|
@@ -38,6 +39,22 @@ export interface ProviderCapabilitySummaryReport {
|
|
|
38
39
|
warnings: string[];
|
|
39
40
|
}>;
|
|
40
41
|
}
|
|
42
|
+
export interface ApiProviderHealthEntry {
|
|
43
|
+
name: string;
|
|
44
|
+
kind: "openai-compatible" | "anthropic" | "xai-responses";
|
|
45
|
+
base_url: string;
|
|
46
|
+
default_model: string;
|
|
47
|
+
models: string[] | null;
|
|
48
|
+
api_key_env: string | null;
|
|
49
|
+
api_key_present: boolean;
|
|
50
|
+
reachable: boolean | null;
|
|
51
|
+
reachability_error?: string;
|
|
52
|
+
login_guidance: ApiProviderLoginGuidance;
|
|
53
|
+
}
|
|
54
|
+
export interface ApiProviderHealthReport {
|
|
55
|
+
enabled_count: number;
|
|
56
|
+
providers: Record<string, ApiProviderHealthEntry>;
|
|
57
|
+
}
|
|
41
58
|
export interface VibeSessionLoggingStatus {
|
|
42
59
|
config_path: string;
|
|
43
60
|
config_present: boolean;
|
|
@@ -138,6 +155,7 @@ export interface DoctorReport {
|
|
|
138
155
|
};
|
|
139
156
|
cache_awareness: CacheAwarenessReport;
|
|
140
157
|
provider_capabilities: ProviderCapabilitySummaryReport;
|
|
158
|
+
api_providers?: ApiProviderHealthReport;
|
|
141
159
|
upstream: {
|
|
142
160
|
note: string;
|
|
143
161
|
recommendation: string;
|
|
@@ -154,8 +172,18 @@ export interface CreateDoctorReportOptions {
|
|
|
154
172
|
flightRecorder?: FlightRecorderQuery;
|
|
155
173
|
cacheAwareness?: CacheAwarenessConfig;
|
|
156
174
|
probeUpstream?: boolean;
|
|
175
|
+
providersConfig?: ProvidersConfig;
|
|
176
|
+
apiReachability?: Record<string, {
|
|
177
|
+
reachable: boolean;
|
|
178
|
+
error?: string;
|
|
179
|
+
}>;
|
|
157
180
|
}
|
|
158
181
|
export declare function createDoctorReport(envOrOptions?: NodeJS.ProcessEnv | CreateDoctorReportOptions): DoctorReport;
|
|
159
182
|
export declare function printDoctorJson(opts?: {
|
|
160
183
|
probeUpstream?: boolean;
|
|
161
|
-
|
|
184
|
+
probeApiProviders?: boolean;
|
|
185
|
+
}): Promise<void>;
|
|
186
|
+
export declare function probeApiProviderReachability(baseUrl: string, timeoutMs?: number): Promise<{
|
|
187
|
+
reachable: boolean;
|
|
188
|
+
error?: string;
|
|
189
|
+
}>;
|
package/dist/doctor.js
CHANGED
|
@@ -4,14 +4,15 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { loadAuthConfig } from "./auth.js";
|
|
6
6
|
import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
|
|
7
|
-
import { listProviderRuntimeStatuses, } from "./provider-status.js";
|
|
7
|
+
import { getApiProviderStatus, listProviderRuntimeStatuses, } from "./provider-status.js";
|
|
8
|
+
import { getApiProviderLoginGuidance, } from "./provider-login-guidance.js";
|
|
8
9
|
import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
|
|
9
|
-
import { loadCacheAwarenessConfig, loadRemoteOAuthConfig, } from "./config.js";
|
|
10
|
+
import { enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, loadRemoteOAuthConfig, } from "./config.js";
|
|
10
11
|
import { loadWorkspaceRegistry } from "./workspace-registry.js";
|
|
11
12
|
import { computeGlobalCacheStats } from "./cache-stats.js";
|
|
12
13
|
import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
|
|
13
14
|
import { buildUpstreamContractReport } from "./upstream-contracts.js";
|
|
14
|
-
import { getProviderToolCapabilities,
|
|
15
|
+
import { getProviderToolCapabilities, knownProviderCapabilityIds, } from "./provider-tool-capabilities.js";
|
|
15
16
|
export function checkVibeSessionLogging(home = homedir()) {
|
|
16
17
|
const configPath = join(home, ".vibe", "config.toml");
|
|
17
18
|
if (!existsSync(configPath)) {
|
|
@@ -172,6 +173,34 @@ function chatGPTConnectorUrl(env, rawPublicUrl) {
|
|
|
172
173
|
return null;
|
|
173
174
|
return "<redacted>";
|
|
174
175
|
}
|
|
176
|
+
function buildApiProviderHealthReport(opts, env) {
|
|
177
|
+
const config = opts.providersConfig;
|
|
178
|
+
if (!config)
|
|
179
|
+
return undefined;
|
|
180
|
+
const providers = {};
|
|
181
|
+
for (const providerConfig of Object.values(config.providers)) {
|
|
182
|
+
const status = getApiProviderStatus(providerConfig, env);
|
|
183
|
+
if (!status.enabled)
|
|
184
|
+
continue;
|
|
185
|
+
const probe = opts.apiReachability?.[status.provider];
|
|
186
|
+
providers[status.provider] = {
|
|
187
|
+
name: status.provider,
|
|
188
|
+
kind: status.kind,
|
|
189
|
+
base_url: status.baseUrl,
|
|
190
|
+
default_model: status.defaultModel,
|
|
191
|
+
models: status.models,
|
|
192
|
+
api_key_env: status.apiKeyEnv,
|
|
193
|
+
api_key_present: status.apiKeyPresent,
|
|
194
|
+
reachable: probe ? probe.reachable : null,
|
|
195
|
+
...(probe?.error ? { reachability_error: probe.error } : {}),
|
|
196
|
+
login_guidance: getApiProviderLoginGuidance(providerConfig),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const names = Object.keys(providers);
|
|
200
|
+
if (names.length === 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
return { enabled_count: names.length, providers };
|
|
203
|
+
}
|
|
175
204
|
function buildCacheAwarenessReport(opts) {
|
|
176
205
|
const enabled = [];
|
|
177
206
|
if (opts.cacheAwareness?.emitAnthropicCacheControl) {
|
|
@@ -234,7 +263,7 @@ function buildProviderCapabilitySummary(providerStatuses) {
|
|
|
234
263
|
includeUnsupported: true,
|
|
235
264
|
includePaths: false,
|
|
236
265
|
});
|
|
237
|
-
const providers = Object.fromEntries(
|
|
266
|
+
const providers = Object.fromEntries(knownProviderCapabilityIds().map(provider => {
|
|
238
267
|
const capability = capabilities[provider];
|
|
239
268
|
if (!capability) {
|
|
240
269
|
throw new Error(`Missing provider capability record for ${provider}`);
|
|
@@ -264,7 +293,7 @@ function buildProviderCapabilitySummary(providerStatuses) {
|
|
|
264
293
|
tool: "provider_tool_capabilities",
|
|
265
294
|
resources: {
|
|
266
295
|
catalog: "provider-tools://catalog",
|
|
267
|
-
providers: Object.fromEntries(
|
|
296
|
+
providers: Object.fromEntries(knownProviderCapabilityIds().map(provider => [provider, `provider-tools://${provider}`])),
|
|
268
297
|
},
|
|
269
298
|
cache_ttl_ms: 60_000,
|
|
270
299
|
providers,
|
|
@@ -364,6 +393,10 @@ export function createDoctorReport(envOrOptions = process.env) {
|
|
|
364
393
|
upstream,
|
|
365
394
|
next_actions: [],
|
|
366
395
|
};
|
|
396
|
+
const apiProviders = buildApiProviderHealthReport(opts, env);
|
|
397
|
+
if (apiProviders) {
|
|
398
|
+
report.api_providers = apiProviders;
|
|
399
|
+
}
|
|
367
400
|
if (transport === "http" && auth.required && !auth.tokenConfigured) {
|
|
368
401
|
report.ok = false;
|
|
369
402
|
report.next_actions.push("Set LLM_GATEWAY_AUTH_TOKEN before starting HTTP transport.");
|
|
@@ -402,9 +435,10 @@ export function createDoctorReport(envOrOptions = process.env) {
|
|
|
402
435
|
}
|
|
403
436
|
return report;
|
|
404
437
|
}
|
|
405
|
-
export function printDoctorJson(opts = {}) {
|
|
438
|
+
export async function printDoctorJson(opts = {}) {
|
|
406
439
|
let cacheAwareness;
|
|
407
440
|
let flightRecorder;
|
|
441
|
+
let providersConfig;
|
|
408
442
|
try {
|
|
409
443
|
cacheAwareness = loadCacheAwarenessConfig();
|
|
410
444
|
}
|
|
@@ -417,11 +451,25 @@ export function printDoctorJson(opts = {}) {
|
|
|
417
451
|
}
|
|
418
452
|
catch {
|
|
419
453
|
}
|
|
454
|
+
try {
|
|
455
|
+
providersConfig = loadProvidersConfig();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
}
|
|
459
|
+
let apiReachability;
|
|
460
|
+
if (opts.probeApiProviders && providersConfig) {
|
|
461
|
+
apiReachability = {};
|
|
462
|
+
for (const runtime of enabledApiProviders(providersConfig)) {
|
|
463
|
+
apiReachability[runtime.name] = await probeApiProviderReachability(runtime.baseUrl);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
420
466
|
const report = createDoctorReport({
|
|
421
467
|
env: process.env,
|
|
422
468
|
cacheAwareness,
|
|
423
469
|
flightRecorder,
|
|
424
470
|
probeUpstream: opts.probeUpstream,
|
|
471
|
+
providersConfig,
|
|
472
|
+
apiReachability,
|
|
425
473
|
});
|
|
426
474
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
427
475
|
if (flightRecorder) {
|
|
@@ -432,6 +480,30 @@ export function printDoctorJson(opts = {}) {
|
|
|
432
480
|
}
|
|
433
481
|
}
|
|
434
482
|
}
|
|
483
|
+
export async function probeApiProviderReachability(baseUrl, timeoutMs = 5_000) {
|
|
484
|
+
let url;
|
|
485
|
+
try {
|
|
486
|
+
url = new URL(baseUrl);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return { reachable: false, error: "invalid base_url" };
|
|
490
|
+
}
|
|
491
|
+
const transport = url.protocol === "http:" ? await import("node:http") : await import("node:https");
|
|
492
|
+
return new Promise(resolve => {
|
|
493
|
+
const request = transport.request(url, { method: "GET", timeout: timeoutMs }, response => {
|
|
494
|
+
response.resume();
|
|
495
|
+
resolve({ reachable: true });
|
|
496
|
+
});
|
|
497
|
+
request.on("timeout", () => {
|
|
498
|
+
request.destroy();
|
|
499
|
+
resolve({ reachable: false, error: `timed out after ${timeoutMs}ms` });
|
|
500
|
+
});
|
|
501
|
+
request.on("error", err => {
|
|
502
|
+
resolve({ reachable: false, error: err instanceof Error ? err.message : String(err) });
|
|
503
|
+
});
|
|
504
|
+
request.end();
|
|
505
|
+
});
|
|
506
|
+
}
|
|
435
507
|
function isCreateDoctorReportOptions(value) {
|
|
436
508
|
if (value === null || typeof value !== "object")
|
|
437
509
|
return false;
|
|
@@ -68,16 +68,28 @@ export function redactDiagnosticUrl(rawUrl) {
|
|
|
68
68
|
const redactSensitivePairs = (value) => value.replace(new RegExp(`((${sensitiveKeyPattern.source})=)[^&\\s#]+`, "gi"), "$1<redacted>");
|
|
69
69
|
try {
|
|
70
70
|
const url = new URL(rawUrl);
|
|
71
|
-
|
|
71
|
+
let redacted = false;
|
|
72
|
+
if (url.username) {
|
|
72
73
|
url.username = "<redacted>";
|
|
73
|
-
|
|
74
|
+
redacted = true;
|
|
75
|
+
}
|
|
76
|
+
if (url.password) {
|
|
74
77
|
url.password = "<redacted>";
|
|
78
|
+
redacted = true;
|
|
79
|
+
}
|
|
75
80
|
for (const key of Array.from(url.searchParams.keys())) {
|
|
76
81
|
if (sensitiveKeyPattern.test(key)) {
|
|
77
82
|
url.searchParams.set(key, "<redacted>");
|
|
83
|
+
redacted = true;
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
|
-
|
|
86
|
+
const redactedHash = redactSensitivePairs(url.hash);
|
|
87
|
+
if (redactedHash !== url.hash) {
|
|
88
|
+
url.hash = redactedHash;
|
|
89
|
+
redacted = true;
|
|
90
|
+
}
|
|
91
|
+
if (!redacted)
|
|
92
|
+
return rawUrl;
|
|
81
93
|
return url.toString().replace(/%3Credacted%3E/gi, "<redacted>");
|
|
82
94
|
}
|
|
83
95
|
catch {
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type Server } from "node:http";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import type { GatewayServerDeps } from "./index.js";
|
|
4
|
+
import { type HttpSessionLimitsConfig } from "./config.js";
|
|
4
5
|
export interface HttpTransportOptions {
|
|
5
6
|
host?: string;
|
|
6
7
|
port?: number;
|
|
7
8
|
path?: string;
|
|
8
9
|
deps?: GatewayServerDeps;
|
|
9
10
|
createGatewayServer: (deps?: GatewayServerDeps) => McpServer;
|
|
11
|
+
httpLimits?: HttpSessionLimitsConfig;
|
|
10
12
|
logger?: {
|
|
11
13
|
info: (...args: any[]) => void;
|
|
12
14
|
error: (...args: any[]) => void;
|
|
@@ -18,5 +20,6 @@ export interface HttpGatewayHandle {
|
|
|
18
20
|
url: string;
|
|
19
21
|
close: () => Promise<void>;
|
|
20
22
|
sessionCount: () => number;
|
|
23
|
+
sessionHealth: () => Record<string, unknown>;
|
|
21
24
|
}
|
|
22
25
|
export declare function startHttpGateway(options: HttpTransportOptions): Promise<HttpGatewayHandle>;
|
package/dist/http-transport.js
CHANGED
|
@@ -3,7 +3,7 @@ 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, resolveTrustedPrincipal, writeAuthFailure, } from "./auth.js";
|
|
6
|
-
import { loadRemoteOAuthConfig } from "./config.js";
|
|
6
|
+
import { loadRemoteOAuthConfig, loadLimitsConfig } from "./config.js";
|
|
7
7
|
import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
|
|
8
8
|
import { runWithRequestContext } from "./request-context.js";
|
|
9
9
|
import { readCappedRawBody, maxHttpBodyBytes } from "./request-limits.js";
|
|
@@ -79,6 +79,8 @@ export async function startHttpGateway(options) {
|
|
|
79
79
|
const noAuthPaths = parseNoAuthPaths(process.env.LLM_GATEWAY_NO_AUTH_PATHS, path);
|
|
80
80
|
const logger = options.logger ?? noopLogger;
|
|
81
81
|
const sessions = new Map();
|
|
82
|
+
let pendingInitializes = 0;
|
|
83
|
+
const httpLimits = options.httpLimits ?? loadLimitsConfig(logger).http;
|
|
82
84
|
const token = getRequiredBearerToken();
|
|
83
85
|
const oauthConfig = loadRemoteOAuthConfig(logger);
|
|
84
86
|
if (oauthConfig.enabled &&
|
|
@@ -101,14 +103,33 @@ export async function startHttpGateway(options) {
|
|
|
101
103
|
.catch(error => logger.error("HTTP transport close failed", error));
|
|
102
104
|
await entry.server.close().catch(error => logger.error("HTTP MCP server close failed", error));
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
function touchSessionComplete(entry) {
|
|
107
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1);
|
|
108
|
+
entry.lastActivityAt = Date.now();
|
|
109
|
+
}
|
|
110
|
+
async function createSession(releaseInitializeReservation) {
|
|
105
111
|
const gatewayServer = options.createGatewayServer(options.deps);
|
|
112
|
+
let entry;
|
|
106
113
|
const transport = new StreamableHTTPServerTransport({
|
|
107
114
|
sessionIdGenerator: () => randomUUID(),
|
|
108
115
|
onsessioninitialized: sessionId => {
|
|
109
|
-
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
releaseInitializeReservation();
|
|
118
|
+
entry.sessionId = sessionId;
|
|
119
|
+
entry.createdAt = now;
|
|
120
|
+
entry.lastActivityAt = now;
|
|
121
|
+
entry.inFlight++;
|
|
122
|
+
sessions.set(sessionId, entry);
|
|
110
123
|
},
|
|
111
124
|
});
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
entry = {
|
|
127
|
+
server: gatewayServer,
|
|
128
|
+
transport,
|
|
129
|
+
createdAt: now,
|
|
130
|
+
lastActivityAt: now,
|
|
131
|
+
inFlight: 0,
|
|
132
|
+
};
|
|
112
133
|
transport.onclose = () => {
|
|
113
134
|
if (transport.sessionId) {
|
|
114
135
|
sessions.delete(transport.sessionId);
|
|
@@ -116,7 +137,70 @@ export async function startHttpGateway(options) {
|
|
|
116
137
|
};
|
|
117
138
|
transport.onerror = error => logger.error("HTTP MCP transport error", error);
|
|
118
139
|
await gatewayServer.connect(transport);
|
|
119
|
-
return
|
|
140
|
+
return entry;
|
|
141
|
+
}
|
|
142
|
+
function reapIdleSessions() {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
for (const [sessionId, entry] of sessions) {
|
|
145
|
+
if (entry.inFlight > 0)
|
|
146
|
+
continue;
|
|
147
|
+
if (now - entry.lastActivityAt < httpLimits.sessionIdleTtlMs)
|
|
148
|
+
continue;
|
|
149
|
+
logger.info(`Reaping idle HTTP MCP session (idle ${now - entry.lastActivityAt}ms >= ${httpLimits.sessionIdleTtlMs}ms)`);
|
|
150
|
+
void closeSession(sessionId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const reaperTimer = setInterval(reapIdleSessions, httpLimits.sessionReaperIntervalMs);
|
|
154
|
+
if (reaperTimer.unref)
|
|
155
|
+
reaperTimer.unref();
|
|
156
|
+
function sessionHealth() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
let oldestAgeMs = 0;
|
|
159
|
+
for (const entry of sessions.values()) {
|
|
160
|
+
const age = now - entry.createdAt;
|
|
161
|
+
if (age > oldestAgeMs)
|
|
162
|
+
oldestAgeMs = age;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
current: sessions.size,
|
|
166
|
+
max: httpLimits.maxSessions,
|
|
167
|
+
oldestAgeMs,
|
|
168
|
+
idleTtlMs: httpLimits.sessionIdleTtlMs,
|
|
169
|
+
reaperIntervalMs: httpLimits.sessionReaperIntervalMs,
|
|
170
|
+
saturated: sessions.size + pendingInitializes >= httpLimits.maxSessions,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function healthPayload() {
|
|
174
|
+
const manager = options.deps?.asyncJobManager;
|
|
175
|
+
const mem = process.memoryUsage();
|
|
176
|
+
const payload = {
|
|
177
|
+
sessions: sessionHealth(),
|
|
178
|
+
memory: {
|
|
179
|
+
rss: mem.rss,
|
|
180
|
+
heapUsed: mem.heapUsed,
|
|
181
|
+
heapTotal: mem.heapTotal,
|
|
182
|
+
external: mem.external,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
if (manager) {
|
|
186
|
+
const limiter = manager.getLimiterSnapshot();
|
|
187
|
+
const limits = manager.getConfiguredLimits();
|
|
188
|
+
payload.jobs = {
|
|
189
|
+
running: limiter.running,
|
|
190
|
+
queued: limiter.queued,
|
|
191
|
+
runningByProvider: limiter.runningByProvider,
|
|
192
|
+
queuedByProvider: limiter.queuedByProvider,
|
|
193
|
+
maxRunning: limiter.maxRunning,
|
|
194
|
+
maxRunningPerProvider: limiter.maxRunningPerProvider,
|
|
195
|
+
maxQueued: limiter.maxQueued,
|
|
196
|
+
rejected: limiter.rejected,
|
|
197
|
+
timedOut: limiter.timedOut,
|
|
198
|
+
saturated: limiter.saturated,
|
|
199
|
+
completedJobMemoryTtlMs: limits.completedJobMemoryTtlMs,
|
|
200
|
+
maxJobOutputBytes: limits.maxJobOutputBytes,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return payload;
|
|
120
204
|
}
|
|
121
205
|
const httpServer = createServer(async (req, res) => {
|
|
122
206
|
try {
|
|
@@ -127,7 +211,7 @@ export async function startHttpGateway(options) {
|
|
|
127
211
|
const resourceMetadataUrl = oauthServer && oauthOrigin ? oauthServer.resourceMetadataUrl(oauthOrigin) : undefined;
|
|
128
212
|
if (url.pathname === "/healthz") {
|
|
129
213
|
res.writeHead(200, { "content-type": "application/json" });
|
|
130
|
-
res.end(JSON.stringify({ ok: true,
|
|
214
|
+
res.end(JSON.stringify({ ok: true, ...healthPayload() }));
|
|
131
215
|
return;
|
|
132
216
|
}
|
|
133
217
|
if (oauthServer) {
|
|
@@ -188,7 +272,14 @@ export async function startHttpGateway(options) {
|
|
|
188
272
|
return;
|
|
189
273
|
}
|
|
190
274
|
const body = req.method === "POST" ? await readBody(req) : undefined;
|
|
191
|
-
|
|
275
|
+
entry.lastActivityAt = Date.now();
|
|
276
|
+
entry.inFlight++;
|
|
277
|
+
try {
|
|
278
|
+
await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
touchSessionComplete(entry);
|
|
282
|
+
}
|
|
192
283
|
return;
|
|
193
284
|
}
|
|
194
285
|
if (req.method !== "POST") {
|
|
@@ -204,8 +295,45 @@ export async function startHttpGateway(options) {
|
|
|
204
295
|
jsonError(res, 400, "First request must be initialize");
|
|
205
296
|
return;
|
|
206
297
|
}
|
|
207
|
-
const
|
|
208
|
-
|
|
298
|
+
const capacityInUse = sessions.size + pendingInitializes;
|
|
299
|
+
if (capacityInUse >= httpLimits.maxSessions) {
|
|
300
|
+
res.writeHead(429, {
|
|
301
|
+
"content-type": "application/json",
|
|
302
|
+
"retry-after": "5",
|
|
303
|
+
});
|
|
304
|
+
res.end(JSON.stringify({
|
|
305
|
+
error: "Gateway at session capacity",
|
|
306
|
+
code: "session_capacity",
|
|
307
|
+
retryable: true,
|
|
308
|
+
sessions: {
|
|
309
|
+
current: sessions.size,
|
|
310
|
+
pending: pendingInitializes,
|
|
311
|
+
max: httpLimits.maxSessions,
|
|
312
|
+
},
|
|
313
|
+
}));
|
|
314
|
+
logger.info(`Rejected new HTTP MCP session: at capacity (${capacityInUse}/${httpLimits.maxSessions})`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
pendingInitializes++;
|
|
318
|
+
let reservationReleased = false;
|
|
319
|
+
const releaseInitializeReservation = () => {
|
|
320
|
+
if (reservationReleased)
|
|
321
|
+
return;
|
|
322
|
+
reservationReleased = true;
|
|
323
|
+
pendingInitializes = Math.max(0, pendingInitializes - 1);
|
|
324
|
+
};
|
|
325
|
+
let entry;
|
|
326
|
+
try {
|
|
327
|
+
const created = await createSession(releaseInitializeReservation);
|
|
328
|
+
entry = created;
|
|
329
|
+
await runWithRequestContext(requestContext, () => created.transport.handleRequest(req, res, body));
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
releaseInitializeReservation();
|
|
333
|
+
if (entry?.sessionId && sessions.get(entry.sessionId) === entry && entry.inFlight > 0) {
|
|
334
|
+
touchSessionComplete(entry);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
209
337
|
}
|
|
210
338
|
catch (error) {
|
|
211
339
|
logger.error("HTTP transport request failed", error);
|
|
@@ -241,11 +369,13 @@ export async function startHttpGateway(options) {
|
|
|
241
369
|
server: httpServer,
|
|
242
370
|
url,
|
|
243
371
|
close: async () => {
|
|
372
|
+
clearInterval(reaperTimer);
|
|
244
373
|
await Promise.all([...sessions.keys()].map(closeSession));
|
|
245
374
|
await new Promise((resolve, reject) => {
|
|
246
375
|
httpServer.close(error => (error ? reject(error) : resolve()));
|
|
247
376
|
});
|
|
248
377
|
},
|
|
249
378
|
sessionCount: () => sessions.size,
|
|
379
|
+
sessionHealth,
|
|
250
380
|
};
|
|
251
381
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -124,6 +124,20 @@ export declare function createErrorResponse(cli: string, code: number, stderr: s
|
|
|
124
124
|
httpStatus?: number | null;
|
|
125
125
|
responseBody?: string;
|
|
126
126
|
}): {
|
|
127
|
+
content: {
|
|
128
|
+
type: "text";
|
|
129
|
+
text: string;
|
|
130
|
+
}[];
|
|
131
|
+
isError: boolean;
|
|
132
|
+
structuredContent: {
|
|
133
|
+
response: string;
|
|
134
|
+
correlationId: string | null;
|
|
135
|
+
cli: string;
|
|
136
|
+
exitCode: number;
|
|
137
|
+
errorCategory: string;
|
|
138
|
+
retryable: boolean;
|
|
139
|
+
};
|
|
140
|
+
} | {
|
|
127
141
|
content: {
|
|
128
142
|
type: "text";
|
|
129
143
|
text: string;
|
|
@@ -137,6 +151,7 @@ export declare function createErrorResponse(cli: string, code: number, stderr: s
|
|
|
137
151
|
cli: string;
|
|
138
152
|
exitCode: number;
|
|
139
153
|
errorCategory: string;
|
|
154
|
+
retryable?: undefined;
|
|
140
155
|
};
|
|
141
156
|
};
|
|
142
157
|
export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin", output: string, outputFormat?: string, ctx?: {
|