@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.
Files changed (212) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +7 -1
  3. package/dist/auth/anthropicOAuth.d.ts +18 -3
  4. package/dist/auth/anthropicOAuth.js +149 -4
  5. package/dist/auth/providers/firebase.js +5 -1
  6. package/dist/auth/providers/jwt.js +5 -1
  7. package/dist/auth/providers/workos.js +5 -1
  8. package/dist/auth/sessionManager.d.ts +1 -1
  9. package/dist/auth/sessionManager.js +58 -27
  10. package/dist/browser/neurolink.min.js +354 -334
  11. package/dist/cli/commands/mcp.d.ts +6 -0
  12. package/dist/cli/commands/mcp.js +188 -181
  13. package/dist/cli/commands/proxy.d.ts +2 -1
  14. package/dist/cli/commands/proxy.js +713 -431
  15. package/dist/cli/commands/task.js +3 -0
  16. package/dist/cli/factories/commandFactory.d.ts +2 -0
  17. package/dist/cli/factories/commandFactory.js +38 -0
  18. package/dist/cli/parser.js +4 -3
  19. package/dist/client/aiSdkAdapter.js +3 -0
  20. package/dist/client/streamingClient.js +30 -10
  21. package/dist/core/baseProvider.d.ts +6 -1
  22. package/dist/core/baseProvider.js +208 -230
  23. package/dist/core/factory.d.ts +3 -0
  24. package/dist/core/factory.js +138 -188
  25. package/dist/core/modules/GenerationHandler.js +3 -2
  26. package/dist/core/redisConversationMemoryManager.js +7 -3
  27. package/dist/evaluation/BatchEvaluator.js +4 -1
  28. package/dist/evaluation/hooks/observabilityHooks.js +5 -3
  29. package/dist/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
  30. package/dist/evaluation/pipeline/evaluationPipeline.js +24 -9
  31. package/dist/evaluation/pipeline/strategies/batchStrategy.js +6 -3
  32. package/dist/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
  33. package/dist/evaluation/scorers/scorerRegistry.d.ts +3 -0
  34. package/dist/evaluation/scorers/scorerRegistry.js +353 -282
  35. package/dist/lib/auth/anthropicOAuth.d.ts +18 -3
  36. package/dist/lib/auth/anthropicOAuth.js +149 -4
  37. package/dist/lib/auth/providers/firebase.js +5 -1
  38. package/dist/lib/auth/providers/jwt.js +5 -1
  39. package/dist/lib/auth/providers/workos.js +5 -1
  40. package/dist/lib/auth/sessionManager.d.ts +1 -1
  41. package/dist/lib/auth/sessionManager.js +58 -27
  42. package/dist/lib/client/aiSdkAdapter.js +3 -0
  43. package/dist/lib/client/streamingClient.js +30 -10
  44. package/dist/lib/core/baseProvider.d.ts +6 -1
  45. package/dist/lib/core/baseProvider.js +208 -230
  46. package/dist/lib/core/factory.d.ts +3 -0
  47. package/dist/lib/core/factory.js +138 -188
  48. package/dist/lib/core/modules/GenerationHandler.js +3 -2
  49. package/dist/lib/core/redisConversationMemoryManager.js +7 -3
  50. package/dist/lib/evaluation/BatchEvaluator.js +4 -1
  51. package/dist/lib/evaluation/hooks/observabilityHooks.js +5 -3
  52. package/dist/lib/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
  53. package/dist/lib/evaluation/pipeline/evaluationPipeline.js +24 -9
  54. package/dist/lib/evaluation/pipeline/strategies/batchStrategy.js +6 -3
  55. package/dist/lib/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
  56. package/dist/lib/evaluation/scorers/scorerRegistry.d.ts +3 -0
  57. package/dist/lib/evaluation/scorers/scorerRegistry.js +353 -282
  58. package/dist/lib/mcp/toolRegistry.d.ts +2 -0
  59. package/dist/lib/mcp/toolRegistry.js +32 -31
  60. package/dist/lib/neurolink.d.ts +41 -2
  61. package/dist/lib/neurolink.js +1616 -1681
  62. package/dist/lib/observability/otelBridge.d.ts +2 -2
  63. package/dist/lib/observability/otelBridge.js +12 -3
  64. package/dist/lib/providers/amazonBedrock.js +2 -4
  65. package/dist/lib/providers/anthropic.d.ts +9 -5
  66. package/dist/lib/providers/anthropic.js +19 -14
  67. package/dist/lib/providers/anthropicBaseProvider.d.ts +3 -3
  68. package/dist/lib/providers/anthropicBaseProvider.js +5 -4
  69. package/dist/lib/providers/azureOpenai.d.ts +1 -1
  70. package/dist/lib/providers/azureOpenai.js +5 -4
  71. package/dist/lib/providers/googleAiStudio.js +30 -6
  72. package/dist/lib/providers/googleVertex.d.ts +10 -0
  73. package/dist/lib/providers/googleVertex.js +437 -423
  74. package/dist/lib/providers/huggingFace.d.ts +3 -3
  75. package/dist/lib/providers/huggingFace.js +6 -8
  76. package/dist/lib/providers/litellm.d.ts +1 -0
  77. package/dist/lib/providers/litellm.js +76 -55
  78. package/dist/lib/providers/mistral.js +2 -1
  79. package/dist/lib/providers/ollama.js +93 -23
  80. package/dist/lib/providers/openAI.d.ts +2 -0
  81. package/dist/lib/providers/openAI.js +141 -141
  82. package/dist/lib/providers/openRouter.js +2 -1
  83. package/dist/lib/providers/openaiCompatible.d.ts +4 -4
  84. package/dist/lib/providers/openaiCompatible.js +4 -4
  85. package/dist/lib/proxy/claudeFormat.d.ts +3 -2
  86. package/dist/lib/proxy/claudeFormat.js +27 -14
  87. package/dist/lib/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
  88. package/dist/lib/proxy/cloaking/plugins/sessionIdentity.js +9 -33
  89. package/dist/lib/proxy/modelRouter.js +3 -0
  90. package/dist/lib/proxy/oauthFetch.d.ts +1 -1
  91. package/dist/lib/proxy/oauthFetch.js +289 -316
  92. package/dist/lib/proxy/proxyConfig.js +46 -24
  93. package/dist/lib/proxy/proxyEnv.d.ts +19 -0
  94. package/dist/lib/proxy/proxyEnv.js +73 -0
  95. package/dist/lib/proxy/proxyFetch.js +291 -217
  96. package/dist/lib/proxy/proxyTracer.d.ts +133 -0
  97. package/dist/lib/proxy/proxyTracer.js +645 -0
  98. package/dist/lib/proxy/rawStreamCapture.d.ts +10 -0
  99. package/dist/lib/proxy/rawStreamCapture.js +83 -0
  100. package/dist/lib/proxy/requestLogger.d.ts +32 -5
  101. package/dist/lib/proxy/requestLogger.js +503 -47
  102. package/dist/lib/proxy/sseInterceptor.d.ts +97 -0
  103. package/dist/lib/proxy/sseInterceptor.js +427 -0
  104. package/dist/lib/proxy/usageStats.d.ts +4 -3
  105. package/dist/lib/proxy/usageStats.js +25 -12
  106. package/dist/lib/rag/chunkers/MarkdownChunker.js +13 -5
  107. package/dist/lib/rag/chunking/markdownChunker.js +15 -6
  108. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +17 -3
  109. package/dist/lib/server/routes/claudeProxyRoutes.js +3032 -1349
  110. package/dist/lib/services/server/ai/observability/instrumentation.d.ts +7 -1
  111. package/dist/lib/services/server/ai/observability/instrumentation.js +337 -161
  112. package/dist/lib/tasks/backends/bullmqBackend.d.ts +1 -0
  113. package/dist/lib/tasks/backends/bullmqBackend.js +35 -22
  114. package/dist/lib/tasks/store/redisTaskStore.d.ts +1 -0
  115. package/dist/lib/tasks/store/redisTaskStore.js +54 -39
  116. package/dist/lib/tasks/taskManager.d.ts +5 -0
  117. package/dist/lib/tasks/taskManager.js +158 -30
  118. package/dist/lib/telemetry/index.d.ts +2 -1
  119. package/dist/lib/telemetry/index.js +2 -1
  120. package/dist/lib/telemetry/telemetryService.d.ts +3 -0
  121. package/dist/lib/telemetry/telemetryService.js +69 -5
  122. package/dist/lib/types/cli.d.ts +10 -0
  123. package/dist/lib/types/proxyTypes.d.ts +160 -5
  124. package/dist/lib/types/streamTypes.d.ts +25 -3
  125. package/dist/lib/utils/messageBuilder.js +3 -2
  126. package/dist/lib/utils/providerHealth.d.ts +19 -0
  127. package/dist/lib/utils/providerHealth.js +279 -33
  128. package/dist/lib/utils/providerUtils.js +17 -22
  129. package/dist/lib/utils/toolChoice.d.ts +4 -0
  130. package/dist/lib/utils/toolChoice.js +7 -0
  131. package/dist/mcp/toolRegistry.d.ts +2 -0
  132. package/dist/mcp/toolRegistry.js +32 -31
  133. package/dist/neurolink.d.ts +41 -2
  134. package/dist/neurolink.js +1616 -1681
  135. package/dist/observability/otelBridge.d.ts +2 -2
  136. package/dist/observability/otelBridge.js +12 -3
  137. package/dist/providers/amazonBedrock.js +2 -4
  138. package/dist/providers/anthropic.d.ts +9 -5
  139. package/dist/providers/anthropic.js +19 -14
  140. package/dist/providers/anthropicBaseProvider.d.ts +3 -3
  141. package/dist/providers/anthropicBaseProvider.js +5 -4
  142. package/dist/providers/azureOpenai.d.ts +1 -1
  143. package/dist/providers/azureOpenai.js +5 -4
  144. package/dist/providers/googleAiStudio.js +30 -6
  145. package/dist/providers/googleVertex.d.ts +10 -0
  146. package/dist/providers/googleVertex.js +437 -423
  147. package/dist/providers/huggingFace.d.ts +3 -3
  148. package/dist/providers/huggingFace.js +6 -7
  149. package/dist/providers/litellm.d.ts +1 -0
  150. package/dist/providers/litellm.js +76 -55
  151. package/dist/providers/mistral.js +2 -1
  152. package/dist/providers/ollama.js +93 -23
  153. package/dist/providers/openAI.d.ts +2 -0
  154. package/dist/providers/openAI.js +141 -141
  155. package/dist/providers/openRouter.js +2 -1
  156. package/dist/providers/openaiCompatible.d.ts +4 -4
  157. package/dist/providers/openaiCompatible.js +4 -3
  158. package/dist/proxy/claudeFormat.d.ts +3 -2
  159. package/dist/proxy/claudeFormat.js +27 -14
  160. package/dist/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
  161. package/dist/proxy/cloaking/plugins/sessionIdentity.js +9 -33
  162. package/dist/proxy/modelRouter.js +3 -0
  163. package/dist/proxy/oauthFetch.d.ts +1 -1
  164. package/dist/proxy/oauthFetch.js +289 -316
  165. package/dist/proxy/proxyConfig.js +46 -24
  166. package/dist/proxy/proxyEnv.d.ts +19 -0
  167. package/dist/proxy/proxyEnv.js +72 -0
  168. package/dist/proxy/proxyFetch.js +291 -217
  169. package/dist/proxy/proxyTracer.d.ts +133 -0
  170. package/dist/proxy/proxyTracer.js +644 -0
  171. package/dist/proxy/rawStreamCapture.d.ts +10 -0
  172. package/dist/proxy/rawStreamCapture.js +82 -0
  173. package/dist/proxy/requestLogger.d.ts +32 -5
  174. package/dist/proxy/requestLogger.js +503 -47
  175. package/dist/proxy/sseInterceptor.d.ts +97 -0
  176. package/dist/proxy/sseInterceptor.js +426 -0
  177. package/dist/proxy/usageStats.d.ts +4 -3
  178. package/dist/proxy/usageStats.js +25 -12
  179. package/dist/rag/chunkers/MarkdownChunker.js +13 -5
  180. package/dist/rag/chunking/markdownChunker.js +15 -6
  181. package/dist/server/routes/claudeProxyRoutes.d.ts +17 -3
  182. package/dist/server/routes/claudeProxyRoutes.js +3032 -1349
  183. package/dist/services/server/ai/observability/instrumentation.d.ts +7 -1
  184. package/dist/services/server/ai/observability/instrumentation.js +337 -161
  185. package/dist/tasks/backends/bullmqBackend.d.ts +1 -0
  186. package/dist/tasks/backends/bullmqBackend.js +35 -22
  187. package/dist/tasks/store/redisTaskStore.d.ts +1 -0
  188. package/dist/tasks/store/redisTaskStore.js +54 -39
  189. package/dist/tasks/taskManager.d.ts +5 -0
  190. package/dist/tasks/taskManager.js +158 -30
  191. package/dist/telemetry/index.d.ts +2 -1
  192. package/dist/telemetry/index.js +2 -1
  193. package/dist/telemetry/telemetryService.d.ts +3 -0
  194. package/dist/telemetry/telemetryService.js +69 -5
  195. package/dist/types/cli.d.ts +10 -0
  196. package/dist/types/proxyTypes.d.ts +160 -5
  197. package/dist/types/streamTypes.d.ts +25 -3
  198. package/dist/utils/messageBuilder.js +3 -2
  199. package/dist/utils/providerHealth.d.ts +19 -0
  200. package/dist/utils/providerHealth.js +279 -33
  201. package/dist/utils/providerUtils.js +18 -22
  202. package/dist/utils/toolChoice.d.ts +4 -0
  203. package/dist/utils/toolChoice.js +6 -0
  204. package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +6609 -0
  205. package/docs/changelog.md +252 -0
  206. package/package.json +19 -2
  207. package/scripts/observability/check-proxy-telemetry.mjs +235 -0
  208. package/scripts/observability/docker-compose.proxy-observability.yaml +55 -0
  209. package/scripts/observability/import-openobserve-dashboard.mjs +240 -0
  210. package/scripts/observability/manage-local-openobserve.sh +215 -0
  211. package/scripts/observability/otel-collector.proxy-observability.yaml +78 -0
  212. 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
- const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
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
- console.info(` Requests: ${stats.totalRequests} total, ${stats.totalSuccess} success, ${stats.totalErrors} errors`);
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
- console.info(` ${a.label.padEnd(20)} ${a.type.padEnd(8)} ${String(a.requests).padEnd(6)} reqs ${acctStatus}`);
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 buildPlist(port, host) {
1257
- const nodeExec = process.execPath;
1258
- const entryScript = process.argv[1] ?? join(__dirname, "..", "index.js");
1488
+ function escapeXml(s) {
1489
+ return s
1490
+ .replace(/&/g, "&amp;")
1491
+ .replace(/</g, "&lt;")
1492
+ .replace(/>/g, "&gt;")
1493
+ .replace(/"/g, "&quot;")
1494
+ .replace(/'/g, "&apos;");
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
  }