@juspay/neurolink 9.41.0 → 9.42.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.
- package/CHANGELOG.md +8 -0
- package/README.md +7 -1
- package/dist/auth/anthropicOAuth.d.ts +18 -3
- package/dist/auth/anthropicOAuth.js +149 -4
- package/dist/auth/providers/firebase.js +5 -1
- package/dist/auth/providers/jwt.js +5 -1
- package/dist/auth/providers/workos.js +5 -1
- package/dist/auth/sessionManager.d.ts +1 -1
- package/dist/auth/sessionManager.js +58 -27
- package/dist/browser/neurolink.min.js +354 -334
- package/dist/cli/commands/mcp.d.ts +6 -0
- package/dist/cli/commands/mcp.js +188 -181
- package/dist/cli/commands/proxy.d.ts +2 -1
- package/dist/cli/commands/proxy.js +713 -431
- package/dist/cli/commands/task.js +3 -0
- package/dist/cli/factories/commandFactory.d.ts +2 -0
- package/dist/cli/factories/commandFactory.js +38 -0
- package/dist/cli/parser.js +4 -3
- package/dist/client/aiSdkAdapter.js +3 -0
- package/dist/client/streamingClient.js +30 -10
- package/dist/core/baseProvider.d.ts +6 -1
- package/dist/core/baseProvider.js +208 -230
- package/dist/core/factory.d.ts +3 -0
- package/dist/core/factory.js +138 -188
- package/dist/core/modules/GenerationHandler.js +3 -2
- package/dist/core/redisConversationMemoryManager.js +7 -3
- package/dist/evaluation/BatchEvaluator.js +4 -1
- package/dist/evaluation/hooks/observabilityHooks.js +5 -3
- package/dist/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
- package/dist/evaluation/pipeline/evaluationPipeline.js +24 -9
- package/dist/evaluation/pipeline/strategies/batchStrategy.js +6 -3
- package/dist/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
- package/dist/evaluation/scorers/scorerRegistry.d.ts +3 -0
- package/dist/evaluation/scorers/scorerRegistry.js +353 -282
- package/dist/lib/auth/anthropicOAuth.d.ts +18 -3
- package/dist/lib/auth/anthropicOAuth.js +149 -4
- package/dist/lib/auth/providers/firebase.js +5 -1
- package/dist/lib/auth/providers/jwt.js +5 -1
- package/dist/lib/auth/providers/workos.js +5 -1
- package/dist/lib/auth/sessionManager.d.ts +1 -1
- package/dist/lib/auth/sessionManager.js +58 -27
- package/dist/lib/client/aiSdkAdapter.js +3 -0
- package/dist/lib/client/streamingClient.js +30 -10
- package/dist/lib/core/baseProvider.d.ts +6 -1
- package/dist/lib/core/baseProvider.js +208 -230
- package/dist/lib/core/factory.d.ts +3 -0
- package/dist/lib/core/factory.js +138 -188
- package/dist/lib/core/modules/GenerationHandler.js +3 -2
- package/dist/lib/core/redisConversationMemoryManager.js +7 -3
- package/dist/lib/evaluation/BatchEvaluator.js +4 -1
- package/dist/lib/evaluation/hooks/observabilityHooks.js +5 -3
- package/dist/lib/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
- package/dist/lib/evaluation/pipeline/evaluationPipeline.js +24 -9
- package/dist/lib/evaluation/pipeline/strategies/batchStrategy.js +6 -3
- package/dist/lib/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
- package/dist/lib/evaluation/scorers/scorerRegistry.d.ts +3 -0
- package/dist/lib/evaluation/scorers/scorerRegistry.js +353 -282
- package/dist/lib/mcp/toolRegistry.d.ts +2 -0
- package/dist/lib/mcp/toolRegistry.js +32 -31
- package/dist/lib/neurolink.d.ts +41 -2
- package/dist/lib/neurolink.js +1616 -1681
- package/dist/lib/observability/otelBridge.d.ts +2 -2
- package/dist/lib/observability/otelBridge.js +12 -3
- package/dist/lib/providers/amazonBedrock.js +2 -4
- package/dist/lib/providers/anthropic.d.ts +9 -5
- package/dist/lib/providers/anthropic.js +19 -14
- package/dist/lib/providers/anthropicBaseProvider.d.ts +3 -3
- package/dist/lib/providers/anthropicBaseProvider.js +5 -4
- package/dist/lib/providers/azureOpenai.d.ts +1 -1
- package/dist/lib/providers/azureOpenai.js +5 -4
- package/dist/lib/providers/googleAiStudio.js +30 -6
- package/dist/lib/providers/googleVertex.d.ts +10 -0
- package/dist/lib/providers/googleVertex.js +437 -423
- package/dist/lib/providers/huggingFace.d.ts +3 -3
- package/dist/lib/providers/huggingFace.js +6 -8
- package/dist/lib/providers/litellm.d.ts +1 -0
- package/dist/lib/providers/litellm.js +76 -55
- package/dist/lib/providers/mistral.js +2 -1
- package/dist/lib/providers/ollama.js +93 -23
- package/dist/lib/providers/openAI.d.ts +2 -0
- package/dist/lib/providers/openAI.js +141 -141
- package/dist/lib/providers/openRouter.js +2 -1
- package/dist/lib/providers/openaiCompatible.d.ts +4 -4
- package/dist/lib/providers/openaiCompatible.js +4 -4
- package/dist/lib/proxy/claudeFormat.d.ts +3 -2
- package/dist/lib/proxy/claudeFormat.js +27 -14
- package/dist/lib/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
- package/dist/lib/proxy/cloaking/plugins/sessionIdentity.js +9 -33
- package/dist/lib/proxy/modelRouter.js +3 -0
- package/dist/lib/proxy/oauthFetch.d.ts +1 -1
- package/dist/lib/proxy/oauthFetch.js +289 -316
- package/dist/lib/proxy/proxyConfig.js +46 -24
- package/dist/lib/proxy/proxyEnv.d.ts +19 -0
- package/dist/lib/proxy/proxyEnv.js +73 -0
- package/dist/lib/proxy/proxyFetch.js +291 -217
- package/dist/lib/proxy/proxyTracer.d.ts +133 -0
- package/dist/lib/proxy/proxyTracer.js +645 -0
- package/dist/lib/proxy/rawStreamCapture.d.ts +10 -0
- package/dist/lib/proxy/rawStreamCapture.js +83 -0
- package/dist/lib/proxy/requestLogger.d.ts +32 -5
- package/dist/lib/proxy/requestLogger.js +503 -47
- package/dist/lib/proxy/sseInterceptor.d.ts +97 -0
- package/dist/lib/proxy/sseInterceptor.js +427 -0
- package/dist/lib/proxy/usageStats.d.ts +4 -3
- package/dist/lib/proxy/usageStats.js +25 -12
- package/dist/lib/rag/chunkers/MarkdownChunker.js +13 -5
- package/dist/lib/rag/chunking/markdownChunker.js +15 -6
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +17 -3
- package/dist/lib/server/routes/claudeProxyRoutes.js +3032 -1349
- package/dist/lib/services/server/ai/observability/instrumentation.d.ts +7 -1
- package/dist/lib/services/server/ai/observability/instrumentation.js +337 -161
- package/dist/lib/tasks/backends/bullmqBackend.d.ts +1 -0
- package/dist/lib/tasks/backends/bullmqBackend.js +35 -22
- package/dist/lib/tasks/store/redisTaskStore.d.ts +1 -0
- package/dist/lib/tasks/store/redisTaskStore.js +54 -39
- package/dist/lib/tasks/taskManager.d.ts +5 -0
- package/dist/lib/tasks/taskManager.js +158 -30
- package/dist/lib/telemetry/index.d.ts +2 -1
- package/dist/lib/telemetry/index.js +2 -1
- package/dist/lib/telemetry/telemetryService.d.ts +3 -0
- package/dist/lib/telemetry/telemetryService.js +69 -5
- package/dist/lib/types/cli.d.ts +10 -0
- package/dist/lib/types/proxyTypes.d.ts +160 -5
- package/dist/lib/types/streamTypes.d.ts +25 -3
- package/dist/lib/utils/messageBuilder.js +3 -2
- package/dist/lib/utils/providerHealth.d.ts +19 -0
- package/dist/lib/utils/providerHealth.js +279 -33
- package/dist/lib/utils/providerUtils.js +17 -22
- package/dist/lib/utils/toolChoice.d.ts +4 -0
- package/dist/lib/utils/toolChoice.js +7 -0
- package/dist/mcp/toolRegistry.d.ts +2 -0
- package/dist/mcp/toolRegistry.js +32 -31
- package/dist/neurolink.d.ts +41 -2
- package/dist/neurolink.js +1616 -1681
- package/dist/observability/otelBridge.d.ts +2 -2
- package/dist/observability/otelBridge.js +12 -3
- package/dist/providers/amazonBedrock.js +2 -4
- package/dist/providers/anthropic.d.ts +9 -5
- package/dist/providers/anthropic.js +19 -14
- package/dist/providers/anthropicBaseProvider.d.ts +3 -3
- package/dist/providers/anthropicBaseProvider.js +5 -4
- package/dist/providers/azureOpenai.d.ts +1 -1
- package/dist/providers/azureOpenai.js +5 -4
- package/dist/providers/googleAiStudio.js +30 -6
- package/dist/providers/googleVertex.d.ts +10 -0
- package/dist/providers/googleVertex.js +437 -423
- package/dist/providers/huggingFace.d.ts +3 -3
- package/dist/providers/huggingFace.js +6 -7
- package/dist/providers/litellm.d.ts +1 -0
- package/dist/providers/litellm.js +76 -55
- package/dist/providers/mistral.js +2 -1
- package/dist/providers/ollama.js +93 -23
- package/dist/providers/openAI.d.ts +2 -0
- package/dist/providers/openAI.js +141 -141
- package/dist/providers/openRouter.js +2 -1
- package/dist/providers/openaiCompatible.d.ts +4 -4
- package/dist/providers/openaiCompatible.js +4 -3
- package/dist/proxy/claudeFormat.d.ts +3 -2
- package/dist/proxy/claudeFormat.js +27 -14
- package/dist/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
- package/dist/proxy/cloaking/plugins/sessionIdentity.js +9 -33
- package/dist/proxy/modelRouter.js +3 -0
- package/dist/proxy/oauthFetch.d.ts +1 -1
- package/dist/proxy/oauthFetch.js +289 -316
- package/dist/proxy/proxyConfig.js +46 -24
- package/dist/proxy/proxyEnv.d.ts +19 -0
- package/dist/proxy/proxyEnv.js +72 -0
- package/dist/proxy/proxyFetch.js +291 -217
- package/dist/proxy/proxyTracer.d.ts +133 -0
- package/dist/proxy/proxyTracer.js +644 -0
- package/dist/proxy/rawStreamCapture.d.ts +10 -0
- package/dist/proxy/rawStreamCapture.js +82 -0
- package/dist/proxy/requestLogger.d.ts +32 -5
- package/dist/proxy/requestLogger.js +503 -47
- package/dist/proxy/sseInterceptor.d.ts +97 -0
- package/dist/proxy/sseInterceptor.js +426 -0
- package/dist/proxy/usageStats.d.ts +4 -3
- package/dist/proxy/usageStats.js +25 -12
- package/dist/rag/chunkers/MarkdownChunker.js +13 -5
- package/dist/rag/chunking/markdownChunker.js +15 -6
- package/dist/server/routes/claudeProxyRoutes.d.ts +17 -3
- package/dist/server/routes/claudeProxyRoutes.js +3032 -1349
- package/dist/services/server/ai/observability/instrumentation.d.ts +7 -1
- package/dist/services/server/ai/observability/instrumentation.js +337 -161
- package/dist/tasks/backends/bullmqBackend.d.ts +1 -0
- package/dist/tasks/backends/bullmqBackend.js +35 -22
- package/dist/tasks/store/redisTaskStore.d.ts +1 -0
- package/dist/tasks/store/redisTaskStore.js +54 -39
- package/dist/tasks/taskManager.d.ts +5 -0
- package/dist/tasks/taskManager.js +158 -30
- package/dist/telemetry/index.d.ts +2 -1
- package/dist/telemetry/index.js +2 -1
- package/dist/telemetry/telemetryService.d.ts +3 -0
- package/dist/telemetry/telemetryService.js +69 -5
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/proxyTypes.d.ts +160 -5
- package/dist/types/streamTypes.d.ts +25 -3
- package/dist/utils/messageBuilder.js +3 -2
- package/dist/utils/providerHealth.d.ts +19 -0
- package/dist/utils/providerHealth.js +279 -33
- package/dist/utils/providerUtils.js +18 -22
- package/dist/utils/toolChoice.d.ts +4 -0
- package/dist/utils/toolChoice.js +6 -0
- package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +6609 -0
- package/docs/changelog.md +252 -0
- package/package.json +19 -2
- package/scripts/observability/check-proxy-telemetry.mjs +235 -0
- package/scripts/observability/docker-compose.proxy-observability.yaml +55 -0
- package/scripts/observability/import-openobserve-dashboard.mjs +240 -0
- package/scripts/observability/manage-local-openobserve.sh +215 -0
- package/scripts/observability/otel-collector.proxy-observability.yaml +78 -0
- package/scripts/observability/proxy-observability.env.example +23 -0
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import ora from "ora";
|
|
17
17
|
import { logger } from "../../lib/utils/logger.js";
|
|
18
18
|
import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
|
|
19
|
+
import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
|
|
19
20
|
import { createRequire } from "node:module";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
20
22
|
const _require = createRequire(import.meta.url);
|
|
21
23
|
const { version: PROXY_VERSION } = _require("../../../package.json");
|
|
24
|
+
const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
|
|
22
25
|
// =============================================================================
|
|
23
26
|
// STATE MANAGEMENT
|
|
24
27
|
// =============================================================================
|
|
@@ -220,6 +223,32 @@ function spawnFailOpenGuard(host, port, parentPid) {
|
|
|
220
223
|
return undefined;
|
|
221
224
|
}
|
|
222
225
|
}
|
|
226
|
+
async function runProxyTelemetryManager(command) {
|
|
227
|
+
const { existsSync } = await import("fs");
|
|
228
|
+
if (!existsSync(PROXY_TELEMETRY_SCRIPT_PATH)) {
|
|
229
|
+
throw new Error("Proxy telemetry helper files were not found in this installation. Reinstall NeuroLink with observability assets included.");
|
|
230
|
+
}
|
|
231
|
+
await new Promise((resolve, reject) => {
|
|
232
|
+
const child = spawn("bash", [PROXY_TELEMETRY_SCRIPT_PATH, command], {
|
|
233
|
+
stdio: "inherit",
|
|
234
|
+
env: process.env,
|
|
235
|
+
});
|
|
236
|
+
child.on("error", (error) => {
|
|
237
|
+
reject(error);
|
|
238
|
+
});
|
|
239
|
+
child.on("exit", (code, signal) => {
|
|
240
|
+
if (signal) {
|
|
241
|
+
reject(new Error(`proxy telemetry ${command} terminated by signal ${signal}`));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (code !== 0) {
|
|
245
|
+
reject(new Error(`proxy telemetry ${command} exited with code ${code ?? 1}`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
resolve();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
223
252
|
// =============================================================================
|
|
224
253
|
// STARTUP BANNER
|
|
225
254
|
// =============================================================================
|
|
@@ -264,6 +293,535 @@ export function mapClaudeErrorTypeToStatus(errorType) {
|
|
|
264
293
|
return 502;
|
|
265
294
|
}
|
|
266
295
|
}
|
|
296
|
+
async function ensureProxyStartAllowed(spinner) {
|
|
297
|
+
const existingState = loadProxyState();
|
|
298
|
+
if (existingState) {
|
|
299
|
+
if (isProcessRunning(existingState.pid)) {
|
|
300
|
+
if (spinner) {
|
|
301
|
+
spinner.fail(chalk.red(`Proxy already running on port ${existingState.port} (PID: ${existingState.pid})`));
|
|
302
|
+
}
|
|
303
|
+
logger.always(chalk.yellow("Stop it first or use 'neurolink proxy status' to inspect"));
|
|
304
|
+
process.exit(process.ppid === 1 ? 0 : 1);
|
|
305
|
+
}
|
|
306
|
+
clearProxyState();
|
|
307
|
+
}
|
|
308
|
+
if (process.ppid === 1 || !(await isLaunchdManaging())) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (spinner) {
|
|
312
|
+
spinner.fail(chalk.red("Proxy is managed by launchd. Manual start would cause port conflicts."));
|
|
313
|
+
}
|
|
314
|
+
logger.always(chalk.yellow("Use 'neurolink proxy uninstall' to remove the service first, " +
|
|
315
|
+
"or 'launchctl kickstart gui/$(id -u)/com.neurolink.proxy' to restart."));
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
async function loadProxyStartEnv(argv, spinner) {
|
|
319
|
+
try {
|
|
320
|
+
const envResult = await loadProxyEnvFile({
|
|
321
|
+
explicitEnvFile: argv.envFile,
|
|
322
|
+
});
|
|
323
|
+
if (spinner && envResult.path) {
|
|
324
|
+
spinner.text = `Loaded proxy env from ${envResult.path}`;
|
|
325
|
+
}
|
|
326
|
+
return envResult.path;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (spinner) {
|
|
330
|
+
spinner.fail(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
331
|
+
}
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function createProxyNeurolinkRuntime() {
|
|
336
|
+
process.env.NEUROLINK_SKIP_MCP = "true";
|
|
337
|
+
const { NeuroLink } = await import("../../lib/neurolink.js");
|
|
338
|
+
const neurolink = new NeuroLink();
|
|
339
|
+
const { initRequestLogger, cleanupLogs } = await import("../../lib/proxy/requestLogger.js");
|
|
340
|
+
initRequestLogger(true);
|
|
341
|
+
cleanupLogs(7, 500);
|
|
342
|
+
return { neurolink, cleanupLogs };
|
|
343
|
+
}
|
|
344
|
+
async function loadProxyStartConfiguration(argv, spinner) {
|
|
345
|
+
const configPath = argv.config ?? join(homedir(), ".neurolink", "proxy-config.yaml");
|
|
346
|
+
let proxyConfig = null;
|
|
347
|
+
try {
|
|
348
|
+
const { loadProxyConfig } = await import("../../lib/proxy/proxyConfig.js");
|
|
349
|
+
proxyConfig = (await loadProxyConfig(configPath));
|
|
350
|
+
if (spinner) {
|
|
351
|
+
spinner.text = `Loaded proxy config from ${configPath}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (configError) {
|
|
355
|
+
if (argv.config) {
|
|
356
|
+
if (spinner) {
|
|
357
|
+
spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
|
|
358
|
+
}
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
const isNotFound = configError instanceof Error &&
|
|
362
|
+
"code" in configError &&
|
|
363
|
+
configError.code === "ENOENT";
|
|
364
|
+
if (!isNotFound) {
|
|
365
|
+
logger.warn(`[proxy] Ignoring default config ${configPath}: ${configError instanceof Error ? configError.message : String(configError)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const strategy = (argv.strategy ??
|
|
369
|
+
proxyConfig?.routing?.strategy ??
|
|
370
|
+
"fill-first");
|
|
371
|
+
let modelRouter;
|
|
372
|
+
if (proxyConfig?.routing) {
|
|
373
|
+
const { ModelRouter } = await import("../../lib/proxy/modelRouter.js");
|
|
374
|
+
modelRouter = new ModelRouter({
|
|
375
|
+
strategy,
|
|
376
|
+
modelMappings: proxyConfig.routing.modelMappings ?? [],
|
|
377
|
+
fallbackChain: proxyConfig.routing.fallbackChain ?? [],
|
|
378
|
+
passthroughModels: proxyConfig.routing.passthroughModels,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
configPath,
|
|
383
|
+
proxyConfig,
|
|
384
|
+
strategy,
|
|
385
|
+
modelRouter,
|
|
386
|
+
passthrough: argv.passthrough ?? false,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async function createProxyStartApp(params) {
|
|
390
|
+
const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
|
|
391
|
+
const { Hono } = await import("hono");
|
|
392
|
+
const app = new Hono();
|
|
393
|
+
app.onError((err, c) => {
|
|
394
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
395
|
+
logger.always(`[proxy] unhandled error: ${errMsg}`);
|
|
396
|
+
if (err instanceof Error && err.stack) {
|
|
397
|
+
logger.debug(`[proxy] stack: ${err.stack}`);
|
|
398
|
+
}
|
|
399
|
+
return c.json({
|
|
400
|
+
type: "error",
|
|
401
|
+
error: {
|
|
402
|
+
type: "api_error",
|
|
403
|
+
message: `Proxy internal error: ${errMsg}`,
|
|
404
|
+
},
|
|
405
|
+
}, 502);
|
|
406
|
+
});
|
|
407
|
+
const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough);
|
|
408
|
+
for (const route of routeGroup.routes) {
|
|
409
|
+
const method = route.method.toLowerCase();
|
|
410
|
+
app[method](route.path, async (c) => {
|
|
411
|
+
const emptyBody = {};
|
|
412
|
+
let body;
|
|
413
|
+
let rawBody;
|
|
414
|
+
if (method === "post") {
|
|
415
|
+
rawBody = await c.req.text().catch(() => undefined);
|
|
416
|
+
try {
|
|
417
|
+
body = rawBody ? JSON.parse(rawBody) : emptyBody;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return c.json({
|
|
421
|
+
type: "error",
|
|
422
|
+
error: {
|
|
423
|
+
type: "invalid_request_error",
|
|
424
|
+
message: "Request body must be valid JSON",
|
|
425
|
+
},
|
|
426
|
+
}, 400);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const model = body?.model ?? "-";
|
|
430
|
+
const stream = body?.stream
|
|
431
|
+
? "stream"
|
|
432
|
+
: "non-stream";
|
|
433
|
+
const bodyRec = body;
|
|
434
|
+
const toolCount = Array.isArray(bodyRec?.tools)
|
|
435
|
+
? bodyRec.tools.length
|
|
436
|
+
: 0;
|
|
437
|
+
logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
|
|
438
|
+
const ctx = {
|
|
439
|
+
requestId: crypto.randomUUID(),
|
|
440
|
+
method: c.req.method,
|
|
441
|
+
path: c.req.path,
|
|
442
|
+
headers: Object.fromEntries(c.req.raw.headers.entries()),
|
|
443
|
+
query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
|
|
444
|
+
params: c.req.param(),
|
|
445
|
+
body,
|
|
446
|
+
rawBody,
|
|
447
|
+
neurolink: params.neurolink,
|
|
448
|
+
toolRegistry: params.neurolink.getToolRegistry(),
|
|
449
|
+
timestamp: Date.now(),
|
|
450
|
+
metadata: {},
|
|
451
|
+
};
|
|
452
|
+
const result = await route.handler(ctx);
|
|
453
|
+
if (result instanceof Response) {
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
if (result &&
|
|
457
|
+
typeof result === "object" &&
|
|
458
|
+
Symbol.asyncIterator in Object(result)) {
|
|
459
|
+
const iterator = result[Symbol.asyncIterator]();
|
|
460
|
+
let cancelled = false;
|
|
461
|
+
const responseStream = new ReadableStream({
|
|
462
|
+
async start(controller) {
|
|
463
|
+
try {
|
|
464
|
+
while (!cancelled) {
|
|
465
|
+
const { value, done } = await iterator.next();
|
|
466
|
+
if (done) {
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
controller.enqueue(new TextEncoder().encode(value));
|
|
470
|
+
}
|
|
471
|
+
controller.close();
|
|
472
|
+
}
|
|
473
|
+
catch (streamErr) {
|
|
474
|
+
if (cancelled) {
|
|
475
|
+
controller.close();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const errMsg = streamErr instanceof Error
|
|
479
|
+
? streamErr.message
|
|
480
|
+
: String(streamErr);
|
|
481
|
+
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Stream interrupted: ${errMsg}` } })}\n\n`;
|
|
482
|
+
try {
|
|
483
|
+
controller.enqueue(new TextEncoder().encode(errorEvent));
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Controller already errored — ignore
|
|
487
|
+
}
|
|
488
|
+
controller.close();
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
async cancel() {
|
|
492
|
+
cancelled = true;
|
|
493
|
+
await iterator.return?.();
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
return new Response(responseStream, {
|
|
497
|
+
headers: {
|
|
498
|
+
"Content-Type": "text/event-stream",
|
|
499
|
+
"Cache-Control": "no-cache",
|
|
500
|
+
Connection: "keep-alive",
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (result &&
|
|
505
|
+
typeof result === "object" &&
|
|
506
|
+
"httpStatus" in result) {
|
|
507
|
+
const httpResult = result;
|
|
508
|
+
const status = httpResult.httpStatus ?? 200;
|
|
509
|
+
delete httpResult.httpStatus;
|
|
510
|
+
return c.json(result, status);
|
|
511
|
+
}
|
|
512
|
+
if (result &&
|
|
513
|
+
typeof result === "object" &&
|
|
514
|
+
"type" in result &&
|
|
515
|
+
result.type === "error") {
|
|
516
|
+
const errorResult = result;
|
|
517
|
+
const status = mapClaudeErrorTypeToStatus(errorResult.error?.type);
|
|
518
|
+
return c.json(result, status);
|
|
519
|
+
}
|
|
520
|
+
return c.json(result ?? {});
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
app.get("/health", (c) => c.json({
|
|
524
|
+
status: "ok",
|
|
525
|
+
strategy: params.strategy,
|
|
526
|
+
uptime: process.uptime(),
|
|
527
|
+
version: PROXY_VERSION,
|
|
528
|
+
}));
|
|
529
|
+
app.get("/status", async (c) => {
|
|
530
|
+
const { getStats } = await import("../../lib/proxy/usageStats.js");
|
|
531
|
+
const stats = getStats();
|
|
532
|
+
return c.json({
|
|
533
|
+
status: "running",
|
|
534
|
+
pid: process.pid,
|
|
535
|
+
port: params.port,
|
|
536
|
+
host: params.host,
|
|
537
|
+
strategy: params.strategy,
|
|
538
|
+
uptime: process.uptime(),
|
|
539
|
+
version: PROXY_VERSION,
|
|
540
|
+
stats: {
|
|
541
|
+
totalAttempts: stats.totalAttempts,
|
|
542
|
+
totalRequests: stats.totalRequests,
|
|
543
|
+
totalSuccess: stats.totalSuccess,
|
|
544
|
+
totalErrors: stats.totalErrors,
|
|
545
|
+
totalRateLimits: stats.totalRateLimits,
|
|
546
|
+
accounts: Object.values(stats.accounts).map((account) => ({
|
|
547
|
+
label: account.label,
|
|
548
|
+
type: account.type,
|
|
549
|
+
attempts: account.attemptCount,
|
|
550
|
+
requests: account.attemptCount,
|
|
551
|
+
success: account.successCount,
|
|
552
|
+
errors: account.errorCount,
|
|
553
|
+
rateLimits: account.rateLimitCount,
|
|
554
|
+
backoffLevel: account.currentBackoffLevel,
|
|
555
|
+
cooling: account.coolingUntil
|
|
556
|
+
? account.coolingUntil > Date.now()
|
|
557
|
+
: false,
|
|
558
|
+
})),
|
|
559
|
+
},
|
|
560
|
+
config: params.proxyConfig
|
|
561
|
+
? { hasRouting: !!params.proxyConfig.routing }
|
|
562
|
+
: null,
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
return { app };
|
|
566
|
+
}
|
|
567
|
+
async function initializeProxyOpenTelemetry() {
|
|
568
|
+
try {
|
|
569
|
+
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
570
|
+
if (!process.env.OTEL_SERVICE_NAME) {
|
|
571
|
+
process.env.OTEL_SERVICE_NAME = "neurolink-proxy";
|
|
572
|
+
}
|
|
573
|
+
process.env.OTEL_RESOURCE_ATTRIBUTES = [
|
|
574
|
+
"service.name=neurolink-proxy",
|
|
575
|
+
`service.version=${PROXY_VERSION}`,
|
|
576
|
+
"deployment.environment=local",
|
|
577
|
+
process.env.OTEL_RESOURCE_ATTRIBUTES,
|
|
578
|
+
]
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join(",");
|
|
581
|
+
const { initializeOpenTelemetry, isOpenTelemetryInitialized } = await import("../../lib/services/server/ai/observability/instrumentation.js");
|
|
582
|
+
const { buildObservabilityConfigFromEnv } = await import("../../lib/utils/observabilityHelpers.js");
|
|
583
|
+
if (isOpenTelemetryInitialized()) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const observabilityConfig = buildObservabilityConfigFromEnv();
|
|
587
|
+
const langfuseConfig = observabilityConfig?.langfuse;
|
|
588
|
+
const langfuseEnabled = langfuseConfig?.enabled === true;
|
|
589
|
+
initializeOpenTelemetry({
|
|
590
|
+
enabled: langfuseEnabled,
|
|
591
|
+
publicKey: langfuseConfig?.publicKey || "",
|
|
592
|
+
secretKey: langfuseConfig?.secretKey || "",
|
|
593
|
+
baseUrl: langfuseConfig?.baseUrl,
|
|
594
|
+
environment: "proxy",
|
|
595
|
+
release: PROXY_VERSION,
|
|
596
|
+
userId: "neurolink-proxy",
|
|
597
|
+
autoDetectOperationName: true,
|
|
598
|
+
});
|
|
599
|
+
if (langfuseEnabled) {
|
|
600
|
+
logger.always(`[proxy] Langfuse enabled — exporting to ${langfuseConfig.baseUrl || "https://cloud.langfuse.com"} (environment=proxy)`);
|
|
601
|
+
}
|
|
602
|
+
if (otlpEndpoint) {
|
|
603
|
+
logger.always(`[proxy] OTLP exporter enabled — exporting to ${otlpEndpoint} (service.name=neurolink-proxy)`);
|
|
604
|
+
}
|
|
605
|
+
if (!langfuseEnabled && !otlpEndpoint) {
|
|
606
|
+
logger.always("[proxy] OpenTelemetry exporters disabled — set OTEL_EXPORTER_OTLP_ENDPOINT or Langfuse credentials to enable proxy observability");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
logger.debug(`[proxy] OpenTelemetry init failed (non-fatal): ${error instanceof Error ? error.message : String(error)}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function refreshProxyTokensInBackground() {
|
|
614
|
+
const { needsRefresh, refreshToken, persistTokens } = await import("../../lib/proxy/tokenRefresh.js");
|
|
615
|
+
const { tokenStore } = await import("../../lib/auth/tokenStore.js");
|
|
616
|
+
try {
|
|
617
|
+
const allKeys = await tokenStore.listProviders();
|
|
618
|
+
const anthropicKeys = allKeys.filter((key) => key.startsWith("anthropic:"));
|
|
619
|
+
for (const key of anthropicKeys) {
|
|
620
|
+
try {
|
|
621
|
+
const tokens = await tokenStore.loadTokens(key);
|
|
622
|
+
if (!tokens) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const account = {
|
|
626
|
+
label: key,
|
|
627
|
+
token: tokens.accessToken,
|
|
628
|
+
refreshToken: tokens.refreshToken,
|
|
629
|
+
expiresAt: tokens.expiresAt,
|
|
630
|
+
};
|
|
631
|
+
if (needsRefresh(account)) {
|
|
632
|
+
const result = await refreshToken(account);
|
|
633
|
+
if (result.success) {
|
|
634
|
+
await persistTokens({ providerKey: key }, account);
|
|
635
|
+
logger.debug(`[proxy] background token refresh succeeded for ${key}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// non-fatal per-account
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// non-fatal
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const credPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
|
|
649
|
+
const { readFileSync } = await import("fs");
|
|
650
|
+
const creds = JSON.parse(readFileSync(credPath, "utf8"));
|
|
651
|
+
if (!creds.oauth) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const account = {
|
|
655
|
+
label: "background",
|
|
656
|
+
token: creds.oauth.accessToken,
|
|
657
|
+
refreshToken: creds.oauth.refreshToken,
|
|
658
|
+
expiresAt: creds.oauth.expiresAt,
|
|
659
|
+
};
|
|
660
|
+
if (needsRefresh(account)) {
|
|
661
|
+
const result = await refreshToken(account);
|
|
662
|
+
if (result.success) {
|
|
663
|
+
await persistTokens(credPath, account);
|
|
664
|
+
logger.debug("[proxy] background token refresh succeeded");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// non-fatal
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function startProxyBackgroundMaintenance(cleanupLogs) {
|
|
673
|
+
const refreshInterval = setInterval(() => {
|
|
674
|
+
void refreshProxyTokensInBackground();
|
|
675
|
+
}, 30_000);
|
|
676
|
+
const logCleanupInterval = setInterval(() => {
|
|
677
|
+
cleanupLogs(7, 500);
|
|
678
|
+
}, 60 * 60 * 1000);
|
|
679
|
+
return { refreshInterval, logCleanupInterval };
|
|
680
|
+
}
|
|
681
|
+
function registerProxyShutdownHandlers(params) {
|
|
682
|
+
const shutdown = async (signal) => {
|
|
683
|
+
clearInterval(params.refreshInterval);
|
|
684
|
+
clearInterval(params.logCleanupInterval);
|
|
685
|
+
logger.always(`\nShutting down proxy (${signal})...`);
|
|
686
|
+
try {
|
|
687
|
+
const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
|
|
688
|
+
await flushOpenTelemetry();
|
|
689
|
+
await shutdownOpenTelemetry();
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// non-fatal — proxy shutdown must not block on OTel
|
|
693
|
+
}
|
|
694
|
+
if (signal === "SIGINT") {
|
|
695
|
+
try {
|
|
696
|
+
const shutdownHost = params.host === "0.0.0.0" ? "localhost" : params.host;
|
|
697
|
+
await clearClaudeProxySettings(`http://${shutdownHost}:${params.port}`);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// non-fatal
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
params.server.close?.();
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// Best-effort close
|
|
708
|
+
}
|
|
709
|
+
clearProxyState();
|
|
710
|
+
process.exit(signal === "SIGINT" ? 0 : 1);
|
|
711
|
+
};
|
|
712
|
+
process.on("SIGTERM", () => {
|
|
713
|
+
void shutdown("SIGTERM");
|
|
714
|
+
});
|
|
715
|
+
process.on("SIGINT", () => {
|
|
716
|
+
void shutdown("SIGINT");
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async function startProxyRuntime(params) {
|
|
720
|
+
const { serve } = await import("@hono/node-server");
|
|
721
|
+
const server = serve({
|
|
722
|
+
fetch: params.app.fetch,
|
|
723
|
+
port: params.port,
|
|
724
|
+
hostname: params.host,
|
|
725
|
+
});
|
|
726
|
+
const guardPid = spawnFailOpenGuard(params.host, params.port, process.pid);
|
|
727
|
+
const fallbackChain = params.proxyConfig?.routing?.fallbackChain?.map((entry) => ({
|
|
728
|
+
provider: entry.provider,
|
|
729
|
+
model: entry.model,
|
|
730
|
+
}));
|
|
731
|
+
saveProxyState({
|
|
732
|
+
pid: process.pid,
|
|
733
|
+
port: params.port,
|
|
734
|
+
host: params.host,
|
|
735
|
+
strategy: params.strategy,
|
|
736
|
+
startTime: new Date().toISOString(),
|
|
737
|
+
envFile: params.loadedEnvFile,
|
|
738
|
+
fallbackChain,
|
|
739
|
+
guardPid,
|
|
740
|
+
managedBy: process.platform === "darwin" && process.ppid === 1
|
|
741
|
+
? "launchd"
|
|
742
|
+
: "manual",
|
|
743
|
+
passthrough: params.passthrough,
|
|
744
|
+
});
|
|
745
|
+
if (params.spinner) {
|
|
746
|
+
params.spinner.succeed(chalk.green("Claude proxy started successfully"));
|
|
747
|
+
}
|
|
748
|
+
const normalizedHost = params.host === "0.0.0.0" ? "localhost" : params.host;
|
|
749
|
+
const url = `http://${normalizedHost}:${params.port}`;
|
|
750
|
+
printProxyBanner(url, params.strategy);
|
|
751
|
+
logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(params.passthrough ? "passthrough" : "full")}`);
|
|
752
|
+
if (params.passthrough) {
|
|
753
|
+
logger.always(chalk.yellow(" ! Passthrough mode forwards client auth directly to Anthropic"));
|
|
754
|
+
logger.always(chalk.dim(" Stored proxy OAuth/API credentials are ignored; clients need their own valid Anthropic auth."));
|
|
755
|
+
}
|
|
756
|
+
if (params.loadedEnvFile) {
|
|
757
|
+
logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(params.loadedEnvFile)}`);
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
await setClaudeProxySettings(url);
|
|
761
|
+
logger.always(chalk.green(" ✓ Auto-configured Claude Code settings"));
|
|
762
|
+
logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
logger.debug("[proxy] Failed to auto-configure Claude Code: " +
|
|
766
|
+
(error instanceof Error ? error.message : String(error)));
|
|
767
|
+
}
|
|
768
|
+
const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs);
|
|
769
|
+
registerProxyShutdownHandlers({
|
|
770
|
+
server,
|
|
771
|
+
host: params.host,
|
|
772
|
+
port: params.port,
|
|
773
|
+
...maintenance,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
async function startProxyCommandHandler(argv) {
|
|
777
|
+
const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
|
|
778
|
+
try {
|
|
779
|
+
await ensureProxyStartAllowed(spinner);
|
|
780
|
+
const loadedEnvFile = await loadProxyStartEnv(argv, spinner);
|
|
781
|
+
const { neurolink, cleanupLogs } = await createProxyNeurolinkRuntime();
|
|
782
|
+
const { proxyConfig, strategy, modelRouter, passthrough } = await loadProxyStartConfiguration(argv, spinner);
|
|
783
|
+
if (spinner) {
|
|
784
|
+
spinner.text = "Configuring server...";
|
|
785
|
+
}
|
|
786
|
+
const port = argv.port ?? 55669;
|
|
787
|
+
const host = argv.host ?? "127.0.0.1";
|
|
788
|
+
const { app } = await createProxyStartApp({
|
|
789
|
+
neurolink,
|
|
790
|
+
modelRouter,
|
|
791
|
+
strategy,
|
|
792
|
+
passthrough,
|
|
793
|
+
port,
|
|
794
|
+
host,
|
|
795
|
+
proxyConfig,
|
|
796
|
+
});
|
|
797
|
+
await initializeProxyOpenTelemetry();
|
|
798
|
+
if (spinner) {
|
|
799
|
+
spinner.text = `Starting proxy on ${host}:${port}...`;
|
|
800
|
+
}
|
|
801
|
+
await startProxyRuntime({
|
|
802
|
+
argv,
|
|
803
|
+
spinner,
|
|
804
|
+
app,
|
|
805
|
+
host,
|
|
806
|
+
port,
|
|
807
|
+
strategy,
|
|
808
|
+
proxyConfig,
|
|
809
|
+
loadedEnvFile,
|
|
810
|
+
passthrough,
|
|
811
|
+
cleanupLogs,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
if (spinner) {
|
|
816
|
+
spinner.fail(chalk.red("Failed to start proxy"));
|
|
817
|
+
}
|
|
818
|
+
logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
819
|
+
if (argv.debug && error instanceof Error && error.stack) {
|
|
820
|
+
logger.error(chalk.gray(error.stack));
|
|
821
|
+
}
|
|
822
|
+
process.exit(1);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
267
825
|
// =============================================================================
|
|
268
826
|
// PROXY START COMMAND
|
|
269
827
|
// =============================================================================
|
|
@@ -313,440 +871,31 @@ export const proxyStartCommand = {
|
|
|
313
871
|
alias: "c",
|
|
314
872
|
description: "Path to proxy config file (YAML/JSON)",
|
|
315
873
|
defaultDescription: "~/.neurolink/proxy-config.yaml",
|
|
874
|
+
})
|
|
875
|
+
.option("env-file", {
|
|
876
|
+
type: "string",
|
|
877
|
+
alias: "envFile",
|
|
878
|
+
description: "Path to proxy provider env file (overrides cwd .env for the proxy process)",
|
|
879
|
+
})
|
|
880
|
+
.option("passthrough", {
|
|
881
|
+
type: "boolean",
|
|
882
|
+
default: false,
|
|
883
|
+
description: "Run in transparent passthrough mode (no retry, no rotation, no polyfill)",
|
|
316
884
|
})
|
|
317
885
|
.example("neurolink proxy start", "Start proxy on default port 55669 with fill-first strategy")
|
|
318
886
|
.example("neurolink proxy start -p 8080 -s fill-first", "Start proxy on port 8080 with fill-first")
|
|
319
887
|
.example("neurolink proxy start --health-interval 60", "Start proxy with 60-second health checks");
|
|
320
888
|
},
|
|
321
889
|
handler: async (argv) => {
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
// Guard: proxy already running
|
|
325
|
-
const existingState = loadProxyState();
|
|
326
|
-
if (existingState) {
|
|
327
|
-
if (isProcessRunning(existingState.pid)) {
|
|
328
|
-
if (spinner) {
|
|
329
|
-
spinner.fail(chalk.red(`Proxy already running on port ${existingState.port} (PID: ${existingState.pid})`));
|
|
330
|
-
}
|
|
331
|
-
logger.always(chalk.yellow("Stop it first or use 'neurolink proxy status' to inspect"));
|
|
332
|
-
// Exit cleanly when managed by launchd so it doesn't treat this
|
|
333
|
-
// as a crash and spam-restart every ThrottleInterval seconds.
|
|
334
|
-
// For manual invocations, use non-zero so scripts/CI detect failure.
|
|
335
|
-
process.exit(process.ppid === 1 ? 0 : 1);
|
|
336
|
-
}
|
|
337
|
-
else {
|
|
338
|
-
// Stale state file from a previous process that is no longer running.
|
|
339
|
-
// Clear it so subsequent startup logic doesn't get confused.
|
|
340
|
-
clearProxyState();
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
// Guard: launchd is managing the service — don't start manually.
|
|
344
|
-
// Skip this guard when WE are the process launchd is managing
|
|
345
|
-
// (PPID 1 = launched by launchd on macOS).
|
|
346
|
-
if (process.ppid !== 1 && (await isLaunchdManaging())) {
|
|
347
|
-
if (spinner) {
|
|
348
|
-
spinner.fail(chalk.red("Proxy is managed by launchd. Manual start would cause port conflicts."));
|
|
349
|
-
}
|
|
350
|
-
logger.always(chalk.yellow("Use 'neurolink proxy uninstall' to remove the service first, " +
|
|
351
|
-
"or 'launchctl kickstart gui/$(id -u)/com.neurolink.proxy' to restart."));
|
|
352
|
-
// This guard only runs for manual invocations (ppid !== 1),
|
|
353
|
-
// so launchd KeepAlive is unaffected. Use non-zero exit code
|
|
354
|
-
// so scripts/CI can detect that the proxy was not started.
|
|
355
|
-
process.exit(1);
|
|
356
|
-
}
|
|
357
|
-
// -----------------------------------------------------------------
|
|
358
|
-
// 1. Create NeuroLink instance — reads all env vars automatically
|
|
359
|
-
// -----------------------------------------------------------------
|
|
360
|
-
// Skip MCP initialization for proxy — tools come from Claude Code, not MCP servers
|
|
361
|
-
process.env.NEUROLINK_SKIP_MCP = "true";
|
|
362
|
-
const { NeuroLink } = await import("../../lib/neurolink.js");
|
|
363
|
-
const neurolink = new NeuroLink();
|
|
364
|
-
// Initialize request logger and usage stats
|
|
365
|
-
const { initRequestLogger, cleanupLogs } = await import("../../lib/proxy/requestLogger.js");
|
|
366
|
-
const { getStats: _getStats, resetStats: _resetStats } = await import("../../lib/proxy/usageStats.js");
|
|
367
|
-
initRequestLogger(true);
|
|
368
|
-
cleanupLogs(7, 500); // Delete logs older than 7 days or if total exceeds 500 MB
|
|
369
|
-
// -----------------------------------------------------------------
|
|
370
|
-
// 2. Load proxy config file (if --config or default exists)
|
|
371
|
-
// -----------------------------------------------------------------
|
|
372
|
-
const configPath = argv.config ?? join(homedir(), ".neurolink", "proxy-config.yaml");
|
|
373
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
374
|
-
let proxyConfig = null;
|
|
375
|
-
try {
|
|
376
|
-
const { loadProxyConfig } = await import("../../lib/proxy/proxyConfig.js");
|
|
377
|
-
proxyConfig = await loadProxyConfig(configPath);
|
|
378
|
-
if (spinner) {
|
|
379
|
-
spinner.text = `Loaded proxy config from ${configPath}`;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
catch (configError) {
|
|
383
|
-
if (argv.config) {
|
|
384
|
-
if (spinner) {
|
|
385
|
-
spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
|
|
386
|
-
}
|
|
387
|
-
process.exit(1);
|
|
388
|
-
}
|
|
389
|
-
// Only silently ignore file-not-found for the default config path.
|
|
390
|
-
// Log a warning for other errors (parse failures, permission issues, etc.)
|
|
391
|
-
const isNotFound = configError instanceof Error &&
|
|
392
|
-
"code" in configError &&
|
|
393
|
-
configError.code === "ENOENT";
|
|
394
|
-
if (!isNotFound) {
|
|
395
|
-
logger.warn(`[proxy] Ignoring default config ${configPath}: ${configError instanceof Error ? configError.message : String(configError)}`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
// -----------------------------------------------------------------
|
|
399
|
-
// 3. Create ModelRouter from config (if routing configured)
|
|
400
|
-
// -----------------------------------------------------------------
|
|
401
|
-
const strategy = argv.strategy ?? proxyConfig?.routing?.strategy ?? "fill-first";
|
|
402
|
-
let modelRouter;
|
|
403
|
-
if (proxyConfig?.routing) {
|
|
404
|
-
const { ModelRouter } = await import("../../lib/proxy/modelRouter.js");
|
|
405
|
-
modelRouter = new ModelRouter({
|
|
406
|
-
strategy: strategy,
|
|
407
|
-
modelMappings: proxyConfig.routing.modelMappings ?? [],
|
|
408
|
-
fallbackChain: proxyConfig.routing.fallbackChain ?? [],
|
|
409
|
-
passthroughModels: proxyConfig.routing.passthroughModels,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
if (spinner) {
|
|
413
|
-
spinner.text = "Configuring server...";
|
|
414
|
-
}
|
|
415
|
-
// -----------------------------------------------------------------
|
|
416
|
-
// 4. Build Hono app with Claude proxy routes and NeuroLink context
|
|
417
|
-
// -----------------------------------------------------------------
|
|
418
|
-
const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
|
|
419
|
-
const { Hono } = await import("hono");
|
|
420
|
-
const { serve } = await import("@hono/node-server");
|
|
421
|
-
const app = new Hono();
|
|
422
|
-
app.onError((err, c) => {
|
|
423
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
424
|
-
logger.always(`[proxy] unhandled error: ${errMsg}`);
|
|
425
|
-
if (err instanceof Error && err.stack) {
|
|
426
|
-
logger.debug(`[proxy] stack: ${err.stack}`);
|
|
427
|
-
}
|
|
428
|
-
return c.json({
|
|
429
|
-
type: "error",
|
|
430
|
-
error: {
|
|
431
|
-
type: "api_error",
|
|
432
|
-
message: `Proxy internal error: ${errMsg}`,
|
|
433
|
-
},
|
|
434
|
-
}, 502);
|
|
435
|
-
});
|
|
436
|
-
const routeGroup = createClaudeProxyRoutes(modelRouter, "", strategy);
|
|
437
|
-
// Register proxy routes — inject NeuroLink into ServerContext
|
|
438
|
-
for (const route of routeGroup.routes) {
|
|
439
|
-
const method = route.method.toLowerCase();
|
|
440
|
-
app[method](route.path, async (c) => {
|
|
441
|
-
const emptyBody = {};
|
|
442
|
-
const body = method === "post"
|
|
443
|
-
? await c.req.json().catch(() => emptyBody)
|
|
444
|
-
: undefined;
|
|
445
|
-
// Log incoming request
|
|
446
|
-
const model = body?.model ?? "-";
|
|
447
|
-
const stream = body?.stream
|
|
448
|
-
? "stream"
|
|
449
|
-
: "non-stream";
|
|
450
|
-
const bodyRec = body;
|
|
451
|
-
const toolCount = Array.isArray(bodyRec?.tools)
|
|
452
|
-
? bodyRec.tools.length
|
|
453
|
-
: 0;
|
|
454
|
-
logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
|
|
455
|
-
// Build ServerContext with the real NeuroLink instance
|
|
456
|
-
const ctx = {
|
|
457
|
-
requestId: crypto.randomUUID(),
|
|
458
|
-
method: c.req.method,
|
|
459
|
-
path: c.req.path,
|
|
460
|
-
headers: Object.fromEntries(c.req.raw.headers.entries()),
|
|
461
|
-
query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
|
|
462
|
-
params: c.req.param(),
|
|
463
|
-
body,
|
|
464
|
-
neurolink, // NeuroLink instance for generate/stream
|
|
465
|
-
toolRegistry: neurolink.getToolRegistry(),
|
|
466
|
-
timestamp: Date.now(),
|
|
467
|
-
metadata: {},
|
|
468
|
-
};
|
|
469
|
-
const result = await route.handler(ctx);
|
|
470
|
-
// Handle raw Response objects (passthrough streaming from upstream)
|
|
471
|
-
if (result instanceof Response) {
|
|
472
|
-
return result;
|
|
473
|
-
}
|
|
474
|
-
// Handle streaming response (async iterable)
|
|
475
|
-
if (result &&
|
|
476
|
-
typeof result === "object" &&
|
|
477
|
-
Symbol.asyncIterator in Object(result)) {
|
|
478
|
-
const iterator = result[Symbol.asyncIterator]();
|
|
479
|
-
let cancelled = false;
|
|
480
|
-
const stream = new ReadableStream({
|
|
481
|
-
async start(controller) {
|
|
482
|
-
try {
|
|
483
|
-
while (!cancelled) {
|
|
484
|
-
const { value, done } = await iterator.next();
|
|
485
|
-
if (done) {
|
|
486
|
-
break;
|
|
487
|
-
}
|
|
488
|
-
controller.enqueue(new TextEncoder().encode(value));
|
|
489
|
-
}
|
|
490
|
-
controller.close();
|
|
491
|
-
}
|
|
492
|
-
catch (streamErr) {
|
|
493
|
-
if (cancelled) {
|
|
494
|
-
// Client disconnected — just close
|
|
495
|
-
controller.close();
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
// Emit an SSE error frame so the client gets a meaningful
|
|
499
|
-
// error instead of a silent EOF / connection drop.
|
|
500
|
-
const errMsg = streamErr instanceof Error
|
|
501
|
-
? streamErr.message
|
|
502
|
-
: String(streamErr);
|
|
503
|
-
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Stream interrupted: ${errMsg}` } })}\n\n`;
|
|
504
|
-
try {
|
|
505
|
-
controller.enqueue(new TextEncoder().encode(errorEvent));
|
|
506
|
-
}
|
|
507
|
-
catch {
|
|
508
|
-
// Controller already errored — ignore
|
|
509
|
-
}
|
|
510
|
-
controller.close();
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
async cancel() {
|
|
514
|
-
cancelled = true;
|
|
515
|
-
await iterator.return?.();
|
|
516
|
-
},
|
|
517
|
-
});
|
|
518
|
-
return new Response(stream, {
|
|
519
|
-
headers: {
|
|
520
|
-
"Content-Type": "text/event-stream",
|
|
521
|
-
"Cache-Control": "no-cache",
|
|
522
|
-
Connection: "keep-alive",
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
// Handle error responses with httpStatus field
|
|
527
|
-
if (result &&
|
|
528
|
-
typeof result === "object" &&
|
|
529
|
-
"httpStatus" in result) {
|
|
530
|
-
const httpResult = result;
|
|
531
|
-
const status = httpResult.httpStatus ?? 200;
|
|
532
|
-
delete httpResult.httpStatus;
|
|
533
|
-
return c.json(result, status);
|
|
534
|
-
}
|
|
535
|
-
// Handle Anthropic-style error responses
|
|
536
|
-
if (result &&
|
|
537
|
-
typeof result === "object" &&
|
|
538
|
-
"type" in result &&
|
|
539
|
-
result.type === "error") {
|
|
540
|
-
const errorResult = result;
|
|
541
|
-
const status = mapClaudeErrorTypeToStatus(errorResult.error?.type);
|
|
542
|
-
return c.json(result, status);
|
|
543
|
-
}
|
|
544
|
-
return c.json(result ?? {});
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
// Health endpoint
|
|
548
|
-
app.get("/health", (c) => c.json({
|
|
549
|
-
status: "ok",
|
|
550
|
-
strategy,
|
|
551
|
-
uptime: process.uptime(),
|
|
552
|
-
version: PROXY_VERSION,
|
|
553
|
-
}));
|
|
554
|
-
// Status endpoint (detailed)
|
|
555
|
-
app.get("/status", async (c) => {
|
|
556
|
-
const { getStats } = await import("../../lib/proxy/usageStats.js");
|
|
557
|
-
const stats = getStats();
|
|
558
|
-
return c.json({
|
|
559
|
-
status: "running",
|
|
560
|
-
pid: process.pid,
|
|
561
|
-
port,
|
|
562
|
-
host,
|
|
563
|
-
strategy,
|
|
564
|
-
uptime: process.uptime(),
|
|
565
|
-
version: PROXY_VERSION,
|
|
566
|
-
stats: {
|
|
567
|
-
totalRequests: stats.totalRequests,
|
|
568
|
-
totalSuccess: stats.totalSuccess,
|
|
569
|
-
totalErrors: stats.totalErrors,
|
|
570
|
-
totalRateLimits: stats.totalRateLimits,
|
|
571
|
-
accounts: Object.values(stats.accounts).map((a) => ({
|
|
572
|
-
label: a.label,
|
|
573
|
-
type: a.type,
|
|
574
|
-
requests: a.requestCount,
|
|
575
|
-
success: a.successCount,
|
|
576
|
-
errors: a.errorCount,
|
|
577
|
-
rateLimits: a.rateLimitCount,
|
|
578
|
-
backoffLevel: a.currentBackoffLevel,
|
|
579
|
-
cooling: a.coolingUntil ? a.coolingUntil > Date.now() : false,
|
|
580
|
-
})),
|
|
581
|
-
},
|
|
582
|
-
config: proxyConfig ? { hasRouting: !!proxyConfig.routing } : null,
|
|
583
|
-
});
|
|
584
|
-
});
|
|
585
|
-
// -----------------------------------------------------------------
|
|
586
|
-
// 5. Start listening
|
|
587
|
-
// -----------------------------------------------------------------
|
|
588
|
-
const port = argv.port ?? 55669;
|
|
589
|
-
const host = argv.host ?? "127.0.0.1";
|
|
590
|
-
if (spinner) {
|
|
591
|
-
spinner.text = `Starting proxy on ${host}:${port}...`;
|
|
592
|
-
}
|
|
593
|
-
const server = serve({
|
|
594
|
-
fetch: app.fetch,
|
|
595
|
-
port,
|
|
596
|
-
hostname: host,
|
|
597
|
-
});
|
|
598
|
-
const guardPid = spawnFailOpenGuard(host, port, process.pid);
|
|
599
|
-
// Extract fallback chain from proxy config (if available)
|
|
600
|
-
const fallbackChain = proxyConfig?.routing?.fallbackChain?.map((e) => ({
|
|
601
|
-
provider: e.provider,
|
|
602
|
-
model: e.model,
|
|
603
|
-
}));
|
|
604
|
-
// Persist state (including fallback chain for `proxy status`)
|
|
605
|
-
const state = {
|
|
606
|
-
pid: process.pid,
|
|
607
|
-
port,
|
|
608
|
-
host,
|
|
609
|
-
strategy,
|
|
610
|
-
startTime: new Date().toISOString(),
|
|
611
|
-
fallbackChain,
|
|
612
|
-
guardPid,
|
|
613
|
-
managedBy: "manual",
|
|
614
|
-
};
|
|
615
|
-
saveProxyState(state);
|
|
616
|
-
if (spinner) {
|
|
617
|
-
spinner.succeed(chalk.green("Claude proxy started successfully"));
|
|
618
|
-
}
|
|
619
|
-
const normalizedHost = host === "0.0.0.0" ? "localhost" : host;
|
|
620
|
-
const url = `http://${normalizedHost}:${port}`;
|
|
621
|
-
printProxyBanner(url, strategy);
|
|
622
|
-
// Auto-configure Claude Code — use the normalized URL (localhost, not 0.0.0.0)
|
|
623
|
-
try {
|
|
624
|
-
await setClaudeProxySettings(url);
|
|
625
|
-
logger.always(chalk.green(" ✓ Auto-configured Claude Code settings"));
|
|
626
|
-
logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
|
|
627
|
-
}
|
|
628
|
-
catch (e) {
|
|
629
|
-
logger.debug("[proxy] Failed to auto-configure Claude Code: " +
|
|
630
|
-
(e instanceof Error ? e.message : String(e)));
|
|
631
|
-
}
|
|
632
|
-
// -----------------------------------------------------------------
|
|
633
|
-
// 6. Background token refresh (every 30 seconds)
|
|
634
|
-
// -----------------------------------------------------------------
|
|
635
|
-
const { needsRefresh, refreshToken, persistTokens } = await import("../../lib/proxy/tokenRefresh.js");
|
|
636
|
-
const { tokenStore } = await import("../../lib/auth/tokenStore.js");
|
|
637
|
-
const refreshInterval = setInterval(async () => {
|
|
638
|
-
// Refresh token-pool accounts
|
|
639
|
-
try {
|
|
640
|
-
const allKeys = await tokenStore.listProviders();
|
|
641
|
-
const anthropicKeys = allKeys.filter((k) => k.startsWith("anthropic:"));
|
|
642
|
-
for (const key of anthropicKeys) {
|
|
643
|
-
try {
|
|
644
|
-
const tokens = await tokenStore.loadTokens(key);
|
|
645
|
-
if (!tokens) {
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
const account = {
|
|
649
|
-
label: key,
|
|
650
|
-
token: tokens.accessToken,
|
|
651
|
-
refreshToken: tokens.refreshToken,
|
|
652
|
-
expiresAt: tokens.expiresAt,
|
|
653
|
-
};
|
|
654
|
-
if (needsRefresh(account)) {
|
|
655
|
-
const result = await refreshToken(account);
|
|
656
|
-
if (result.success) {
|
|
657
|
-
await persistTokens({ providerKey: key }, account);
|
|
658
|
-
logger.debug(`[proxy] background token refresh succeeded for ${key}`);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
catch {
|
|
663
|
-
/* non-fatal per-account */
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
catch {
|
|
668
|
-
/* non-fatal */
|
|
669
|
-
}
|
|
670
|
-
// Refresh legacy credentials file
|
|
671
|
-
try {
|
|
672
|
-
const credPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
|
|
673
|
-
const { readFileSync } = await import("fs");
|
|
674
|
-
const creds = JSON.parse(readFileSync(credPath, "utf8"));
|
|
675
|
-
if (creds.oauth) {
|
|
676
|
-
const account = {
|
|
677
|
-
label: "background",
|
|
678
|
-
token: creds.oauth.accessToken,
|
|
679
|
-
refreshToken: creds.oauth.refreshToken,
|
|
680
|
-
expiresAt: creds.oauth.expiresAt,
|
|
681
|
-
};
|
|
682
|
-
if (needsRefresh(account)) {
|
|
683
|
-
const result = await refreshToken(account);
|
|
684
|
-
if (result.success) {
|
|
685
|
-
await persistTokens(credPath, account);
|
|
686
|
-
logger.debug("[proxy] background token refresh succeeded");
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
catch {
|
|
692
|
-
/* non-fatal */
|
|
693
|
-
}
|
|
694
|
-
}, 30_000);
|
|
695
|
-
// Hourly log cleanup
|
|
696
|
-
const logCleanupInterval = setInterval(() => {
|
|
697
|
-
cleanupLogs(7, 500);
|
|
698
|
-
}, 60 * 60 * 1000);
|
|
699
|
-
// -----------------------------------------------------------------
|
|
700
|
-
// 7. Graceful shutdown
|
|
701
|
-
// -----------------------------------------------------------------
|
|
702
|
-
const shutdown = async (signal) => {
|
|
703
|
-
clearInterval(refreshInterval);
|
|
704
|
-
clearInterval(logCleanupInterval);
|
|
705
|
-
logger.always(`\nShutting down proxy (${signal})...`);
|
|
706
|
-
// Only clear Claude settings on user-initiated stop (SIGINT).
|
|
707
|
-
// On SIGTERM (launchd restart cycle), leave settings intact so
|
|
708
|
-
// the restarted proxy picks up seamlessly.
|
|
709
|
-
if (signal === "SIGINT") {
|
|
710
|
-
try {
|
|
711
|
-
const shutdownHost = host === "0.0.0.0" ? "localhost" : host;
|
|
712
|
-
await clearClaudeProxySettings(`http://${shutdownHost}:${port}`);
|
|
713
|
-
}
|
|
714
|
-
catch {
|
|
715
|
-
/* non-fatal */
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
try {
|
|
719
|
-
if (server &&
|
|
720
|
-
typeof server.close === "function") {
|
|
721
|
-
server.close();
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
catch {
|
|
725
|
-
// Best-effort close
|
|
726
|
-
}
|
|
727
|
-
clearProxyState();
|
|
728
|
-
// SIGINT = user pressed Ctrl+C → exit 0 (launchd won't restart)
|
|
729
|
-
// SIGTERM = launchd/system stop → exit 1 (launchd WILL restart)
|
|
730
|
-
process.exit(signal === "SIGINT" ? 0 : 1);
|
|
731
|
-
};
|
|
732
|
-
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
733
|
-
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
734
|
-
}
|
|
735
|
-
catch (error) {
|
|
736
|
-
if (spinner) {
|
|
737
|
-
spinner.fail(chalk.red("Failed to start proxy"));
|
|
738
|
-
}
|
|
739
|
-
logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
740
|
-
if (argv.debug && error instanceof Error && error.stack) {
|
|
741
|
-
logger.error(chalk.gray(error.stack));
|
|
742
|
-
}
|
|
743
|
-
process.exit(1);
|
|
744
|
-
}
|
|
890
|
+
await startProxyCommandHandler(argv);
|
|
745
891
|
},
|
|
746
892
|
};
|
|
747
893
|
function printStatusStats(stats) {
|
|
748
894
|
console.info(`\n Stats:`);
|
|
749
|
-
|
|
895
|
+
if (stats.totalAttempts !== undefined) {
|
|
896
|
+
console.info(` Attempts: ${stats.totalAttempts}`);
|
|
897
|
+
}
|
|
898
|
+
console.info(` Completed: ${stats.totalRequests} total, ${stats.totalSuccess} success, ${stats.totalErrors} errors`);
|
|
750
899
|
console.info(` Rate limits: ${stats.totalRateLimits}`);
|
|
751
900
|
if (stats.accounts?.length) {
|
|
752
901
|
console.info(`\n Accounts:`);
|
|
@@ -754,7 +903,11 @@ function printStatusStats(stats) {
|
|
|
754
903
|
const acctStatus = a.cooling
|
|
755
904
|
? chalk.red("cooling")
|
|
756
905
|
: chalk.green("active");
|
|
757
|
-
|
|
906
|
+
const attempts = a.attempts ?? a.requests ?? 0;
|
|
907
|
+
const success = a.success ?? 0;
|
|
908
|
+
const errors = a.errors ?? 0;
|
|
909
|
+
const rateLimits = a.rateLimits ?? 0;
|
|
910
|
+
console.info(` ${a.label.padEnd(20)} ${a.type.padEnd(8)} ${String(attempts).padEnd(6)} attempts ${String(success).padEnd(6)} success ${String(errors).padEnd(6)} errors ${String(rateLimits).padEnd(6)} rl ${acctStatus}`);
|
|
758
911
|
}
|
|
759
912
|
}
|
|
760
913
|
}
|
|
@@ -789,10 +942,12 @@ export const proxyStatusCommand = {
|
|
|
789
942
|
pid: null,
|
|
790
943
|
port: null,
|
|
791
944
|
host: null,
|
|
945
|
+
mode: null,
|
|
792
946
|
strategy: null,
|
|
793
947
|
uptime: null,
|
|
794
948
|
startTime: null,
|
|
795
949
|
url: null,
|
|
950
|
+
envFile: null,
|
|
796
951
|
fallbackChain: null,
|
|
797
952
|
};
|
|
798
953
|
if (state && isProcessRunning(state.pid)) {
|
|
@@ -800,14 +955,30 @@ export const proxyStatusCommand = {
|
|
|
800
955
|
status.pid = state.pid;
|
|
801
956
|
status.port = state.port;
|
|
802
957
|
status.host = state.host;
|
|
958
|
+
status.mode = state.passthrough ? "passthrough" : "full";
|
|
803
959
|
status.strategy = state.strategy;
|
|
804
960
|
status.startTime = state.startTime;
|
|
805
961
|
status.uptime = Date.now() - new Date(state.startTime).getTime();
|
|
806
962
|
status.url = `http://${state.host === "0.0.0.0" ? "localhost" : state.host}:${state.port}`;
|
|
963
|
+
status.envFile = state.envFile ?? null;
|
|
807
964
|
status.fallbackChain = state.fallbackChain ?? null;
|
|
808
965
|
}
|
|
966
|
+
// Fetch live stats before rendering (JSON or text)
|
|
967
|
+
let liveStats = null;
|
|
968
|
+
if (status.running && status.url) {
|
|
969
|
+
try {
|
|
970
|
+
const statusResp = await fetch(`${status.url}/status`);
|
|
971
|
+
if (statusResp.ok) {
|
|
972
|
+
const statusData = (await statusResp.json());
|
|
973
|
+
liveStats = statusData.stats;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
// Non-fatal — live stats unavailable
|
|
978
|
+
}
|
|
979
|
+
}
|
|
809
980
|
if (argv.format === "json") {
|
|
810
|
-
logger.always(JSON.stringify(status, null, 2));
|
|
981
|
+
logger.always(JSON.stringify({ ...status, stats: liveStats }, null, 2));
|
|
811
982
|
return;
|
|
812
983
|
}
|
|
813
984
|
// Text format
|
|
@@ -820,8 +991,12 @@ export const proxyStatusCommand = {
|
|
|
820
991
|
logger.always(` ${chalk.bold("PID:")} ${chalk.cyan(status.pid)}`);
|
|
821
992
|
logger.always(` ${chalk.bold("URL:")} ${chalk.cyan(status.url)}`);
|
|
822
993
|
logger.always(` ${chalk.bold("Strategy:")} ${chalk.cyan(status.strategy)}`);
|
|
994
|
+
logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(status.mode ?? "full")}`);
|
|
823
995
|
logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
|
|
824
996
|
logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
|
|
997
|
+
if (status.envFile) {
|
|
998
|
+
logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(status.envFile)}`);
|
|
999
|
+
}
|
|
825
1000
|
// Display fallback chain if configured
|
|
826
1001
|
if (status.fallbackChain && status.fallbackChain.length > 0) {
|
|
827
1002
|
logger.always("");
|
|
@@ -875,6 +1050,58 @@ export const proxyStatusCommand = {
|
|
|
875
1050
|
},
|
|
876
1051
|
};
|
|
877
1052
|
// =============================================================================
|
|
1053
|
+
// PROXY TELEMETRY COMMAND
|
|
1054
|
+
// =============================================================================
|
|
1055
|
+
const PROXY_TELEMETRY_ACTIONS = [
|
|
1056
|
+
"setup",
|
|
1057
|
+
"start",
|
|
1058
|
+
"stop",
|
|
1059
|
+
"status",
|
|
1060
|
+
"logs",
|
|
1061
|
+
"import-dashboard",
|
|
1062
|
+
];
|
|
1063
|
+
export const proxyTelemetryCommand = {
|
|
1064
|
+
command: "telemetry <action>",
|
|
1065
|
+
describe: "Manage the local OpenObserve stack and dashboard for proxy observability",
|
|
1066
|
+
builder: (yargs) => yargs
|
|
1067
|
+
.positional("action", {
|
|
1068
|
+
type: "string",
|
|
1069
|
+
choices: [...PROXY_TELEMETRY_ACTIONS],
|
|
1070
|
+
describe: "Telemetry action: setup, start, stop, status, logs, or import-dashboard",
|
|
1071
|
+
})
|
|
1072
|
+
.option("quiet", {
|
|
1073
|
+
type: "boolean",
|
|
1074
|
+
alias: "q",
|
|
1075
|
+
default: false,
|
|
1076
|
+
description: "Suppress the local CLI spinner and delegate directly",
|
|
1077
|
+
})
|
|
1078
|
+
.example("neurolink proxy telemetry setup", "Start OpenObserve, start the OTEL collector, and import the dashboard")
|
|
1079
|
+
.example("neurolink proxy telemetry start", "Start the local proxy telemetry stack without re-importing the dashboard")
|
|
1080
|
+
.example("neurolink proxy telemetry stop", "Stop the local OpenObserve and OTEL collector containers"),
|
|
1081
|
+
handler: async (argv) => {
|
|
1082
|
+
const action = argv.action;
|
|
1083
|
+
const spinner = argv.quiet
|
|
1084
|
+
? null
|
|
1085
|
+
: ora(`Running proxy telemetry ${action}...`).start();
|
|
1086
|
+
try {
|
|
1087
|
+
if (spinner) {
|
|
1088
|
+
spinner.stop();
|
|
1089
|
+
}
|
|
1090
|
+
await runProxyTelemetryManager(action);
|
|
1091
|
+
if (spinner) {
|
|
1092
|
+
spinner.succeed(`proxy telemetry ${action} completed`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
if (spinner) {
|
|
1097
|
+
spinner.fail(`proxy telemetry ${action} failed`);
|
|
1098
|
+
}
|
|
1099
|
+
logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
// =============================================================================
|
|
878
1105
|
// PROXY FAIL-OPEN GUARD COMMAND (HIDDEN)
|
|
879
1106
|
// =============================================================================
|
|
880
1107
|
export const proxyGuardCommand = {
|
|
@@ -1152,6 +1379,11 @@ export const proxySetupCommand = {
|
|
|
1152
1379
|
type: "boolean",
|
|
1153
1380
|
default: false,
|
|
1154
1381
|
description: "Skip service installation and start proxy in foreground instead",
|
|
1382
|
+
})
|
|
1383
|
+
.option("env-file", {
|
|
1384
|
+
type: "string",
|
|
1385
|
+
alias: "envFile",
|
|
1386
|
+
description: "Path to proxy provider env file to persist for the proxy",
|
|
1155
1387
|
})
|
|
1156
1388
|
.example("neurolink proxy setup", "Full setup with defaults")
|
|
1157
1389
|
.example("neurolink proxy setup -p 9000", "Setup on custom port")
|
|
@@ -1253,9 +1485,27 @@ export const proxySetupCommand = {
|
|
|
1253
1485
|
// =============================================================================
|
|
1254
1486
|
// PROXY INSTALL / UNINSTALL — launchd service (macOS)
|
|
1255
1487
|
// =============================================================================
|
|
1256
|
-
function
|
|
1257
|
-
|
|
1258
|
-
|
|
1488
|
+
function escapeXml(s) {
|
|
1489
|
+
return s
|
|
1490
|
+
.replace(/&/g, "&")
|
|
1491
|
+
.replace(/</g, "<")
|
|
1492
|
+
.replace(/>/g, ">")
|
|
1493
|
+
.replace(/"/g, """)
|
|
1494
|
+
.replace(/'/g, "'");
|
|
1495
|
+
}
|
|
1496
|
+
function buildPlist(port, host, envFile, configFile) {
|
|
1497
|
+
const nodeExec = escapeXml(process.execPath);
|
|
1498
|
+
const entryScript = escapeXml(process.argv[1] ?? join(__dirname, "..", "index.js"));
|
|
1499
|
+
const envFileArgs = envFile
|
|
1500
|
+
? `
|
|
1501
|
+
<string>--env-file</string>
|
|
1502
|
+
<string>${escapeXml(envFile)}</string>`
|
|
1503
|
+
: "";
|
|
1504
|
+
const configArgs = configFile
|
|
1505
|
+
? `
|
|
1506
|
+
<string>--config</string>
|
|
1507
|
+
<string>${escapeXml(configFile)}</string>`
|
|
1508
|
+
: "";
|
|
1259
1509
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1260
1510
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
1261
1511
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -1274,6 +1524,8 @@ function buildPlist(port, host) {
|
|
|
1274
1524
|
<string>${port}</string>
|
|
1275
1525
|
<string>--host</string>
|
|
1276
1526
|
<string>${host}</string>
|
|
1527
|
+
${envFileArgs}
|
|
1528
|
+
${configArgs}
|
|
1277
1529
|
<string>--quiet</string>
|
|
1278
1530
|
</array>
|
|
1279
1531
|
|
|
@@ -1320,6 +1572,15 @@ export const proxyInstallCommand = {
|
|
|
1320
1572
|
type: "string",
|
|
1321
1573
|
default: "127.0.0.1",
|
|
1322
1574
|
description: "Proxy host",
|
|
1575
|
+
})
|
|
1576
|
+
.option("env-file", {
|
|
1577
|
+
type: "string",
|
|
1578
|
+
alias: "envFile",
|
|
1579
|
+
description: "Path to proxy provider env file to persist for the service",
|
|
1580
|
+
})
|
|
1581
|
+
.option("config", {
|
|
1582
|
+
type: "string",
|
|
1583
|
+
description: "Path to proxy routing config file to persist for the service",
|
|
1323
1584
|
})
|
|
1324
1585
|
.example("neurolink proxy install", "Install with defaults (port 55669)")
|
|
1325
1586
|
.example("neurolink proxy install -p 9000", "Install on custom port");
|
|
@@ -1333,6 +1594,23 @@ export const proxyInstallCommand = {
|
|
|
1333
1594
|
process.exit(1);
|
|
1334
1595
|
}
|
|
1335
1596
|
const { writeFileSync, mkdirSync, existsSync } = await import("fs");
|
|
1597
|
+
const envResolution = resolveProxyEnvFile({
|
|
1598
|
+
explicitEnvFile: argv.envFile,
|
|
1599
|
+
});
|
|
1600
|
+
const envFile = envResolution.path;
|
|
1601
|
+
const explicitConfig = argv.config;
|
|
1602
|
+
const configPath = explicitConfig
|
|
1603
|
+
? resolve(explicitConfig)
|
|
1604
|
+
: join(homedir(), ".neurolink", "proxy-config.yaml");
|
|
1605
|
+
if (explicitConfig && !existsSync(configPath)) {
|
|
1606
|
+
console.info(chalk.red(`Proxy config file not found: ${configPath}`));
|
|
1607
|
+
process.exit(1);
|
|
1608
|
+
}
|
|
1609
|
+
const configFile = existsSync(configPath) ? configPath : undefined;
|
|
1610
|
+
if (envFile && !existsSync(envFile)) {
|
|
1611
|
+
console.info(chalk.red(`Proxy env file not found: ${envFile}`));
|
|
1612
|
+
process.exit(1);
|
|
1613
|
+
}
|
|
1336
1614
|
const logsDir = join(homedir(), ".neurolink", "logs");
|
|
1337
1615
|
if (!existsSync(logsDir)) {
|
|
1338
1616
|
mkdirSync(logsDir, { recursive: true });
|
|
@@ -1340,9 +1618,12 @@ export const proxyInstallCommand = {
|
|
|
1340
1618
|
if (!existsSync(PLIST_DIR)) {
|
|
1341
1619
|
mkdirSync(PLIST_DIR, { recursive: true });
|
|
1342
1620
|
}
|
|
1343
|
-
const plist = buildPlist(port, host);
|
|
1621
|
+
const plist = buildPlist(port, host, envFile, configFile);
|
|
1344
1622
|
writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
1345
1623
|
console.info(chalk.green(`✓ Plist written to ${PLIST_PATH}`));
|
|
1624
|
+
if (envFile) {
|
|
1625
|
+
console.info(chalk.green(`✓ Proxy env file: ${envFile}`));
|
|
1626
|
+
}
|
|
1346
1627
|
try {
|
|
1347
1628
|
const { execFileSync } = await import("node:child_process");
|
|
1348
1629
|
execFileSync("launchctl", ["unload", PLIST_PATH], {
|
|
@@ -1375,6 +1656,7 @@ export const proxyInstallCommand = {
|
|
|
1375
1656
|
host,
|
|
1376
1657
|
strategy: "fill-first",
|
|
1377
1658
|
startTime: new Date().toISOString(),
|
|
1659
|
+
envFile,
|
|
1378
1660
|
managedBy: "launchd",
|
|
1379
1661
|
});
|
|
1380
1662
|
}
|