@juspay/neurolink 12.17.3 → 12.18.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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Native Claude-to-Vertex passthrough.
3
+ *
4
+ * Claude on Vertex speaks the Anthropic Messages API, so a fallback to it needs
5
+ * no translation at all — the client's body goes upstream essentially verbatim
6
+ * and the SSE comes back in the shape the client already expects.
7
+ *
8
+ * This exists because the SDK/translation path cannot carry an agentic turn:
9
+ * it runs its own tool loop, and a request whose tools have no executor (every
10
+ * proxied request, since the caller executes them) ends with the pending tool
11
+ * call discarded and a step-cap apology returned as assistant text. Tools,
12
+ * `tool_use`, `tool_result`, `cache_control` and thinking blocks all survive
13
+ * here because nothing reshapes them.
14
+ */
15
+ import { GoogleAuth } from "google-auth-library";
16
+ /** Vertex pins the Anthropic API version in the body, not a header. */
17
+ const VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
18
+ const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
19
+ /**
20
+ * Fields the Anthropic first-party API accepts that Vertex's publisher endpoint
21
+ * does not.
22
+ *
23
+ * Deliberately short. `model` moves into the URL, and `context_management` is
24
+ * refused outright ("context_management: Extra inputs are not permitted").
25
+ * Everything else the client sends is carried, including `metadata` and
26
+ * `thinking`, both of which Vertex accepts — dropping them would silently
27
+ * discard caller intent.
28
+ */
29
+ const UNSUPPORTED_FIELDS = ["model", "context_management"];
30
+ /**
31
+ * Reasoning effort levels Vertex's Claude models accept, and how a level they
32
+ * do not accept maps onto one they do.
33
+ *
34
+ * Claude Code sends `xhigh` for the Claude 5 family; `claude-opus-4-6` refuses
35
+ * it by name ("Supported levels: high, low, max, medium"). Mapping it to the
36
+ * top supported level preserves the caller's intent — run this turn as hard as
37
+ * the model allows — where dropping the field would quietly downgrade it.
38
+ */
39
+ const VERTEX_EFFORT_BY_LEVEL = {
40
+ low: "low",
41
+ medium: "medium",
42
+ high: "high",
43
+ max: "max",
44
+ xhigh: "max",
45
+ };
46
+ function record(value) {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value);
48
+ }
49
+ /**
50
+ * Translate `output_config` for Vertex.
51
+ *
52
+ * An effort level Vertex knows passes through; one it does not is mapped onto
53
+ * the nearest level it does. A level with no mapping at all loses only the
54
+ * `effort` key, never the sibling settings beside it.
55
+ */
56
+ export function mapVertexOutputConfig(value) {
57
+ if (!record(value)) {
58
+ return undefined;
59
+ }
60
+ const effort = value.effort;
61
+ if (typeof effort !== "string") {
62
+ return value;
63
+ }
64
+ const mapped = VERTEX_EFFORT_BY_LEVEL[effort.toLowerCase()];
65
+ if (mapped !== undefined) {
66
+ return { ...value, effort: mapped };
67
+ }
68
+ const { effort: _unsupported, ...rest } = value;
69
+ return Object.keys(rest).length > 0 ? rest : undefined;
70
+ }
71
+ /** Strip what Vertex rejects and pin the API version it requires. */
72
+ export function buildVertexAnthropicPayload(body) {
73
+ const dropped = new Set(UNSUPPORTED_FIELDS);
74
+ const payload = Object.fromEntries(Object.entries(body).filter(([key]) => !dropped.has(key)));
75
+ if ("output_config" in payload) {
76
+ const mapped = mapVertexOutputConfig(payload.output_config);
77
+ if (mapped === undefined) {
78
+ delete payload.output_config;
79
+ }
80
+ else {
81
+ payload.output_config = mapped;
82
+ }
83
+ }
84
+ return { ...payload, anthropic_version: VERTEX_ANTHROPIC_VERSION };
85
+ }
86
+ /**
87
+ * Publisher endpoint for a Claude model on Vertex.
88
+ *
89
+ * Streaming and non-streaming are different verbs on the same resource, so the
90
+ * caller's `stream` flag selects the verb rather than changing the body.
91
+ */
92
+ export function buildVertexAnthropicUrl(target) {
93
+ const verb = target.stream ? "streamRawPredict" : "rawPredict";
94
+ // The global endpoint is not a region: it drops the host prefix entirely,
95
+ // while the path still carries `locations/global`. Prefixing it yields
96
+ // `global-aiplatform.googleapis.com`, which does not resolve to an API.
97
+ const host = target.location === "global"
98
+ ? "aiplatform.googleapis.com"
99
+ : `${target.location}-aiplatform.googleapis.com`;
100
+ return (`https://${host}/v1/projects/${target.projectId}/locations/` +
101
+ `${target.location}/publishers/anthropic/models/${target.model}:${verb}`);
102
+ }
103
+ let cachedAuth;
104
+ function auth() {
105
+ cachedAuth ??= new GoogleAuth({ scopes: [CLOUD_PLATFORM_SCOPE] });
106
+ return cachedAuth;
107
+ }
108
+ /**
109
+ * Send one Anthropic-shaped request to Vertex and return the upstream response
110
+ * untouched, so the caller can stream its bytes straight to the client.
111
+ */
112
+ export async function dispatchVertexAnthropicPassthrough(request) {
113
+ const token = await auth().getAccessToken();
114
+ if (!token) {
115
+ throw new Error("Vertex passthrough could not obtain a Google access token; " +
116
+ "check Application Default Credentials for this project");
117
+ }
118
+ const stream = request.body.stream === true;
119
+ const url = buildVertexAnthropicUrl({
120
+ projectId: request.projectId,
121
+ location: request.location,
122
+ model: request.model,
123
+ stream,
124
+ });
125
+ return fetch(url, {
126
+ method: "POST",
127
+ headers: {
128
+ authorization: `Bearer ${token}`,
129
+ "content-type": "application/json",
130
+ accept: stream ? "text/event-stream" : "application/json",
131
+ },
132
+ body: JSON.stringify(buildVertexAnthropicPayload(request.body)),
133
+ ...(request.signal ? { signal: request.signal } : {}),
134
+ });
135
+ }
136
+ /** Claude models are the only publisher models this passthrough addresses. */
137
+ export function isVertexAnthropicModel(model) {
138
+ return model.toLowerCase().startsWith("claude");
139
+ }
140
+ /**
141
+ * Resolve where Vertex traffic is addressed, using the same environment the
142
+ * Vertex provider reads so a fallback lands on the project and region the rest
143
+ * of the process already uses.
144
+ */
145
+ export function resolveVertexTarget() {
146
+ const projectId = process.env.GOOGLE_CLOUD_PROJECT ??
147
+ process.env.VERTEX_PROJECT_ID ??
148
+ process.env.GOOGLE_VERTEX_PROJECT ??
149
+ process.env.GOOGLE_CLOUD_PROJECT_ID;
150
+ if (!projectId?.trim()) {
151
+ return undefined;
152
+ }
153
+ const location = process.env.GOOGLE_CLOUD_LOCATION ??
154
+ process.env.VERTEX_LOCATION ??
155
+ process.env.GOOGLE_VERTEX_LOCATION ??
156
+ "us-central1";
157
+ return { projectId: projectId.trim(), location };
158
+ }
159
+ /**
160
+ * Run one Claude-on-Vertex fallback attempt and hand back a response the route
161
+ * can serve directly.
162
+ *
163
+ * A non-2xx upstream throws so the caller's attempt loop records the failure
164
+ * and moves to the next hop, exactly as the other providers behave.
165
+ */
166
+ export async function executeVertexAnthropicFallback(args) {
167
+ const target = resolveVertexTarget();
168
+ if (!target) {
169
+ throw new Error("Vertex fallback needs a Google Cloud project; set GOOGLE_CLOUD_PROJECT");
170
+ }
171
+ const upstream = await dispatchVertexAnthropicPassthrough({
172
+ body: args.body,
173
+ projectId: target.projectId,
174
+ location: target.location,
175
+ model: args.model,
176
+ ...(args.signal ? { signal: args.signal } : {}),
177
+ });
178
+ if (!upstream.ok) {
179
+ const detail = await upstream.text().catch(() => "");
180
+ throw new Error(`Vertex ${args.model} responded ${upstream.status}: ${detail.slice(0, 300)}`);
181
+ }
182
+ // A non-streaming reply is one JSON object with no `data:` lines, so the SSE
183
+ // observer would read nothing from it and report all-zero usage as if it were
184
+ // real. Parse that shape directly instead, and still hand the client the exact
185
+ // bytes Vertex sent.
186
+ if (args.onTerminal && args.body.stream !== true) {
187
+ const text = await upstream.text();
188
+ args.onTerminal({ usage: readJsonUsage(text), status: upstream.status });
189
+ return new Response(text, {
190
+ status: upstream.status,
191
+ headers: {
192
+ "content-type": upstream.headers.get("content-type") ?? "application/json",
193
+ },
194
+ });
195
+ }
196
+ // The body is already Anthropic-shaped, so it is forwarded untouched rather
197
+ // than parsed and re-serialized. Usage is read from a copy of each chunk so
198
+ // this hop is metered like every other one instead of going dark.
199
+ const body = upstream.body && args.onTerminal
200
+ ? observeVertexUsage(upstream.body, args.onTerminal)
201
+ : upstream.body;
202
+ return new Response(body, {
203
+ status: upstream.status,
204
+ headers: {
205
+ "content-type": upstream.headers.get("content-type") ??
206
+ (args.body.stream === true ? "text/event-stream" : "application/json"),
207
+ },
208
+ });
209
+ }
210
+ /**
211
+ * Read token usage out of an Anthropic SSE stream.
212
+ *
213
+ * `message_start` carries the input side and the cache breakdown;
214
+ * `message_delta` carries the running output count, the last of which is the
215
+ * turn's total. Both are accumulated so a passthrough hop reports the same
216
+ * usage as any other, rather than going unmetered.
217
+ */
218
+ function readUsageEvent(event, into) {
219
+ const message = event.message;
220
+ const usage = (event.type === "message_start" && record(message)
221
+ ? message.usage
222
+ : event.usage);
223
+ if (!record(usage)) {
224
+ return;
225
+ }
226
+ const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
227
+ if (event.type === "message_start") {
228
+ into.inputTokens = num(usage.input_tokens);
229
+ into.cacheCreationTokens = num(usage.cache_creation_input_tokens);
230
+ into.cacheReadTokens = num(usage.cache_read_input_tokens);
231
+ }
232
+ // Every message_delta restates the running total, so the last one wins.
233
+ const output = num(usage.output_tokens);
234
+ if (output > 0) {
235
+ into.outputTokens = output;
236
+ }
237
+ }
238
+ /**
239
+ * Read usage out of a non-streaming Anthropic reply.
240
+ *
241
+ * `rawPredict` returns a single `{"type":"message","usage":{...}}` object, which
242
+ * carries the whole turn's counts at once rather than splitting them across
243
+ * `message_start` and `message_delta`.
244
+ */
245
+ export function readJsonUsage(text) {
246
+ const usage = {
247
+ inputTokens: 0,
248
+ outputTokens: 0,
249
+ cacheCreationTokens: 0,
250
+ cacheReadTokens: 0,
251
+ };
252
+ try {
253
+ const parsed = JSON.parse(text);
254
+ readUsageEvent({ ...parsed, type: "message_start", message: parsed }, usage);
255
+ // A single object states the final output count directly.
256
+ const raw = record(parsed.usage) ? parsed.usage.output_tokens : undefined;
257
+ if (typeof raw === "number" && Number.isFinite(raw)) {
258
+ usage.outputTokens = raw;
259
+ }
260
+ }
261
+ catch {
262
+ // An unparseable body is reported as zero rather than guessed at.
263
+ }
264
+ return usage;
265
+ }
266
+ /**
267
+ * Forward a stream unchanged while observing its usage events.
268
+ *
269
+ * The bytes are passed through untouched — parsing happens on a copy of each
270
+ * chunk — so the client sees exactly what Vertex sent.
271
+ *
272
+ * A transform's `flush` only runs on a clean end, which would leave a stream
273
+ * error or a client disconnect with no terminal record at all. An explicit
274
+ * reader loop is used instead, so `onTerminal` fires exactly once on every
275
+ * exit: normal end, upstream failure, and cancellation.
276
+ */
277
+ export function observeVertexUsage(body, onTerminal) {
278
+ const usage = {
279
+ inputTokens: 0,
280
+ outputTokens: 0,
281
+ cacheCreationTokens: 0,
282
+ cacheReadTokens: 0,
283
+ };
284
+ const decoder = new TextDecoder();
285
+ const reader = body.getReader();
286
+ let pending = "";
287
+ let reported = false;
288
+ const report = (status, errorMessage) => {
289
+ if (reported) {
290
+ return;
291
+ }
292
+ reported = true;
293
+ onTerminal({ usage, status, ...(errorMessage ? { errorMessage } : {}) });
294
+ };
295
+ const consume = (chunk) => {
296
+ pending += decoder.decode(chunk, { stream: true });
297
+ const lines = pending.split("\n");
298
+ // Keep the trailing fragment; it completes on a later chunk.
299
+ pending = lines.pop() ?? "";
300
+ for (const line of lines) {
301
+ if (!line.startsWith("data:")) {
302
+ continue;
303
+ }
304
+ const raw = line.slice(5).trim();
305
+ if (!raw || raw === "[DONE]") {
306
+ continue;
307
+ }
308
+ try {
309
+ readUsageEvent(JSON.parse(raw), usage);
310
+ }
311
+ catch {
312
+ // A partial or non-JSON payload is never fatal to the passthrough.
313
+ }
314
+ }
315
+ };
316
+ return new ReadableStream({
317
+ async pull(controller) {
318
+ try {
319
+ const { done, value } = await reader.read();
320
+ if (done) {
321
+ report(200);
322
+ controller.close();
323
+ return;
324
+ }
325
+ consume(value);
326
+ controller.enqueue(value);
327
+ }
328
+ catch (error) {
329
+ report(502, error instanceof Error ? error.message : String(error));
330
+ controller.error(error);
331
+ }
332
+ },
333
+ cancel(reason) {
334
+ // The client went away mid-stream; tokens already spent still count.
335
+ report(499, reason === undefined ? undefined : String(reason));
336
+ return reader.cancel(reason);
337
+ },
338
+ });
339
+ }
@@ -28,6 +28,7 @@ import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quot
28
28
  import { buildClaudeError, ClaudeStreamSerializer, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
29
29
  import { getProxyUpstreamFailure } from "../../proxy/proxyFailureDetails.js";
30
30
  import { CodexFallbackResponseError, CodexFallbackStreamError, consumeCodexFallbackResponse, createCodexFallbackStream, convertClaudeRequestToCodex, } from "../../proxy/codexFallback.js";
31
+ import { executeVertexAnthropicFallback, isVertexAnthropicModel, } from "../../proxy/vertexAnthropicFallback.js";
31
32
  import { isProxyRequestFinalized, getProxyRequestAccounting, registerProxyResponseObserver, takeProxyResponseObservers, trackProxyResponse, } from "../../proxy/proxyActivity.js";
32
33
  import { buildAnthropicModelsListResponse, buildTranslationOptions, handleTranslatedJsonRequest, handleTranslatedStreamRequest, } from "../../proxy/proxyTranslationEngine.js";
33
34
  import { tracers } from "../../telemetry/tracers.js";
@@ -4046,6 +4047,44 @@ async function tryConfiguredClaudeFallbackChain(args) {
4046
4047
  logFinalRequest,
4047
4048
  });
4048
4049
  }
4050
+ else if (fallback.provider === "vertex" &&
4051
+ isVertexAnthropicModel(fallback.model)) {
4052
+ // Claude on Vertex speaks the Anthropic Messages API, so the request
4053
+ // goes up as-is. The SDK path below runs its own tool loop and drops
4054
+ // the caller's pending tool call, which strands every agentic turn.
4055
+ const vertexModel = fallback.model;
4056
+ const vertexAccount = `vertex/${vertexModel}`;
4057
+ response = await executeVertexAnthropicFallback({
4058
+ body,
4059
+ model: vertexModel,
4060
+ ...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}),
4061
+ onTerminal: ({ usage, status, errorMessage }) => {
4062
+ // The hop finalizes itself, like the Codex path does. Nothing else
4063
+ // ends this tracer: the route returns before the stream drains, so
4064
+ // without this the root span stays open and cost, request and
4065
+ // duration metrics are never emitted.
4066
+ //
4067
+ // Billing has to follow the model that served the turn. Left on the
4068
+ // requested alias, the pricing lookup misses and the charge is
4069
+ // silently dropped, which is why the Vertex hop had token counters
4070
+ // but no `proxy_cost_usd_total` series at all.
4071
+ tracer?.setModelSubstitution(body.model, vertexModel, "vertex");
4072
+ // Without this the spend lands under the Anthropic account that
4073
+ // never served the turn, hiding Vertex cost inside a subscription
4074
+ // account's figures.
4075
+ tracer?.setServedAccount(vertexAccount, "vertex");
4076
+ // setUsage must precede end(), which is what turns usage into cost.
4077
+ tracer?.setUsage(usage);
4078
+ tracer?.end(status, Date.now() - requestStartTime);
4079
+ logFinalRequest(status, vertexAccount, "vertex", status === 200 ? undefined : "vertex_fallback_stream", errorMessage, {
4080
+ inputTokens: usage.inputTokens,
4081
+ outputTokens: usage.outputTokens,
4082
+ cacheCreationTokens: usage.cacheCreationTokens,
4083
+ cacheReadTokens: usage.cacheReadTokens,
4084
+ });
4085
+ },
4086
+ });
4087
+ }
4049
4088
  else {
4050
4089
  const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(fallback.provider, fallback.model);
4051
4090
  if (!availability.available) {
@@ -4174,4 +4174,26 @@ export type ProxyServiceInstallSettings = {
4174
4174
  port?: number;
4175
4175
  environment: Record<string, string>;
4176
4176
  };
4177
+ /** Where a native Claude-to-Vertex passthrough is addressed. */
4178
+ export type VertexAnthropicPassthroughTarget = {
4179
+ projectId: string;
4180
+ location: string;
4181
+ model: string;
4182
+ stream: boolean;
4183
+ };
4184
+ /** One native Claude-to-Vertex passthrough dispatch. */
4185
+ export type VertexAnthropicPassthroughRequest = {
4186
+ body: Readonly<Record<string, unknown>>;
4187
+ projectId: string;
4188
+ location: string;
4189
+ model: string;
4190
+ signal?: AbortSignal;
4191
+ };
4192
+ /** How a Claude-on-Vertex passthrough ended, so the hop can be finalized once. */
4193
+ export type VertexPassthroughTerminal = {
4194
+ usage: UsageContext;
4195
+ /** 200 served, 499 client cancelled, 502 upstream stream failure. */
4196
+ status: number;
4197
+ errorMessage?: string;
4198
+ };
4177
4199
  export {};
@@ -7,6 +7,10 @@ export type ProxyContextPolicy = {
7
7
  models?: Record<string, {
8
8
  contextWindow: number;
9
9
  maxOutputTokens?: number;
10
+ /** Input estimate above which history is reduced. Bounds per-turn cost. */
11
+ compactAtTokens?: number;
12
+ /** Input estimate to reduce to. Must be below compactAtTokens. */
13
+ compactToTokens?: number;
10
14
  }>;
11
15
  };
12
16
  export type ProxyContextEvidence = {
@@ -24,7 +28,11 @@ export type ProxyContextEvidence = {
24
28
  multimodalEstimate: boolean;
25
29
  originalToolCount: number;
26
30
  retainedToolCount: number;
27
- historyModified: false;
31
+ historyModified: boolean;
32
+ /** Complete history units dropped by pre-dispatch truncation. */
33
+ historyUnitsRemoved?: number;
34
+ /** Input estimate before truncation, when truncation ran. */
35
+ inputTokensBeforeTruncation?: number;
28
36
  };
29
37
  export type ProxyPreparedContext<T> = {
30
38
  body: T;
@@ -33,3 +41,13 @@ export type ProxyPreparedContext<T> = {
33
41
  totalTokensReservation: number;
34
42
  evidence: ProxyContextEvidence;
35
43
  };
44
+ /** Estimator injected into history truncation so it matches preflight accounting. */
45
+ export type ProxyHistoryEstimate = (value: unknown) => number;
46
+ /** The history container a proxy body carries. */
47
+ export type ProxyHistoryField = "messages" | "input" | "conversationMessages";
48
+ export type ProxyHistoryTruncationResult<T> = {
49
+ body: T;
50
+ historyModified: boolean;
51
+ unitsRemoved: number;
52
+ itemsRemoved: number;
53
+ };