@juspay/neurolink 9.79.1 → 9.79.2

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 (31) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +326 -324
  3. package/dist/core/modules/GenerationHandler.js +43 -1
  4. package/dist/core/modules/structuredOutputPolicy.d.ts +42 -6
  5. package/dist/core/modules/structuredOutputPolicy.js +58 -7
  6. package/dist/lib/core/modules/GenerationHandler.js +43 -1
  7. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +42 -6
  8. package/dist/lib/core/modules/structuredOutputPolicy.js +58 -7
  9. package/dist/lib/processors/document/ExcelProcessor.js +9 -1
  10. package/dist/lib/providers/anthropic.js +37 -41
  11. package/dist/lib/providers/anthropicImageBlocks.d.ts +47 -0
  12. package/dist/lib/providers/anthropicImageBlocks.js +223 -0
  13. package/dist/lib/proxy/oauthFetch.js +26 -14
  14. package/dist/lib/proxy/proxyTracer.d.ts +7 -1
  15. package/dist/lib/proxy/proxyTracer.js +29 -0
  16. package/dist/lib/proxy/systemRelocation.d.ts +21 -0
  17. package/dist/lib/proxy/systemRelocation.js +51 -0
  18. package/dist/lib/server/routes/claudeProxyRoutes.js +147 -19
  19. package/dist/lib/types/proxy.d.ts +10 -0
  20. package/dist/processors/document/ExcelProcessor.js +9 -1
  21. package/dist/providers/anthropic.js +37 -41
  22. package/dist/providers/anthropicImageBlocks.d.ts +47 -0
  23. package/dist/providers/anthropicImageBlocks.js +222 -0
  24. package/dist/proxy/oauthFetch.js +26 -14
  25. package/dist/proxy/proxyTracer.d.ts +7 -1
  26. package/dist/proxy/proxyTracer.js +29 -0
  27. package/dist/proxy/systemRelocation.d.ts +21 -0
  28. package/dist/proxy/systemRelocation.js +50 -0
  29. package/dist/server/routes/claudeProxyRoutes.js +147 -19
  30. package/dist/types/proxy.d.ts +10 -0
  31. package/package.json +8 -2
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Pure converters from multimodal content parts into native Anthropic
3
+ * Messages-API content blocks (image / document).
4
+ *
5
+ * Two input shapes reach the native Anthropic surface:
6
+ *
7
+ * 1. NeuroLink/V3 multimodal parts — `{ type: "image", image }` produced by
8
+ * the multimodal message builder (base64, data URL, https URL, or bytes).
9
+ *
10
+ * 2. AI-SDK LanguageModel prompt parts — `{ type: "file", mediaType, data }`.
11
+ * This is how `ai@6` encodes BOTH images and PDFs in the prompt that the
12
+ * provider's `doGenerate(options.prompt)` receives. Before this module the
13
+ * converter only handled `type:"image"`, so on the tool-using generate
14
+ * path (which always normalises images to `type:"file"`) the image part
15
+ * was silently dropped — the model never saw the image ("no image
16
+ * detected"). Gemini/Vertex are immune because they read `input.images`
17
+ * directly in their own builders instead of going through this conversion.
18
+ *
19
+ * Media type is taken from the AI-SDK-provided `mediaType` when present, then
20
+ * sniffed from magic bytes, and only then defaulted — a hardcoded `image/png`
21
+ * default silently corrupts JPEG/GIF/WebP uploads (the Anthropic API rejects a
22
+ * mislabeled base64 image with HTTP 400 and the image vanishes).
23
+ */
24
+ // The base64 image media types the Anthropic Messages API accepts are exactly
25
+ // `Anthropic.Messages.Base64ImageSource["media_type"]` — referenced inline below
26
+ // rather than redeclared as a local alias (project rule: type aliases live in
27
+ // src/lib/types/, and this provider-internal shape does not warrant a barrel entry).
28
+ const SUPPORTED_IMAGE_MEDIA_TYPES = new Set([
29
+ "image/jpeg",
30
+ "image/png",
31
+ "image/gif",
32
+ "image/webp",
33
+ ]);
34
+ /** Map a caller-provided MIME hint onto a supported image media type (or undefined). */
35
+ const normalizeImageMediaType = (hint) => {
36
+ if (!hint) {
37
+ return undefined;
38
+ }
39
+ const h = hint.toLowerCase().split(";")[0].trim();
40
+ if (h === "image/jpg") {
41
+ return "image/jpeg";
42
+ }
43
+ return SUPPORTED_IMAGE_MEDIA_TYPES.has(h)
44
+ ? h
45
+ : undefined;
46
+ };
47
+ /**
48
+ * Detect a supported image media type from a buffer's magic bytes. Returns
49
+ * undefined when the bytes are not one of the four Anthropic-supported formats.
50
+ */
51
+ export function sniffImageMediaType(bytes) {
52
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
53
+ if (bytes.length >= 8 &&
54
+ bytes[0] === 0x89 &&
55
+ bytes[1] === 0x50 &&
56
+ bytes[2] === 0x4e &&
57
+ bytes[3] === 0x47) {
58
+ return "image/png";
59
+ }
60
+ // JPEG: FF D8 FF
61
+ if (bytes.length >= 3 &&
62
+ bytes[0] === 0xff &&
63
+ bytes[1] === 0xd8 &&
64
+ bytes[2] === 0xff) {
65
+ return "image/jpeg";
66
+ }
67
+ // GIF: "GIF"
68
+ if (bytes.length >= 6 &&
69
+ bytes[0] === 0x47 &&
70
+ bytes[1] === 0x49 &&
71
+ bytes[2] === 0x46) {
72
+ return "image/gif";
73
+ }
74
+ // WebP: "RIFF"...."WEBP"
75
+ if (bytes.length >= 12 &&
76
+ bytes[0] === 0x52 &&
77
+ bytes[1] === 0x49 &&
78
+ bytes[2] === 0x46 &&
79
+ bytes[3] === 0x46 &&
80
+ bytes[8] === 0x57 &&
81
+ bytes[9] === 0x45 &&
82
+ bytes[10] === 0x42 &&
83
+ bytes[11] === 0x50) {
84
+ return "image/webp";
85
+ }
86
+ return undefined;
87
+ }
88
+ // The longest signature `sniffImageMediaType` checks is WebP — "RIFF"…"WEBP",
89
+ // which spans the first 12 decoded bytes. Base64 packs 3 bytes per 4 chars, so
90
+ // 16 chars already cover those 12 bytes; we slice 32 (a clean 4-char boundary
91
+ // that decodes to 24 bytes) for a comfortable margin over every signature.
92
+ const SNIFF_BASE64_CHARS = 32;
93
+ /** Sniff the leading bytes of a base64 payload (best-effort, never throws). */
94
+ const sniffBase64 = (b64) => {
95
+ try {
96
+ return sniffImageMediaType(Buffer.from(b64.slice(0, SNIFF_BASE64_CHARS), "base64"));
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ };
102
+ /**
103
+ * Convert an image part (data URL, bare base64, https URL, byte array, or
104
+ * ArrayBuffer) into an Anthropic image block. Honors `mediaTypeHint` (the
105
+ * AI-SDK `mediaType`), falls back to magic-byte sniffing, then to image/png.
106
+ * Returns undefined for unusable inputs.
107
+ */
108
+ export function toAnthropicImageBlock(data, mediaTypeHint) {
109
+ if (data instanceof ArrayBuffer) {
110
+ return toAnthropicImageBlock(new Uint8Array(data), mediaTypeHint);
111
+ }
112
+ if (data instanceof Uint8Array) {
113
+ const media_type = normalizeImageMediaType(mediaTypeHint) ??
114
+ sniffImageMediaType(data) ??
115
+ "image/png";
116
+ return {
117
+ type: "image",
118
+ source: {
119
+ type: "base64",
120
+ media_type,
121
+ data: Buffer.from(data).toString("base64"),
122
+ },
123
+ };
124
+ }
125
+ if (typeof data !== "string" && !(data instanceof URL)) {
126
+ return undefined;
127
+ }
128
+ const str = data instanceof URL ? data.toString() : data;
129
+ const dataUrlMatch = str.match(/^data:(image\/[a-z0-9+.-]+);base64,(.+)$/i);
130
+ if (dataUrlMatch) {
131
+ const media_type = normalizeImageMediaType(dataUrlMatch[1]) ?? sniffBase64(dataUrlMatch[2]);
132
+ if (!media_type) {
133
+ return undefined;
134
+ }
135
+ return {
136
+ type: "image",
137
+ source: { type: "base64", media_type, data: dataUrlMatch[2] },
138
+ };
139
+ }
140
+ if (/^https?:\/\//i.test(str)) {
141
+ return { type: "image", source: { type: "url", url: str } };
142
+ }
143
+ // Bare base64 payload — prefer the hint, then sniff, then assume PNG
144
+ // (matches the OpenAI-compat client's historical default).
145
+ const media_type = normalizeImageMediaType(mediaTypeHint) ?? sniffBase64(str) ?? "image/png";
146
+ return {
147
+ type: "image",
148
+ source: { type: "base64", media_type, data: str },
149
+ };
150
+ }
151
+ /** Convert a PDF file part into an Anthropic document block. */
152
+ const toAnthropicPdfBlock = (data) => {
153
+ if (data instanceof ArrayBuffer) {
154
+ return toAnthropicPdfBlock(new Uint8Array(data));
155
+ }
156
+ if (data instanceof Uint8Array) {
157
+ return {
158
+ type: "document",
159
+ source: {
160
+ type: "base64",
161
+ media_type: "application/pdf",
162
+ data: Buffer.from(data).toString("base64"),
163
+ },
164
+ };
165
+ }
166
+ if (data instanceof URL) {
167
+ return { type: "document", source: { type: "url", url: data.toString() } };
168
+ }
169
+ if (typeof data === "string") {
170
+ const m = data.match(/^data:application\/pdf;base64,(.+)$/i);
171
+ if (m) {
172
+ return {
173
+ type: "document",
174
+ source: { type: "base64", media_type: "application/pdf", data: m[1] },
175
+ };
176
+ }
177
+ if (/^https?:\/\//i.test(data)) {
178
+ return { type: "document", source: { type: "url", url: data } };
179
+ }
180
+ return {
181
+ type: "document",
182
+ source: { type: "base64", media_type: "application/pdf", data },
183
+ };
184
+ }
185
+ return undefined;
186
+ };
187
+ /**
188
+ * Convert an AI-SDK `{ type: "file", mediaType, data }` content part into the
189
+ * matching Anthropic content block: image/* → image block (media type honored,
190
+ * not hardcoded), application/pdf → document block. Returns undefined for
191
+ * unsupported media types (so the caller simply omits them rather than 400ing).
192
+ * When `mediaType` is absent, image bytes are still salvaged via sniffing.
193
+ */
194
+ export function fileToAnthropicBlock(part) {
195
+ const data = part?.data;
196
+ if (data === undefined || data === null) {
197
+ return undefined;
198
+ }
199
+ const mediaType = typeof part.mediaType === "string"
200
+ ? part.mediaType.toLowerCase().split(";")[0].trim()
201
+ : "";
202
+ if (SUPPORTED_IMAGE_MEDIA_TYPES.has(mediaType) || mediaType === "image/jpg") {
203
+ // Hint is one of the four Anthropic-supported image types (or the jpg alias).
204
+ return toAnthropicImageBlock(data, mediaType);
205
+ }
206
+ if (mediaType === "application/pdf") {
207
+ return toAnthropicPdfBlock(data);
208
+ }
209
+ // No media type, unknown media type, OR an unsupported image/* hint
210
+ // (e.g. image/svg+xml, image/bmp) — attempt magic-byte salvage so that the
211
+ // part is included only when the actual bytes sniff to a supported format,
212
+ // otherwise omit it rather than mislabeling it as image/png.
213
+ const bytes = data instanceof ArrayBuffer
214
+ ? new Uint8Array(data)
215
+ : data instanceof Uint8Array
216
+ ? data
217
+ : undefined;
218
+ if (bytes && sniffImageMediaType(bytes)) {
219
+ return toAnthropicImageBlock(bytes);
220
+ }
221
+ return undefined;
222
+ }
223
+ //# sourceMappingURL=anthropicImageBlocks.js.map
@@ -13,6 +13,7 @@
13
13
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, MCP_TOOL_PREFIX, } from "../auth/anthropicOAuth.js";
14
14
  import { logger } from "../utils/logger.js";
15
15
  import { createProxyFetch } from "./proxyFetch.js";
16
+ import { relocateClientSystemIntoMessages } from "./systemRelocation.js";
16
17
  // Re-export constants for consumers that previously imported them alongside
17
18
  // the function from `providers/anthropic.ts`.
18
19
  export { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX };
@@ -150,23 +151,21 @@ function transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix)
150
151
  type: "text",
151
152
  text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
152
153
  };
153
- // Normalise `system` to an array and APPEND billing + agent blocks.
154
- // IMPORTANT: We append (not prepend) to preserve the client's cache
155
- // prefix chain. Anthropic's prompt caching uses prefix matching — if
156
- // we insert anything before the client's system blocks, we invalidate
157
- // all cached content (tools, system prompt, message history).
154
+ // Normalise `system` to an array, then route by client type.
158
155
  //
159
- // Claude Code sends a billing block with a `cch=<hash>` value that
160
- // changes on every request. We remove any existing billing/agent
161
- // blocks from their positions and always append our stable
162
- // Claude-Code-shaped versions at the end.
156
+ // The subscription/OAuth path only accepts a `system` it recognises as the
157
+ // genuine Claude Code prompt. A real CC client sends its own billing + agent
158
+ // identity blocks we keep those and just stabilise the volatile billing
159
+ // `cch`. A custom client sends its own arbitrary system prompt with NO agent
160
+ // block; left in `system` it is rejected as `rate_limit_error: "Error"`, so we
161
+ // relocate it into the message stream and send only the recognised billing +
162
+ // agent blocks as `system`.
163
163
  if (parsed.system) {
164
164
  if (typeof parsed.system === "string") {
165
165
  parsed.system = [{ type: "text", text: parsed.system }];
166
166
  }
167
167
  if (Array.isArray(parsed.system)) {
168
- // Find and remove existing billing/agent blocks from wherever
169
- // the client placed them (typically at system[0])
168
+ // Find existing billing/agent blocks wherever the client placed them.
170
169
  const billingIdx = parsed.system.findIndex((b) => typeof b.text === "string" &&
171
170
  b.text.includes("x-anthropic-billing-header"));
172
171
  const agentIdx = parsed.system.findIndex((b) => typeof b.text === "string" && b.text.includes("Claude Agent SDK"));
@@ -174,15 +173,28 @@ function transformOAuthJsonBody(body, requestHeaders, getToken, enableMcpPrefix)
174
173
  type: "text",
175
174
  text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text),
176
175
  };
177
- // Remove in reverse index order so indices stay valid
176
+ // A genuine Claude Code client supplies its own agent-identity block;
177
+ // a custom client does not.
178
+ const isClaudeCodeClient = agentIdx >= 0;
179
+ // Strip billing/agent from their positions (reverse order so indices
180
+ // stay valid). What remains is the client's "extra" system content.
178
181
  const indicesToRemove = [billingIdx, agentIdx]
179
182
  .filter((i) => i >= 0)
180
183
  .sort((a, b) => b - a);
181
184
  for (const idx of indicesToRemove) {
182
185
  parsed.system.splice(idx, 1);
183
186
  }
184
- // Always append deterministic billing + agent blocks at the end
185
- parsed.system = [...parsed.system, billingBlock, agentBlock];
187
+ if (!isClaudeCodeClient && parsed.system.length > 0) {
188
+ // Non-CC client: relocate its system into the message stream so the
189
+ // subscription/OAuth path accepts the request.
190
+ relocateClientSystemIntoMessages(parsed, parsed.system);
191
+ parsed.system = [billingBlock, agentBlock];
192
+ }
193
+ else {
194
+ // Genuine Claude Code (or no extra blocks): keep system and append the
195
+ // deterministic billing + agent blocks at the end.
196
+ parsed.system = [...parsed.system, billingBlock, agentBlock];
197
+ }
186
198
  }
187
199
  }
188
200
  else {
@@ -14,7 +14,7 @@
14
14
  * - TelemetryService for metrics recording
15
15
  */
16
16
  import { type Span } from "@opentelemetry/api";
17
- import type { AccountSelectionContext, ProxyRequestContext, UpstreamAttemptContext, UsageContext } from "../types/index.js";
17
+ import type { AccountSelectionContext, ProxyRequestContext, ResponseInfoContext, UpstreamAttemptContext, UsageContext } from "../types/index.js";
18
18
  declare class ProxyTracer {
19
19
  private readonly rootSpan;
20
20
  private readonly proxyTracer;
@@ -46,6 +46,12 @@ declare class ProxyTracer {
46
46
  setAccountSelection(ctx: AccountSelectionContext): void;
47
47
  /** Record token usage and cost on the root span. */
48
48
  setUsage(ctx: UsageContext): void;
49
+ /**
50
+ * Record response-side details parsed from the upstream reply: the model
51
+ * that actually answered, the finish reason, and which tools the model
52
+ * invoked (tool_use blocks). Uses gen_ai.* semantic-convention attributes.
53
+ */
54
+ setResponseInfo(ctx: ResponseInfoContext): void;
49
55
  /** Record an error on the root span. */
50
56
  setError(errorType: string, errorMessage: string): void;
51
57
  /** Record whether the request was handled in full or passthrough mode. */
@@ -240,6 +240,10 @@ class ProxyTracer {
240
240
  if (ctx.userAgent) {
241
241
  rootSpan.setAttribute("http.user_agent", ctx.userAgent);
242
242
  }
243
+ if (ctx.toolNames && ctx.toolNames.length > 0) {
244
+ // What the caller exposed to the model (tool catalogue for this request).
245
+ rootSpan.setAttribute("gen_ai.request.tool_names", JSON.stringify(ctx.toolNames));
246
+ }
243
247
  // Read x-neurolink-* context headers from calling SDK (e.g., Curator)
244
248
  const nlSessionId = incomingHeaders?.["x-neurolink-session-id"];
245
249
  const nlUserId = incomingHeaders?.["x-neurolink-user-id"];
@@ -387,6 +391,31 @@ class ProxyTracer {
387
391
  this.rootSpan.setAttribute("proxy.ratelimit.after.7d", ctx.rateLimitAfter7d);
388
392
  }
389
393
  }
394
+ /**
395
+ * Record response-side details parsed from the upstream reply: the model
396
+ * that actually answered, the finish reason, and which tools the model
397
+ * invoked (tool_use blocks). Uses gen_ai.* semantic-convention attributes.
398
+ */
399
+ setResponseInfo(ctx) {
400
+ if (ctx.responseModel) {
401
+ this.rootSpan.setAttribute("gen_ai.response.model", ctx.responseModel);
402
+ }
403
+ if (ctx.finishReason) {
404
+ this.rootSpan.setAttribute("gen_ai.response.finish_reason", ctx.finishReason);
405
+ }
406
+ if (ctx.stopSequence) {
407
+ this.rootSpan.setAttribute("gen_ai.response.stop_sequence", ctx.stopSequence);
408
+ }
409
+ if (ctx.toolCalls && ctx.toolCalls.length > 0) {
410
+ this.rootSpan.setAttributes({
411
+ "gen_ai.response.tool_calls": JSON.stringify(ctx.toolCalls),
412
+ "gen_ai.response.tool_call_count": ctx.toolCalls.length,
413
+ });
414
+ this.rootSpan.addEvent("proxy.response.tool_calls", {
415
+ "gen_ai.response.tool_calls": JSON.stringify(ctx.toolCalls),
416
+ });
417
+ }
418
+ }
390
419
  /** Record an error on the root span. */
391
420
  setError(errorType, errorMessage) {
392
421
  this.rootSpan.setAttributes({
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Relocate a non-Claude-Code client's `system` blocks into the message stream.
3
+ *
4
+ * Anthropic's subscription/OAuth path rejects any `system` content it does not
5
+ * recognise as the genuine Claude Code system prompt — anti-abuse fingerprinting
6
+ * surfaced as a header-less `rate_limit_error: "Error"` (NOT a real rate limit).
7
+ * Custom clients (Curator/Tara) send their own system prompt, so we move it into
8
+ * a leading user block and keep only the recognised billing+agent blocks in
9
+ * `system`. The model still honours the instructions, and any `cache_control` is
10
+ * carried over so the prompt prefix stays cacheable.
11
+ *
12
+ * Shared by both proxy entry points (`oauthFetch.ts` and `claudeProxyRoutes.ts`)
13
+ * so the OAuth anti-abuse workaround stays in one place and can't drift between
14
+ * the two paths.
15
+ */
16
+ export declare function relocateClientSystemIntoMessages(parsed: {
17
+ messages?: unknown;
18
+ }, instructionBlocks: Array<{
19
+ text?: unknown;
20
+ cache_control?: unknown;
21
+ }>): void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Relocate a non-Claude-Code client's `system` blocks into the message stream.
3
+ *
4
+ * Anthropic's subscription/OAuth path rejects any `system` content it does not
5
+ * recognise as the genuine Claude Code system prompt — anti-abuse fingerprinting
6
+ * surfaced as a header-less `rate_limit_error: "Error"` (NOT a real rate limit).
7
+ * Custom clients (Curator/Tara) send their own system prompt, so we move it into
8
+ * a leading user block and keep only the recognised billing+agent blocks in
9
+ * `system`. The model still honours the instructions, and any `cache_control` is
10
+ * carried over so the prompt prefix stays cacheable.
11
+ *
12
+ * Shared by both proxy entry points (`oauthFetch.ts` and `claudeProxyRoutes.ts`)
13
+ * so the OAuth anti-abuse workaround stays in one place and can't drift between
14
+ * the two paths.
15
+ */
16
+ export function relocateClientSystemIntoMessages(parsed, instructionBlocks) {
17
+ if (instructionBlocks.length === 0) {
18
+ return;
19
+ }
20
+ const blocks = instructionBlocks.map((b) => {
21
+ const text = typeof b.text === "string" ? b.text : String(b.text ?? "");
22
+ const out = {
23
+ type: "text",
24
+ text,
25
+ };
26
+ if (b.cache_control) {
27
+ out.cache_control = b.cache_control;
28
+ }
29
+ return out;
30
+ });
31
+ // Wrap the relocated system in an explicit delimiter so the model treats it
32
+ // as authoritative instructions, clearly separated from the user's message.
33
+ blocks[0].text = `<system_instructions>\n${blocks[0].text}`;
34
+ const last = blocks.length - 1;
35
+ blocks[last].text = `${blocks[last].text}\n</system_instructions>`;
36
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
37
+ const first = messages[0];
38
+ if (first && first.role === "user") {
39
+ const existing = typeof first.content === "string"
40
+ ? [{ type: "text", text: first.content }]
41
+ : Array.isArray(first.content)
42
+ ? first.content
43
+ : [];
44
+ first.content = [...blocks, ...existing];
45
+ }
46
+ else {
47
+ messages.unshift({ role: "user", content: blocks });
48
+ }
49
+ parsed.messages = messages;
50
+ }
51
+ //# sourceMappingURL=systemRelocation.js.map