@juspay/neurolink 9.42.0 → 9.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/auth/anthropicOAuth.js +12 -0
  3. package/dist/browser/neurolink.min.js +335 -334
  4. package/dist/cli/commands/mcp.d.ts +6 -0
  5. package/dist/cli/commands/mcp.js +200 -184
  6. package/dist/cli/commands/proxy.js +560 -518
  7. package/dist/core/baseProvider.d.ts +6 -1
  8. package/dist/core/baseProvider.js +219 -232
  9. package/dist/core/factory.d.ts +3 -0
  10. package/dist/core/factory.js +140 -190
  11. package/dist/core/modules/ToolsManager.d.ts +1 -0
  12. package/dist/core/modules/ToolsManager.js +40 -42
  13. package/dist/core/toolEvents.d.ts +3 -0
  14. package/dist/core/toolEvents.js +7 -0
  15. package/dist/evaluation/pipeline/evaluationPipeline.js +5 -2
  16. package/dist/evaluation/scorers/scorerRegistry.d.ts +3 -0
  17. package/dist/evaluation/scorers/scorerRegistry.js +356 -284
  18. package/dist/lib/auth/anthropicOAuth.js +12 -0
  19. package/dist/lib/core/baseProvider.d.ts +6 -1
  20. package/dist/lib/core/baseProvider.js +219 -232
  21. package/dist/lib/core/factory.d.ts +3 -0
  22. package/dist/lib/core/factory.js +140 -190
  23. package/dist/lib/core/modules/ToolsManager.d.ts +1 -0
  24. package/dist/lib/core/modules/ToolsManager.js +40 -42
  25. package/dist/lib/core/toolEvents.d.ts +3 -0
  26. package/dist/lib/core/toolEvents.js +8 -0
  27. package/dist/lib/evaluation/pipeline/evaluationPipeline.js +5 -2
  28. package/dist/lib/evaluation/scorers/scorerRegistry.d.ts +3 -0
  29. package/dist/lib/evaluation/scorers/scorerRegistry.js +356 -284
  30. package/dist/lib/mcp/toolRegistry.d.ts +2 -0
  31. package/dist/lib/mcp/toolRegistry.js +32 -31
  32. package/dist/lib/neurolink.d.ts +38 -0
  33. package/dist/lib/neurolink.js +1890 -1707
  34. package/dist/lib/providers/googleAiStudio.js +0 -5
  35. package/dist/lib/providers/googleNativeGemini3.d.ts +4 -0
  36. package/dist/lib/providers/googleNativeGemini3.js +39 -1
  37. package/dist/lib/providers/googleVertex.d.ts +10 -0
  38. package/dist/lib/providers/googleVertex.js +445 -445
  39. package/dist/lib/providers/litellm.d.ts +1 -0
  40. package/dist/lib/providers/litellm.js +73 -64
  41. package/dist/lib/providers/ollama.js +17 -4
  42. package/dist/lib/providers/openAI.d.ts +2 -0
  43. package/dist/lib/providers/openAI.js +139 -140
  44. package/dist/lib/proxy/claudeFormat.js +14 -5
  45. package/dist/lib/proxy/oauthFetch.js +298 -318
  46. package/dist/lib/proxy/proxyConfig.js +3 -1
  47. package/dist/lib/proxy/proxyFetch.js +250 -222
  48. package/dist/lib/proxy/proxyHealth.d.ts +17 -0
  49. package/dist/lib/proxy/proxyHealth.js +55 -0
  50. package/dist/lib/proxy/requestLogger.js +140 -48
  51. package/dist/lib/proxy/routingPolicy.d.ts +33 -0
  52. package/dist/lib/proxy/routingPolicy.js +255 -0
  53. package/dist/lib/proxy/snapshotPersistence.d.ts +2 -0
  54. package/dist/lib/proxy/snapshotPersistence.js +41 -0
  55. package/dist/lib/proxy/sseInterceptor.js +36 -11
  56. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -1
  57. package/dist/lib/server/routes/claudeProxyRoutes.js +2916 -2377
  58. package/dist/lib/services/server/ai/observability/instrumentation.js +194 -218
  59. package/dist/lib/tasks/backends/bullmqBackend.js +24 -18
  60. package/dist/lib/tasks/store/redisTaskStore.js +42 -17
  61. package/dist/lib/tasks/taskManager.d.ts +2 -0
  62. package/dist/lib/tasks/taskManager.js +100 -5
  63. package/dist/lib/telemetry/telemetryService.js +9 -5
  64. package/dist/lib/types/cli.d.ts +4 -0
  65. package/dist/lib/types/proxyTypes.d.ts +211 -1
  66. package/dist/lib/types/tools.d.ts +18 -0
  67. package/dist/lib/utils/providerHealth.d.ts +1 -0
  68. package/dist/lib/utils/providerHealth.js +46 -31
  69. package/dist/lib/utils/providerUtils.js +11 -22
  70. package/dist/lib/utils/schemaConversion.d.ts +1 -0
  71. package/dist/lib/utils/schemaConversion.js +3 -0
  72. package/dist/mcp/toolRegistry.d.ts +2 -0
  73. package/dist/mcp/toolRegistry.js +32 -31
  74. package/dist/neurolink.d.ts +38 -0
  75. package/dist/neurolink.js +1890 -1707
  76. package/dist/providers/googleAiStudio.js +0 -5
  77. package/dist/providers/googleNativeGemini3.d.ts +4 -0
  78. package/dist/providers/googleNativeGemini3.js +39 -1
  79. package/dist/providers/googleVertex.d.ts +10 -0
  80. package/dist/providers/googleVertex.js +445 -445
  81. package/dist/providers/litellm.d.ts +1 -0
  82. package/dist/providers/litellm.js +73 -64
  83. package/dist/providers/ollama.js +17 -4
  84. package/dist/providers/openAI.d.ts +2 -0
  85. package/dist/providers/openAI.js +139 -140
  86. package/dist/proxy/claudeFormat.js +14 -5
  87. package/dist/proxy/oauthFetch.js +298 -318
  88. package/dist/proxy/proxyConfig.js +3 -1
  89. package/dist/proxy/proxyFetch.js +250 -222
  90. package/dist/proxy/proxyHealth.d.ts +17 -0
  91. package/dist/proxy/proxyHealth.js +54 -0
  92. package/dist/proxy/requestLogger.js +140 -48
  93. package/dist/proxy/routingPolicy.d.ts +33 -0
  94. package/dist/proxy/routingPolicy.js +254 -0
  95. package/dist/proxy/snapshotPersistence.d.ts +2 -0
  96. package/dist/proxy/snapshotPersistence.js +40 -0
  97. package/dist/proxy/sseInterceptor.js +36 -11
  98. package/dist/server/routes/claudeProxyRoutes.d.ts +2 -1
  99. package/dist/server/routes/claudeProxyRoutes.js +2916 -2377
  100. package/dist/services/server/ai/observability/instrumentation.js +194 -218
  101. package/dist/tasks/backends/bullmqBackend.js +24 -18
  102. package/dist/tasks/store/redisTaskStore.js +42 -17
  103. package/dist/tasks/taskManager.d.ts +2 -0
  104. package/dist/tasks/taskManager.js +100 -5
  105. package/dist/telemetry/telemetryService.js +9 -5
  106. package/dist/types/cli.d.ts +4 -0
  107. package/dist/types/proxyTypes.d.ts +211 -1
  108. package/dist/types/tools.d.ts +18 -0
  109. package/dist/utils/providerHealth.d.ts +1 -0
  110. package/dist/utils/providerHealth.js +46 -31
  111. package/dist/utils/providerUtils.js +12 -22
  112. package/dist/utils/schemaConversion.d.ts +1 -0
  113. package/dist/utils/schemaConversion.js +3 -0
  114. package/package.json +3 -2
  115. package/scripts/observability/check-proxy-telemetry.mjs +1 -1
  116. package/scripts/observability/manage-local-openobserve.sh +36 -5
@@ -11,9 +11,10 @@
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
+ import { buildProxyHealthResponse, createProxyReadinessState, markProxyReady, waitForProxyReadiness, } from "../../lib/proxy/proxyHealth.js";
17
18
  import { logger } from "../../lib/utils/logger.js";
18
19
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
19
20
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
@@ -293,6 +294,557 @@ export function mapClaudeErrorTypeToStatus(errorType) {
293
294
  return 502;
294
295
  }
295
296
  }
297
+ async function ensureProxyStartAllowed(spinner) {
298
+ const existingState = loadProxyState();
299
+ if (existingState) {
300
+ if (isProcessRunning(existingState.pid)) {
301
+ if (spinner) {
302
+ spinner.fail(chalk.red(`Proxy already running on port ${existingState.port} (PID: ${existingState.pid})`));
303
+ }
304
+ logger.always(chalk.yellow("Stop it first or use 'neurolink proxy status' to inspect"));
305
+ process.exit(process.ppid === 1 ? 0 : 1);
306
+ }
307
+ clearProxyState();
308
+ }
309
+ if (process.ppid === 1 || !(await isLaunchdManaging())) {
310
+ return;
311
+ }
312
+ if (spinner) {
313
+ spinner.fail(chalk.red("Proxy is managed by launchd. Manual start would cause port conflicts."));
314
+ }
315
+ logger.always(chalk.yellow("Use 'neurolink proxy uninstall' to remove the service first, " +
316
+ "or 'launchctl kickstart gui/$(id -u)/com.neurolink.proxy' to restart."));
317
+ process.exit(1);
318
+ }
319
+ async function loadProxyStartEnv(argv, spinner) {
320
+ try {
321
+ const envResult = await loadProxyEnvFile({
322
+ explicitEnvFile: argv.envFile,
323
+ });
324
+ if (spinner && envResult.path) {
325
+ spinner.text = `Loaded proxy env from ${envResult.path}`;
326
+ }
327
+ return envResult.path;
328
+ }
329
+ catch (error) {
330
+ if (spinner) {
331
+ spinner.fail(chalk.red(error instanceof Error ? error.message : String(error)));
332
+ }
333
+ process.exit(1);
334
+ }
335
+ }
336
+ async function createProxyNeurolinkRuntime() {
337
+ process.env.NEUROLINK_SKIP_MCP = "true";
338
+ const { NeuroLink } = await import("../../lib/neurolink.js");
339
+ const neurolink = new NeuroLink();
340
+ const { initRequestLogger, cleanupLogs } = await import("../../lib/proxy/requestLogger.js");
341
+ initRequestLogger(true);
342
+ cleanupLogs(7, 500);
343
+ return { neurolink, cleanupLogs };
344
+ }
345
+ async function loadProxyStartConfiguration(argv, spinner) {
346
+ const configPath = argv.config ?? join(homedir(), ".neurolink", "proxy-config.yaml");
347
+ let proxyConfig = null;
348
+ try {
349
+ const { loadProxyConfig } = await import("../../lib/proxy/proxyConfig.js");
350
+ proxyConfig = (await loadProxyConfig(configPath));
351
+ if (spinner) {
352
+ spinner.text = `Loaded proxy config from ${configPath}`;
353
+ }
354
+ }
355
+ catch (configError) {
356
+ if (argv.config) {
357
+ if (spinner) {
358
+ spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
359
+ }
360
+ process.exit(1);
361
+ }
362
+ const isNotFound = configError instanceof Error &&
363
+ "code" in configError &&
364
+ configError.code === "ENOENT";
365
+ if (!isNotFound) {
366
+ logger.warn(`[proxy] Ignoring default config ${configPath}: ${configError instanceof Error ? configError.message : String(configError)}`);
367
+ }
368
+ }
369
+ const strategy = (argv.strategy ??
370
+ proxyConfig?.routing?.strategy ??
371
+ "fill-first");
372
+ let modelRouter;
373
+ if (proxyConfig?.routing) {
374
+ const { ModelRouter } = await import("../../lib/proxy/modelRouter.js");
375
+ modelRouter = new ModelRouter({
376
+ strategy,
377
+ modelMappings: proxyConfig.routing.modelMappings ?? [],
378
+ fallbackChain: proxyConfig.routing.fallbackChain ?? [],
379
+ passthroughModels: proxyConfig.routing.passthroughModels,
380
+ });
381
+ }
382
+ return {
383
+ configPath,
384
+ proxyConfig,
385
+ strategy,
386
+ modelRouter,
387
+ passthrough: argv.passthrough ?? false,
388
+ };
389
+ }
390
+ async function createProxyStartApp(params) {
391
+ const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
392
+ const { Hono } = await import("hono");
393
+ const app = new Hono();
394
+ const readiness = createProxyReadinessState();
395
+ app.onError((err, c) => {
396
+ const errMsg = err instanceof Error ? err.message : String(err);
397
+ logger.always(`[proxy] unhandled error: ${errMsg}`);
398
+ if (err instanceof Error && err.stack) {
399
+ logger.debug(`[proxy] stack: ${err.stack}`);
400
+ }
401
+ return c.json({
402
+ type: "error",
403
+ error: {
404
+ type: "api_error",
405
+ message: `Proxy internal error: ${errMsg}`,
406
+ },
407
+ }, 502);
408
+ });
409
+ const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough);
410
+ for (const route of routeGroup.routes) {
411
+ const method = route.method.toLowerCase();
412
+ app[method](route.path, async (c) => {
413
+ const emptyBody = {};
414
+ let body;
415
+ let rawBody;
416
+ if (method === "post") {
417
+ rawBody = await c.req.text().catch(() => undefined);
418
+ try {
419
+ body = rawBody ? JSON.parse(rawBody) : emptyBody;
420
+ }
421
+ catch {
422
+ return c.json({
423
+ type: "error",
424
+ error: {
425
+ type: "invalid_request_error",
426
+ message: "Request body must be valid JSON",
427
+ },
428
+ }, 400);
429
+ }
430
+ }
431
+ const model = body?.model ?? "-";
432
+ const stream = body?.stream
433
+ ? "stream"
434
+ : "non-stream";
435
+ const bodyRec = body;
436
+ const toolCount = Array.isArray(bodyRec?.tools)
437
+ ? bodyRec.tools.length
438
+ : 0;
439
+ logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
440
+ const ctx = {
441
+ requestId: crypto.randomUUID(),
442
+ method: c.req.method,
443
+ path: c.req.path,
444
+ headers: Object.fromEntries(c.req.raw.headers.entries()),
445
+ query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
446
+ params: c.req.param(),
447
+ body,
448
+ rawBody,
449
+ neurolink: params.neurolink,
450
+ toolRegistry: params.neurolink.getToolRegistry(),
451
+ timestamp: Date.now(),
452
+ metadata: {},
453
+ };
454
+ const result = await route.handler(ctx);
455
+ if (result instanceof Response) {
456
+ return result;
457
+ }
458
+ if (result &&
459
+ typeof result === "object" &&
460
+ Symbol.asyncIterator in Object(result)) {
461
+ const iterator = result[Symbol.asyncIterator]();
462
+ let cancelled = false;
463
+ const responseStream = new ReadableStream({
464
+ async start(controller) {
465
+ try {
466
+ while (!cancelled) {
467
+ const { value, done } = await iterator.next();
468
+ if (done) {
469
+ break;
470
+ }
471
+ controller.enqueue(new TextEncoder().encode(value));
472
+ }
473
+ controller.close();
474
+ }
475
+ catch (streamErr) {
476
+ if (cancelled) {
477
+ controller.close();
478
+ return;
479
+ }
480
+ const errMsg = streamErr instanceof Error
481
+ ? streamErr.message
482
+ : String(streamErr);
483
+ const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Stream interrupted: ${errMsg}` } })}\n\n`;
484
+ try {
485
+ controller.enqueue(new TextEncoder().encode(errorEvent));
486
+ }
487
+ catch {
488
+ // Controller already errored — ignore
489
+ }
490
+ controller.close();
491
+ }
492
+ },
493
+ async cancel() {
494
+ cancelled = true;
495
+ await iterator.return?.();
496
+ },
497
+ });
498
+ return new Response(responseStream, {
499
+ headers: {
500
+ "Content-Type": "text/event-stream",
501
+ "Cache-Control": "no-cache",
502
+ Connection: "keep-alive",
503
+ },
504
+ });
505
+ }
506
+ if (result &&
507
+ typeof result === "object" &&
508
+ "httpStatus" in result) {
509
+ const httpResult = result;
510
+ const status = httpResult.httpStatus ?? 200;
511
+ delete httpResult.httpStatus;
512
+ return c.json(result, status);
513
+ }
514
+ if (result &&
515
+ typeof result === "object" &&
516
+ "type" in result &&
517
+ result.type === "error") {
518
+ const errorResult = result;
519
+ const status = mapClaudeErrorTypeToStatus(errorResult.error?.type);
520
+ return c.json(result, status);
521
+ }
522
+ return c.json(result ?? {});
523
+ });
524
+ }
525
+ app.get("/health", (c) => c.json(buildProxyHealthResponse(readiness, {
526
+ strategy: params.strategy,
527
+ passthrough: params.passthrough,
528
+ version: PROXY_VERSION,
529
+ })));
530
+ app.get("/status", async (c) => {
531
+ const { getStats } = await import("../../lib/proxy/usageStats.js");
532
+ const stats = getStats();
533
+ const health = buildProxyHealthResponse(readiness, {
534
+ strategy: params.strategy,
535
+ passthrough: params.passthrough,
536
+ version: PROXY_VERSION,
537
+ });
538
+ return c.json({
539
+ status: "running",
540
+ ready: health.ready,
541
+ acceptingConnections: health.acceptingConnections,
542
+ readyAt: health.readyAt,
543
+ pid: process.pid,
544
+ port: params.port,
545
+ host: params.host,
546
+ strategy: params.strategy,
547
+ uptime: process.uptime(),
548
+ version: PROXY_VERSION,
549
+ health,
550
+ stats: {
551
+ totalAttempts: stats.totalAttempts,
552
+ totalRequests: stats.totalRequests,
553
+ totalSuccess: stats.totalSuccess,
554
+ totalErrors: stats.totalErrors,
555
+ totalRateLimits: stats.totalRateLimits,
556
+ accounts: Object.values(stats.accounts).map((account) => ({
557
+ label: account.label,
558
+ type: account.type,
559
+ attempts: account.attemptCount,
560
+ requests: account.attemptCount,
561
+ success: account.successCount,
562
+ errors: account.errorCount,
563
+ rateLimits: account.rateLimitCount,
564
+ backoffLevel: account.currentBackoffLevel,
565
+ cooling: account.coolingUntil
566
+ ? account.coolingUntil > Date.now()
567
+ : false,
568
+ })),
569
+ },
570
+ config: params.proxyConfig
571
+ ? { hasRouting: !!params.proxyConfig.routing }
572
+ : null,
573
+ });
574
+ });
575
+ return { app, readiness };
576
+ }
577
+ async function initializeProxyOpenTelemetry() {
578
+ try {
579
+ const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
580
+ if (!process.env.OTEL_SERVICE_NAME) {
581
+ process.env.OTEL_SERVICE_NAME = "neurolink-proxy";
582
+ }
583
+ process.env.OTEL_RESOURCE_ATTRIBUTES = [
584
+ "service.name=neurolink-proxy",
585
+ `service.version=${PROXY_VERSION}`,
586
+ "deployment.environment=local",
587
+ process.env.OTEL_RESOURCE_ATTRIBUTES,
588
+ ]
589
+ .filter(Boolean)
590
+ .join(",");
591
+ const { initializeOpenTelemetry, isOpenTelemetryInitialized } = await import("../../lib/services/server/ai/observability/instrumentation.js");
592
+ const { buildObservabilityConfigFromEnv } = await import("../../lib/utils/observabilityHelpers.js");
593
+ if (isOpenTelemetryInitialized()) {
594
+ return;
595
+ }
596
+ const observabilityConfig = buildObservabilityConfigFromEnv();
597
+ const langfuseConfig = observabilityConfig?.langfuse;
598
+ const langfuseEnabled = langfuseConfig?.enabled === true;
599
+ initializeOpenTelemetry({
600
+ enabled: langfuseEnabled,
601
+ publicKey: langfuseConfig?.publicKey || "",
602
+ secretKey: langfuseConfig?.secretKey || "",
603
+ baseUrl: langfuseConfig?.baseUrl,
604
+ environment: "proxy",
605
+ release: PROXY_VERSION,
606
+ userId: "neurolink-proxy",
607
+ autoDetectOperationName: true,
608
+ });
609
+ if (langfuseEnabled) {
610
+ logger.always(`[proxy] Langfuse enabled — exporting to ${langfuseConfig.baseUrl || "https://cloud.langfuse.com"} (environment=proxy)`);
611
+ }
612
+ if (otlpEndpoint) {
613
+ logger.always(`[proxy] OTLP exporter enabled — exporting to ${otlpEndpoint} (service.name=neurolink-proxy)`);
614
+ }
615
+ if (!langfuseEnabled && !otlpEndpoint) {
616
+ logger.always("[proxy] OpenTelemetry exporters disabled — set OTEL_EXPORTER_OTLP_ENDPOINT or Langfuse credentials to enable proxy observability");
617
+ }
618
+ }
619
+ catch (error) {
620
+ logger.debug(`[proxy] OpenTelemetry init failed (non-fatal): ${error instanceof Error ? error.message : String(error)}`);
621
+ }
622
+ }
623
+ async function refreshProxyTokensInBackground() {
624
+ const { needsRefresh, refreshToken, persistTokens } = await import("../../lib/proxy/tokenRefresh.js");
625
+ const { tokenStore } = await import("../../lib/auth/tokenStore.js");
626
+ try {
627
+ const allKeys = await tokenStore.listProviders();
628
+ const anthropicKeys = allKeys.filter((key) => key.startsWith("anthropic:"));
629
+ for (const key of anthropicKeys) {
630
+ try {
631
+ const tokens = await tokenStore.loadTokens(key);
632
+ if (!tokens) {
633
+ continue;
634
+ }
635
+ const account = {
636
+ label: key,
637
+ token: tokens.accessToken,
638
+ refreshToken: tokens.refreshToken,
639
+ expiresAt: tokens.expiresAt,
640
+ };
641
+ if (needsRefresh(account)) {
642
+ const result = await refreshToken(account);
643
+ if (result.success) {
644
+ await persistTokens({ providerKey: key }, account);
645
+ logger.debug(`[proxy] background token refresh succeeded for ${key}`);
646
+ }
647
+ }
648
+ }
649
+ catch {
650
+ // non-fatal per-account
651
+ }
652
+ }
653
+ }
654
+ catch {
655
+ // non-fatal
656
+ }
657
+ try {
658
+ const credPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
659
+ const { readFileSync } = await import("fs");
660
+ const creds = JSON.parse(readFileSync(credPath, "utf8"));
661
+ if (!creds.oauth) {
662
+ return;
663
+ }
664
+ const account = {
665
+ label: "background",
666
+ token: creds.oauth.accessToken,
667
+ refreshToken: creds.oauth.refreshToken,
668
+ expiresAt: creds.oauth.expiresAt,
669
+ };
670
+ if (needsRefresh(account)) {
671
+ const result = await refreshToken(account);
672
+ if (result.success) {
673
+ await persistTokens(credPath, account);
674
+ logger.debug("[proxy] background token refresh succeeded");
675
+ }
676
+ }
677
+ }
678
+ catch {
679
+ // non-fatal
680
+ }
681
+ }
682
+ function startProxyBackgroundMaintenance(cleanupLogs) {
683
+ const refreshInterval = setInterval(() => {
684
+ void refreshProxyTokensInBackground();
685
+ }, 30_000);
686
+ const logCleanupInterval = setInterval(() => {
687
+ cleanupLogs(7, 500);
688
+ }, 60 * 60 * 1000);
689
+ return { refreshInterval, logCleanupInterval };
690
+ }
691
+ function registerProxyShutdownHandlers(params) {
692
+ const shutdown = async (signal) => {
693
+ clearInterval(params.refreshInterval);
694
+ clearInterval(params.logCleanupInterval);
695
+ logger.always(`\nShutting down proxy (${signal})...`);
696
+ try {
697
+ const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
698
+ await flushOpenTelemetry();
699
+ await shutdownOpenTelemetry();
700
+ }
701
+ catch {
702
+ // non-fatal — proxy shutdown must not block on OTel
703
+ }
704
+ if (signal === "SIGINT") {
705
+ try {
706
+ const shutdownHost = params.host === "0.0.0.0" ? "localhost" : params.host;
707
+ await clearClaudeProxySettings(`http://${shutdownHost}:${params.port}`);
708
+ }
709
+ catch {
710
+ // non-fatal
711
+ }
712
+ }
713
+ try {
714
+ params.server.close?.();
715
+ }
716
+ catch {
717
+ // Best-effort close
718
+ }
719
+ clearProxyState();
720
+ process.exit(signal === "SIGINT" ? 0 : 1);
721
+ };
722
+ process.on("SIGTERM", () => {
723
+ void shutdown("SIGTERM");
724
+ });
725
+ process.on("SIGINT", () => {
726
+ void shutdown("SIGINT");
727
+ });
728
+ }
729
+ async function startProxyRuntime(params) {
730
+ const { serve } = await import("@hono/node-server");
731
+ const server = serve({
732
+ fetch: params.app.fetch,
733
+ port: params.port,
734
+ hostname: params.host,
735
+ });
736
+ const guardPid = spawnFailOpenGuard(params.host, params.port, process.pid);
737
+ const readinessHost = params.host === "0.0.0.0" ? "127.0.0.1" : params.host;
738
+ await waitForProxyReadiness({
739
+ host: readinessHost,
740
+ port: params.port,
741
+ });
742
+ markProxyReady(params.readiness);
743
+ const fallbackChain = params.proxyConfig?.routing?.fallbackChain?.map((entry) => ({
744
+ provider: entry.provider,
745
+ model: entry.model,
746
+ }));
747
+ saveProxyState({
748
+ pid: process.pid,
749
+ port: params.port,
750
+ host: params.host,
751
+ strategy: params.strategy,
752
+ startTime: new Date().toISOString(),
753
+ ready: true,
754
+ readyAt: params.readiness.readyAtMs
755
+ ? new Date(params.readiness.readyAtMs).toISOString()
756
+ : undefined,
757
+ healthPath: "/health",
758
+ statusPath: "/status",
759
+ envFile: params.loadedEnvFile,
760
+ fallbackChain,
761
+ guardPid,
762
+ managedBy: process.platform === "darwin" && process.ppid === 1
763
+ ? "launchd"
764
+ : "manual",
765
+ passthrough: params.passthrough,
766
+ });
767
+ if (params.spinner) {
768
+ params.spinner.succeed(chalk.green("Claude proxy started successfully"));
769
+ }
770
+ const normalizedHost = params.host === "0.0.0.0" ? "localhost" : params.host;
771
+ const url = `http://${normalizedHost}:${params.port}`;
772
+ printProxyBanner(url, params.strategy);
773
+ logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(params.passthrough ? "passthrough" : "full")}`);
774
+ if (params.passthrough) {
775
+ logger.always(chalk.yellow(" ! Passthrough mode forwards client auth directly to Anthropic"));
776
+ logger.always(chalk.dim(" Stored proxy OAuth/API credentials are ignored; clients need their own valid Anthropic auth."));
777
+ }
778
+ if (params.loadedEnvFile) {
779
+ logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(params.loadedEnvFile)}`);
780
+ }
781
+ try {
782
+ await setClaudeProxySettings(url);
783
+ logger.always(chalk.green(" ✓ Auto-configured Claude Code settings"));
784
+ logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
785
+ }
786
+ catch (error) {
787
+ logger.debug("[proxy] Failed to auto-configure Claude Code: " +
788
+ (error instanceof Error ? error.message : String(error)));
789
+ }
790
+ const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs);
791
+ registerProxyShutdownHandlers({
792
+ server,
793
+ host: params.host,
794
+ port: params.port,
795
+ ...maintenance,
796
+ });
797
+ }
798
+ async function startProxyCommandHandler(argv) {
799
+ const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
800
+ try {
801
+ await ensureProxyStartAllowed(spinner);
802
+ const loadedEnvFile = await loadProxyStartEnv(argv, spinner);
803
+ const { neurolink, cleanupLogs } = await createProxyNeurolinkRuntime();
804
+ const { proxyConfig, strategy, modelRouter, passthrough } = await loadProxyStartConfiguration(argv, spinner);
805
+ if (spinner) {
806
+ spinner.text = "Configuring server...";
807
+ }
808
+ const port = argv.port ?? 55669;
809
+ const host = argv.host ?? "127.0.0.1";
810
+ const { app, readiness } = await createProxyStartApp({
811
+ neurolink,
812
+ modelRouter,
813
+ strategy,
814
+ passthrough,
815
+ port,
816
+ host,
817
+ proxyConfig,
818
+ });
819
+ await initializeProxyOpenTelemetry();
820
+ if (spinner) {
821
+ spinner.text = `Starting proxy on ${host}:${port}...`;
822
+ }
823
+ await startProxyRuntime({
824
+ argv,
825
+ spinner,
826
+ app,
827
+ readiness,
828
+ host,
829
+ port,
830
+ strategy,
831
+ proxyConfig,
832
+ loadedEnvFile,
833
+ passthrough,
834
+ cleanupLogs,
835
+ });
836
+ }
837
+ catch (error) {
838
+ if (spinner) {
839
+ spinner.fail(chalk.red("Failed to start proxy"));
840
+ }
841
+ logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
842
+ if (argv.debug && error instanceof Error && error.stack) {
843
+ logger.error(chalk.gray(error.stack));
844
+ }
845
+ process.exit(1);
846
+ }
847
+ }
296
848
  // =============================================================================
297
849
  // PROXY START COMMAND
298
850
  // =============================================================================
@@ -358,522 +910,7 @@ export const proxyStartCommand = {
358
910
  .example("neurolink proxy start --health-interval 60", "Start proxy with 60-second health checks");
359
911
  },
360
912
  handler: async (argv) => {
361
- const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
362
- try {
363
- // Guard: proxy already running
364
- const existingState = loadProxyState();
365
- if (existingState) {
366
- if (isProcessRunning(existingState.pid)) {
367
- if (spinner) {
368
- spinner.fail(chalk.red(`Proxy already running on port ${existingState.port} (PID: ${existingState.pid})`));
369
- }
370
- logger.always(chalk.yellow("Stop it first or use 'neurolink proxy status' to inspect"));
371
- // Exit cleanly when managed by launchd so it doesn't treat this
372
- // as a crash and spam-restart every ThrottleInterval seconds.
373
- // For manual invocations, use non-zero so scripts/CI detect failure.
374
- process.exit(process.ppid === 1 ? 0 : 1);
375
- }
376
- else {
377
- // Stale state file from a previous process that is no longer running.
378
- // Clear it so subsequent startup logic doesn't get confused.
379
- clearProxyState();
380
- }
381
- }
382
- // Guard: launchd is managing the service — don't start manually.
383
- // Skip this guard when WE are the process launchd is managing
384
- // (PPID 1 = launched by launchd on macOS).
385
- if (process.ppid !== 1 && (await isLaunchdManaging())) {
386
- if (spinner) {
387
- spinner.fail(chalk.red("Proxy is managed by launchd. Manual start would cause port conflicts."));
388
- }
389
- logger.always(chalk.yellow("Use 'neurolink proxy uninstall' to remove the service first, " +
390
- "or 'launchctl kickstart gui/$(id -u)/com.neurolink.proxy' to restart."));
391
- // This guard only runs for manual invocations (ppid !== 1),
392
- // so launchd KeepAlive is unaffected. Use non-zero exit code
393
- // so scripts/CI can detect that the proxy was not started.
394
- process.exit(1);
395
- }
396
- // -----------------------------------------------------------------
397
- // 1. Create NeuroLink instance — reads all env vars automatically
398
- // -----------------------------------------------------------------
399
- let loadedEnvFile;
400
- try {
401
- const envResult = await loadProxyEnvFile({
402
- explicitEnvFile: argv.envFile,
403
- });
404
- loadedEnvFile = envResult.path;
405
- if (spinner && loadedEnvFile) {
406
- spinner.text = `Loaded proxy env from ${loadedEnvFile}`;
407
- }
408
- }
409
- catch (envError) {
410
- if (spinner) {
411
- spinner.fail(chalk.red(envError instanceof Error ? envError.message : String(envError)));
412
- }
413
- process.exit(1);
414
- }
415
- // Skip MCP initialization for proxy — tools come from Claude Code, not MCP servers
416
- process.env.NEUROLINK_SKIP_MCP = "true";
417
- const { NeuroLink } = await import("../../lib/neurolink.js");
418
- const neurolink = new NeuroLink();
419
- // Initialize request logger and usage stats
420
- const { initRequestLogger, cleanupLogs } = await import("../../lib/proxy/requestLogger.js");
421
- const { getStats: _getStats, resetStats: _resetStats } = await import("../../lib/proxy/usageStats.js");
422
- initRequestLogger(true);
423
- cleanupLogs(7, 500); // Delete logs older than 7 days or if total exceeds 500 MB
424
- // -----------------------------------------------------------------
425
- // 2. Load proxy config file (if --config or default exists)
426
- // -----------------------------------------------------------------
427
- const configPath = argv.config ?? join(homedir(), ".neurolink", "proxy-config.yaml");
428
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
- let proxyConfig = null;
430
- try {
431
- const { loadProxyConfig } = await import("../../lib/proxy/proxyConfig.js");
432
- proxyConfig = await loadProxyConfig(configPath);
433
- if (spinner) {
434
- spinner.text = `Loaded proxy config from ${configPath}`;
435
- }
436
- }
437
- catch (configError) {
438
- if (argv.config) {
439
- if (spinner) {
440
- spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
441
- }
442
- process.exit(1);
443
- }
444
- // Only silently ignore file-not-found for the default config path.
445
- // Log a warning for other errors (parse failures, permission issues, etc.)
446
- const isNotFound = configError instanceof Error &&
447
- "code" in configError &&
448
- configError.code === "ENOENT";
449
- if (!isNotFound) {
450
- logger.warn(`[proxy] Ignoring default config ${configPath}: ${configError instanceof Error ? configError.message : String(configError)}`);
451
- }
452
- }
453
- // -----------------------------------------------------------------
454
- // 3. Create ModelRouter from config (if routing configured)
455
- // -----------------------------------------------------------------
456
- const strategy = argv.strategy ?? proxyConfig?.routing?.strategy ?? "fill-first";
457
- let modelRouter;
458
- if (proxyConfig?.routing) {
459
- const { ModelRouter } = await import("../../lib/proxy/modelRouter.js");
460
- modelRouter = new ModelRouter({
461
- strategy: strategy,
462
- modelMappings: proxyConfig.routing.modelMappings ?? [],
463
- fallbackChain: proxyConfig.routing.fallbackChain ?? [],
464
- passthroughModels: proxyConfig.routing.passthroughModels,
465
- });
466
- }
467
- if (spinner) {
468
- spinner.text = "Configuring server...";
469
- }
470
- // -----------------------------------------------------------------
471
- // 4. Build Hono app with Claude proxy routes and NeuroLink context
472
- // -----------------------------------------------------------------
473
- const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
474
- const { Hono } = await import("hono");
475
- const { serve } = await import("@hono/node-server");
476
- const app = new Hono();
477
- app.onError((err, c) => {
478
- const errMsg = err instanceof Error ? err.message : String(err);
479
- logger.always(`[proxy] unhandled error: ${errMsg}`);
480
- if (err instanceof Error && err.stack) {
481
- logger.debug(`[proxy] stack: ${err.stack}`);
482
- }
483
- return c.json({
484
- type: "error",
485
- error: {
486
- type: "api_error",
487
- message: `Proxy internal error: ${errMsg}`,
488
- },
489
- }, 502);
490
- });
491
- const passthrough = argv.passthrough ?? false;
492
- const routeGroup = createClaudeProxyRoutes(modelRouter, "", strategy, passthrough);
493
- // Register proxy routes — inject NeuroLink into ServerContext
494
- for (const route of routeGroup.routes) {
495
- const method = route.method.toLowerCase();
496
- app[method](route.path, async (c) => {
497
- const emptyBody = {};
498
- let body;
499
- let rawBody;
500
- if (method === "post") {
501
- rawBody = await c.req.text().catch(() => undefined);
502
- try {
503
- body = rawBody ? JSON.parse(rawBody) : emptyBody;
504
- }
505
- catch {
506
- body = emptyBody;
507
- }
508
- }
509
- // Log incoming request
510
- const model = body?.model ?? "-";
511
- const stream = body?.stream
512
- ? "stream"
513
- : "non-stream";
514
- const bodyRec = body;
515
- const toolCount = Array.isArray(bodyRec?.tools)
516
- ? bodyRec.tools.length
517
- : 0;
518
- logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
519
- // Build ServerContext with the real NeuroLink instance
520
- const ctx = {
521
- requestId: crypto.randomUUID(),
522
- method: c.req.method,
523
- path: c.req.path,
524
- headers: Object.fromEntries(c.req.raw.headers.entries()),
525
- query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
526
- params: c.req.param(),
527
- body,
528
- rawBody, // Preserve original bytes for passthrough mode
529
- neurolink, // NeuroLink instance for generate/stream
530
- toolRegistry: neurolink.getToolRegistry(),
531
- timestamp: Date.now(),
532
- metadata: {},
533
- };
534
- const result = await route.handler(ctx);
535
- // Handle raw Response objects (passthrough streaming from upstream)
536
- if (result instanceof Response) {
537
- return result;
538
- }
539
- // Handle streaming response (async iterable)
540
- if (result &&
541
- typeof result === "object" &&
542
- Symbol.asyncIterator in Object(result)) {
543
- const iterator = result[Symbol.asyncIterator]();
544
- let cancelled = false;
545
- const stream = new ReadableStream({
546
- async start(controller) {
547
- try {
548
- while (!cancelled) {
549
- const { value, done } = await iterator.next();
550
- if (done) {
551
- break;
552
- }
553
- controller.enqueue(new TextEncoder().encode(value));
554
- }
555
- controller.close();
556
- }
557
- catch (streamErr) {
558
- if (cancelled) {
559
- // Client disconnected — just close
560
- controller.close();
561
- return;
562
- }
563
- // Emit an SSE error frame so the client gets a meaningful
564
- // error instead of a silent EOF / connection drop.
565
- const errMsg = streamErr instanceof Error
566
- ? streamErr.message
567
- : String(streamErr);
568
- const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Stream interrupted: ${errMsg}` } })}\n\n`;
569
- try {
570
- controller.enqueue(new TextEncoder().encode(errorEvent));
571
- }
572
- catch {
573
- // Controller already errored — ignore
574
- }
575
- controller.close();
576
- }
577
- },
578
- async cancel() {
579
- cancelled = true;
580
- await iterator.return?.();
581
- },
582
- });
583
- return new Response(stream, {
584
- headers: {
585
- "Content-Type": "text/event-stream",
586
- "Cache-Control": "no-cache",
587
- Connection: "keep-alive",
588
- },
589
- });
590
- }
591
- // Handle error responses with httpStatus field
592
- if (result &&
593
- typeof result === "object" &&
594
- "httpStatus" in result) {
595
- const httpResult = result;
596
- const status = httpResult.httpStatus ?? 200;
597
- delete httpResult.httpStatus;
598
- return c.json(result, status);
599
- }
600
- // Handle Anthropic-style error responses
601
- if (result &&
602
- typeof result === "object" &&
603
- "type" in result &&
604
- result.type === "error") {
605
- const errorResult = result;
606
- const status = mapClaudeErrorTypeToStatus(errorResult.error?.type);
607
- return c.json(result, status);
608
- }
609
- return c.json(result ?? {});
610
- });
611
- }
612
- // Health endpoint
613
- app.get("/health", (c) => c.json({
614
- status: "ok",
615
- strategy,
616
- uptime: process.uptime(),
617
- version: PROXY_VERSION,
618
- }));
619
- // Status endpoint (detailed)
620
- app.get("/status", async (c) => {
621
- const { getStats } = await import("../../lib/proxy/usageStats.js");
622
- const stats = getStats();
623
- return c.json({
624
- status: "running",
625
- pid: process.pid,
626
- port,
627
- host,
628
- strategy,
629
- uptime: process.uptime(),
630
- version: PROXY_VERSION,
631
- stats: {
632
- totalAttempts: stats.totalAttempts,
633
- totalRequests: stats.totalRequests,
634
- totalSuccess: stats.totalSuccess,
635
- totalErrors: stats.totalErrors,
636
- totalRateLimits: stats.totalRateLimits,
637
- accounts: Object.values(stats.accounts).map((a) => ({
638
- label: a.label,
639
- type: a.type,
640
- attempts: a.attemptCount,
641
- requests: a.attemptCount,
642
- success: a.successCount,
643
- errors: a.errorCount,
644
- rateLimits: a.rateLimitCount,
645
- backoffLevel: a.currentBackoffLevel,
646
- cooling: a.coolingUntil ? a.coolingUntil > Date.now() : false,
647
- })),
648
- },
649
- config: proxyConfig ? { hasRouting: !!proxyConfig.routing } : null,
650
- });
651
- });
652
- // -----------------------------------------------------------------
653
- // 5. Initialize OpenTelemetry for proxy observability
654
- // -----------------------------------------------------------------
655
- try {
656
- const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
657
- if (!process.env.OTEL_SERVICE_NAME) {
658
- process.env.OTEL_SERVICE_NAME = "neurolink-proxy";
659
- }
660
- // Merge resource attributes (preserving any existing ones)
661
- process.env.OTEL_RESOURCE_ATTRIBUTES = [
662
- `service.name=neurolink-proxy`,
663
- `service.version=${PROXY_VERSION}`,
664
- `deployment.environment=local`,
665
- process.env.OTEL_RESOURCE_ATTRIBUTES,
666
- ]
667
- .filter(Boolean)
668
- .join(",");
669
- const { initializeOpenTelemetry, isOpenTelemetryInitialized } = await import("../../lib/services/server/ai/observability/instrumentation.js");
670
- const { buildObservabilityConfigFromEnv } = await import("../../lib/utils/observabilityHelpers.js");
671
- if (!isOpenTelemetryInitialized()) {
672
- const observabilityConfig = buildObservabilityConfigFromEnv();
673
- const langfuseConfig = observabilityConfig?.langfuse;
674
- const langfuseEnabled = langfuseConfig?.enabled === true;
675
- initializeOpenTelemetry({
676
- enabled: langfuseEnabled,
677
- publicKey: langfuseConfig?.publicKey || "",
678
- secretKey: langfuseConfig?.secretKey || "",
679
- baseUrl: langfuseConfig?.baseUrl,
680
- environment: "proxy",
681
- release: PROXY_VERSION,
682
- userId: "neurolink-proxy",
683
- autoDetectOperationName: true,
684
- });
685
- if (langfuseEnabled) {
686
- logger.always(`[proxy] Langfuse enabled — exporting to ${langfuseConfig.baseUrl || "https://cloud.langfuse.com"} (environment=proxy)`);
687
- }
688
- if (otlpEndpoint) {
689
- logger.always(`[proxy] OTLP exporter enabled — exporting to ${otlpEndpoint} (service.name=neurolink-proxy)`);
690
- }
691
- if (!langfuseEnabled && !otlpEndpoint) {
692
- logger.always("[proxy] OpenTelemetry exporters disabled — set OTEL_EXPORTER_OTLP_ENDPOINT or Langfuse credentials to enable proxy observability");
693
- }
694
- }
695
- }
696
- catch (otelError) {
697
- // OTel is non-critical — proxy must still work without it
698
- logger.debug(`[proxy] OpenTelemetry init failed (non-fatal): ${otelError instanceof Error ? otelError.message : String(otelError)}`);
699
- }
700
- // -----------------------------------------------------------------
701
- // 6. Start listening
702
- // -----------------------------------------------------------------
703
- const port = argv.port ?? 55669;
704
- const host = argv.host ?? "127.0.0.1";
705
- if (spinner) {
706
- spinner.text = `Starting proxy on ${host}:${port}...`;
707
- }
708
- const server = serve({
709
- fetch: app.fetch,
710
- port,
711
- hostname: host,
712
- });
713
- const guardPid = spawnFailOpenGuard(host, port, process.pid);
714
- // Extract fallback chain from proxy config (if available)
715
- const fallbackChain = proxyConfig?.routing?.fallbackChain?.map((e) => ({
716
- provider: e.provider,
717
- model: e.model,
718
- }));
719
- // Persist state (including fallback chain for `proxy status`)
720
- const state = {
721
- pid: process.pid,
722
- port,
723
- host,
724
- strategy,
725
- startTime: new Date().toISOString(),
726
- envFile: loadedEnvFile,
727
- fallbackChain,
728
- guardPid,
729
- managedBy: process.platform === "darwin" && process.ppid === 1
730
- ? "launchd"
731
- : "manual",
732
- passthrough,
733
- };
734
- saveProxyState(state);
735
- if (spinner) {
736
- spinner.succeed(chalk.green("Claude proxy started successfully"));
737
- }
738
- const normalizedHost = host === "0.0.0.0" ? "localhost" : host;
739
- const url = `http://${normalizedHost}:${port}`;
740
- printProxyBanner(url, strategy);
741
- logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(passthrough ? "passthrough" : "full")}`);
742
- if (loadedEnvFile) {
743
- logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(loadedEnvFile)}`);
744
- }
745
- // Auto-configure Claude Code — use the normalized URL (localhost, not 0.0.0.0)
746
- try {
747
- await setClaudeProxySettings(url);
748
- logger.always(chalk.green(" ✓ Auto-configured Claude Code settings"));
749
- logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
750
- }
751
- catch (e) {
752
- logger.debug("[proxy] Failed to auto-configure Claude Code: " +
753
- (e instanceof Error ? e.message : String(e)));
754
- }
755
- // -----------------------------------------------------------------
756
- // 7. Background token refresh (every 30 seconds)
757
- // -----------------------------------------------------------------
758
- const { needsRefresh, refreshToken, persistTokens } = await import("../../lib/proxy/tokenRefresh.js");
759
- const { tokenStore } = await import("../../lib/auth/tokenStore.js");
760
- const refreshInterval = setInterval(async () => {
761
- // Refresh token-pool accounts
762
- try {
763
- const allKeys = await tokenStore.listProviders();
764
- const anthropicKeys = allKeys.filter((k) => k.startsWith("anthropic:"));
765
- for (const key of anthropicKeys) {
766
- try {
767
- const tokens = await tokenStore.loadTokens(key);
768
- if (!tokens) {
769
- continue;
770
- }
771
- const account = {
772
- label: key,
773
- token: tokens.accessToken,
774
- refreshToken: tokens.refreshToken,
775
- expiresAt: tokens.expiresAt,
776
- };
777
- if (needsRefresh(account)) {
778
- const result = await refreshToken(account);
779
- if (result.success) {
780
- await persistTokens({ providerKey: key }, account);
781
- logger.debug(`[proxy] background token refresh succeeded for ${key}`);
782
- }
783
- }
784
- }
785
- catch {
786
- /* non-fatal per-account */
787
- }
788
- }
789
- }
790
- catch {
791
- /* non-fatal */
792
- }
793
- // Refresh legacy credentials file
794
- try {
795
- const credPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
796
- const { readFileSync } = await import("fs");
797
- const creds = JSON.parse(readFileSync(credPath, "utf8"));
798
- if (creds.oauth) {
799
- const account = {
800
- label: "background",
801
- token: creds.oauth.accessToken,
802
- refreshToken: creds.oauth.refreshToken,
803
- expiresAt: creds.oauth.expiresAt,
804
- };
805
- if (needsRefresh(account)) {
806
- const result = await refreshToken(account);
807
- if (result.success) {
808
- await persistTokens(credPath, account);
809
- logger.debug("[proxy] background token refresh succeeded");
810
- }
811
- }
812
- }
813
- }
814
- catch {
815
- /* non-fatal */
816
- }
817
- }, 30_000);
818
- // Hourly log cleanup
819
- const logCleanupInterval = setInterval(() => {
820
- cleanupLogs(7, 500);
821
- }, 60 * 60 * 1000);
822
- // -----------------------------------------------------------------
823
- // 8. Graceful shutdown
824
- // -----------------------------------------------------------------
825
- const shutdown = async (signal) => {
826
- clearInterval(refreshInterval);
827
- clearInterval(logCleanupInterval);
828
- logger.always(`\nShutting down proxy (${signal})...`);
829
- // Flush and shutdown OpenTelemetry before closing the server
830
- try {
831
- const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
832
- await flushOpenTelemetry();
833
- await shutdownOpenTelemetry();
834
- }
835
- catch {
836
- /* non-fatal — proxy shutdown must not block on OTel */
837
- }
838
- // Only clear Claude settings on user-initiated stop (SIGINT).
839
- // On SIGTERM (launchd restart cycle), leave settings intact so
840
- // the restarted proxy picks up seamlessly.
841
- if (signal === "SIGINT") {
842
- try {
843
- const shutdownHost = host === "0.0.0.0" ? "localhost" : host;
844
- await clearClaudeProxySettings(`http://${shutdownHost}:${port}`);
845
- }
846
- catch {
847
- /* non-fatal */
848
- }
849
- }
850
- try {
851
- if (server &&
852
- typeof server.close === "function") {
853
- server.close();
854
- }
855
- }
856
- catch {
857
- // Best-effort close
858
- }
859
- clearProxyState();
860
- // SIGINT = user pressed Ctrl+C → exit 0 (launchd won't restart)
861
- // SIGTERM = launchd/system stop → exit 1 (launchd WILL restart)
862
- process.exit(signal === "SIGINT" ? 0 : 1);
863
- };
864
- process.on("SIGTERM", () => shutdown("SIGTERM"));
865
- process.on("SIGINT", () => shutdown("SIGINT"));
866
- }
867
- catch (error) {
868
- if (spinner) {
869
- spinner.fail(chalk.red("Failed to start proxy"));
870
- }
871
- logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
872
- if (argv.debug && error instanceof Error && error.stack) {
873
- logger.error(chalk.gray(error.stack));
874
- }
875
- process.exit(1);
876
- }
913
+ await startProxyCommandHandler(argv);
877
914
  },
878
915
  };
879
916
  function printStatusStats(stats) {
@@ -928,6 +965,7 @@ export const proxyStatusCommand = {
928
965
  pid: null,
929
966
  port: null,
930
967
  host: null,
968
+ mode: null,
931
969
  strategy: null,
932
970
  uptime: null,
933
971
  startTime: null,
@@ -940,6 +978,7 @@ export const proxyStatusCommand = {
940
978
  status.pid = state.pid;
941
979
  status.port = state.port;
942
980
  status.host = state.host;
981
+ status.mode = state.passthrough ? "passthrough" : "full";
943
982
  status.strategy = state.strategy;
944
983
  status.startTime = state.startTime;
945
984
  status.uptime = Date.now() - new Date(state.startTime).getTime();
@@ -975,6 +1014,7 @@ export const proxyStatusCommand = {
975
1014
  logger.always(` ${chalk.bold("PID:")} ${chalk.cyan(status.pid)}`);
976
1015
  logger.always(` ${chalk.bold("URL:")} ${chalk.cyan(status.url)}`);
977
1016
  logger.always(` ${chalk.bold("Strategy:")} ${chalk.cyan(status.strategy)}`);
1017
+ logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(status.mode ?? "full")}`);
978
1018
  logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
979
1019
  logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
980
1020
  if (status.envFile) {
@@ -1582,7 +1622,9 @@ export const proxyInstallCommand = {
1582
1622
  });
1583
1623
  const envFile = envResolution.path;
1584
1624
  const explicitConfig = argv.config;
1585
- const configPath = explicitConfig ?? join(homedir(), ".neurolink", "proxy-config.yaml");
1625
+ const configPath = explicitConfig
1626
+ ? resolve(explicitConfig)
1627
+ : join(homedir(), ".neurolink", "proxy-config.yaml");
1586
1628
  if (explicitConfig && !existsSync(configPath)) {
1587
1629
  console.info(chalk.red(`Proxy config file not found: ${configPath}`));
1588
1630
  process.exit(1);