@juspay/neurolink 12.41.3 → 12.41.5

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,100 @@
1
+ /**
2
+ * Bridges Bedrock's native `BedrockMessage[]` conversation shape and
3
+ * `AgenticLoopChunk` stream to the AI SDK V3 model-middleware protocol
4
+ * (`LanguageModelV3`), so `BaseProvider.applyMiddlewareToModel()` can wrap a
5
+ * turn built entirely out of `runAgenticLoop` — the same engine Bedrock's
6
+ * `generate()` and `executeStream()` already run on.
7
+ *
8
+ * Scope: only the conversation `generate()`/`executeStream()` hand to the
9
+ * loop as its STARTING point ever crosses this boundary. Both reset
10
+ * `conversationHistory` to a single fresh message before calling the loop
11
+ * (see `client.ts`), so that starting point is always `system + one user
12
+ * message` — never prior assistant/tool-result turns, which are constructed
13
+ * entirely inside the loop's own steps and stay in native
14
+ * `BedrockMessage[]`/`ConverseCommand` shape. A general, lossless
15
+ * `BedrockMessage` <-> `ModelMessage` converter is not attempted here, and
16
+ * would not be possible anyway: `BedrockToolResult` carries no `toolName`,
17
+ * which a faithful `ToolModelMessage` requires.
18
+ *
19
+ * Further scoped to TEXT-ONLY turns. A multimodal starting message (image,
20
+ * document, or — pathologically — a tool-use/tool-result block) skips
21
+ * wrapping entirely; the caller checks `isTextOnlyBedrockConversation` and
22
+ * falls back to calling the loop directly, logging that the turn was
23
+ * excluded. This mirrors the plan's AI Studio precedent: an excluded shape
24
+ * must be explicit in code, not implied by no test ever sending one.
25
+ *
26
+ * Who owns the outcome. When the wrapped `doGenerate`/`doStream` ran the loop,
27
+ * the loop's own result is authoritative for `finishReason`, `usage`,
28
+ * `toolExecutions` and the final conversation: a middleware may replace the
29
+ * content or the stream parts a caller sees, but it cannot rewrite the
30
+ * metadata of a turn that already reached the wire. When a middleware never
31
+ * calls through (a cache hit, a refusal, a synthetic answer) no loop ran, and
32
+ * its own V3 result is what gets reported.
33
+ */
34
+ import type { ToolInputSchema } from "@aws-sdk/client-bedrock-runtime";
35
+ import type { AgenticLoopChunk, BedrockMessage, LanguageModelV3CallOptions, LanguageModelV3StreamPart, ModelMessage } from "../../types/index.js";
36
+ /** Names of the unsent options `params` carries; an empty list counts as unset. */
37
+ export declare function unsentBedrockMiddlewareOptions(params: LanguageModelV3CallOptions): string[];
38
+ /**
39
+ * Unwrap Bedrock's `ToolSpecification.inputSchema` — always `{ json:
40
+ * DocumentType }` on the wire (see `ToolInputSchema.JsonMember` in the AWS
41
+ * SDK), never the bare schema — to the plain JSON schema object the V3 tool
42
+ * shape's `inputSchema` field expects. `client.ts`'s own tool-formatting path
43
+ * (`formatToolsForBedrock`) always sends `{ json: schema }` to AWS for this
44
+ * exact reason; handing a middleware the wrapper itself instead of the
45
+ * schema breaks any schema-inspecting middleware (e.g. a guardrail
46
+ * validating tool arguments against the declared shape).
47
+ */
48
+ export declare function unwrapBedrockToolInputSchema(inputSchema: ToolInputSchema | undefined): unknown;
49
+ /** True when every content block of every message is plain text. */
50
+ export declare function isTextOnlyBedrockConversation(conversation: BedrockMessage[]): boolean;
51
+ /**
52
+ * `system` + the (already text-only) starting conversation, as a V3 prompt.
53
+ * The system prompt is always sent as its own leading message — Bedrock
54
+ * carries it out-of-band (`ConverseCommandInput.system`), but the V3 prompt
55
+ * shape has no separate slot, so `bedrockConversationFromV3Prompt` is the
56
+ * inverse that pulls it back out.
57
+ */
58
+ export declare function buildV3PromptFromBedrock(systemPrompt: string, conversation: BedrockMessage[]): ModelMessage[];
59
+ /**
60
+ * Inverse of `buildV3PromptFromBedrock`, read AFTER `transformParams` has
61
+ * run — this is what makes a middleware's prompt rewrite (correction 1)
62
+ * reach the wire: the caller passes the ORIGINAL prompt into `doGenerate`/
63
+ * `doStream`, and by AI SDK contract `transformParams` has already produced
64
+ * the version handed to the closure by the time this runs.
65
+ *
66
+ * A `tool` role message has no faithful Bedrock shape at this boundary (no
67
+ * toolUseId/toolName pairing survives `ToolModelMessage`), so one is
68
+ * dropped with a WARN rather than guessed at. The starting turn Bedrock
69
+ * ever hands across this boundary never contains one; seeing one here means
70
+ * middleware injected it.
71
+ */
72
+ export declare function bedrockConversationFromV3Prompt(prompt: ModelMessage[]): {
73
+ system: string;
74
+ conversation: BedrockMessage[];
75
+ };
76
+ /**
77
+ * Wrap a native `AgenticLoopChunk` source into the V3 stream contract, one
78
+ * chunk at a time, forwarding cancellation to both the source iterator and
79
+ * `cancel` (which the caller wires to the turn's own `AbortController`, so
80
+ * breaking out of a wrapped stream tears down the underlying AWS SDK
81
+ * request — correction 4). `finish` resolves the terminal `finish` part
82
+ * once the loop settles; middleware inspecting the returned stream sees a
83
+ * genuine usage-bearing finish part even when nothing downstream reads it
84
+ * back out (a guardrail that blocks before calling `doStream` never
85
+ * produces this stream at all, which is the `!loopPromise` case its own
86
+ * caller handles).
87
+ */
88
+ export declare function bedrockChunksToV3Stream(source: AsyncIterable<AgenticLoopChunk>, finish: Promise<LanguageModelV3StreamPart>, cancel: () => void): ReadableStream<LanguageModelV3StreamPart>;
89
+ /**
90
+ * Inverse of `bedrockChunksToV3Stream`: drains a (possibly
91
+ * middleware-wrapped) V3 stream back into Bedrock's native chunk shape.
92
+ * `finish`/`error` parts are consumed here but not surfaced as chunks —
93
+ * `streamingConversationLoop` reads the turn's outcome from the loop's own
94
+ * `resultPromise`, which already carries richer usage/finishReason/
95
+ * toolExecutions than a V3 finish part does; this function exists only to
96
+ * satisfy the wrapped stream's own contract (a middleware may expect its
97
+ * returned stream to be drained to completion) and to `throw` a middleware
98
+ * `error` part into the consumer.
99
+ */
100
+ export declare function bedrockV3StreamToChunks(stream: ReadableStream<LanguageModelV3StreamPart>): AsyncIterable<AgenticLoopChunk>;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Bridges Bedrock's native `BedrockMessage[]` conversation shape and
3
+ * `AgenticLoopChunk` stream to the AI SDK V3 model-middleware protocol
4
+ * (`LanguageModelV3`), so `BaseProvider.applyMiddlewareToModel()` can wrap a
5
+ * turn built entirely out of `runAgenticLoop` — the same engine Bedrock's
6
+ * `generate()` and `executeStream()` already run on.
7
+ *
8
+ * Scope: only the conversation `generate()`/`executeStream()` hand to the
9
+ * loop as its STARTING point ever crosses this boundary. Both reset
10
+ * `conversationHistory` to a single fresh message before calling the loop
11
+ * (see `client.ts`), so that starting point is always `system + one user
12
+ * message` — never prior assistant/tool-result turns, which are constructed
13
+ * entirely inside the loop's own steps and stay in native
14
+ * `BedrockMessage[]`/`ConverseCommand` shape. A general, lossless
15
+ * `BedrockMessage` <-> `ModelMessage` converter is not attempted here, and
16
+ * would not be possible anyway: `BedrockToolResult` carries no `toolName`,
17
+ * which a faithful `ToolModelMessage` requires.
18
+ *
19
+ * Further scoped to TEXT-ONLY turns. A multimodal starting message (image,
20
+ * document, or — pathologically — a tool-use/tool-result block) skips
21
+ * wrapping entirely; the caller checks `isTextOnlyBedrockConversation` and
22
+ * falls back to calling the loop directly, logging that the turn was
23
+ * excluded. This mirrors the plan's AI Studio precedent: an excluded shape
24
+ * must be explicit in code, not implied by no test ever sending one.
25
+ *
26
+ * Who owns the outcome. When the wrapped `doGenerate`/`doStream` ran the loop,
27
+ * the loop's own result is authoritative for `finishReason`, `usage`,
28
+ * `toolExecutions` and the final conversation: a middleware may replace the
29
+ * content or the stream parts a caller sees, but it cannot rewrite the
30
+ * metadata of a turn that already reached the wire. When a middleware never
31
+ * calls through (a cache hit, a refusal, a synthetic answer) no loop ran, and
32
+ * its own V3 result is what gets reported.
33
+ */
34
+ import { logger } from "../../utils/logger.js";
35
+ /**
36
+ * Sampling and output-shaping options of a V3 call that Converse is never
37
+ * sent: both turn builders fill `inferenceConfig` with `maxTokens` and
38
+ * `temperature` alone. The bridge seeds none of these, so a value present
39
+ * when `doGenerate`/`doStream` runs was put there by a middleware, and would
40
+ * otherwise be dropped without a trace.
41
+ */
42
+ const UNSENT_V3_OPTIONS = [
43
+ "topP",
44
+ "topK",
45
+ "presencePenalty",
46
+ "frequencyPenalty",
47
+ "stopSequences",
48
+ "seed",
49
+ "toolChoice",
50
+ "responseFormat",
51
+ ];
52
+ /** Names of the unsent options `params` carries; an empty list counts as unset. */
53
+ export function unsentBedrockMiddlewareOptions(params) {
54
+ return UNSENT_V3_OPTIONS.filter((key) => {
55
+ const value = params[key];
56
+ return Array.isArray(value) ? value.length > 0 : value !== undefined;
57
+ });
58
+ }
59
+ /**
60
+ * Unwrap Bedrock's `ToolSpecification.inputSchema` — always `{ json:
61
+ * DocumentType }` on the wire (see `ToolInputSchema.JsonMember` in the AWS
62
+ * SDK), never the bare schema — to the plain JSON schema object the V3 tool
63
+ * shape's `inputSchema` field expects. `client.ts`'s own tool-formatting path
64
+ * (`formatToolsForBedrock`) always sends `{ json: schema }` to AWS for this
65
+ * exact reason; handing a middleware the wrapper itself instead of the
66
+ * schema breaks any schema-inspecting middleware (e.g. a guardrail
67
+ * validating tool arguments against the declared shape).
68
+ */
69
+ export function unwrapBedrockToolInputSchema(inputSchema) {
70
+ return inputSchema && "json" in inputSchema ? inputSchema.json : undefined;
71
+ }
72
+ /** True when every content block of every message is plain text. */
73
+ export function isTextOnlyBedrockConversation(conversation) {
74
+ return conversation.every((message) => message.content.every((block) => block.text !== undefined &&
75
+ block.image === undefined &&
76
+ block.document === undefined &&
77
+ block.toolUse === undefined &&
78
+ block.toolResult === undefined));
79
+ }
80
+ /**
81
+ * `system` + the (already text-only) starting conversation, as a V3 prompt.
82
+ * The system prompt is always sent as its own leading message — Bedrock
83
+ * carries it out-of-band (`ConverseCommandInput.system`), but the V3 prompt
84
+ * shape has no separate slot, so `bedrockConversationFromV3Prompt` is the
85
+ * inverse that pulls it back out.
86
+ */
87
+ export function buildV3PromptFromBedrock(systemPrompt, conversation) {
88
+ const prompt = [{ role: "system", content: systemPrompt }];
89
+ for (const message of conversation) {
90
+ const text = message.content.map((block) => block.text ?? "").join("");
91
+ if (message.role === "user") {
92
+ prompt.push({ role: "user", content: text });
93
+ }
94
+ else {
95
+ prompt.push({ role: "assistant", content: text });
96
+ }
97
+ }
98
+ return prompt;
99
+ }
100
+ function contentToText(content) {
101
+ if (typeof content === "string") {
102
+ return content;
103
+ }
104
+ return content
105
+ .filter((part) => typeof part === "object" && part !== null && part.type === "text")
106
+ .map((part) => part.text)
107
+ .join("");
108
+ }
109
+ /**
110
+ * Inverse of `buildV3PromptFromBedrock`, read AFTER `transformParams` has
111
+ * run — this is what makes a middleware's prompt rewrite (correction 1)
112
+ * reach the wire: the caller passes the ORIGINAL prompt into `doGenerate`/
113
+ * `doStream`, and by AI SDK contract `transformParams` has already produced
114
+ * the version handed to the closure by the time this runs.
115
+ *
116
+ * A `tool` role message has no faithful Bedrock shape at this boundary (no
117
+ * toolUseId/toolName pairing survives `ToolModelMessage`), so one is
118
+ * dropped with a WARN rather than guessed at. The starting turn Bedrock
119
+ * ever hands across this boundary never contains one; seeing one here means
120
+ * middleware injected it.
121
+ */
122
+ export function bedrockConversationFromV3Prompt(prompt) {
123
+ const systemParts = [];
124
+ const conversation = [];
125
+ for (const message of prompt) {
126
+ if (message.role === "system") {
127
+ systemParts.push(message.content);
128
+ continue;
129
+ }
130
+ if (message.role === "tool") {
131
+ logger.warn("[AmazonBedrockProvider] middleware transformParams introduced a 'tool' role message; it has no Bedrock wire shape at this boundary and was dropped");
132
+ continue;
133
+ }
134
+ conversation.push({
135
+ role: message.role,
136
+ content: [{ text: contentToText(message.content) }],
137
+ });
138
+ }
139
+ return { system: systemParts.join("\n\n"), conversation };
140
+ }
141
+ /**
142
+ * Wrap a native `AgenticLoopChunk` source into the V3 stream contract, one
143
+ * chunk at a time, forwarding cancellation to both the source iterator and
144
+ * `cancel` (which the caller wires to the turn's own `AbortController`, so
145
+ * breaking out of a wrapped stream tears down the underlying AWS SDK
146
+ * request — correction 4). `finish` resolves the terminal `finish` part
147
+ * once the loop settles; middleware inspecting the returned stream sees a
148
+ * genuine usage-bearing finish part even when nothing downstream reads it
149
+ * back out (a guardrail that blocks before calling `doStream` never
150
+ * produces this stream at all, which is the `!loopPromise` case its own
151
+ * caller handles).
152
+ */
153
+ export function bedrockChunksToV3Stream(source, finish, cancel) {
154
+ const iterator = source[Symbol.asyncIterator]();
155
+ return new ReadableStream({
156
+ async pull(controller) {
157
+ try {
158
+ const next = await iterator.next();
159
+ if (next.done) {
160
+ controller.enqueue(await finish);
161
+ controller.close();
162
+ }
163
+ else if (next.value.reasoning) {
164
+ controller.enqueue({
165
+ type: "reasoning-delta",
166
+ delta: next.value.reasoning,
167
+ });
168
+ }
169
+ else {
170
+ controller.enqueue({ type: "text-delta", delta: next.value.content });
171
+ }
172
+ }
173
+ catch (error) {
174
+ controller.error(error);
175
+ }
176
+ },
177
+ async cancel() {
178
+ cancel();
179
+ await iterator.return?.();
180
+ },
181
+ });
182
+ }
183
+ /**
184
+ * Inverse of `bedrockChunksToV3Stream`: drains a (possibly
185
+ * middleware-wrapped) V3 stream back into Bedrock's native chunk shape.
186
+ * `finish`/`error` parts are consumed here but not surfaced as chunks —
187
+ * `streamingConversationLoop` reads the turn's outcome from the loop's own
188
+ * `resultPromise`, which already carries richer usage/finishReason/
189
+ * toolExecutions than a V3 finish part does; this function exists only to
190
+ * satisfy the wrapped stream's own contract (a middleware may expect its
191
+ * returned stream to be drained to completion) and to `throw` a middleware
192
+ * `error` part into the consumer.
193
+ */
194
+ export async function* bedrockV3StreamToChunks(stream) {
195
+ const reader = stream.getReader();
196
+ let done = false;
197
+ try {
198
+ while (true) {
199
+ const next = await reader.read();
200
+ if (next.done) {
201
+ done = true;
202
+ return;
203
+ }
204
+ const part = next.value;
205
+ if (part.type === "text-delta") {
206
+ yield { content: part.delta };
207
+ }
208
+ else if (part.type === "reasoning-delta") {
209
+ yield { content: "", reasoning: part.delta };
210
+ }
211
+ else if (part.type === "error") {
212
+ throw part.error;
213
+ }
214
+ }
215
+ }
216
+ finally {
217
+ try {
218
+ if (!done) {
219
+ await reader.cancel();
220
+ }
221
+ }
222
+ finally {
223
+ reader.releaseLock();
224
+ }
225
+ }
226
+ }
@@ -271,6 +271,26 @@ export declare class GoogleVertexProvider extends BaseProvider {
271
271
  * this for free via the AI SDK `wrapStream` middleware; native @google/genai
272
272
  * bypasses that wrapper, so native consumers need their lifecycle
273
273
  * callbacks invoked from here.
274
+ *
275
+ * `BaseProvider.stream()` always passes the result this method returns
276
+ * through `wrapStreamWithLifecycleCallbacks` afterwards too (that wrapper
277
+ * has "no early return when there are no callbacks" — see its own doc
278
+ * comment) — so unconditionally firing here double-fires onChunk/onFinish
279
+ * for every native Vertex stream. `isLifecycleStreamCallbacksOwnedByBaseProvider`
280
+ * reads the SAME nested lifecycle config off the SAME `options` object
281
+ * `wrapStreamWithLifecycleCallbacks` reads from (NeuroLink.applyStreamLifecycleMiddleware
282
+ * stamps it before either method runs), so both sites agree on which one
283
+ * actually fires without a second, Vertex-specific marker — and without
284
+ * relying on a Symbol on the `StreamResult` itself, which would not
285
+ * survive `withStreamModelFallback`'s rebuild of a plain forwarding object
286
+ * between this method and `wrapStreamWithLifecycleCallbacks`.
287
+ *
288
+ * onError is deliberately NOT gated by that marker — same precedent as
289
+ * `middleware/builtin/lifecycle.ts`'s `wrapStream` ("onError is NOT
290
+ * covered by this flag: it keeps its own, independent dedup via
291
+ * fireOnErrorOnce's shared fired-marker"). `fireOnErrorOnce` stamps the
292
+ * error so `BaseProvider`'s own catch (which checks
293
+ * `hasLifecycleErrorFired`) skips its own fire for the same failure.
274
294
  */
275
295
  private wrapStreamResultWithLifecycle;
276
296
  /**
@@ -22,6 +22,7 @@ import { FileDetector } from "../../utils/fileDetector.js";
22
22
  import { mergeMediaFileAliases, normalizeVisionImageFormats, processUnifiedFilesArray, } from "../../utils/messageBuilder.js";
23
23
  import { logger } from "../../utils/logger.js";
24
24
  import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
25
+ import { fireOnErrorOnce, getLifecycleMiddlewareConfig, isLifecycleStreamCallbacksOwnedByBaseProvider, } from "../../utils/lifecycleCallbacks.js";
25
26
  import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
26
27
  import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
27
28
  import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, toVertexAnthropicModelId, } from "../../utils/modelDetection.js";
@@ -5618,24 +5619,47 @@ export class GoogleVertexProvider extends BaseProvider {
5618
5619
  * this for free via the AI SDK `wrapStream` middleware; native @google/genai
5619
5620
  * bypasses that wrapper, so native consumers need their lifecycle
5620
5621
  * callbacks invoked from here.
5622
+ *
5623
+ * `BaseProvider.stream()` always passes the result this method returns
5624
+ * through `wrapStreamWithLifecycleCallbacks` afterwards too (that wrapper
5625
+ * has "no early return when there are no callbacks" — see its own doc
5626
+ * comment) — so unconditionally firing here double-fires onChunk/onFinish
5627
+ * for every native Vertex stream. `isLifecycleStreamCallbacksOwnedByBaseProvider`
5628
+ * reads the SAME nested lifecycle config off the SAME `options` object
5629
+ * `wrapStreamWithLifecycleCallbacks` reads from (NeuroLink.applyStreamLifecycleMiddleware
5630
+ * stamps it before either method runs), so both sites agree on which one
5631
+ * actually fires without a second, Vertex-specific marker — and without
5632
+ * relying on a Symbol on the `StreamResult` itself, which would not
5633
+ * survive `withStreamModelFallback`'s rebuild of a plain forwarding object
5634
+ * between this method and `wrapStreamWithLifecycleCallbacks`.
5635
+ *
5636
+ * onError is deliberately NOT gated by that marker — same precedent as
5637
+ * `middleware/builtin/lifecycle.ts`'s `wrapStream` ("onError is NOT
5638
+ * covered by this flag: it keeps its own, independent dedup via
5639
+ * fireOnErrorOnce's shared fired-marker"). `fireOnErrorOnce` stamps the
5640
+ * error so `BaseProvider`'s own catch (which checks
5641
+ * `hasLifecycleErrorFired`) skips its own fire for the same failure.
5621
5642
  */
5622
5643
  wrapStreamResultWithLifecycle(options, result, startTime) {
5623
5644
  const onChunk = options.onChunk;
5624
5645
  const onFinish = options
5625
5646
  .onFinish;
5626
- const onError = options
5627
- .onError;
5647
+ // `StreamOptions.onError` is already properly typed as `OnErrorCallback`
5648
+ // (unlike the two casts above, kept from before that field existed on
5649
+ // the type) — read directly so `fireOnErrorOnce` below gets it without
5650
+ // a cast.
5651
+ const onError = options.onError;
5628
5652
  if (typeof onChunk !== "function" &&
5629
5653
  typeof onFinish !== "function" &&
5630
5654
  typeof onError !== "function") {
5631
5655
  return result;
5632
5656
  }
5657
+ const streamCallbacksOwnedByBaseProvider = isLifecycleStreamCallbacksOwnedByBaseProvider(getLifecycleMiddlewareConfig(options));
5633
5658
  const originalIterable = result.stream;
5634
5659
  let accumulated = "";
5635
5660
  let sequence = 0;
5636
- const provider = this.providerName;
5637
5661
  const fireOnChunk = (payload) => {
5638
- if (typeof onChunk !== "function") {
5662
+ if (typeof onChunk !== "function" || streamCallbacksOwnedByBaseProvider) {
5639
5663
  return;
5640
5664
  }
5641
5665
  try {
@@ -5647,7 +5671,8 @@ export class GoogleVertexProvider extends BaseProvider {
5647
5671
  }
5648
5672
  };
5649
5673
  const fireOnFinish = (payload) => {
5650
- if (typeof onFinish !== "function") {
5674
+ if (typeof onFinish !== "function" ||
5675
+ streamCallbacksOwnedByBaseProvider) {
5651
5676
  return;
5652
5677
  }
5653
5678
  try {
@@ -5686,21 +5711,18 @@ export class GoogleVertexProvider extends BaseProvider {
5686
5711
  });
5687
5712
  }
5688
5713
  catch (err) {
5689
- if (typeof onError === "function") {
5690
- try {
5691
- const errInst = err instanceof Error ? err : new Error(String(err));
5692
- const cbResult = onError({
5693
- error: errInst,
5694
- duration: Date.now() - startTime,
5695
- recoverable: false,
5696
- });
5697
- Promise.resolve(cbResult).catch((e) => logger.warn(`[${provider}] onError callback rejected: ${e instanceof Error ? e.message : String(e)}`));
5698
- }
5699
- catch (e) {
5700
- logger.warn(`[${provider}] onError callback threw: ${e instanceof Error ? e.message : String(e)}`);
5701
- }
5702
- }
5703
- throw err;
5714
+ const errInst = err instanceof Error ? err : new Error(String(err));
5715
+ fireOnErrorOnce(onError, errInst, {
5716
+ error: errInst,
5717
+ duration: Date.now() - startTime,
5718
+ recoverable: false,
5719
+ });
5720
+ // Rethrow the normalized `errInst` (not the raw `err`) so the
5721
+ // fired-mark `fireOnErrorOnce` just stamped on it propagates to
5722
+ // BaseProvider's own catch (`hasLifecycleErrorFired`), which
5723
+ // would otherwise see an unstamped error and fire onError again
5724
+ // for the same logical failure.
5725
+ throw errInst;
5704
5726
  }
5705
5727
  },
5706
5728
  };
@@ -27,7 +27,36 @@
27
27
  * structuredClone / cross-realm postMessage where a closed-over
28
28
  * WeakSet does not.
29
29
  */
30
- import type { LifecycleErrorPayload, OnErrorCallback } from "../types/index.js";
30
+ import type { LifecycleErrorPayload, LifecycleMiddlewareConfig, OnErrorCallback } from "../types/index.js";
31
+ /**
32
+ * Read the consumer-facing lifecycle callbacks buried inside a request's
33
+ * middleware blob. The parameter is `unknown` on purpose: request options
34
+ * arrive as several structurally-unrelated shapes (StreamOptions,
35
+ * TextGenerationOptions), and the lifecycle branch is an optional add-on
36
+ * none of them declare — a single structural view keeps the read cast-free
37
+ * at every call site.
38
+ *
39
+ * Shared (moved out of `BaseProvider`) because two independent firing sites
40
+ * now need the same read: `BaseProvider.wrapStreamWithLifecycleCallbacks`
41
+ * (every provider's stream) and `GoogleVertexProvider.wrapStreamResultWithLifecycle`
42
+ * (Vertex's own native-stream firer, which must check the SAME
43
+ * `isLifecycleStreamCallbacksOwnedByBaseProvider` marker on the SAME nested
44
+ * config object that BaseProvider's wrapper reads from `options`, so that
45
+ * both sites agree on who owns firing without a second marker).
46
+ */
47
+ export declare function getLifecycleMiddlewareConfig(options: unknown): LifecycleMiddlewareConfig | undefined;
48
+ /**
49
+ * Stamp a stream lifecycle config object with
50
+ * `STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER`. See that symbol's doc comment
51
+ * for who may call this (only `applyStreamLifecycleMiddleware`) and why.
52
+ */
53
+ export declare function markLifecycleStreamCallbacksOwnedByBaseProvider(config: object): void;
54
+ /**
55
+ * True when `markLifecycleStreamCallbacksOwnedByBaseProvider` already
56
+ * stamped this config. The model-level lifecycle middleware reads this to
57
+ * decide whether to fire its own onChunk/onFinish for a stream config.
58
+ */
59
+ export declare function isLifecycleStreamCallbacksOwnedByBaseProvider(config: unknown): boolean;
31
60
  /**
32
61
  * Returns true when `markLifecycleErrorFired` or a previous
33
62
  * `fireOnErrorOnce` call has already stamped this error.
@@ -28,6 +28,99 @@
28
28
  * WeakSet does not.
29
29
  */
30
30
  const ON_ERROR_FIRED = Symbol.for("neurolink.onErrorFired");
31
+ /**
32
+ * Read the consumer-facing lifecycle callbacks buried inside a request's
33
+ * middleware blob. The parameter is `unknown` on purpose: request options
34
+ * arrive as several structurally-unrelated shapes (StreamOptions,
35
+ * TextGenerationOptions), and the lifecycle branch is an optional add-on
36
+ * none of them declare — a single structural view keeps the read cast-free
37
+ * at every call site.
38
+ *
39
+ * Shared (moved out of `BaseProvider`) because two independent firing sites
40
+ * now need the same read: `BaseProvider.wrapStreamWithLifecycleCallbacks`
41
+ * (every provider's stream) and `GoogleVertexProvider.wrapStreamResultWithLifecycle`
42
+ * (Vertex's own native-stream firer, which must check the SAME
43
+ * `isLifecycleStreamCallbacksOwnedByBaseProvider` marker on the SAME nested
44
+ * config object that BaseProvider's wrapper reads from `options`, so that
45
+ * both sites agree on who owns firing without a second marker).
46
+ */
47
+ export function getLifecycleMiddlewareConfig(options) {
48
+ return options?.middleware
49
+ ?.middlewareConfig?.lifecycle?.config;
50
+ }
51
+ /**
52
+ * Marks a stream lifecycle config as "BaseProvider.wrapStreamWithLifecycleCallbacks
53
+ * already fires onChunk/onFinish for this config, authoritatively, from
54
+ * NeuroLink's own normalized stream — the model-level lifecycle middleware
55
+ * (middleware/builtin/lifecycle.ts) must not ALSO fire them from the raw
56
+ * per-provider V3 chunks it sees."
57
+ *
58
+ * Only `NeuroLink.applyStreamLifecycleMiddleware()` stamps a config with this
59
+ * marker, and only for stream()'s config: it is the one function that builds
60
+ * a lifecycle config fresh from stream()'s top-level onChunk/onFinish/onError
61
+ * options, for a call that unconditionally passes through
62
+ * `BaseProvider.wrapStreamWithLifecycleCallbacks` afterwards — see that
63
+ * method's doc comment ("the only point every provider's stream passes
64
+ * through unconditionally"). Without this marker both layers fire
65
+ * onChunk/onFinish for the same logical stream: once (unreliable, raw,
66
+ * provider-shape-dependent) from the model-level middleware, once (reliable,
67
+ * normalized) from BaseProvider.
68
+ *
69
+ * `NeuroLink.applyGenerateLifecycleMiddleware()` must NEVER stamp its config
70
+ * this way. BaseProvider has no equivalent guaranteed-delivery layer for
71
+ * generate()'s onFinish: `BaseProvider.finalizeNativeGenerate`'s own doc
72
+ * comment states onFinish is deliberately NOT fired there because "the
73
+ * native paths now wrap their model, so the lifecycle middleware fires it" —
74
+ * the model-level middleware is the SOLE firing site for every native
75
+ * generate loop that goes through the model wrap (sagemaker,
76
+ * openaiChatCompletionsBase, anthropic). The one generate path that bypasses
77
+ * the model wrap (GoogleVertex's native @google/genai loop) fires onFinish
78
+ * itself via `BaseProvider.fireGenerateOnFinish` precisely because the
79
+ * middleware never runs for it. Stamping the generate config would make the
80
+ * model-level middleware suppress its own firing with nothing left to fire
81
+ * it, silently breaking onFinish for every one of those providers.
82
+ *
83
+ * A config a consumer builds directly — e.g. calling the exported
84
+ * `createLifecycleMiddleware` and applying it to their own model via the
85
+ * `ai` package's `wrapLanguageModel`, entirely outside NeuroLink's
86
+ * BaseProvider/stream()/generate() call path — is never stamped, so that
87
+ * standalone use keeps firing onChunk/onFinish exactly as before (CLAUDE.md
88
+ * rule 5: public SDK API must not break existing callers).
89
+ *
90
+ * Symbol-stamped on the object itself (not a typed field) for the same
91
+ * reason as `ON_ERROR_FIRED` above: it survives across separate module
92
+ * copies of the same config object where a closed-over WeakSet would not.
93
+ */
94
+ const STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER = Symbol.for("neurolink.lifecycleStreamCallbacksOwnedByBaseProvider");
95
+ /**
96
+ * Stamp a stream lifecycle config object with
97
+ * `STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER`. See that symbol's doc comment
98
+ * for who may call this (only `applyStreamLifecycleMiddleware`) and why.
99
+ */
100
+ export function markLifecycleStreamCallbacksOwnedByBaseProvider(config) {
101
+ try {
102
+ Object.defineProperty(config, STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER, {
103
+ value: true,
104
+ enumerable: false,
105
+ writable: false,
106
+ configurable: false,
107
+ });
108
+ }
109
+ catch {
110
+ // Non-extensible config object — worst case is the pre-fix double fire.
111
+ }
112
+ }
113
+ /**
114
+ * True when `markLifecycleStreamCallbacksOwnedByBaseProvider` already
115
+ * stamped this config. The model-level lifecycle middleware reads this to
116
+ * decide whether to fire its own onChunk/onFinish for a stream config.
117
+ */
118
+ export function isLifecycleStreamCallbacksOwnedByBaseProvider(config) {
119
+ if (config === null || typeof config !== "object") {
120
+ return false;
121
+ }
122
+ return (config[STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER] === true);
123
+ }
31
124
  function stampFired(error) {
32
125
  try {
33
126
  Object.defineProperty(error, ON_ERROR_FIRED, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.41.3",
3
+ "version": "12.41.5",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {