@juspay/neurolink 9.41.0 → 9.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -1
  3. package/dist/auth/anthropicOAuth.d.ts +18 -3
  4. package/dist/auth/anthropicOAuth.js +137 -4
  5. package/dist/auth/providers/firebase.js +5 -1
  6. package/dist/auth/providers/jwt.js +5 -1
  7. package/dist/auth/providers/workos.js +5 -1
  8. package/dist/auth/sessionManager.d.ts +1 -1
  9. package/dist/auth/sessionManager.js +58 -27
  10. package/dist/browser/neurolink.min.js +337 -318
  11. package/dist/cli/commands/mcp.js +3 -0
  12. package/dist/cli/commands/proxy.d.ts +2 -1
  13. package/dist/cli/commands/proxy.js +279 -16
  14. package/dist/cli/commands/task.js +3 -0
  15. package/dist/cli/factories/commandFactory.d.ts +2 -0
  16. package/dist/cli/factories/commandFactory.js +38 -0
  17. package/dist/cli/parser.js +4 -3
  18. package/dist/client/aiSdkAdapter.js +3 -0
  19. package/dist/client/streamingClient.js +30 -10
  20. package/dist/core/modules/GenerationHandler.js +3 -2
  21. package/dist/core/redisConversationMemoryManager.js +7 -3
  22. package/dist/evaluation/BatchEvaluator.js +4 -1
  23. package/dist/evaluation/hooks/observabilityHooks.js +5 -3
  24. package/dist/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
  25. package/dist/evaluation/pipeline/evaluationPipeline.js +20 -8
  26. package/dist/evaluation/pipeline/strategies/batchStrategy.js +6 -3
  27. package/dist/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
  28. package/dist/lib/auth/anthropicOAuth.d.ts +18 -3
  29. package/dist/lib/auth/anthropicOAuth.js +137 -4
  30. package/dist/lib/auth/providers/firebase.js +5 -1
  31. package/dist/lib/auth/providers/jwt.js +5 -1
  32. package/dist/lib/auth/providers/workos.js +5 -1
  33. package/dist/lib/auth/sessionManager.d.ts +1 -1
  34. package/dist/lib/auth/sessionManager.js +58 -27
  35. package/dist/lib/client/aiSdkAdapter.js +3 -0
  36. package/dist/lib/client/streamingClient.js +30 -10
  37. package/dist/lib/core/modules/GenerationHandler.js +3 -2
  38. package/dist/lib/core/redisConversationMemoryManager.js +7 -3
  39. package/dist/lib/evaluation/BatchEvaluator.js +4 -1
  40. package/dist/lib/evaluation/hooks/observabilityHooks.js +5 -3
  41. package/dist/lib/evaluation/pipeline/evaluationPipeline.d.ts +3 -2
  42. package/dist/lib/evaluation/pipeline/evaluationPipeline.js +20 -8
  43. package/dist/lib/evaluation/pipeline/strategies/batchStrategy.js +6 -3
  44. package/dist/lib/evaluation/pipeline/strategies/samplingStrategy.js +18 -10
  45. package/dist/lib/neurolink.d.ts +3 -2
  46. package/dist/lib/neurolink.js +260 -494
  47. package/dist/lib/observability/otelBridge.d.ts +2 -2
  48. package/dist/lib/observability/otelBridge.js +12 -3
  49. package/dist/lib/providers/amazonBedrock.js +2 -4
  50. package/dist/lib/providers/anthropic.d.ts +9 -5
  51. package/dist/lib/providers/anthropic.js +19 -14
  52. package/dist/lib/providers/anthropicBaseProvider.d.ts +3 -3
  53. package/dist/lib/providers/anthropicBaseProvider.js +5 -4
  54. package/dist/lib/providers/azureOpenai.d.ts +1 -1
  55. package/dist/lib/providers/azureOpenai.js +5 -4
  56. package/dist/lib/providers/googleAiStudio.js +30 -1
  57. package/dist/lib/providers/googleVertex.js +28 -6
  58. package/dist/lib/providers/huggingFace.d.ts +3 -3
  59. package/dist/lib/providers/huggingFace.js +6 -8
  60. package/dist/lib/providers/litellm.js +41 -29
  61. package/dist/lib/providers/mistral.js +2 -1
  62. package/dist/lib/providers/ollama.js +80 -23
  63. package/dist/lib/providers/openAI.js +3 -2
  64. package/dist/lib/providers/openRouter.js +2 -1
  65. package/dist/lib/providers/openaiCompatible.d.ts +4 -4
  66. package/dist/lib/providers/openaiCompatible.js +4 -4
  67. package/dist/lib/proxy/claudeFormat.d.ts +3 -2
  68. package/dist/lib/proxy/claudeFormat.js +25 -20
  69. package/dist/lib/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
  70. package/dist/lib/proxy/cloaking/plugins/sessionIdentity.js +9 -33
  71. package/dist/lib/proxy/modelRouter.js +3 -0
  72. package/dist/lib/proxy/oauthFetch.d.ts +1 -1
  73. package/dist/lib/proxy/oauthFetch.js +65 -72
  74. package/dist/lib/proxy/proxyConfig.js +44 -24
  75. package/dist/lib/proxy/proxyEnv.d.ts +19 -0
  76. package/dist/lib/proxy/proxyEnv.js +73 -0
  77. package/dist/lib/proxy/proxyFetch.js +50 -4
  78. package/dist/lib/proxy/proxyTracer.d.ts +133 -0
  79. package/dist/lib/proxy/proxyTracer.js +645 -0
  80. package/dist/lib/proxy/rawStreamCapture.d.ts +10 -0
  81. package/dist/lib/proxy/rawStreamCapture.js +83 -0
  82. package/dist/lib/proxy/requestLogger.d.ts +32 -5
  83. package/dist/lib/proxy/requestLogger.js +406 -37
  84. package/dist/lib/proxy/sseInterceptor.d.ts +97 -0
  85. package/dist/lib/proxy/sseInterceptor.js +402 -0
  86. package/dist/lib/proxy/usageStats.d.ts +4 -3
  87. package/dist/lib/proxy/usageStats.js +25 -12
  88. package/dist/lib/rag/chunkers/MarkdownChunker.js +13 -5
  89. package/dist/lib/rag/chunking/markdownChunker.js +15 -6
  90. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +7 -2
  91. package/dist/lib/server/routes/claudeProxyRoutes.js +1737 -508
  92. package/dist/lib/services/server/ai/observability/instrumentation.d.ts +7 -1
  93. package/dist/lib/services/server/ai/observability/instrumentation.js +240 -40
  94. package/dist/lib/tasks/backends/bullmqBackend.d.ts +1 -0
  95. package/dist/lib/tasks/backends/bullmqBackend.js +14 -7
  96. package/dist/lib/tasks/store/redisTaskStore.d.ts +1 -0
  97. package/dist/lib/tasks/store/redisTaskStore.js +34 -26
  98. package/dist/lib/tasks/taskManager.d.ts +3 -0
  99. package/dist/lib/tasks/taskManager.js +63 -30
  100. package/dist/lib/telemetry/index.d.ts +2 -1
  101. package/dist/lib/telemetry/index.js +2 -1
  102. package/dist/lib/telemetry/telemetryService.d.ts +3 -0
  103. package/dist/lib/telemetry/telemetryService.js +65 -5
  104. package/dist/lib/types/cli.d.ts +10 -0
  105. package/dist/lib/types/proxyTypes.d.ts +37 -5
  106. package/dist/lib/types/streamTypes.d.ts +25 -3
  107. package/dist/lib/utils/messageBuilder.js +3 -2
  108. package/dist/lib/utils/providerHealth.d.ts +18 -0
  109. package/dist/lib/utils/providerHealth.js +240 -9
  110. package/dist/lib/utils/providerUtils.js +14 -8
  111. package/dist/lib/utils/toolChoice.d.ts +4 -0
  112. package/dist/lib/utils/toolChoice.js +7 -0
  113. package/dist/neurolink.d.ts +3 -2
  114. package/dist/neurolink.js +260 -494
  115. package/dist/observability/otelBridge.d.ts +2 -2
  116. package/dist/observability/otelBridge.js +12 -3
  117. package/dist/providers/amazonBedrock.js +2 -4
  118. package/dist/providers/anthropic.d.ts +9 -5
  119. package/dist/providers/anthropic.js +19 -14
  120. package/dist/providers/anthropicBaseProvider.d.ts +3 -3
  121. package/dist/providers/anthropicBaseProvider.js +5 -4
  122. package/dist/providers/azureOpenai.d.ts +1 -1
  123. package/dist/providers/azureOpenai.js +5 -4
  124. package/dist/providers/googleAiStudio.js +30 -1
  125. package/dist/providers/googleVertex.js +28 -6
  126. package/dist/providers/huggingFace.d.ts +3 -3
  127. package/dist/providers/huggingFace.js +6 -7
  128. package/dist/providers/litellm.js +41 -29
  129. package/dist/providers/mistral.js +2 -1
  130. package/dist/providers/ollama.js +80 -23
  131. package/dist/providers/openAI.js +3 -2
  132. package/dist/providers/openRouter.js +2 -1
  133. package/dist/providers/openaiCompatible.d.ts +4 -4
  134. package/dist/providers/openaiCompatible.js +4 -3
  135. package/dist/proxy/claudeFormat.d.ts +3 -2
  136. package/dist/proxy/claudeFormat.js +25 -20
  137. package/dist/proxy/cloaking/plugins/sessionIdentity.d.ts +2 -6
  138. package/dist/proxy/cloaking/plugins/sessionIdentity.js +9 -33
  139. package/dist/proxy/modelRouter.js +3 -0
  140. package/dist/proxy/oauthFetch.d.ts +1 -1
  141. package/dist/proxy/oauthFetch.js +65 -72
  142. package/dist/proxy/proxyConfig.js +44 -24
  143. package/dist/proxy/proxyEnv.d.ts +19 -0
  144. package/dist/proxy/proxyEnv.js +72 -0
  145. package/dist/proxy/proxyFetch.js +50 -4
  146. package/dist/proxy/proxyTracer.d.ts +133 -0
  147. package/dist/proxy/proxyTracer.js +644 -0
  148. package/dist/proxy/rawStreamCapture.d.ts +10 -0
  149. package/dist/proxy/rawStreamCapture.js +82 -0
  150. package/dist/proxy/requestLogger.d.ts +32 -5
  151. package/dist/proxy/requestLogger.js +406 -37
  152. package/dist/proxy/sseInterceptor.d.ts +97 -0
  153. package/dist/proxy/sseInterceptor.js +401 -0
  154. package/dist/proxy/usageStats.d.ts +4 -3
  155. package/dist/proxy/usageStats.js +25 -12
  156. package/dist/rag/chunkers/MarkdownChunker.js +13 -5
  157. package/dist/rag/chunking/markdownChunker.js +15 -6
  158. package/dist/server/routes/claudeProxyRoutes.d.ts +7 -2
  159. package/dist/server/routes/claudeProxyRoutes.js +1737 -508
  160. package/dist/services/server/ai/observability/instrumentation.d.ts +7 -1
  161. package/dist/services/server/ai/observability/instrumentation.js +240 -40
  162. package/dist/tasks/backends/bullmqBackend.d.ts +1 -0
  163. package/dist/tasks/backends/bullmqBackend.js +14 -7
  164. package/dist/tasks/store/redisTaskStore.d.ts +1 -0
  165. package/dist/tasks/store/redisTaskStore.js +34 -26
  166. package/dist/tasks/taskManager.d.ts +3 -0
  167. package/dist/tasks/taskManager.js +63 -30
  168. package/dist/telemetry/index.d.ts +2 -1
  169. package/dist/telemetry/index.js +2 -1
  170. package/dist/telemetry/telemetryService.d.ts +3 -0
  171. package/dist/telemetry/telemetryService.js +65 -5
  172. package/dist/types/cli.d.ts +10 -0
  173. package/dist/types/proxyTypes.d.ts +37 -5
  174. package/dist/types/streamTypes.d.ts +25 -3
  175. package/dist/utils/messageBuilder.js +3 -2
  176. package/dist/utils/providerHealth.d.ts +18 -0
  177. package/dist/utils/providerHealth.js +240 -9
  178. package/dist/utils/providerUtils.js +14 -8
  179. package/dist/utils/toolChoice.d.ts +4 -0
  180. package/dist/utils/toolChoice.js +6 -0
  181. package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +6609 -0
  182. package/docs/changelog.md +252 -0
  183. package/package.json +17 -1
  184. package/scripts/observability/check-proxy-telemetry.mjs +235 -0
  185. package/scripts/observability/docker-compose.proxy-observability.yaml +55 -0
  186. package/scripts/observability/import-openobserve-dashboard.mjs +240 -0
  187. package/scripts/observability/manage-local-openobserve.sh +184 -0
  188. package/scripts/observability/otel-collector.proxy-observability.yaml +78 -0
  189. package/scripts/observability/proxy-observability.env.example +23 -0
@@ -10,63 +10,12 @@
10
10
  *
11
11
  * @module proxy/oauthFetch
12
12
  */
13
- import { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX, } from "../auth/anthropicOAuth.js";
13
+ import { CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, MCP_TOOL_PREFIX, buildStableClaudeCodeBillingHeader, getOrCreateClaudeCodeIdentity, } from "../auth/anthropicOAuth.js";
14
14
  import { logger } from "../utils/logger.js";
15
- import { randomBytes, randomUUID } from "crypto";
16
15
  // Re-export constants for consumers that previously imported them alongside
17
16
  // the function from `providers/anthropic.ts`.
18
17
  export { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX };
19
18
  // ---------------------------------------------------------------------------
20
- // Helpers
21
- // ---------------------------------------------------------------------------
22
- /** Cache fake user IDs per token prefix (1-hour TTL, max 1000 entries). */
23
- const userIdCache = new Map();
24
- const USER_ID_CACHE_TTL_MS = 3_600_000; // 1 hour
25
- const USER_ID_CACHE_MAX_SIZE = 1_000;
26
- /** Evict expired entries and enforce max size (LRU-approximation via insertion order). */
27
- function evictUserIdCache() {
28
- const now = Date.now();
29
- for (const [key, entry] of userIdCache) {
30
- if (entry.expires <= now) {
31
- userIdCache.delete(key);
32
- }
33
- }
34
- // If still over capacity, remove oldest entries (Map preserves insertion order)
35
- if (userIdCache.size > USER_ID_CACHE_MAX_SIZE) {
36
- const excess = userIdCache.size - USER_ID_CACHE_MAX_SIZE;
37
- let removed = 0;
38
- for (const key of userIdCache.keys()) {
39
- if (removed >= excess) {
40
- break;
41
- }
42
- userIdCache.delete(key);
43
- removed++;
44
- }
45
- }
46
- }
47
- function getOrCreateUserId(tokenPrefix) {
48
- evictUserIdCache();
49
- const cached = userIdCache.get(tokenPrefix);
50
- if (cached && cached.expires > Date.now()) {
51
- return cached.id;
52
- }
53
- const hex = randomBytes(32).toString("hex");
54
- const id = `user_${hex}_account_${randomUUID()}_session_${randomUUID()}`;
55
- userIdCache.set(tokenPrefix, {
56
- id,
57
- expires: Date.now() + USER_ID_CACHE_TTL_MS,
58
- });
59
- return id;
60
- }
61
- /** Compute a short hex hash of a string using Web Crypto (SHA-256). */
62
- async function shortSha256(data) {
63
- const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
64
- return Array.from(new Uint8Array(buf))
65
- .map((b) => b.toString(16).padStart(2, "0"))
66
- .join("")
67
- .substring(0, 5);
68
- }
69
- // ---------------------------------------------------------------------------
70
19
  // Main factory
71
20
  // ---------------------------------------------------------------------------
72
21
  /**
@@ -77,7 +26,7 @@ async function shortSha256(data) {
77
26
  * - Sets User-Agent to Claude CLI
78
27
  * - Adds ?beta=true query parameter to /v1/messages
79
28
  * - Injects billing header & agent block into system prompt
80
- * - Injects fake user ID into metadata
29
+ * - Injects Claude-Code-shaped user ID into metadata
81
30
  * - Adds Stainless SDK headers for fingerprint matching
82
31
  * - Disables thinking when tool_choice is forced
83
32
  *
@@ -154,7 +103,9 @@ export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMc
154
103
  // Only add our betas if not already present in client's headers
155
104
  if (!skipBodyTransform) {
156
105
  // Direct NeuroLink usage — set full beta list
157
- requiredBetas.push("claude-code-20250219", ...(includeOptionalBetas ? ["interleaved-thinking-2025-05-14"] : []), "prompt-caching-scope-2026-01-05");
106
+ requiredBetas.push(...(includeOptionalBetas
107
+ ? CLAUDE_CODE_OAUTH_BETAS.filter((beta) => beta !== "oauth-2025-04-20")
108
+ : []));
158
109
  }
159
110
  const allBetas = [...new Set([...existingBetas, ...requiredBetas])];
160
111
  const mergedBetas = allBetas.join(",");
@@ -165,6 +116,8 @@ export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMc
165
116
  if (!skipBodyTransform) {
166
117
  // Only override user-agent for direct NeuroLink usage
167
118
  requestHeaders.set("user-agent", CLAUDE_CLI_USER_AGENT);
119
+ requestHeaders.set("anthropic-version", "2023-06-01");
120
+ requestHeaders.set("accept", "application/json");
168
121
  }
169
122
  requestHeaders.delete("x-api-key");
170
123
  // Identity / fingerprint headers (skip in passthrough — client sends its own)
@@ -248,41 +201,67 @@ export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMc
248
201
  parsed.tool_choice?.type === "tool") {
249
202
  delete parsed.thinking;
250
203
  }
251
- // --- Inject billing header + agent block into system prompt ----
252
- const version = "2.1.63";
253
- const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(2)))
254
- .map((b) => b.toString(16).padStart(2, "0"))
255
- .join("")
256
- .substring(0, 3);
257
- const bodyString = JSON.stringify(parsed);
258
- const cch = await shortSha256(bodyString);
259
- const billingBlock = {
260
- type: "text",
261
- text: `x-anthropic-billing-header: cc_version=${version}.${randomHex}; cc_entrypoint=cli; cch=${cch};`,
262
- };
263
204
  const agentBlock = {
264
205
  type: "text",
265
206
  text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
266
207
  };
267
- // Normalise `system` to an array and prepend billing + agent blocks
208
+ // Normalise `system` to an array and APPEND billing + agent blocks.
209
+ // IMPORTANT: We append (not prepend) to preserve the client's cache
210
+ // prefix chain. Anthropic's prompt caching uses prefix matching — if
211
+ // we insert anything before the client's system blocks, we invalidate
212
+ // all cached content (tools, system prompt, message history).
213
+ //
214
+ // Claude Code sends a billing block with a `cch=<hash>` value that
215
+ // changes on every request. We remove any existing billing/agent
216
+ // blocks from their positions and always append our stable
217
+ // Claude-Code-shaped versions at the end.
268
218
  if (parsed.system) {
269
219
  if (typeof parsed.system === "string") {
270
220
  parsed.system = [{ type: "text", text: parsed.system }];
271
221
  }
272
222
  if (Array.isArray(parsed.system)) {
273
- parsed.system = [billingBlock, agentBlock, ...parsed.system];
223
+ // Find and remove existing billing/agent blocks from wherever
224
+ // the client placed them (typically at system[0])
225
+ const billingIdx = parsed.system.findIndex((b) => typeof b.text === "string" &&
226
+ b.text.includes("x-anthropic-billing-header"));
227
+ const agentIdx = parsed.system.findIndex((b) => typeof b.text === "string" &&
228
+ b.text.includes("Claude Agent SDK"));
229
+ const billingBlock = {
230
+ type: "text",
231
+ text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text),
232
+ };
233
+ // Remove in reverse index order so indices stay valid
234
+ const indicesToRemove = [billingIdx, agentIdx]
235
+ .filter((i) => i >= 0)
236
+ .sort((a, b) => b - a);
237
+ for (const idx of indicesToRemove) {
238
+ parsed.system.splice(idx, 1);
239
+ }
240
+ // Always append deterministic billing + agent blocks at the end
241
+ parsed.system = [...parsed.system, billingBlock, agentBlock];
274
242
  }
275
243
  }
276
244
  else {
245
+ const billingBlock = {
246
+ type: "text",
247
+ text: buildStableClaudeCodeBillingHeader(),
248
+ };
277
249
  parsed.system = [billingBlock, agentBlock];
278
250
  }
279
- // --- Inject fake user ID into metadata -------------------------
280
- const token = getToken();
281
- const tokenPrefix = token.substring(0, Math.min(20, token.length));
251
+ // --- Inject Claude-Code-shaped identity into metadata ----------
252
+ // Prefer existing metadata.user_id (refresh-stable) over the access
253
+ // token prefix, which changes on every token rotation.
254
+ const stableId = parsed.metadata?.user_id ??
255
+ getToken().substring(0, Math.min(20, getToken().length));
256
+ const identity = getOrCreateClaudeCodeIdentity(stableId, {
257
+ existingUserId: parsed.metadata?.user_id,
258
+ preferredSessionId: requestHeaders.get("x-claude-code-session-id") ?? undefined,
259
+ });
282
260
  parsed.metadata = {
283
261
  ...parsed.metadata,
284
- user_id: getOrCreateUserId(tokenPrefix),
262
+ user_id: identity.metadataUserId,
285
263
  };
264
+ requestHeaders.set("x-claude-code-session-id", identity.sessionId);
286
265
  body = JSON.stringify(parsed);
287
266
  }
288
267
  catch {
@@ -292,6 +271,20 @@ export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMc
292
271
  // Remove any inherited content-length — the body may have been transformed
293
272
  // above, so the original length is stale. Let fetch/undici recalculate it.
294
273
  requestHeaders.delete("content-length");
274
+ // Inject OTel traceparent so the proxy can link to this trace
275
+ try {
276
+ const { propagation: otelPropagation, context: otelContext } = await import("@opentelemetry/api");
277
+ const carrier = {};
278
+ otelPropagation.inject(otelContext.active(), carrier);
279
+ for (const [key, value] of Object.entries(carrier)) {
280
+ if (!requestHeaders.has(key)) {
281
+ requestHeaders.set(key, value);
282
+ }
283
+ }
284
+ }
285
+ catch {
286
+ // OTel not available — skip silently
287
+ }
295
288
  // Make the request
296
289
  const response = await fetch(requestUrl?.toString() ||
297
290
  (input instanceof Request ? input.url : input.toString()), {
@@ -198,33 +198,47 @@ export function validateProxyConfig(config) {
198
198
  if (cfg.version !== undefined && typeof cfg.version !== "number") {
199
199
  errors.push(`"version" must be a number, got ${typeof cfg.version}`);
200
200
  }
201
- if (!cfg.accounts ||
202
- typeof cfg.accounts !== "object" ||
203
- Array.isArray(cfg.accounts)) {
201
+ const hasAccounts = !!cfg.accounts &&
202
+ typeof cfg.accounts === "object" &&
203
+ !Array.isArray(cfg.accounts);
204
+ const hasRouting = !!cfg.routing &&
205
+ typeof cfg.routing === "object" &&
206
+ !Array.isArray(cfg.routing);
207
+ if (cfg.routing !== undefined && !hasRouting) {
208
+ errors.push('"routing" must be an object');
209
+ return errors;
210
+ }
211
+ if (!hasAccounts && !hasRouting) {
212
+ errors.push('Config must contain at least one of "accounts" or "routing"');
213
+ return errors;
214
+ }
215
+ if (cfg.accounts !== undefined && !hasAccounts) {
204
216
  errors.push('"accounts" must be an object mapping provider names to account arrays');
205
217
  return errors;
206
218
  }
207
- const accounts = cfg.accounts;
208
- let totalAccounts = 0;
209
- for (const [provider, list] of Object.entries(accounts)) {
210
- if (!Array.isArray(list)) {
211
- errors.push(`accounts.${provider} must be an array, got ${typeof list}`);
212
- continue;
213
- }
214
- totalAccounts += list.length;
215
- for (let i = 0; i < list.length; i++) {
216
- const acct = list[i];
217
- if (!acct || typeof acct !== "object") {
218
- errors.push(`accounts.${provider}[${i}] must be an object`);
219
+ if (hasAccounts) {
220
+ const accounts = cfg.accounts;
221
+ let totalAccounts = 0;
222
+ for (const [provider, list] of Object.entries(accounts)) {
223
+ if (!Array.isArray(list)) {
224
+ errors.push(`accounts.${provider} must be an array, got ${typeof list}`);
219
225
  continue;
220
226
  }
221
- if (typeof acct.apiKey !== "string" || acct.apiKey.length === 0) {
222
- errors.push(`accounts.${provider}[${i}].apiKey is required and must be a non-empty string`);
227
+ totalAccounts += list.length;
228
+ for (let i = 0; i < list.length; i++) {
229
+ const acct = list[i];
230
+ if (!acct || typeof acct !== "object") {
231
+ errors.push(`accounts.${provider}[${i}] must be an object`);
232
+ continue;
233
+ }
234
+ if (typeof acct.apiKey !== "string" || acct.apiKey.length === 0) {
235
+ errors.push(`accounts.${provider}[${i}].apiKey is required and must be a non-empty string`);
236
+ }
223
237
  }
224
238
  }
225
- }
226
- if (totalAccounts === 0) {
227
- errors.push('"accounts" must contain at least one account');
239
+ if (totalAccounts === 0 && !hasRouting) {
240
+ errors.push('"accounts" must contain at least one account');
241
+ }
228
242
  }
229
243
  return errors;
230
244
  }
@@ -422,8 +436,10 @@ export async function loadProxyConfig(filePath, options = {}) {
422
436
  const raw = parsed;
423
437
  const accounts = {};
424
438
  const rawAccounts = raw.accounts;
425
- for (const [provider, list] of Object.entries(rawAccounts)) {
426
- accounts[provider] = list.map((item) => applyAccountDefaults(item));
439
+ if (rawAccounts && typeof rawAccounts === "object") {
440
+ for (const [provider, list] of Object.entries(rawAccounts)) {
441
+ accounts[provider] = list.map((item) => applyAccountDefaults(item));
442
+ }
427
443
  }
428
444
  // 7. Extract routing config
429
445
  const routing = parseRoutingConfig(raw.routing);
@@ -483,8 +499,12 @@ export async function parseProxyConfigString(content, options = {}) {
483
499
  const raw = parsed;
484
500
  const accounts = {};
485
501
  const rawAccounts = raw.accounts;
486
- for (const [provider, list] of Object.entries(rawAccounts)) {
487
- accounts[provider] = list.map((item) => applyAccountDefaults(item));
502
+ if (rawAccounts &&
503
+ typeof rawAccounts === "object" &&
504
+ !Array.isArray(rawAccounts)) {
505
+ for (const [provider, list] of Object.entries(rawAccounts)) {
506
+ accounts[provider] = list.map((item) => applyAccountDefaults(item));
507
+ }
488
508
  }
489
509
  const routing = parseRoutingConfig(raw.routing);
490
510
  const cloaking = validateCloakingConfig(raw.cloaking);
@@ -0,0 +1,19 @@
1
+ export type ProxyEnvSource = "cli" | "environment" | "default" | "none";
2
+ export type ProxyEnvResolution = {
3
+ path?: string;
4
+ source: ProxyEnvSource;
5
+ required: boolean;
6
+ };
7
+ export type ProxyEnvLoadResult = {
8
+ loaded: boolean;
9
+ path?: string;
10
+ source: ProxyEnvSource;
11
+ };
12
+ type ProxyEnvOptions = {
13
+ explicitEnvFile?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ homeDir?: string;
16
+ };
17
+ export declare function resolveProxyEnvFile(options?: ProxyEnvOptions): ProxyEnvResolution;
18
+ export declare function loadProxyEnvFile(options?: ProxyEnvOptions): Promise<ProxyEnvLoadResult>;
19
+ export {};
@@ -0,0 +1,72 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ export function resolveProxyEnvFile(options = {}) {
5
+ const env = options.env ?? process.env;
6
+ const homeDir = options.homeDir ?? homedir();
7
+ if (options.explicitEnvFile?.trim()) {
8
+ return {
9
+ path: resolve(options.explicitEnvFile.trim()),
10
+ source: "cli",
11
+ required: true,
12
+ };
13
+ }
14
+ if (env.NEUROLINK_ENV_FILE?.trim()) {
15
+ return {
16
+ path: resolve(env.NEUROLINK_ENV_FILE.trim()),
17
+ source: "environment",
18
+ required: true,
19
+ };
20
+ }
21
+ const defaultPath = resolve(homeDir, ".neurolink", ".env");
22
+ if (existsSync(defaultPath)) {
23
+ return {
24
+ path: defaultPath,
25
+ source: "default",
26
+ required: false,
27
+ };
28
+ }
29
+ return {
30
+ source: "none",
31
+ required: false,
32
+ };
33
+ }
34
+ export async function loadProxyEnvFile(options = {}) {
35
+ const resolution = resolveProxyEnvFile(options);
36
+ const env = options.env ?? process.env;
37
+ if (!resolution.path) {
38
+ return {
39
+ loaded: false,
40
+ source: "none",
41
+ };
42
+ }
43
+ if (!existsSync(resolution.path)) {
44
+ if (resolution.required) {
45
+ throw new Error(`Proxy env file not found: ${resolution.path}`);
46
+ }
47
+ return {
48
+ loaded: false,
49
+ source: resolution.source,
50
+ };
51
+ }
52
+ try {
53
+ const { config } = await import("dotenv");
54
+ const result = config({
55
+ path: resolution.path,
56
+ override: true,
57
+ quiet: true,
58
+ });
59
+ if (result.error) {
60
+ throw result.error;
61
+ }
62
+ }
63
+ catch (error) {
64
+ throw new Error(`Failed to load proxy env file ${resolution.path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
65
+ }
66
+ env.NEUROLINK_ENV_FILE = resolution.path;
67
+ return {
68
+ loaded: true,
69
+ path: resolution.path,
70
+ source: resolution.source,
71
+ };
72
+ }
@@ -4,9 +4,51 @@
4
4
  * Lightweight implementation extracted from research of major proxy packages
5
5
  */
6
6
  import { logger } from "../utils/logger.js";
7
- import { SpanStatusCode } from "@opentelemetry/api";
7
+ import { SpanStatusCode, propagation, context } from "@opentelemetry/api";
8
8
  import { tracers } from "../telemetry/tracers.js";
9
9
  import { shouldBypassProxy } from "./utils/noProxyUtils.js";
10
+ async function getLangfuseContext() {
11
+ try {
12
+ // Dynamic import to avoid hard dependency — getLangfuseContext is only
13
+ // available when the observability module is loaded.
14
+ const mod = await import("../services/server/ai/observability/instrumentation.js");
15
+ return mod.getLangfuseContext?.();
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ /**
22
+ * Inject OTel trace context (traceparent/tracestate) and NeuroLink session context
23
+ * into outgoing request headers. This enables:
24
+ * - The NeuroLink proxy to link proxy spans as children of the calling SDK's trace
25
+ * - Conversation-level session/user attribution on proxy spans
26
+ */
27
+ async function injectTraceContext(init) {
28
+ const carrier = {};
29
+ propagation.inject(context.active(), carrier);
30
+ // Also inject NeuroLink session context from Langfuse AsyncLocalStorage
31
+ const langfuseContext = await getLangfuseContext();
32
+ if (langfuseContext?.sessionId) {
33
+ carrier["x-neurolink-session-id"] = langfuseContext.sessionId;
34
+ }
35
+ if (langfuseContext?.userId) {
36
+ carrier["x-neurolink-user-id"] = langfuseContext.userId;
37
+ }
38
+ if (langfuseContext?.conversationId) {
39
+ carrier["x-neurolink-conversation-id"] = langfuseContext.conversationId;
40
+ }
41
+ if (Object.keys(carrier).length === 0) {
42
+ return init ?? {};
43
+ }
44
+ const existingHeaders = new Headers(init?.headers);
45
+ for (const [key, value] of Object.entries(carrier)) {
46
+ if (!existingHeaders.has(key)) {
47
+ existingHeaders.set(key, value);
48
+ }
49
+ }
50
+ return { ...init, headers: existingHeaders };
51
+ }
10
52
  const fetchTracer = tracers.http;
11
53
  /**
12
54
  * Extract hostname from a URL string for safe logging (no auth tokens or paths).
@@ -317,6 +359,8 @@ export function createProxyFetch() {
317
359
  if (!httpsProxy && !httpProxy && !allProxy && !socksProxy) {
318
360
  logger.debug("[Proxy Fetch] No proxy environment variables found - using standard fetch");
319
361
  return async (input, init) => {
362
+ // Inject OTel traceparent so the proxy can link to this trace
363
+ const enrichedInit = await injectTraceContext(init);
320
364
  const reqId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
321
365
  const startTs = Date.now();
322
366
  const url = typeof input === "string"
@@ -325,17 +369,17 @@ export function createProxyFetch() {
325
369
  ? input.href
326
370
  : input.url;
327
371
  if (logger.shouldLog("debug")) {
328
- const { size: bodySize, type: bodyType } = parseBody(init?.body);
372
+ const { size: bodySize, type: bodyType } = parseBody(enrichedInit?.body);
329
373
  logger.debug("[Observability] HTTP request to LLM provider", {
330
374
  requestId: reqId,
331
375
  url,
332
- method: init?.method || "POST",
376
+ method: enrichedInit?.method || "POST",
333
377
  bodySize,
334
378
  bodyType,
335
379
  });
336
380
  }
337
381
  try {
338
- const response = await fetchWithRetry(input, init);
382
+ const response = await fetchWithRetry(input, enrichedInit);
339
383
  if (logger.shouldLog("debug")) {
340
384
  const { parsed: responseBody, size: responseSize, type: responseType, headers: responseHeaders, } = await readResponseBody(response);
341
385
  logger.debug("[Observability] HTTP response from LLM provider", {
@@ -371,6 +415,8 @@ export function createProxyFetch() {
371
415
  logger.debug(`[Proxy Fetch] NO_PROXY: ${noProxy || "not set"}`);
372
416
  // Return enhanced proxy-aware fetch function
373
417
  return async (input, init) => {
418
+ // Inject OTel traceparent so the proxy can link to this trace
419
+ init = await injectTraceContext(init);
374
420
  const requestId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
375
421
  const requestStartTime = Date.now();
376
422
  // Determine target URL
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Proxy Request Tracer
3
+ *
4
+ * Creates and manages OTel spans for the proxy request lifecycle.
5
+ * Provides a clean API for claudeProxyRoutes to trace each phase:
6
+ * receive -> account_selection -> upstream (per retry) -> stream -> end
7
+ *
8
+ * Uses the existing instrumentation infrastructure:
9
+ * - getTracer() from instrumentation.ts for span creation
10
+ * - setLangfuseContext() for Langfuse enrichment
11
+ * - OtelBridge for context propagation to/from upstream
12
+ * - SpanAttributes from spanTypes.ts for attribute naming
13
+ * - calculateCost() from pricing.ts for cost tracking
14
+ * - TelemetryService for metrics recording
15
+ */
16
+ import { type Span } from "@opentelemetry/api";
17
+ type ProxyRequestContext = {
18
+ requestId: string;
19
+ method: string;
20
+ path: string;
21
+ model: string;
22
+ stream: boolean;
23
+ toolCount: number;
24
+ sessionId?: string;
25
+ userAgent?: string;
26
+ clientApp?: string;
27
+ };
28
+ type AccountSelectionContext = {
29
+ strategy: string;
30
+ accountsTotal: number;
31
+ accountsHealthy: number;
32
+ selectedAccount: string;
33
+ accountType: string;
34
+ rateLimitBefore5h?: number;
35
+ rateLimitBefore7d?: number;
36
+ };
37
+ type UpstreamAttemptContext = {
38
+ attempt: number;
39
+ account: string;
40
+ polyfillHeaders: boolean;
41
+ polyfillBody: boolean;
42
+ upstreamUrl: string;
43
+ };
44
+ type UsageContext = {
45
+ inputTokens: number;
46
+ outputTokens: number;
47
+ cacheCreationTokens: number;
48
+ cacheReadTokens: number;
49
+ reasoningTokens?: number;
50
+ rateLimitAfter5h?: number;
51
+ rateLimitAfter7d?: number;
52
+ };
53
+ declare class ProxyTracer {
54
+ private readonly rootSpan;
55
+ private readonly proxyTracer;
56
+ private readonly bridge;
57
+ private readonly requestId;
58
+ private readonly model;
59
+ private readonly startTime;
60
+ private readonly isStream;
61
+ private accountEmail?;
62
+ private usage?;
63
+ private mode;
64
+ private constructor();
65
+ /**
66
+ * Create a root span for a proxy request and set Langfuse context.
67
+ *
68
+ * If the incoming request carries a `traceparent` header, the root span
69
+ * will be linked to the caller's trace via OtelBridge.extractContext().
70
+ */
71
+ static startRequest(ctx: ProxyRequestContext, incomingHeaders?: Record<string, string>): ProxyTracer;
72
+ /** Span covering the initial request receive and parse phase. */
73
+ startReceive(): Span;
74
+ /** Span covering account selection logic (fill-first / round-robin). */
75
+ startAccountSelection(): Span;
76
+ /** Span covering a single upstream attempt. One per retry. */
77
+ startUpstreamAttempt(ctx: UpstreamAttemptContext): Span;
78
+ /** Span covering the SSE stream relay phase. */
79
+ startStream(): Span;
80
+ /** Record account selection outcome on the root span. */
81
+ setAccountSelection(ctx: AccountSelectionContext): void;
82
+ /** Record token usage and cost on the root span. */
83
+ setUsage(ctx: UsageContext): void;
84
+ /** Record an error on the root span. */
85
+ setError(errorType: string, errorMessage: string): void;
86
+ /** Record whether the request was handled in full or passthrough mode. */
87
+ setMode(mode: "full" | "passthrough" | "passthrough-cli"): void;
88
+ /**
89
+ * Record that the proxy substituted a different model than was requested.
90
+ * Sets span attributes and increments the substitution metric counter.
91
+ */
92
+ setModelSubstitution(requestedModel: string, actualModel: string): void;
93
+ /** Log the incoming client request body (redacted). */
94
+ logRequestBody(body: string): void;
95
+ /** Log the incoming client request headers (redacted). */
96
+ logRequestHeaders(headers: Record<string, string>): void;
97
+ /** Log the upstream request body (redacted, as sent to Anthropic). */
98
+ logUpstreamRequestBody(body: string): void;
99
+ /** Log the upstream request headers (redacted). */
100
+ logUpstreamRequestHeaders(headers: Record<string, string>): void;
101
+ /** Log the upstream response headers (redacted). */
102
+ logUpstreamResponseHeaders(headers: Record<string, string>): void;
103
+ /** Log the upstream response body (redacted). */
104
+ logUpstreamResponseBody(body: string): void;
105
+ /** Log SSE stream events (each event has type, timestamp, data). */
106
+ logStreamEvents(events: Array<{
107
+ type: string;
108
+ timestamp: number;
109
+ data: string;
110
+ }>): void;
111
+ /** Record an upstream retry attempt. */
112
+ recordRetry(account: string, reason: string): void;
113
+ /** Record request and/or response body sizes for bandwidth tracking. */
114
+ recordBodySizes(requestBytes?: number, responseBytes?: number): void;
115
+ /** Return the OTel trace/span IDs for this request (for log correlation). */
116
+ getTraceContext(): {
117
+ traceId: string;
118
+ spanId: string;
119
+ };
120
+ /** Return the captured usage (set by setUsage). */
121
+ getUsage(): UsageContext | undefined;
122
+ /** End the root span with final HTTP status and duration, and emit OTEL metrics. */
123
+ end(responseStatus: number, durationMs: number): void;
124
+ /** Record metrics via TelemetryService (call after setUsage). */
125
+ recordMetrics(): void;
126
+ /**
127
+ * Get trace context headers for propagation to the upstream Anthropic request.
128
+ * Injects the current trace's `traceparent` / `tracestate` into a new header map.
129
+ */
130
+ getTraceHeaders(): Record<string, string>;
131
+ }
132
+ export { ProxyTracer };
133
+ export type { ProxyRequestContext, AccountSelectionContext, UpstreamAttemptContext, UsageContext, };