@juspay/neurolink 9.79.0 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +330 -328
- package/dist/core/modules/GenerationHandler.js +43 -1
- package/dist/core/modules/structuredOutputPolicy.d.ts +42 -6
- package/dist/core/modules/structuredOutputPolicy.js +58 -7
- package/dist/lib/core/modules/GenerationHandler.js +43 -1
- package/dist/lib/core/modules/structuredOutputPolicy.d.ts +42 -6
- package/dist/lib/core/modules/structuredOutputPolicy.js +58 -7
- package/dist/lib/processors/document/ExcelProcessor.js +9 -1
- package/dist/lib/providers/anthropic.js +37 -41
- package/dist/lib/providers/anthropicImageBlocks.d.ts +47 -0
- package/dist/lib/providers/anthropicImageBlocks.js +223 -0
- package/dist/lib/providers/googleVertex.js +81 -3
- package/dist/lib/proxy/oauthFetch.js +26 -14
- package/dist/lib/proxy/proxyTracer.d.ts +7 -1
- package/dist/lib/proxy/proxyTracer.js +29 -0
- package/dist/lib/proxy/systemRelocation.d.ts +21 -0
- package/dist/lib/proxy/systemRelocation.js +51 -0
- package/dist/lib/server/routes/claudeProxyRoutes.js +147 -19
- package/dist/lib/types/providers.d.ts +46 -0
- package/dist/lib/types/proxy.d.ts +10 -0
- package/dist/lib/utils/anthropicCacheBreakpoints.d.ts +15 -0
- package/dist/lib/utils/anthropicCacheBreakpoints.js +98 -0
- package/dist/processors/document/ExcelProcessor.js +9 -1
- package/dist/providers/anthropic.js +37 -41
- package/dist/providers/anthropicImageBlocks.d.ts +47 -0
- package/dist/providers/anthropicImageBlocks.js +222 -0
- package/dist/providers/googleVertex.js +81 -3
- package/dist/proxy/oauthFetch.js +26 -14
- package/dist/proxy/proxyTracer.d.ts +7 -1
- package/dist/proxy/proxyTracer.js +29 -0
- package/dist/proxy/systemRelocation.d.ts +21 -0
- package/dist/proxy/systemRelocation.js +50 -0
- package/dist/server/routes/claudeProxyRoutes.js +147 -19
- package/dist/types/providers.d.ts +46 -0
- package/dist/types/proxy.d.ts +10 -0
- package/dist/utils/anthropicCacheBreakpoints.d.ts +15 -0
- package/dist/utils/anthropicCacheBreakpoints.js +97 -0
- package/package.json +9 -2
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import type Anthropic from "@anthropic-ai/sdk";
|
|
25
|
+
/**
|
|
26
|
+
* Detect a supported image media type from a buffer's magic bytes. Returns
|
|
27
|
+
* undefined when the bytes are not one of the four Anthropic-supported formats.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sniffImageMediaType(bytes: Uint8Array): Anthropic.Messages.Base64ImageSource["media_type"] | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Convert an image part (data URL, bare base64, https URL, byte array, or
|
|
32
|
+
* ArrayBuffer) into an Anthropic image block. Honors `mediaTypeHint` (the
|
|
33
|
+
* AI-SDK `mediaType`), falls back to magic-byte sniffing, then to image/png.
|
|
34
|
+
* Returns undefined for unusable inputs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function toAnthropicImageBlock(data: unknown, mediaTypeHint?: string): Anthropic.Messages.ImageBlockParam | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Convert an AI-SDK `{ type: "file", mediaType, data }` content part into the
|
|
39
|
+
* matching Anthropic content block: image/* → image block (media type honored,
|
|
40
|
+
* not hardcoded), application/pdf → document block. Returns undefined for
|
|
41
|
+
* unsupported media types (so the caller simply omits them rather than 400ing).
|
|
42
|
+
* When `mediaType` is absent, image bytes are still salvaged via sniffing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fileToAnthropicBlock(part: {
|
|
45
|
+
mediaType?: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
}): Anthropic.Messages.ContentBlockParam | undefined;
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { ModelConfigurationManager } from "../core/modelConfiguration.js";
|
|
|
10
10
|
import { createProxyFetch } from "../proxy/proxyFetch.js";
|
|
11
11
|
import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
|
|
12
12
|
import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
|
|
13
|
+
import { applyVertexAnthropicCacheBreakpoints } from "../utils/anthropicCacheBreakpoints.js";
|
|
13
14
|
import { FileDetector } from "../utils/fileDetector.js";
|
|
14
15
|
import { processUnifiedFilesArray } from "../utils/messageBuilder.js";
|
|
15
16
|
import { logger } from "../utils/logger.js";
|
|
@@ -2491,9 +2492,27 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2491
2492
|
}, turnContext);
|
|
2492
2493
|
let response;
|
|
2493
2494
|
try {
|
|
2495
|
+
// Vertex has no automatic prompt caching — place explicit
|
|
2496
|
+
// cache_control breakpoints (system, tools, rolling history) so the
|
|
2497
|
+
// conversation prefix is cached across turns instead of re-billed as
|
|
2498
|
+
// fresh input every call. Re-applied per step: the stable prefix
|
|
2499
|
+
// stays byte-identical (consistent cache key) while the rolling
|
|
2500
|
+
// breakpoint follows the growing tail.
|
|
2501
|
+
const cachedStream = applyVertexAnthropicCacheBreakpoints({
|
|
2502
|
+
system: systemPromptWithSchema,
|
|
2503
|
+
tools,
|
|
2504
|
+
messages: currentMessages,
|
|
2505
|
+
});
|
|
2494
2506
|
const stream = await client.messages.stream({
|
|
2495
2507
|
...requestParams,
|
|
2496
|
-
|
|
2508
|
+
...(cachedStream.system !== undefined && {
|
|
2509
|
+
system: cachedStream.system,
|
|
2510
|
+
}),
|
|
2511
|
+
...(cachedStream.tools &&
|
|
2512
|
+
cachedStream.tools.length > 0 && {
|
|
2513
|
+
tools: cachedStream.tools,
|
|
2514
|
+
}),
|
|
2515
|
+
messages: cachedStream.messages,
|
|
2497
2516
|
});
|
|
2498
2517
|
activeStream = stream;
|
|
2499
2518
|
// Forward each text delta as it arrives — the Anthropic SDK fires
|
|
@@ -2750,6 +2769,17 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2750
2769
|
}
|
|
2751
2770
|
metadata.responseTime = Date.now() - startTime;
|
|
2752
2771
|
metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
2772
|
+
// Surface cache metrics once, after the agentic loop, from the
|
|
2773
|
+
// cumulative turnCacheUsage running totals (accumulated via += on every
|
|
2774
|
+
// step above). Assigned here — not per-iteration — so the final values
|
|
2775
|
+
// are unambiguous: analytics sees caching is working and calculateCost
|
|
2776
|
+
// can price the cache-read/write tiers instead of full input rate.
|
|
2777
|
+
if (turnCacheUsage.read > 0) {
|
|
2778
|
+
usage.cacheReadTokens = turnCacheUsage.read;
|
|
2779
|
+
}
|
|
2780
|
+
if (turnCacheUsage.creation > 0) {
|
|
2781
|
+
usage.cacheCreationTokens = turnCacheUsage.creation;
|
|
2782
|
+
}
|
|
2753
2783
|
const turnOutputAttribute = spanJsonAttribute({
|
|
2754
2784
|
text: aggregatedTurnText,
|
|
2755
2785
|
...(structuredOutputRef.value
|
|
@@ -3110,6 +3140,12 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3110
3140
|
const allToolCalls = [];
|
|
3111
3141
|
let totalInputTokens = 0;
|
|
3112
3142
|
let totalOutputTokens = 0;
|
|
3143
|
+
// Cache metrics (Anthropic reports these separately from input_tokens, which
|
|
3144
|
+
// is the uncached remainder). Surfaced on the result so analytics can see
|
|
3145
|
+
// caching is working and calculateCost can price the ~0.1x cache-read /
|
|
3146
|
+
// ~1.25x cache-write tiers instead of billing everything at full input rate.
|
|
3147
|
+
let totalCacheReadTokens = 0;
|
|
3148
|
+
let totalCacheCreationTokens = 0;
|
|
3113
3149
|
// Track the final Anthropic stop_reason so we can surface finishReason
|
|
3114
3150
|
// (notably "length" on token truncation) — the legacy native path always
|
|
3115
3151
|
// reported "stop", hiding truncation from callers.
|
|
@@ -3121,13 +3157,36 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3121
3157
|
// Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
|
|
3122
3158
|
// generate forever. options.timeout wins if set, otherwise default
|
|
3123
3159
|
// to 5 min — generous for tool-heavy turns.
|
|
3160
|
+
// Vertex has no automatic prompt caching — place explicit cache_control
|
|
3161
|
+
// breakpoints (system, tools, rolling history) so the conversation
|
|
3162
|
+
// prefix is cached across turns instead of re-billed as fresh input
|
|
3163
|
+
// every call. Re-applied per step: the stable prefix stays
|
|
3164
|
+
// byte-identical (consistent cache key) while the rolling breakpoint
|
|
3165
|
+
// follows the growing tail.
|
|
3166
|
+
const cachedGenerate = applyVertexAnthropicCacheBreakpoints({
|
|
3167
|
+
system: systemPromptWithSchema,
|
|
3168
|
+
tools,
|
|
3169
|
+
messages: currentMessages,
|
|
3170
|
+
});
|
|
3124
3171
|
const response = await withTimeout(client.messages.create({
|
|
3125
3172
|
...requestParams,
|
|
3126
|
-
|
|
3173
|
+
...(cachedGenerate.system !== undefined && {
|
|
3174
|
+
system: cachedGenerate.system,
|
|
3175
|
+
}),
|
|
3176
|
+
...(cachedGenerate.tools &&
|
|
3177
|
+
cachedGenerate.tools.length > 0 && {
|
|
3178
|
+
tools: cachedGenerate.tools,
|
|
3179
|
+
}),
|
|
3180
|
+
messages: cachedGenerate.messages,
|
|
3127
3181
|
}), generateTimeoutMs, "Anthropic generate timed out");
|
|
3128
|
-
// Update token counts
|
|
3182
|
+
// Update token counts. input_tokens is the uncached remainder; cache
|
|
3183
|
+
// reads/writes are reported separately and accumulated here so the
|
|
3184
|
+
// result reflects the full picture.
|
|
3129
3185
|
totalInputTokens += response.usage?.input_tokens || 0;
|
|
3130
3186
|
totalOutputTokens += response.usage?.output_tokens || 0;
|
|
3187
|
+
totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
|
|
3188
|
+
totalCacheCreationTokens +=
|
|
3189
|
+
response.usage?.cache_creation_input_tokens || 0;
|
|
3131
3190
|
lastStopReason = response.stop_reason;
|
|
3132
3191
|
// Check if we need to handle tool use
|
|
3133
3192
|
const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
|
|
@@ -3287,6 +3346,12 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3287
3346
|
input: totalInputTokens,
|
|
3288
3347
|
output: totalOutputTokens,
|
|
3289
3348
|
total: totalInputTokens + totalOutputTokens,
|
|
3349
|
+
...(totalCacheReadTokens > 0 && {
|
|
3350
|
+
cacheReadTokens: totalCacheReadTokens,
|
|
3351
|
+
}),
|
|
3352
|
+
...(totalCacheCreationTokens > 0 && {
|
|
3353
|
+
cacheCreationTokens: totalCacheCreationTokens,
|
|
3354
|
+
}),
|
|
3290
3355
|
},
|
|
3291
3356
|
responseTime,
|
|
3292
3357
|
toolsUsed: externalToolCalls.map((tc) => tc.toolName),
|
|
@@ -3761,6 +3826,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3761
3826
|
const inputTokens = usage.input ?? usage.inputTokens ?? 0;
|
|
3762
3827
|
const outputTokens = usage.output ?? usage.outputTokens ?? 0;
|
|
3763
3828
|
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens;
|
|
3829
|
+
const cacheReadTokens = usage.cacheReadTokens ?? 0;
|
|
3830
|
+
const cacheCreationTokens = usage.cacheCreationTokens ?? 0;
|
|
3764
3831
|
if (inputTokens > 0) {
|
|
3765
3832
|
span.setAttribute("gen_ai.usage.input_tokens", inputTokens);
|
|
3766
3833
|
}
|
|
@@ -3770,11 +3837,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3770
3837
|
if (totalTokens > 0) {
|
|
3771
3838
|
span.setAttribute("gen_ai.usage.total_tokens", totalTokens);
|
|
3772
3839
|
}
|
|
3840
|
+
if (cacheReadTokens > 0) {
|
|
3841
|
+
span.setAttribute("gen_ai.usage.cache_read_input_tokens", cacheReadTokens);
|
|
3842
|
+
}
|
|
3843
|
+
if (cacheCreationTokens > 0) {
|
|
3844
|
+
span.setAttribute("gen_ai.usage.cache_creation_input_tokens", cacheCreationTokens);
|
|
3845
|
+
}
|
|
3773
3846
|
try {
|
|
3847
|
+
// Pass cache tokens through so calculateCost prices the ~0.1x cache-read
|
|
3848
|
+
// and ~1.25x cache-write tiers instead of billing cached content at the
|
|
3849
|
+
// full input rate.
|
|
3774
3850
|
const cost = calculateCost(this.providerName, modelName, {
|
|
3775
3851
|
input: inputTokens,
|
|
3776
3852
|
output: outputTokens,
|
|
3777
3853
|
total: totalTokens,
|
|
3854
|
+
cacheReadTokens,
|
|
3855
|
+
cacheCreationTokens,
|
|
3778
3856
|
});
|
|
3779
3857
|
if (typeof cost === "number" && cost > 0) {
|
|
3780
3858
|
span.setAttribute("neurolink.cost", cost);
|
package/dist/proxy/oauthFetch.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// blocks
|
|
162
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
185
|
-
|
|
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,50 @@
|
|
|
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
|
+
}
|