@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +280 -273
- package/dist/lib/models/anthropicModels.d.ts +3 -1
- package/dist/lib/models/anthropicModels.js +37 -1
- package/dist/lib/neurolink.d.ts +6 -1
- package/dist/lib/neurolink.js +76 -12
- package/dist/lib/providers/anthropic.js +38 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +187 -13
- package/dist/lib/types/generateTypes.d.ts +29 -0
- package/dist/lib/types/index.d.ts +1 -1
- package/dist/lib/types/streamTypes.d.ts +7 -0
- package/dist/models/anthropicModels.d.ts +3 -1
- package/dist/models/anthropicModels.js +37 -1
- package/dist/neurolink.d.ts +6 -1
- package/dist/neurolink.js +76 -12
- package/dist/providers/anthropic.js +38 -6
- package/dist/server/routes/claudeProxyRoutes.js +187 -13
- package/dist/types/generateTypes.d.ts +29 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/streamTypes.d.ts +7 -0
- package/package.json +2 -2
|
@@ -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: [
|
|
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]) {
|
package/dist/lib/neurolink.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -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
|
-
|
|
875
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
223
|
-
this.authMethod =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|