@juspay/neurolink 9.38.0 → 9.39.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.
@@ -9,6 +9,9 @@
9
9
  * provider/model pairs (e.g. "claude-sonnet-4-20250514" -> vertex/gemini-2.5-pro).
10
10
  * Without a router, models are passed through to the Anthropic provider.
11
11
  */
12
+ import { readFile, access } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
12
15
  import { parseClaudeRequest, serializeClaudeResponse, ClaudeStreamSerializer, buildClaudeError, generateToolUseId, } from "../../proxy/claudeFormat.js";
13
16
  import { logger } from "../../utils/logger.js";
14
17
  import { recordRequest, recordSuccess, recordError, recordCooldown, } from "../../proxy/usageStats.js";
@@ -81,6 +84,127 @@ function advancePrimaryIfCurrent(accountKey, enabledCount, primaryAccountKey) {
81
84
  primaryAccountIndex = (primaryAccountIndex + 1) % enabledCount;
82
85
  }
83
86
  // ---------------------------------------------------------------------------
87
+ // OAuth polyfill helpers (extracted to reduce block nesting)
88
+ // ---------------------------------------------------------------------------
89
+ const snapshotCache = new Map();
90
+ const SNAPSHOT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
91
+ /**
92
+ * Load a header snapshot captured from a real Claude Code session and apply
93
+ * any headers the client didn't send. This makes non-Claude-Code requests
94
+ * (e.g. from Curator, custom apps) appear identical to Claude Code.
95
+ */
96
+ async function applyHeaderSnapshot(headers, accountLabel) {
97
+ try {
98
+ // Sanitize accountLabel to prevent directory traversal
99
+ const safeLabel = accountLabel.replace(/[^a-zA-Z0-9._@-]/g, "_");
100
+ // Check cache first
101
+ const cached = snapshotCache.get(safeLabel);
102
+ if (cached && Date.now() - cached.loadedAt < SNAPSHOT_CACHE_TTL_MS) {
103
+ for (const [sk, sv] of Object.entries(cached.headers)) {
104
+ const lower = sk.toLowerCase();
105
+ if (typeof sv === "string" &&
106
+ !headers[lower] &&
107
+ !BLOCKED_UPSTREAM_HEADERS.has(lower) &&
108
+ lower !== "authorization" &&
109
+ lower !== "x-api-key") {
110
+ headers[lower] = sv;
111
+ }
112
+ }
113
+ return;
114
+ }
115
+ const snapshotPath = join(homedir(), ".neurolink", "header-snapshots", `anthropic_${safeLabel}.json`);
116
+ try {
117
+ await access(snapshotPath);
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
123
+ if (!snapshot.headers) {
124
+ return;
125
+ }
126
+ // Store in cache
127
+ snapshotCache.set(safeLabel, {
128
+ headers: snapshot.headers,
129
+ loadedAt: Date.now(),
130
+ });
131
+ for (const [sk, sv] of Object.entries(snapshot.headers)) {
132
+ const lower = sk.toLowerCase();
133
+ if (typeof sv === "string" &&
134
+ !headers[lower] &&
135
+ !BLOCKED_UPSTREAM_HEADERS.has(lower) &&
136
+ lower !== "authorization" &&
137
+ lower !== "x-api-key") {
138
+ headers[lower] = sv;
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ // Snapshot missing or corrupt — continue without it
144
+ }
145
+ }
146
+ /**
147
+ * Polyfill the request body for OAuth accounts.
148
+ * Claude Code injects a billing header, agent block, and metadata.user_id
149
+ * into the body. Non-CC clients (Curator, custom apps) don't send these —
150
+ * Anthropic rejects without them.
151
+ */
152
+ function polyfillOAuthBody(bodyStr, accountToken) {
153
+ try {
154
+ const parsed = JSON.parse(bodyStr);
155
+ // Billing header block (required by Anthropic for OAuth)
156
+ const randomHex = Math.random().toString(16).substring(2, 5);
157
+ const billingBlock = {
158
+ type: "text",
159
+ text: `x-anthropic-billing-header: cc_version=2.1.86.${randomHex}; cc_entrypoint=cli; cch=proxy;`,
160
+ };
161
+ const agentBlock = {
162
+ type: "text",
163
+ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
164
+ };
165
+ // Normalise system to array and prepend billing + agent
166
+ if (parsed.system) {
167
+ if (typeof parsed.system === "string") {
168
+ parsed.system = [{ type: "text", text: parsed.system }];
169
+ }
170
+ if (Array.isArray(parsed.system)) {
171
+ const hasBilling = parsed.system.some((b) => typeof b.text === "string" &&
172
+ b.text.includes("x-anthropic-billing-header"));
173
+ const hasAgent = parsed.system.some((b) => typeof b.text === "string" && b.text.includes("Claude Agent SDK"));
174
+ const toInsert = [];
175
+ if (!hasBilling) {
176
+ toInsert.push(billingBlock);
177
+ }
178
+ if (!hasAgent) {
179
+ toInsert.push(agentBlock);
180
+ }
181
+ if (toInsert.length > 0) {
182
+ parsed.system = [...toInsert, ...parsed.system];
183
+ }
184
+ }
185
+ }
186
+ else {
187
+ parsed.system = [billingBlock, agentBlock];
188
+ }
189
+ // Inject metadata.user_id (required for OAuth)
190
+ if (!parsed.metadata?.user_id) {
191
+ const tokenPrefix = accountToken.substring(0, Math.min(20, accountToken.length));
192
+ const hash = Array.from(new TextEncoder().encode(tokenPrefix))
193
+ .reduce((a, b) => ((a << 5) - a + b) | 0, 0)
194
+ .toString(16)
195
+ .replace("-", "");
196
+ parsed.metadata = {
197
+ ...parsed.metadata,
198
+ user_id: `proxy-${hash}`,
199
+ };
200
+ }
201
+ return JSON.stringify(parsed);
202
+ }
203
+ catch {
204
+ return bodyStr; // JSON parse failed — use original body
205
+ }
206
+ }
207
+ // ---------------------------------------------------------------------------
84
208
  // Legacy credential refresh helper (extracted to reduce block nesting)
85
209
  // ---------------------------------------------------------------------------
86
210
  async function tryLoadLegacyAccount(creds, legacyCredPath) {
@@ -158,17 +282,29 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
158
282
  handler: async (ctx) => {
159
283
  const body = ctx.body;
160
284
  // 1. Validate
161
- if (!body?.model || !body?.messages) {
285
+ if (typeof body?.model !== "string" ||
286
+ !Array.isArray(body?.messages)) {
162
287
  return buildClaudeError(400, "Missing required fields: model, messages");
163
288
  }
164
289
  // 2. Resolve model via router (or pass through to anthropic)
290
+ // Guard: without a model router, only Claude models are allowed.
291
+ const modelLower = body.model.toLowerCase();
292
+ if (!modelRouter && !modelLower.startsWith("claude-")) {
293
+ return buildClaudeError(404, `Model '${body.model}' is not an Anthropic model. ` +
294
+ `The proxy only supports Claude models. ` +
295
+ `Use a model router to route non-Claude models to other providers.`);
296
+ }
165
297
  const route = modelRouter?.resolve(body.model) ?? {
166
298
  provider: "anthropic",
167
299
  model: body.model,
168
300
  };
169
301
  try {
170
302
  // 3. Route based on target provider
171
- const isClaudeTarget = route.provider === "anthropic" || route.provider === null;
303
+ if (route.provider === null) {
304
+ return buildClaudeError(404, `Model '${body.model}' is not a Claude model. ` +
305
+ `Use a model router to route it to another provider.`);
306
+ }
307
+ const isClaudeTarget = route.provider === "anthropic";
172
308
  if (isClaudeTarget) {
173
309
  // ─── PASSTHROUGH MODE (Claude → Claude) ───────────────
174
310
  const fs = await import("fs");
@@ -435,27 +571,54 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
435
571
  headers["content-type"] = "application/json";
436
572
  if (isOAuth) {
437
573
  headers["authorization"] = `Bearer ${account.token}`;
574
+ delete headers["x-api-key"];
438
575
  }
439
576
  else {
440
577
  headers["x-api-key"] = account.token;
441
578
  delete headers["authorization"];
442
579
  }
443
- // Defaults: only set when client didn't send them
580
+ // Apply header snapshot defaults for OAuth accounts
581
+ if (isOAuth) {
582
+ await applyHeaderSnapshot(headers, account.label);
583
+ }
584
+ // Hard defaults for anything still missing
444
585
  if (!headers["user-agent"]) {
445
- headers["user-agent"] = "claude-cli/2.1.80 (external, cli)";
586
+ headers["user-agent"] = "claude-cli/2.1.86 (external, cli)";
446
587
  }
447
588
  if (!headers["anthropic-version"]) {
448
589
  headers["anthropic-version"] = "2023-06-01";
449
590
  }
450
- // Ensure oauth beta is always present in the beta list
451
- const existingBetas = headers["anthropic-beta"] ?? "";
452
- if (!existingBetas) {
453
- headers["anthropic-beta"] = "oauth-2025-04-20";
591
+ if (!headers["anthropic-dangerous-direct-browser-access"]) {
592
+ headers["anthropic-dangerous-direct-browser-access"] = "true";
593
+ }
594
+ // Manage anthropic-beta header based on auth type.
595
+ // OAuth requires specific betas; API-key must NOT carry them.
596
+ if (isOAuth) {
597
+ const existing = new Set((headers["anthropic-beta"] ?? "")
598
+ .split(",")
599
+ .map((s) => s.trim())
600
+ .filter(Boolean));
601
+ existing.add("oauth-2025-04-20");
602
+ existing.add("claude-code-20250219");
603
+ headers["anthropic-beta"] = [...existing].join(",");
454
604
  }
455
- else if (!existingBetas.includes("oauth")) {
456
- headers["anthropic-beta"] =
457
- `${existingBetas},oauth-2025-04-20`;
605
+ else {
606
+ // Strip OAuth-specific betas that may have leaked from client
607
+ const cleaned = (headers["anthropic-beta"] ?? "")
608
+ .split(",")
609
+ .map((s) => s.trim())
610
+ .filter((s) => s && s !== "oauth-2025-04-20")
611
+ .join(",");
612
+ if (cleaned) {
613
+ headers["anthropic-beta"] = cleaned;
614
+ }
615
+ else {
616
+ delete headers["anthropic-beta"];
617
+ }
458
618
  }
619
+ // Polyfill request body for OAuth accounts
620
+ const buildUpstreamBody = () => isOAuth ? polyfillOAuthBody(bodyStr, account.token) : bodyStr;
621
+ const finalBodyStr = buildUpstreamBody();
459
622
  logger.always(`[proxy] → account=${account.label} (${account.type})`);
460
623
  recordRequest(account.label, account.type);
461
624
  // Log full request for debugging (written to ~/.neurolink/logs/proxy-debug-*.jsonl)
@@ -465,7 +628,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
465
628
  response = await fetch(url, {
466
629
  method: "POST",
467
630
  headers,
468
- body: bodyStr,
631
+ body: finalBodyStr,
469
632
  signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS),
470
633
  });
471
634
  }
@@ -497,6 +660,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
497
660
  }
498
661
  else {
499
662
  const date = new Date(retryAfter);
663
+ // eslint-disable-next-line max-depth
500
664
  if (!Number.isNaN(date.getTime())) {
501
665
  cooldownMs = Math.max(date.getTime() - Date.now(), 1000);
502
666
  }
@@ -530,12 +694,14 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
530
694
  authRetryError = `refresh failed for account=${account.label} attempt ${authRetry + 1}/${MAX_AUTH_RETRIES}: ${refreshSucceeded.error?.slice(0, 200) ?? "unknown"}`;
531
695
  lastError = authRetryError;
532
696
  logger.always(`[proxy] ⚠ account=${account.label} refresh failed on attempt ${authRetry + 1}`);
697
+ // eslint-disable-next-line max-depth
533
698
  if (accountState.consecutiveRefreshFailures >=
534
699
  MAX_CONSECUTIVE_REFRESH_FAILURES) {
535
700
  await disableAccountUntilReauth(account, accountState);
536
701
  authFailureMessage = formatReauthMessage(account.label);
537
702
  break;
538
703
  }
704
+ // eslint-disable-next-line max-depth
539
705
  if (authRetry < MAX_AUTH_RETRIES - 1) {
540
706
  await sleep(2000);
541
707
  }
@@ -549,9 +715,10 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
549
715
  const retryResp = await fetch(url, {
550
716
  method: "POST",
551
717
  headers,
552
- body: bodyStr,
718
+ body: buildUpstreamBody(),
553
719
  signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS),
554
720
  });
721
+ // eslint-disable-next-line max-depth
555
722
  if (retryResp.ok) {
556
723
  authRetrySucceeded = true;
557
724
  accountState.consecutiveRefreshFailures = 0;
@@ -647,6 +814,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
647
814
  lastError = retryBody;
648
815
  logger.debug(`[proxy] retry ${authRetry + 1} failed: ${retryStatus} ${retryBody.substring(0, 120)}`);
649
816
  recordError(account.label, account.type, retryStatus);
817
+ // eslint-disable-next-line max-depth
650
818
  if (retryStatus === 429) {
651
819
  sawRateLimit = true;
652
820
  const retryAfter = retryResp.headers.get("retry-after");
@@ -659,6 +827,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
659
827
  recordCooldown(account.label, account.type, accountState.coolingUntil, accountState.backoffLevel);
660
828
  break;
661
829
  }
830
+ // eslint-disable-next-line max-depth
662
831
  if (retryStatus === 401 ||
663
832
  retryStatus === 402 ||
664
833
  retryStatus === 403) {
@@ -668,12 +837,14 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
668
837
  }
669
838
  continue;
670
839
  }
840
+ // eslint-disable-next-line max-depth
671
841
  if (isTransientHttpFailure(retryStatus, retryBody)) {
672
842
  // Decision 8: No cooldown for transient errors — rotate immediately
673
843
  sawTransientFailure = true;
674
844
  break;
675
845
  }
676
846
  logAttempt(retryStatus, "api_error", summarizeErrorMessage(retryBody));
847
+ // eslint-disable-next-line max-depth
677
848
  try {
678
849
  return JSON.parse(retryBody);
679
850
  }
@@ -695,7 +866,9 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
695
866
  }
696
867
  }
697
868
  if (!authRetrySucceeded) {
869
+ // eslint-disable-next-line max-depth
698
870
  if (!accountState.permanentlyDisabled) {
871
+ // eslint-disable-next-line max-depth
699
872
  if (!accountState.coolingUntil ||
700
873
  accountState.coolingUntil <= Date.now()) {
701
874
  accountState.coolingUntil =
@@ -953,6 +1126,7 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
953
1126
  "anthropic-ratelimit-tokens-limit",
954
1127
  ]) {
955
1128
  const val = response.headers.get(h);
1129
+ // eslint-disable-next-line max-depth
956
1130
  if (val) {
957
1131
  responseHeaders[h] = val;
958
1132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.38.0",
3
+ "version": "9.39.0",
4
4
  "description": "Universal AI Development Platform with working MCP integration, multi-provider support, and professional CLI. Built-in tools operational, 58+ external MCP servers discoverable. Connect to filesystem, GitHub, database operations, and more. Build, test, and deploy AI applications with 13 providers: OpenAI, Anthropic, Google AI, AWS Bedrock, Azure, Hugging Face, Ollama, and Mistral AI.",
5
5
  "author": {
6
6
  "name": "Juspay Technologies",