@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.
- package/CHANGELOG.md +8 -0
- package/dist/auth/anthropicOAuth.js +12 -0
- package/dist/browser/neurolink.min.js +335 -334
- package/dist/cli/commands/mcp.d.ts +6 -0
- package/dist/cli/commands/mcp.js +200 -184
- package/dist/cli/commands/proxy.js +560 -518
- package/dist/core/baseProvider.d.ts +6 -1
- package/dist/core/baseProvider.js +219 -232
- package/dist/core/factory.d.ts +3 -0
- package/dist/core/factory.js +140 -190
- package/dist/core/modules/ToolsManager.d.ts +1 -0
- package/dist/core/modules/ToolsManager.js +40 -42
- package/dist/core/toolEvents.d.ts +3 -0
- package/dist/core/toolEvents.js +7 -0
- package/dist/evaluation/pipeline/evaluationPipeline.js +5 -2
- package/dist/evaluation/scorers/scorerRegistry.d.ts +3 -0
- package/dist/evaluation/scorers/scorerRegistry.js +356 -284
- package/dist/lib/auth/anthropicOAuth.js +12 -0
- package/dist/lib/core/baseProvider.d.ts +6 -1
- package/dist/lib/core/baseProvider.js +219 -232
- package/dist/lib/core/factory.d.ts +3 -0
- package/dist/lib/core/factory.js +140 -190
- package/dist/lib/core/modules/ToolsManager.d.ts +1 -0
- package/dist/lib/core/modules/ToolsManager.js +40 -42
- package/dist/lib/core/toolEvents.d.ts +3 -0
- package/dist/lib/core/toolEvents.js +8 -0
- package/dist/lib/evaluation/pipeline/evaluationPipeline.js +5 -2
- package/dist/lib/evaluation/scorers/scorerRegistry.d.ts +3 -0
- package/dist/lib/evaluation/scorers/scorerRegistry.js +356 -284
- package/dist/lib/mcp/toolRegistry.d.ts +2 -0
- package/dist/lib/mcp/toolRegistry.js +32 -31
- package/dist/lib/neurolink.d.ts +38 -0
- package/dist/lib/neurolink.js +1890 -1707
- package/dist/lib/providers/googleAiStudio.js +0 -5
- package/dist/lib/providers/googleNativeGemini3.d.ts +4 -0
- package/dist/lib/providers/googleNativeGemini3.js +39 -1
- package/dist/lib/providers/googleVertex.d.ts +10 -0
- package/dist/lib/providers/googleVertex.js +445 -445
- package/dist/lib/providers/litellm.d.ts +1 -0
- package/dist/lib/providers/litellm.js +73 -64
- package/dist/lib/providers/ollama.js +17 -4
- package/dist/lib/providers/openAI.d.ts +2 -0
- package/dist/lib/providers/openAI.js +139 -140
- package/dist/lib/proxy/claudeFormat.js +14 -5
- package/dist/lib/proxy/oauthFetch.js +298 -318
- package/dist/lib/proxy/proxyConfig.js +3 -1
- package/dist/lib/proxy/proxyFetch.js +250 -222
- package/dist/lib/proxy/proxyHealth.d.ts +17 -0
- package/dist/lib/proxy/proxyHealth.js +55 -0
- package/dist/lib/proxy/requestLogger.js +140 -48
- package/dist/lib/proxy/routingPolicy.d.ts +33 -0
- package/dist/lib/proxy/routingPolicy.js +255 -0
- package/dist/lib/proxy/snapshotPersistence.d.ts +2 -0
- package/dist/lib/proxy/snapshotPersistence.js +41 -0
- package/dist/lib/proxy/sseInterceptor.js +36 -11
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -1
- package/dist/lib/server/routes/claudeProxyRoutes.js +2916 -2377
- package/dist/lib/services/server/ai/observability/instrumentation.js +194 -218
- package/dist/lib/tasks/backends/bullmqBackend.js +24 -18
- package/dist/lib/tasks/store/redisTaskStore.js +42 -17
- package/dist/lib/tasks/taskManager.d.ts +2 -0
- package/dist/lib/tasks/taskManager.js +100 -5
- package/dist/lib/telemetry/telemetryService.js +9 -5
- package/dist/lib/types/cli.d.ts +4 -0
- package/dist/lib/types/proxyTypes.d.ts +211 -1
- package/dist/lib/types/tools.d.ts +18 -0
- package/dist/lib/utils/providerHealth.d.ts +1 -0
- package/dist/lib/utils/providerHealth.js +46 -31
- package/dist/lib/utils/providerUtils.js +11 -22
- package/dist/lib/utils/schemaConversion.d.ts +1 -0
- package/dist/lib/utils/schemaConversion.js +3 -0
- package/dist/mcp/toolRegistry.d.ts +2 -0
- package/dist/mcp/toolRegistry.js +32 -31
- package/dist/neurolink.d.ts +38 -0
- package/dist/neurolink.js +1890 -1707
- package/dist/providers/googleAiStudio.js +0 -5
- package/dist/providers/googleNativeGemini3.d.ts +4 -0
- package/dist/providers/googleNativeGemini3.js +39 -1
- package/dist/providers/googleVertex.d.ts +10 -0
- package/dist/providers/googleVertex.js +445 -445
- package/dist/providers/litellm.d.ts +1 -0
- package/dist/providers/litellm.js +73 -64
- package/dist/providers/ollama.js +17 -4
- package/dist/providers/openAI.d.ts +2 -0
- package/dist/providers/openAI.js +139 -140
- package/dist/proxy/claudeFormat.js +14 -5
- package/dist/proxy/oauthFetch.js +298 -318
- package/dist/proxy/proxyConfig.js +3 -1
- package/dist/proxy/proxyFetch.js +250 -222
- package/dist/proxy/proxyHealth.d.ts +17 -0
- package/dist/proxy/proxyHealth.js +54 -0
- package/dist/proxy/requestLogger.js +140 -48
- package/dist/proxy/routingPolicy.d.ts +33 -0
- package/dist/proxy/routingPolicy.js +254 -0
- package/dist/proxy/snapshotPersistence.d.ts +2 -0
- package/dist/proxy/snapshotPersistence.js +40 -0
- package/dist/proxy/sseInterceptor.js +36 -11
- package/dist/server/routes/claudeProxyRoutes.d.ts +2 -1
- package/dist/server/routes/claudeProxyRoutes.js +2916 -2377
- package/dist/services/server/ai/observability/instrumentation.js +194 -218
- package/dist/tasks/backends/bullmqBackend.js +24 -18
- package/dist/tasks/store/redisTaskStore.js +42 -17
- package/dist/tasks/taskManager.d.ts +2 -0
- package/dist/tasks/taskManager.js +100 -5
- package/dist/telemetry/telemetryService.js +9 -5
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/proxyTypes.d.ts +211 -1
- package/dist/types/tools.d.ts +18 -0
- package/dist/utils/providerHealth.d.ts +1 -0
- package/dist/utils/providerHealth.js +46 -31
- package/dist/utils/providerUtils.js +12 -22
- package/dist/utils/schemaConversion.d.ts +1 -0
- package/dist/utils/schemaConversion.js +3 -0
- package/package.json +3 -2
- package/scripts/observability/check-proxy-telemetry.mjs +1 -1
- 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
|
-
|
|
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
|
|
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);
|