@juspay/neurolink 9.38.0 → 9.40.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.
@@ -20,8 +20,10 @@ export declare enum AnthropicModel {
20
20
  CLAUDE_3_5_SONNET = "claude-3-5-sonnet-20241022",
21
21
  CLAUDE_3_5_SONNET_V2 = "claude-3-5-sonnet-v2-20241022",
22
22
  CLAUDE_SONNET_4 = "claude-sonnet-4-20250514",
23
+ CLAUDE_SONNET_4_6 = "claude-sonnet-4-6",
23
24
  CLAUDE_3_OPUS = "claude-3-opus-20240229",
24
- CLAUDE_OPUS_4 = "claude-opus-4-20250514"
25
+ CLAUDE_OPUS_4 = "claude-opus-4-20250514",
26
+ CLAUDE_OPUS_4_6 = "claude-opus-4-6"
25
27
  }
26
28
  /**
27
29
  * Model access mapping by subscription tier
@@ -27,10 +27,14 @@ export var AnthropicModel;
27
27
  AnthropicModel["CLAUDE_3_5_SONNET_V2"] = "claude-3-5-sonnet-v2-20241022";
28
28
  // Claude Sonnet 4 (Latest Sonnet)
29
29
  AnthropicModel["CLAUDE_SONNET_4"] = "claude-sonnet-4-20250514";
30
+ // Claude Sonnet 4.6
31
+ AnthropicModel["CLAUDE_SONNET_4_6"] = "claude-sonnet-4-6";
30
32
  // Claude 3 Opus (Legacy flagship)
31
33
  AnthropicModel["CLAUDE_3_OPUS"] = "claude-3-opus-20240229";
32
34
  // Claude Opus 4 (Latest flagship)
33
35
  AnthropicModel["CLAUDE_OPUS_4"] = "claude-opus-4-20250514";
36
+ // Claude Opus 4.6
37
+ AnthropicModel["CLAUDE_OPUS_4_6"] = "claude-opus-4-6";
34
38
  })(AnthropicModel || (AnthropicModel = {}));
35
39
  // ============================================================================
36
40
  // MODEL TIER ACCESS DEFINITIONS
@@ -56,6 +60,7 @@ export const MODEL_TIER_ACCESS = {
56
60
  AnthropicModel.CLAUDE_3_5_SONNET,
57
61
  AnthropicModel.CLAUDE_3_5_SONNET_V2,
58
62
  AnthropicModel.CLAUDE_SONNET_4,
63
+ AnthropicModel.CLAUDE_SONNET_4_6,
59
64
  ],
60
65
  // Max tier: All models including Opus
61
66
  max: ["*"], // All models
@@ -167,6 +172,32 @@ export const MODEL_METADATA = {
167
172
  family: "opus",
168
173
  description: "Latest flagship model with advanced reasoning",
169
174
  },
175
+ // Claude Sonnet 4.6
176
+ [AnthropicModel.CLAUDE_SONNET_4_6]: {
177
+ displayName: "Claude Sonnet 4.6",
178
+ contextWindow: 1000000,
179
+ maxOutputTokens: 64000,
180
+ supportsVision: true,
181
+ supportsExtendedThinking: true,
182
+ supportsToolUse: true,
183
+ supportsStreaming: true,
184
+ deprecated: false,
185
+ family: "sonnet",
186
+ description: "Claude 4.6 Sonnet with 1M context window",
187
+ },
188
+ // Claude Opus 4.6
189
+ [AnthropicModel.CLAUDE_OPUS_4_6]: {
190
+ displayName: "Claude Opus 4.6",
191
+ contextWindow: 1000000,
192
+ maxOutputTokens: 64000,
193
+ supportsVision: true,
194
+ supportsExtendedThinking: true,
195
+ supportsToolUse: true,
196
+ supportsStreaming: true,
197
+ deprecated: false,
198
+ family: "opus",
199
+ description: "Claude 4.6 Opus flagship with 1M context window",
200
+ },
170
201
  };
171
202
  // ============================================================================
172
203
  // DEFAULT MODELS BY TIER
@@ -417,11 +448,16 @@ export function getLatestModelsByFamily() {
417
448
  const familyPriority = {
418
449
  haiku: [AnthropicModel.CLAUDE_3_5_HAIKU, AnthropicModel.CLAUDE_3_HAIKU],
419
450
  sonnet: [
451
+ AnthropicModel.CLAUDE_SONNET_4_6,
420
452
  AnthropicModel.CLAUDE_SONNET_4,
421
453
  AnthropicModel.CLAUDE_3_5_SONNET_V2,
422
454
  AnthropicModel.CLAUDE_3_5_SONNET,
423
455
  ],
424
- opus: [AnthropicModel.CLAUDE_OPUS_4, AnthropicModel.CLAUDE_3_OPUS],
456
+ opus: [
457
+ AnthropicModel.CLAUDE_OPUS_4_6,
458
+ AnthropicModel.CLAUDE_OPUS_4,
459
+ AnthropicModel.CLAUDE_3_OPUS,
460
+ ],
425
461
  };
426
462
  for (const family of Object.keys(familyPriority)) {
427
463
  for (const model of familyPriority[family]) {
@@ -181,6 +181,10 @@ export declare class NeuroLink {
181
181
  private registerMemoryRetrievalTools;
182
182
  /** Format memory context for prompt inclusion */
183
183
  private formatMemoryContext;
184
+ /**
185
+ * Format memory context from multiple users into a labeled block.
186
+ */
187
+ private formatMultiUserMemoryContext;
184
188
  /**
185
189
  * Determine whether memory should be read (retrieved) for this call.
186
190
  * Respects both the global memory SDK config and per-call overrides.
@@ -192,13 +196,14 @@ export declare class NeuroLink {
192
196
  */
193
197
  private shouldWriteMemory;
194
198
  /**
195
- * Retrieve condensed memory for a user.
199
+ * Retrieve condensed memory for a user (and optionally additional users).
196
200
  * Returns the input text enhanced with memory context, or unchanged if no memory.
197
201
  */
198
202
  private retrieveMemory;
199
203
  /**
200
204
  * Store a conversation turn in memory (non-blocking).
201
205
  * Calls add(userId, content) which internally condenses old + new via LLM.
206
+ * Supports additional users with per-user prompt and maxWords overrides.
202
207
  */
203
208
  private storeMemoryInBackground;
204
209
  /**
@@ -823,6 +823,20 @@ export class NeuroLink {
823
823
 
824
824
  ${memoryContext}
825
825
 
826
+ Current user's request: ${currentInput}`;
827
+ }
828
+ /**
829
+ * Format memory context from multiple users into a labeled block.
830
+ */
831
+ formatMultiUserMemoryContext(memories, currentInput) {
832
+ const memoryBlocks = [];
833
+ for (const [label, memory] of memories) {
834
+ memoryBlocks.push(`[${label}]\n${memory}`);
835
+ }
836
+ return `Context from previous conversations:
837
+
838
+ ${memoryBlocks.join("\n\n")}
839
+
826
840
  Current user's request: ${currentInput}`;
827
841
  }
828
842
  /**
@@ -863,32 +877,71 @@ Current user's request: ${currentInput}`;
863
877
  return true;
864
878
  }
865
879
  /**
866
- * Retrieve condensed memory for a user.
880
+ * Retrieve condensed memory for a user (and optionally additional users).
867
881
  * Returns the input text enhanced with memory context, or unchanged if no memory.
868
882
  */
869
- async retrieveMemory(inputText, userId) {
883
+ async retrieveMemory(inputText, userId, additionalUsers) {
870
884
  const client = this.ensureMemoryReady();
871
885
  if (!client) {
872
886
  return inputText;
873
887
  }
874
- const memory = await client.get(userId);
875
- if (!memory) {
888
+ // Collect all user IDs to read (primary + additional users with read !== false)
889
+ const readableAdditional = (additionalUsers || []).filter((u) => u.read !== false);
890
+ if (readableAdditional.length === 0) {
891
+ // Single user — use original fast path
892
+ const memory = await client.get(userId);
893
+ if (!memory) {
894
+ return inputText;
895
+ }
896
+ return this.formatMemoryContext(memory, inputText);
897
+ }
898
+ // Multi-user: fetch all memories in parallel
899
+ // Build entries with labels for formatting
900
+ const entries = [
901
+ { id: userId, label: "User" },
902
+ ...readableAdditional.map((u) => ({
903
+ id: u.userId,
904
+ label: u.label || u.userId,
905
+ })),
906
+ ];
907
+ const results = await Promise.all(entries.map(async (entry) => {
908
+ const memory = await client.get(entry.id);
909
+ return { ...entry, memory };
910
+ }));
911
+ const memories = new Map();
912
+ for (const { label, memory } of results) {
913
+ if (memory) {
914
+ memories.set(label, memory);
915
+ }
916
+ }
917
+ if (memories.size === 0) {
876
918
  return inputText;
877
919
  }
878
- return this.formatMemoryContext(memory, inputText);
920
+ return this.formatMultiUserMemoryContext(memories, inputText);
879
921
  }
880
922
  /**
881
923
  * Store a conversation turn in memory (non-blocking).
882
924
  * Calls add(userId, content) which internally condenses old + new via LLM.
925
+ * Supports additional users with per-user prompt and maxWords overrides.
883
926
  */
884
- storeMemoryInBackground(originalPrompt, responseContent, userId) {
927
+ storeMemoryInBackground(originalPrompt, responseContent, userId, additionalUsers) {
885
928
  setImmediate(async () => {
886
929
  try {
887
930
  const client = this.ensureMemoryReady();
888
- if (client) {
889
- const content = `User: ${originalPrompt}\nAssistant: ${responseContent}`;
890
- await client.add(userId, content);
931
+ if (!client) {
932
+ return;
891
933
  }
934
+ const content = `User: ${originalPrompt}\nAssistant: ${responseContent}`;
935
+ // Collect all users to write: primary + additional users with write !== false
936
+ const writeOps = [client.add(userId, content)];
937
+ const writableAdditional = (additionalUsers || []).filter((u) => u.write !== false);
938
+ for (const user of writableAdditional) {
939
+ const addOptions = user.prompt || user.maxWords
940
+ ? { prompt: user.prompt, maxWords: user.maxWords }
941
+ : undefined;
942
+ writeOps.push(client.add(user.userId, content, addOptions));
943
+ }
944
+ await Promise.all(writeOps);
892
945
  }
893
946
  catch (error) {
894
947
  logger.warn("Memory storage failed:", error);
@@ -2428,6 +2481,17 @@ Current user's request: ${currentInput}`;
2428
2481
  });
2429
2482
  }
2430
2483
  }
2484
+ // Memory retrieval for generate path
2485
+ if (this.shouldReadMemory(options.memory, options.context?.userId) &&
2486
+ options.context?.userId) {
2487
+ try {
2488
+ options.input.text = await this.retrieveMemory(options.input.text, options.context.userId, options.memory?.additionalUsers);
2489
+ logger.debug("Memory retrieval successful (generate)");
2490
+ }
2491
+ catch (error) {
2492
+ logger.warn("Memory retrieval failed (generate):", error);
2493
+ }
2494
+ }
2431
2495
  // 🔧 CRITICAL FIX: Convert to TextGenerationOptions while preserving the input object for multimodal support
2432
2496
  const baseOptions = {
2433
2497
  prompt: options.input.text,
@@ -2613,7 +2677,7 @@ Current user's request: ${currentInput}`;
2613
2677
  // Memory storage
2614
2678
  if (this.shouldWriteMemory(options.memory, options.context?.userId, generateResult.content) &&
2615
2679
  options.context?.userId) {
2616
- this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId);
2680
+ this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId, options.memory?.additionalUsers);
2617
2681
  }
2618
2682
  }
2619
2683
  /**
@@ -4411,7 +4475,7 @@ Current user's request: ${currentInput}`;
4411
4475
  if (this.shouldReadMemory(options.memory, options.context?.userId) &&
4412
4476
  options.context?.userId) {
4413
4477
  try {
4414
- options.input.text = await this.retrieveMemory(options.input.text, options.context.userId);
4478
+ options.input.text = await this.retrieveMemory(options.input.text, options.context.userId, options.memory?.additionalUsers);
4415
4479
  logger.debug("Memory retrieval successful");
4416
4480
  }
4417
4481
  catch (error) {
@@ -4724,7 +4788,7 @@ Current user's request: ${currentInput}`;
4724
4788
  }
4725
4789
  }
4726
4790
  if (this.shouldWriteMemory(enhancedOptions.memory, enhancedOptions.context?.userId, accumulatedContent)) {
4727
- this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId);
4791
+ this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId, enhancedOptions.memory?.additionalUsers);
4728
4792
  }
4729
4793
  }
4730
4794
  /**
@@ -140,7 +140,24 @@ const detectSubscriptionTier = (oauthToken) => {
140
140
  * OAuth takes precedence over API key if both are available.
141
141
  */
142
142
  const detectAuthMethod = (oauthToken) => {
143
- // OAuth takes precedence if available
143
+ // Explicit env var takes highest precedence allows forcing api_key mode
144
+ // even when OAuth credentials exist (e.g., when using a proxy that handles auth)
145
+ const explicit = process.env.ANTHROPIC_AUTH_METHOD?.toLowerCase();
146
+ if (explicit === "api_key" || explicit === "apikey") {
147
+ logger.debug("[detectAuthMethod] Forced to api_key by ANTHROPIC_AUTH_METHOD env var");
148
+ return "api_key";
149
+ }
150
+ if (explicit === "oauth") {
151
+ if (oauthToken) {
152
+ logger.debug("[detectAuthMethod] Forced to oauth by ANTHROPIC_AUTH_METHOD env var");
153
+ return "oauth";
154
+ }
155
+ logger.warn("[detectAuthMethod] ANTHROPIC_AUTH_METHOD=oauth but no OAuth token found; falling through to auto-detection");
156
+ }
157
+ else if (explicit) {
158
+ logger.warn("[detectAuthMethod] Unrecognized ANTHROPIC_AUTH_METHOD value; falling through to auto-detection", { value: explicit });
159
+ }
160
+ // Auto-detect: OAuth takes precedence if available
144
161
  const method = oauthToken ? "oauth" : "api_key";
145
162
  logger.debug("[detectAuthMethod] Auth method resolved", {
146
163
  method,
@@ -200,11 +217,22 @@ export class AnthropicProvider extends BaseProvider {
200
217
  constructor(modelName, sdk, config) {
201
218
  // Pre-compute effective model with tier validation before calling super
202
219
  const oauthToken = config?.oauthToken ?? getOAuthToken();
203
- const subscriptionTier = config?.subscriptionTier ?? detectSubscriptionTier(oauthToken);
220
+ // Resolve auth method FIRST so that tier detection uses the chosen method.
221
+ // If ANTHROPIC_AUTH_METHOD=api_key wins over an existing OAuth token, the
222
+ // tier must reflect api_key mode (full model access) rather than the OAuth
223
+ // token's subscription level.
224
+ const authMethod = config?.authMethod ?? detectAuthMethod(oauthToken);
225
+ const subscriptionTier = config?.subscriptionTier ??
226
+ (authMethod === "oauth" ? detectSubscriptionTier(oauthToken) : "api");
204
227
  const targetModel = modelName || getDefaultAnthropicModel();
205
- // Determine effective model based on tier access
228
+ // Determine effective model based on tier access.
229
+ // Skip tier validation when a proxy is in use (ANTHROPIC_BASE_URL is set)
230
+ // — the proxy handles model access and auth, so the SDK should pass
231
+ // the requested model through without downgrading.
206
232
  let effectiveModel = targetModel;
207
- if (subscriptionTier !== "api" &&
233
+ const usingProxy = !!process.env.ANTHROPIC_BASE_URL;
234
+ if (!usingProxy &&
235
+ subscriptionTier !== "api" &&
208
236
  !isModelAvailableForTier(targetModel, subscriptionTier)) {
209
237
  effectiveModel = getRecommendedModelForTier(subscriptionTier);
210
238
  logger.warn("Model not available for subscription tier, using recommended model", {
@@ -219,8 +247,8 @@ export class AnthropicProvider extends BaseProvider {
219
247
  // Store computed values
220
248
  this.oauthToken = oauthToken;
221
249
  this.subscriptionTier = subscriptionTier;
222
- // Determine auth method - config takes precedence, then auto-detect
223
- this.authMethod = config?.authMethod ?? detectAuthMethod(this.oauthToken);
250
+ // Use the auth method already resolved above (before tier computation)
251
+ this.authMethod = authMethod;
224
252
  // Build headers based on auth method and subscription tier
225
253
  const headers = this.getAuthHeaders();
226
254
  // Create Anthropic instance based on auth method
@@ -348,6 +376,10 @@ export class AnthropicProvider extends BaseProvider {
348
376
  * ```
349
377
  */
350
378
  validateModelAccess(model) {
379
+ // Proxy mode: bypass tier validation entirely — the proxy handles model access
380
+ if (process.env.ANTHROPIC_BASE_URL) {
381
+ return true;
382
+ }
351
383
  // API tier has access to all models
352
384
  if (this.subscriptionTier === "api") {
353
385
  return true;
@@ -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
  }