@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.
package/dist/neurolink.js CHANGED
@@ -4470,6 +4470,14 @@ Current user's request: ${currentInput}`;
4470
4470
  // remains false for backwards-compat with existing listeners that only
4471
4471
  // branch on the boolean.
4472
4472
  const aborted = isAbortError(error);
4473
+ // A native path that already emitted its own `generation:end` for this
4474
+ // failure marks the error, because on the failure path it has no result
4475
+ // object to mark. Re-emitting here would double every native failure.
4476
+ if (error &&
4477
+ typeof error === "object" &&
4478
+ error._generationEndEmitted) {
4479
+ return;
4480
+ }
4473
4481
  try {
4474
4482
  this.emitter.emit("generation:end", {
4475
4483
  provider: errProvider,
@@ -6501,10 +6509,27 @@ Current user's request: ${currentInput}`;
6501
6509
  // naming the one provider that failed is clearer than the generic
6502
6510
  // multi-provider wording. The multi-provider prefix is left untouched
6503
6511
  // so existing message-matching suites keep passing.
6512
+ // Both wrappers replace the provider's error with a new one, discarding
6513
+ // anything attached to it. `_generationEndEmitted` has to survive: lose it
6514
+ // and emitGenerateErrorEvent emits a second event for a failure the native
6515
+ // provider already reported. Returns the error rather than throwing so each
6516
+ // call site keeps its own `throw` — a never-returning helper reads better
6517
+ // but tsconfig.cli.json will not accept it as terminating the enclosing
6518
+ // function (TS2366). `cause` is set because the original error and its
6519
+ // stack were simply being thrown away.
6520
+ const wrapProviderError = (message) => {
6521
+ const wrapped = new Error(message, { cause: lastError });
6522
+ if (lastError &&
6523
+ lastError._generationEndEmitted) {
6524
+ wrapped._generationEndEmitted =
6525
+ true;
6526
+ }
6527
+ return wrapped;
6528
+ };
6504
6529
  if (providersToTry.length === 1) {
6505
- throw new Error(`Provider ${providersToTry[0]} failed: ${lastError?.message || "Unknown error"}`);
6530
+ throw wrapProviderError(`Provider ${providersToTry[0]} failed: ${lastError?.message || "Unknown error"}`);
6506
6531
  }
6507
- throw new Error(`Failed to generate text with all providers. Last error: ${lastError?.message || "Unknown error"}`);
6532
+ throw wrapProviderError(`Failed to generate text with all providers. Last error: ${lastError?.message || "Unknown error"}`);
6508
6533
  }
6509
6534
  /**
6510
6535
  * Create tool-aware system prompt that informs AI about available tools
@@ -1364,6 +1364,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
1364
1364
  result._generationEndEmitted =
1365
1365
  true;
1366
1366
  }
1367
+ // The failure path has no result to carry that flag — `result` is null —
1368
+ // so mark the error instead. Without this the provider's own
1369
+ // `generation:end` (success: false) and the SDK's emitGenerateErrorEvent
1370
+ // both fire for one failed native generate, double-counting every failure
1371
+ // in analytics and as two Pipeline B spans. The success path above was
1372
+ // already deduplicated; only the catch branch was missed.
1373
+ if (!success && error && typeof error === "object") {
1374
+ error._generationEndEmitted =
1375
+ true;
1376
+ }
1367
1377
  emitter.emit("generation:end", {
1368
1378
  provider: this.providerName,
1369
1379
  responseTime: Date.now() - startTime,
@@ -1,5 +1,6 @@
1
1
  import { getRuntimeContextWindow, getRuntimeOutputCeiling, } from "../constants/contextWindows.js";
2
2
  import { estimateTokens, IMAGE_TOKEN_ESTIMATE, } from "../utils/tokenEstimation.js";
3
+ import { truncateHistoryForBudget } from "./proxyHistoryTruncation.js";
3
4
  /** Local refusal before spending a provider request; never silently truncates. */
4
5
  export class ProxyContextPreflightError extends Error {
5
6
  code;
@@ -65,13 +66,32 @@ export function parseProxyContextPolicy(raw) {
65
66
  throw new ProxyContextPreflightError("invalid_context_policy", "models must be a bounded map of provider/model limits");
66
67
  }
67
68
  for (const [key, limit] of Object.entries(value.models)) {
69
+ // Read each budget into a local before comparing: narrowing from the
70
+ // `positive` predicate does not survive several `||` operands when it is
71
+ // applied to a property access, and the looser compiler flags used by the
72
+ // package build then see `unknown` on one side of `>=`.
73
+ const contextWindow = record(limit) ? limit.contextWindow : undefined;
74
+ const maxOutputTokens = record(limit) ? limit.maxOutputTokens : undefined;
75
+ const compactAt = record(limit) ? limit.compactAtTokens : undefined;
76
+ const compactTo = record(limit) ? limit.compactToTokens : undefined;
68
77
  if (!key.includes("/") ||
69
78
  !record(limit) ||
70
- !positive(limit.contextWindow) ||
71
- (limit.maxOutputTokens !== undefined &&
72
- !positive(limit.maxOutputTokens)) ||
73
- Object.keys(limit).some((k) => !["contextWindow", "maxOutputTokens"].includes(k))) {
74
- throw new ProxyContextPreflightError("invalid_context_policy", "Each model needs an explicit provider/model key and positive contextWindow/output limit");
79
+ !positive(contextWindow) ||
80
+ (maxOutputTokens !== undefined && !positive(maxOutputTokens)) ||
81
+ (compactAt !== undefined &&
82
+ (!positive(compactAt) || compactAt >= contextWindow)) ||
83
+ (compactTo !== undefined &&
84
+ (!positive(compactTo) ||
85
+ !positive(compactAt) ||
86
+ compactTo >= compactAt)) ||
87
+ (compactAt !== undefined && compactTo === undefined) ||
88
+ Object.keys(limit).some((k) => ![
89
+ "contextWindow",
90
+ "maxOutputTokens",
91
+ "compactAtTokens",
92
+ "compactToTokens",
93
+ ].includes(k))) {
94
+ throw new ProxyContextPreflightError("invalid_context_policy", "Each model needs an explicit provider/model key, a positive contextWindow/output limit, and a compaction target below its trigger");
75
95
  }
76
96
  }
77
97
  }
@@ -234,16 +254,21 @@ export function prepareProxyRequestContext(args) {
234
254
  : {}),
235
255
  };
236
256
  }
237
- const wire = body;
238
- const state = { multimodal: false };
257
+ let wire = body;
258
+ // Fixed cost and history are tracked separately: truncation can remove the
259
+ // only image in a request, and a sticky flag would then keep the ceiling
260
+ // check disabled for a request that no longer carries any media.
261
+ const fixedState = { multimodal: false };
262
+ let historyState = { multimodal: false };
263
+ const isMultimodal = () => fixedState.multimodal || historyState.multimodal;
239
264
  const provider = args.provider === "codex" ? "openai" : args.provider;
240
- const toolsTokensEstimate = estimateValue(wire.tools, provider, state);
265
+ const toolsTokensEstimate = estimateValue(wire.tools, provider, fixedState);
241
266
  const instructionsTokensEstimate = [
242
267
  wire.instructions,
243
268
  wire.system,
244
269
  wire.systemPrompt,
245
270
  wire.systemInstruction,
246
- ].reduce((sum, value) => sum + estimateValue(value, provider, state), 0);
271
+ ].reduce((sum, value) => sum + estimateValue(value, provider, fixedState), 0);
247
272
  const schemaTokensEstimate = [
248
273
  wire.response_format,
249
274
  wire.output_config,
@@ -256,19 +281,53 @@ export function prepareProxyRequestContext(args) {
256
281
  record(wire.generationConfig)
257
282
  ? wire.generationConfig.responseJsonSchema
258
283
  : undefined,
259
- ].reduce((sum, value) => sum + estimateValue(value, provider, state), 0);
260
- const inputTokensEstimate = 24 +
284
+ ].reduce((sum, value) => sum + estimateValue(value, provider, fixedState), 0);
285
+ const estimate = (value) => estimateValue(value, provider, historyState);
286
+ const fixedTokensEstimate = 24 +
261
287
  schemaTokensEstimate +
262
288
  toolsTokensEstimate +
263
- instructionsTokensEstimate +
264
- [
265
- wire.messages,
266
- wire.input,
267
- wire.contents,
268
- wire.conversationMessages,
269
- wire.prompt,
270
- ].reduce((sum, value) => sum + estimateValue(value, provider, state), 0);
289
+ instructionsTokensEstimate;
290
+ const historyTokensEstimate = () => [
291
+ wire.messages,
292
+ wire.input,
293
+ wire.contents,
294
+ wire.conversationMessages,
295
+ wire.prompt,
296
+ ].reduce((sum, value) => sum + estimate(value), 0);
297
+ let inputTokensEstimate = fixedTokensEstimate + historyTokensEstimate();
271
298
  const configured = policy.models?.[`${args.provider}/${args.model}`];
299
+ // Cost control: reduce history before the refusal checks below, so those see
300
+ // the reduced estimate and a bounded request is dispatched instead of a
301
+ // rejected one. Runs on every dispatch path because every path lands here.
302
+ let historyModified = false;
303
+ let historyUnitsRemoved = 0;
304
+ const inputTokensBeforeTruncation = inputTokensEstimate;
305
+ if (configured?.compactAtTokens !== undefined &&
306
+ configured.compactToTokens !== undefined &&
307
+ inputTokensEstimate > configured.compactAtTokens) {
308
+ const truncated = truncateHistoryForBudget({
309
+ body,
310
+ inputTokensEstimate,
311
+ targetTokens: configured.compactToTokens,
312
+ estimate,
313
+ });
314
+ if (truncated.historyModified) {
315
+ body = truncated.body;
316
+ wire = body;
317
+ historyModified = true;
318
+ historyUnitsRemoved = truncated.unitsRemoved;
319
+ historyState = { multimodal: false };
320
+ inputTokensEstimate = fixedTokensEstimate + historyTokensEstimate();
321
+ }
322
+ // The design requires a typed local failure rather than a silent dispatch
323
+ // above the configured bound: fixed context alone, or the one unit that
324
+ // must be retained, can exceed the target with nothing left to remove.
325
+ if (inputTokensEstimate > configured.compactToTokens) {
326
+ throw new ProxyContextPreflightError("proxy_context_compaction_failed", `Compaction could not bring ${args.provider}/${args.model} within ` +
327
+ `${configured.compactToTokens} tokens; ${inputTokensEstimate} remain ` +
328
+ `after removing every removable unit`);
329
+ }
330
+ }
272
331
  const discovered = getRuntimeContextWindow(args.provider, args.model);
273
332
  const contextWindow = configured?.contextWindow ?? discovered;
274
333
  const outputCeiling = configured?.maxOutputTokens ??
@@ -306,10 +365,13 @@ export function prepareProxyRequestContext(args) {
306
365
  ? "discovered"
307
366
  : "unknown",
308
367
  tokenCountSource: "estimated",
309
- multimodalEstimate: state.multimodal,
368
+ multimodalEstimate: isMultimodal(),
310
369
  originalToolCount: toolCount(tools),
311
370
  retainedToolCount: toolCount(wire.tools),
312
- historyModified: false,
371
+ historyModified,
372
+ ...(historyModified
373
+ ? { historyUnitsRemoved, inputTokensBeforeTruncation }
374
+ : {}),
313
375
  };
314
376
  if (policy.maxInputTokens !== undefined &&
315
377
  inputTokensEstimate > policy.maxInputTokens) {
@@ -323,9 +385,9 @@ export function prepareProxyRequestContext(args) {
323
385
  }
324
386
  if (contextWindow !== undefined &&
325
387
  policy.enforceDiscoveredLimits !== false &&
326
- !state.multimodal &&
388
+ !isMultimodal() &&
327
389
  inputTokensEstimate + outputTokensReserve > contextWindow) {
328
- throw new ProxyContextPreflightError("proxy_context_window_exceeded", "Estimated input plus output/reasoning reserve exceeds the serving model context window; no history was truncated", evidence);
390
+ throw new ProxyContextPreflightError("proxy_context_window_exceeded", `Estimated input plus output/reasoning reserve exceeds the serving model context window; ${historyModified ? "history was truncated and still does not fit" : "no history was truncated"}`, evidence);
329
391
  }
330
392
  return {
331
393
  body,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Deterministic pre-dispatch history truncation.
3
+ *
4
+ * Runs inside the shared context preflight, so one implementation bounds input
5
+ * cost on every dispatch path: native Anthropic, the translated/SDK fallback
6
+ * that serves Vertex, and native or bridged Codex.
7
+ *
8
+ * Deliberately provider-free. Summarizing history would add an outbound model
9
+ * call to the very requests this exists to make cheaper, so reduction is pure
10
+ * removal of the oldest complete units.
11
+ */
12
+ import type { ProxyHistoryEstimate, ProxyHistoryTruncationResult } from "../types/index.js";
13
+ /**
14
+ * Remove the oldest complete units until the estimate reaches `targetTokens`.
15
+ *
16
+ * Returns the body unchanged when there is nothing safe to remove. The newest
17
+ * unit is always retained: it carries the turn being answered.
18
+ */
19
+ export declare function truncateHistoryForBudget<T extends object>(args: {
20
+ body: T;
21
+ inputTokensEstimate: number;
22
+ targetTokens: number;
23
+ estimate: ProxyHistoryEstimate;
24
+ }): ProxyHistoryTruncationResult<T>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Deterministic pre-dispatch history truncation.
3
+ *
4
+ * Runs inside the shared context preflight, so one implementation bounds input
5
+ * cost on every dispatch path: native Anthropic, the translated/SDK fallback
6
+ * that serves Vertex, and native or bridged Codex.
7
+ *
8
+ * Deliberately provider-free. Summarizing history would add an outbound model
9
+ * call to the very requests this exists to make cheaper, so reduction is pure
10
+ * removal of the oldest complete units.
11
+ */
12
+ function record(value) {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ /**
16
+ * A turn that cannot legally open a history.
17
+ *
18
+ * The Messages API rejects a conversation whose first message is not from the
19
+ * user, so truncation that stops on an assistant turn would trade a local
20
+ * refusal for an upstream 400. Items with no role (Codex `function_call` and
21
+ * `function_call_output`) are not turns and are left alone.
22
+ */
23
+ function isNonUserTurn(item) {
24
+ return record(item) && typeof item.role === "string" && item.role !== "user";
25
+ }
26
+ function blocksOf(item) {
27
+ return Array.isArray(item.content) ? item.content : [item];
28
+ }
29
+ /** Tool-call ids this item opens. */
30
+ function definedCallIds(item) {
31
+ if (!record(item)) {
32
+ return [];
33
+ }
34
+ const ids = [];
35
+ if (item.type === "function_call" && typeof item.call_id === "string") {
36
+ ids.push(item.call_id);
37
+ }
38
+ for (const block of blocksOf(item)) {
39
+ if (record(block) &&
40
+ block.type === "tool_use" &&
41
+ typeof block.id === "string") {
42
+ ids.push(block.id);
43
+ }
44
+ }
45
+ return ids;
46
+ }
47
+ /** Tool-call ids this item answers. */
48
+ function referencedCallIds(item) {
49
+ if (!record(item)) {
50
+ return [];
51
+ }
52
+ const ids = [];
53
+ if (item.type === "function_call_output" &&
54
+ typeof item.call_id === "string") {
55
+ ids.push(item.call_id);
56
+ }
57
+ for (const block of blocksOf(item)) {
58
+ if (record(block) &&
59
+ block.type === "tool_result" &&
60
+ typeof block.tool_use_id === "string") {
61
+ ids.push(block.tool_use_id);
62
+ }
63
+ }
64
+ return ids;
65
+ }
66
+ /**
67
+ * Group items so a tool call and every answer to it stay in one unit.
68
+ *
69
+ * A unit stays open while any call it opened is still unanswered, so removing
70
+ * whole units can never strand a `tool_result` or a `function_call_output`.
71
+ */
72
+ function groupIntoUnits(items) {
73
+ const units = [];
74
+ let current = [];
75
+ const awaiting = new Set();
76
+ for (let index = 0; index < items.length; index += 1) {
77
+ const item = items[index];
78
+ current.push(index);
79
+ for (const id of referencedCallIds(item)) {
80
+ awaiting.delete(id);
81
+ }
82
+ for (const id of definedCallIds(item)) {
83
+ awaiting.add(id);
84
+ }
85
+ if (awaiting.size === 0) {
86
+ units.push(current);
87
+ current = [];
88
+ }
89
+ }
90
+ if (current.length > 0) {
91
+ // An unanswered trailing call still belongs with the turn that opened it.
92
+ units.push(current);
93
+ }
94
+ return units;
95
+ }
96
+ /**
97
+ * Pick the history container.
98
+ *
99
+ * `input` is overloaded across paths: the translated/SDK shape carries the
100
+ * current turn there as an object, while Codex carries the item array. Only an
101
+ * array is history, so the object form is never touched.
102
+ */
103
+ function historyFieldOf(wire) {
104
+ if (Array.isArray(wire.messages)) {
105
+ return "messages";
106
+ }
107
+ if (Array.isArray(wire.conversationMessages)) {
108
+ return "conversationMessages";
109
+ }
110
+ if (Array.isArray(wire.input)) {
111
+ return "input";
112
+ }
113
+ return undefined;
114
+ }
115
+ /**
116
+ * Remove the oldest complete units until the estimate reaches `targetTokens`.
117
+ *
118
+ * Returns the body unchanged when there is nothing safe to remove. The newest
119
+ * unit is always retained: it carries the turn being answered.
120
+ */
121
+ export function truncateHistoryForBudget(args) {
122
+ const unchanged = {
123
+ body: args.body,
124
+ historyModified: false,
125
+ unitsRemoved: 0,
126
+ itemsRemoved: 0,
127
+ };
128
+ const wire = args.body;
129
+ const field = historyFieldOf(wire);
130
+ if (field === undefined) {
131
+ return unchanged;
132
+ }
133
+ const items = wire[field];
134
+ const units = groupIntoUnits(items);
135
+ if (units.length <= 1) {
136
+ return unchanged;
137
+ }
138
+ // Everything outside the history container — tools, instructions, schema —
139
+ // is fixed cost that truncation must never touch, so only unit costs are
140
+ // subtracted from the running projection.
141
+ const unitTokens = units.map((unit) => args.estimate(unit.map((index) => items[index])));
142
+ // The translated shape keeps the current turn in object-valued `input`, so
143
+ // every `conversationMessages` unit is prior history and all of them may go.
144
+ // The other shapes carry the current turn in the array itself, so the newest
145
+ // unit there is the turn being answered and must survive.
146
+ const removable = field === "conversationMessages" ? units.length : units.length - 1;
147
+ let removedUnits = 0;
148
+ let projected = args.inputTokensEstimate;
149
+ while (removedUnits < removable && projected > args.targetTokens) {
150
+ projected -= unitTokens[removedUnits];
151
+ removedUnits += 1;
152
+ }
153
+ if (removedUnits === 0) {
154
+ return unchanged;
155
+ }
156
+ // Advance by whole units so the kept history opens on a user turn without
157
+ // stranding a tool call from its answer.
158
+ while (removedUnits < removable &&
159
+ isNonUserTurn(items[units[removedUnits][0]])) {
160
+ removedUnits += 1;
161
+ }
162
+ const keptIndices = units.slice(removedUnits).flat();
163
+ const kept = keptIndices.map((index) => items[index]);
164
+ const itemsRemoved = items.length - kept.length;
165
+ return {
166
+ body: { ...args.body, [field]: kept },
167
+ historyModified: true,
168
+ unitsRemoved: removedUnits,
169
+ itemsRemoved,
170
+ };
171
+ }
@@ -0,0 +1,82 @@
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 type { UsageContext, VertexPassthroughTerminal, VertexAnthropicPassthroughRequest, VertexAnthropicPassthroughTarget } from "../types/index.js";
16
+ /**
17
+ * Translate `output_config` for Vertex.
18
+ *
19
+ * An effort level Vertex knows passes through; one it does not is mapped onto
20
+ * the nearest level it does. A level with no mapping at all loses only the
21
+ * `effort` key, never the sibling settings beside it.
22
+ */
23
+ export declare function mapVertexOutputConfig(value: unknown): Record<string, unknown> | undefined;
24
+ /** Strip what Vertex rejects and pin the API version it requires. */
25
+ export declare function buildVertexAnthropicPayload(body: Readonly<Record<string, unknown>>): Record<string, unknown>;
26
+ /**
27
+ * Publisher endpoint for a Claude model on Vertex.
28
+ *
29
+ * Streaming and non-streaming are different verbs on the same resource, so the
30
+ * caller's `stream` flag selects the verb rather than changing the body.
31
+ */
32
+ export declare function buildVertexAnthropicUrl(target: VertexAnthropicPassthroughTarget): string;
33
+ /**
34
+ * Send one Anthropic-shaped request to Vertex and return the upstream response
35
+ * untouched, so the caller can stream its bytes straight to the client.
36
+ */
37
+ export declare function dispatchVertexAnthropicPassthrough(request: VertexAnthropicPassthroughRequest): Promise<Response>;
38
+ /** Claude models are the only publisher models this passthrough addresses. */
39
+ export declare function isVertexAnthropicModel(model: string): boolean;
40
+ /**
41
+ * Resolve where Vertex traffic is addressed, using the same environment the
42
+ * Vertex provider reads so a fallback lands on the project and region the rest
43
+ * of the process already uses.
44
+ */
45
+ export declare function resolveVertexTarget(): {
46
+ projectId: string;
47
+ location: string;
48
+ } | undefined;
49
+ /**
50
+ * Run one Claude-on-Vertex fallback attempt and hand back a response the route
51
+ * can serve directly.
52
+ *
53
+ * A non-2xx upstream throws so the caller's attempt loop records the failure
54
+ * and moves to the next hop, exactly as the other providers behave.
55
+ */
56
+ export declare function executeVertexAnthropicFallback(args: {
57
+ body: Readonly<Record<string, unknown>>;
58
+ model: string;
59
+ signal?: AbortSignal;
60
+ /** Fires exactly once on every exit, so the hop is finalized like any other. */
61
+ onTerminal?: (terminal: VertexPassthroughTerminal) => void;
62
+ }): Promise<Response>;
63
+ /**
64
+ * Read usage out of a non-streaming Anthropic reply.
65
+ *
66
+ * `rawPredict` returns a single `{"type":"message","usage":{...}}` object, which
67
+ * carries the whole turn's counts at once rather than splitting them across
68
+ * `message_start` and `message_delta`.
69
+ */
70
+ export declare function readJsonUsage(text: string): UsageContext;
71
+ /**
72
+ * Forward a stream unchanged while observing its usage events.
73
+ *
74
+ * The bytes are passed through untouched — parsing happens on a copy of each
75
+ * chunk — so the client sees exactly what Vertex sent.
76
+ *
77
+ * A transform's `flush` only runs on a clean end, which would leave a stream
78
+ * error or a client disconnect with no terminal record at all. An explicit
79
+ * reader loop is used instead, so `onTerminal` fires exactly once on every
80
+ * exit: normal end, upstream failure, and cancellation.
81
+ */
82
+ export declare function observeVertexUsage(body: ReadableStream<Uint8Array>, onTerminal: (terminal: VertexPassthroughTerminal) => void): ReadableStream<Uint8Array>;