@juspay/neurolink 12.41.5 → 12.41.7

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.
@@ -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 { releaseIterator } from "../../utils/streamCancellation.js";
25
26
  import { fireOnErrorOnce, getLifecycleMiddlewareConfig, isLifecycleStreamCallbacksOwnedByBaseProvider, } from "../../utils/lifecycleCallbacks.js";
26
27
  import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
27
28
  import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
@@ -426,6 +427,120 @@ const createVertexAnthropicSettings = async (region, timeoutMs, direct, baseURL)
426
427
  const isAnthropicModel = (modelName) => {
427
428
  return modelName.toLowerCase().includes("claude");
428
429
  };
430
+ /**
431
+ * Bridge a native Vertex stream loop's chunk iterable into a V3
432
+ * `ReadableStream`, so `wrapLanguageModel`'s `doStream` can drive it through
433
+ * the middleware chain. Lazy and pull-based — one native chunk in, one V3
434
+ * part out per `pull()` — because the native Anthropic loop
435
+ * (`executeNativeAnthropicStream`) is genuinely live: it returns its
436
+ * `StreamResult` while a background loop is still generating, with
437
+ * `usage`/`finishReason` as getters that settle only once the loop's
438
+ * channel closes. Eagerly draining the source here would silently turn that
439
+ * real-time stream into collect-then-replay. The Gemini3 loop's chunks are
440
+ * already fully resolved by the time this runs, so the same lazy pull is
441
+ * just as correct there — it has nothing left to wait on.
442
+ *
443
+ * Mirrors `chunksToV3Stream` in `openaiChatCompletionsBase.ts`, which is not
444
+ * exported, hence a local copy adapted to the native `{content, reasoning?}`
445
+ * chunk shape instead of `OpenAICompatStreamChunk`.
446
+ *
447
+ * `cancel()` releases the native iterator without awaiting it
448
+ * (`releaseIterator`), and the not-awaiting is what matters. The native
449
+ * `source` is `createStreamChannel`'s single-flight generator: `.return()`
450
+ * called while a `.next()` is in flight is queued behind it and only runs once
451
+ * that pending `.next()` settles (the next upstream frame, or the loop's own
452
+ * turn clock). A `ReadableStream` with the default `highWaterMark` calls
453
+ * `pull()` ahead of demand, so a `.next()` is routinely outstanding when the
454
+ * consumer breaks. Awaiting `iterator.return()` here would hold open
455
+ * `v3StreamToNativeChunks`'s `reader.cancel()`, and with it the `for await`
456
+ * exit inside `BaseProvider`'s lifecycle wrapper, whose `finally` is what runs
457
+ * `teardown()` — the abort of the call's own signal that actually releases the
458
+ * upstream connection. A held-open upstream would then keep the consumer's
459
+ * `break` parked until a frame or the turn clock arrived. Releasing without
460
+ * waiting lets `teardown()` run at once; the queued `.return()` then completes
461
+ * by itself once that abort settles the pending `.next()`.
462
+ */
463
+ const nativeChunksToV3Stream = (source, completion) => {
464
+ const iterator = source[Symbol.asyncIterator]();
465
+ return new ReadableStream({
466
+ async pull(controller) {
467
+ try {
468
+ const next = await iterator.next();
469
+ if (next.done) {
470
+ controller.enqueue(completion());
471
+ controller.close();
472
+ return;
473
+ }
474
+ const chunk = next.value;
475
+ if ("content" in chunk && typeof chunk.content === "string") {
476
+ if ("reasoning" in chunk &&
477
+ typeof chunk.reasoning === "string" &&
478
+ chunk.reasoning.length > 0) {
479
+ controller.enqueue({
480
+ type: "reasoning-delta",
481
+ delta: chunk.reasoning,
482
+ });
483
+ }
484
+ if (chunk.content.length > 0) {
485
+ controller.enqueue({ type: "text-delta", delta: chunk.content });
486
+ }
487
+ }
488
+ }
489
+ catch (error) {
490
+ controller.error(error);
491
+ }
492
+ },
493
+ cancel() {
494
+ releaseIterator(iterator);
495
+ },
496
+ });
497
+ };
498
+ /**
499
+ * Drain a V3 `ReadableStream` (the middleware-wrapped native stream) back
500
+ * into the `{content, reasoning?}` chunk shape `StreamResult.stream`
501
+ * callers already iterate. `onFinish` captures the terminal `finish` part
502
+ * instead of yielding it — native chunk consumers have never seen a
503
+ * finish-shaped chunk and don't expect one.
504
+ *
505
+ * Mirrors `v3StreamToChunks` in `openaiChatCompletionsBase.ts` (also not
506
+ * exported).
507
+ */
508
+ async function* v3StreamToNativeChunks(stream, onFinish) {
509
+ const reader = stream.getReader();
510
+ let done = false;
511
+ try {
512
+ while (true) {
513
+ const next = await reader.read();
514
+ if (next.done) {
515
+ done = true;
516
+ return;
517
+ }
518
+ const part = next.value;
519
+ if (part.type === "text-delta") {
520
+ yield { content: part.delta };
521
+ }
522
+ else if (part.type === "reasoning-delta") {
523
+ yield { content: "", reasoning: part.delta };
524
+ }
525
+ else if (part.type === "finish") {
526
+ onFinish(part);
527
+ }
528
+ else if (part.type === "error") {
529
+ throw part.error;
530
+ }
531
+ }
532
+ }
533
+ finally {
534
+ try {
535
+ if (!done) {
536
+ await reader.cancel();
537
+ }
538
+ }
539
+ finally {
540
+ reader.releaseLock();
541
+ }
542
+ }
543
+ }
429
544
  /**
430
545
  * Google Vertex AI Provider v2 - BaseProvider Implementation
431
546
  *
@@ -934,7 +1049,7 @@ export class GoogleVertexProvider extends BaseProvider {
934
1049
  model: modelName,
935
1050
  totalToolCount: Object.keys(optionTools).length,
936
1051
  });
937
- result = await this.executeNativeAnthropicStream(mergedOptions);
1052
+ result = await this.runNativeStreamWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeAnthropicStream(effectiveOptions));
938
1053
  }
939
1054
  else {
940
1055
  // ALL Gemini models use native @google/genai SDK
@@ -942,7 +1057,7 @@ export class GoogleVertexProvider extends BaseProvider {
942
1057
  model: modelName,
943
1058
  totalToolCount: Object.keys(optionTools).length,
944
1059
  });
945
- result = await this.executeNativeGemini3Stream(mergedOptions);
1060
+ result = await this.runNativeStreamWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeGemini3Stream(effectiveOptions));
946
1061
  }
947
1062
  // Cost / token usage on the stream span. Native streams resolve
948
1063
  // usage synchronously (the stream loop has already drained), so
@@ -2012,6 +2127,44 @@ export class GoogleVertexProvider extends BaseProvider {
2012
2127
  }
2013
2128
  return result;
2014
2129
  }
2130
+ /**
2131
+ * Resolve the text a native Vertex *generate* call actually sends, for
2132
+ * both `executeNativeGemini3Generate` and `executeNativeAnthropicGenerate`.
2133
+ *
2134
+ * The baseline here is the same `input.text || prompt || "Please
2135
+ * respond."` chain both call sites have always used — `input.text` wins
2136
+ * when set because the file preprocessors (`processUnifiedFilesArray`,
2137
+ * `processCSVFilesForNativeSDK`) append attached-file content to it while
2138
+ * `options.prompt` stays the pre-attachment snapshot; `prompt` is the
2139
+ * fallback for the legacy prompt-only `generateText()` convention, which
2140
+ * never sets `input` at all; the literal is the last resort when neither
2141
+ * is set.
2142
+ *
2143
+ * That `||` chain alone has a gap model middleware falls into:
2144
+ * `applyTurnParamsRewrite` writes every rewritten turn back onto
2145
+ * `options.input.text`, including an *emptied* one (a redaction). `||`
2146
+ * treats that empty string exactly like an unset `input.text` and falls
2147
+ * through to `options.prompt` — which middleware never touched, since it
2148
+ * is a plain snapshot taken before middleware ran — so a redaction a
2149
+ * middleware applied is silently discarded and the ORIGINAL text still
2150
+ * reaches Vertex.
2151
+ *
2152
+ * The fix is narrow: when `input.text` is *set* (not undefined) and
2153
+ * differs from `prompt`, something deliberately changed it since the
2154
+ * snapshot was taken — a middleware rewrite, or a file preprocessor's
2155
+ * attachment-content append — and that divergence itself is the signal to
2156
+ * trust `input.text` outright, empty string included. When the two agree
2157
+ * (the untouched, no-middleware case, including the STT-silence
2158
+ * `input.text === prompt === ""` placeholder) there is no divergence to
2159
+ * trust, so the original fallback chain still applies unchanged.
2160
+ */
2161
+ resolveNativeGenerateText(options) {
2162
+ if (options.input?.text !== undefined &&
2163
+ options.input.text !== options.prompt) {
2164
+ return options.input.text;
2165
+ }
2166
+ return options.input?.text || options.prompt || "Please respond.";
2167
+ }
2015
2168
  /**
2016
2169
  * Execute generate using native @google/genai SDK for Gemini 3 models on Vertex AI
2017
2170
  * This bypasses @ai-sdk/google-vertex to properly handle thought_signature
@@ -2027,7 +2180,8 @@ export class GoogleVertexProvider extends BaseProvider {
2027
2180
  });
2028
2181
  // Build contents from input with multimodal support
2029
2182
  // Prioritize input.text over prompt since processCSVFilesForNativeSDK modifies input.text with CSV data
2030
- const inputText = options.input?.text || options.prompt || "Please respond.";
2183
+ // (see resolveNativeGenerateText for the full precedence + middleware-redaction rationale)
2184
+ const inputText = this.resolveNativeGenerateText(options);
2031
2185
  const contents = [];
2032
2186
  // Build user message parts - start with text
2033
2187
  const userParts = [{ text: inputText }];
@@ -4310,8 +4464,9 @@ export class GoogleVertexProvider extends BaseProvider {
4310
4464
  // attached file was silently dropped on the Claude generate path — the
4311
4465
  // model answered "no file attached" to a request that carried one. The
4312
4466
  // native Gemini path already reads input.text first for exactly this
4313
- // reason.
4314
- const inputText = options.input?.text || options.prompt || "Please respond.";
4467
+ // reason. (See resolveNativeGenerateText for the full precedence +
4468
+ // middleware-redaction rationale.)
4469
+ const inputText = this.resolveNativeGenerateText(options);
4315
4470
  // Replay conversationMessages (with tool turns), else the legacy text-only conversationHistory.
4316
4471
  if (options.conversationMessages &&
4317
4472
  options.conversationMessages.length > 0) {
@@ -5513,57 +5668,14 @@ export class GoogleVertexProvider extends BaseProvider {
5513
5668
  model: modelName,
5514
5669
  totalToolCount: Object.keys(mergedOptions.tools).length,
5515
5670
  });
5516
- nativeResult =
5517
- await this.executeNativeAnthropicGenerate(mergedOptions);
5671
+ nativeResult = await this.runNativeGenerateWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeAnthropicGenerate(effectiveOptions));
5518
5672
  }
5519
5673
  else {
5520
5674
  logger.info("[GoogleVertex] Routing Gemini generate to native @google/genai", {
5521
5675
  model: modelName,
5522
5676
  totalToolCount: Object.keys(mergedOptions.tools).length,
5523
5677
  });
5524
- try {
5525
- nativeResult =
5526
- await this.executeNativeGemini3Generate(mergedOptions);
5527
- }
5528
- catch (nativeError) {
5529
- // Vertex rejects over-constrained responseSchemas with a
5530
- // deterministic 400 ("too many states" — constrained-decoding
5531
- // state explosion). Re-sending the same schema can never
5532
- // succeed, so retry ONCE with the schema dropped but JSON
5533
- // mode kept: output.format "json" still sets
5534
- // responseMimeType application/json on the no-tools path,
5535
- // so the model is forced to emit JSON — just without the
5536
- // offending schema constraints. The NeuroLink layer then
5537
- // coerces the JSON text into the caller's original schema
5538
- // (generate({schema}) guarantee holds).
5539
- const requestedStructured = mergedOptions.schema !== undefined ||
5540
- mergedOptions.output?.format === "json";
5541
- // Tools present → the executor skips responseMimeType, so a
5542
- // schema-less retry would NOT actually run in JSON mode and
5543
- // the structured-output contract would silently degrade to
5544
- // prose. Only the no-tools case retries faithfully; with
5545
- // tools, surface the 400 to the caller instead.
5546
- const hasTools = !mergedOptions.disableTools &&
5547
- Object.keys(mergedOptions.tools ?? {}).length > 0;
5548
- if (requestedStructured &&
5549
- !hasTools &&
5550
- isSchemaComplexityError(nativeError)) {
5551
- logger.warn("[GoogleVertex] responseSchema too complex for constrained decoding — retrying native generate in schema-less JSON mode", {
5552
- model: modelName,
5553
- error: nativeError instanceof Error
5554
- ? nativeError.message
5555
- : String(nativeError),
5556
- });
5557
- nativeResult = await this.executeNativeGemini3Generate({
5558
- ...mergedOptions,
5559
- schema: undefined,
5560
- output: { format: "json" },
5561
- });
5562
- }
5563
- else {
5564
- throw nativeError;
5565
- }
5566
- }
5678
+ nativeResult = await this.runNativeGenerateWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeGemini3GenerateWithSchemaFallback(effectiveOptions, modelName));
5567
5679
  }
5568
5680
  executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(nativeResult?.content ?? ""));
5569
5681
  return nativeResult;
@@ -5731,15 +5843,484 @@ export class GoogleVertexProvider extends BaseProvider {
5731
5843
  stream: wrappedIterable,
5732
5844
  });
5733
5845
  }
5846
+ /**
5847
+ * The text a native Vertex turn is built from before any model middleware
5848
+ * rewrite — `options.input.text` when the caller set it (the normal
5849
+ * `generate()`/`stream()` convention), else `options.prompt` (the legacy
5850
+ * prompt-only `generateText()` convention, where `input` is never
5851
+ * populated — see `generateTextInInstanceScope` in `src/lib/neurolink.ts`,
5852
+ * which validates `options.prompt` and never touches `options.input`).
5853
+ *
5854
+ * `buildTurnPromptForMiddleware` and `applyTurnParamsRewrite` both read
5855
+ * this so they agree on one baseline: the first so a prompt-only caller
5856
+ * shows middleware its real text instead of an empty turn, the second so
5857
+ * a middleware-rewritten turn is compared against what middleware actually
5858
+ * started from — not a baseline that ignores `prompt` entirely, which
5859
+ * would make a prompt-only call look "unedited" no matter what middleware
5860
+ * did to it. `StreamOptions` carries no top-level `prompt` field, hence
5861
+ * the `"prompt" in options` guard instead of a cast.
5862
+ */
5863
+ vertexTurnBaselineText(options) {
5864
+ if (options.input?.text !== undefined) {
5865
+ return options.input.text;
5866
+ }
5867
+ return "prompt" in options && typeof options.prompt === "string"
5868
+ ? options.prompt
5869
+ : "";
5870
+ }
5871
+ /**
5872
+ * Build the V3 entry-turn prompt middleware sees for a native call.
5873
+ * Native Vertex loops build their own multi-turn conversation internally
5874
+ * once they run — this is only the caller's system prompt + text input,
5875
+ * the one turn middleware is given visibility into before that loop
5876
+ * starts.
5877
+ *
5878
+ * The user turn is an array of text parts rather than a bare string. That
5879
+ * is the V3 prompt shape, and prompt-editing middleware is written against
5880
+ * it: one that spreads `content` into a longer array turns a string into
5881
+ * one element per character, and one that calls `.map` / `.filter` on it
5882
+ * throws. The AI Studio path already hands its middleware parts
5883
+ * (`geminiContentsToV3Prompt`); this makes Vertex agree with it.
5884
+ *
5885
+ * The text itself comes from `vertexTurnBaselineText`, not
5886
+ * `options.input?.text` alone — a prompt-only `generateText()` caller
5887
+ * never sets `input`, and without the `options.prompt` fallback middleware
5888
+ * would be shown an empty turn and have nothing of the caller's to inspect
5889
+ * or build on.
5890
+ */
5891
+ buildTurnPromptForMiddleware(options) {
5892
+ const prompt = [];
5893
+ if (options.systemPrompt) {
5894
+ prompt.push({ role: "system", content: options.systemPrompt });
5895
+ }
5896
+ prompt.push({
5897
+ role: "user",
5898
+ content: [{ type: "text", text: this.vertexTurnBaselineText(options) }],
5899
+ });
5900
+ return prompt;
5901
+ }
5902
+ /** Sampling params a native call already honors, carried onto the V3 view. */
5903
+ buildTurnV3Params(options, prompt) {
5904
+ return {
5905
+ prompt,
5906
+ ...(options.maxTokens !== undefined
5907
+ ? { maxOutputTokens: options.maxTokens }
5908
+ : {}),
5909
+ ...(options.temperature !== undefined
5910
+ ? { temperature: options.temperature }
5911
+ : {}),
5912
+ ...(options.topP !== undefined ? { topP: options.topP } : {}),
5913
+ ...(options.abortSignal !== undefined
5914
+ ? { abortSignal: options.abortSignal }
5915
+ : {}),
5916
+ };
5917
+ }
5918
+ /**
5919
+ * Read a middleware-transformed prompt back into the two things a native
5920
+ * Vertex turn can carry: one system instruction and one user text.
5921
+ *
5922
+ * Every system message counts (joined with a blank line, the way
5923
+ * `v3PromptToGeminiContents` joins them for AI Studio) and so does every
5924
+ * user message, not just the last. A middleware that appends or prepends
5925
+ * context leaves the caller's own text in the prompt beside it; reading
5926
+ * only the last user message would send the addition without the question
5927
+ * it was meant to qualify, or drop it and send the question bare. Text
5928
+ * blocks — across messages and across the parts of one message — are joined
5929
+ * with a blank line for the same reason: they are separate blocks, and
5930
+ * gluing them runs the last word of one into the first of the next.
5931
+ *
5932
+ * `unmapped` counts what a native turn has no place for — non-text user
5933
+ * parts (files, images) and assistant or tool messages — so the caller can
5934
+ * say it was dropped rather than drop it silently.
5935
+ */
5936
+ readTurnFromPrompt(prompt) {
5937
+ const systemTexts = prompt.flatMap((message) => message.role === "system" ? [message.content] : []);
5938
+ const userContents = prompt.flatMap((message) => message.role === "user" ? [message.content] : []);
5939
+ const userTexts = userContents
5940
+ .flatMap((content) => typeof content === "string"
5941
+ ? [content]
5942
+ : content.flatMap((part) => part.type === "text" ? [part.text] : []))
5943
+ .filter((text) => text.length > 0);
5944
+ const unmappedParts = userContents.reduce((count, content) => typeof content === "string"
5945
+ ? count
5946
+ : count + content.filter((part) => part.type !== "text").length, 0);
5947
+ const unmappedMessages = prompt.filter((message) => message.role !== "system" && message.role !== "user").length;
5948
+ return {
5949
+ systemPrompt: systemTexts.length > 0 ? systemTexts.join("\n\n") : undefined,
5950
+ text: userTexts.join("\n\n"),
5951
+ unmapped: unmappedParts + unmappedMessages,
5952
+ };
5953
+ }
5954
+ /**
5955
+ * Apply a middleware-transformed turn back onto the native call options.
5956
+ *
5957
+ * The system instruction and the user text are each written back only when
5958
+ * they differ from what middleware actually started from — `systemPrompt`
5959
+ * against `options.systemPrompt`, user text against
5960
+ * `vertexTurnBaselineText(options)`, not `options.input?.text` alone — so a
5961
+ * middleware that touches neither leaves both exactly as they were. An
5962
+ * edit is honoured in every direction — a changed, added or removed system
5963
+ * message, an appended or prepended user message, a user message emptied
5964
+ * outright (a redaction) — because every native generate/stream executor
5965
+ * resolves its wire text through `options.input.text` once this write-back
5966
+ * has run (`resolveNativeGenerateText` on the generate paths, which
5967
+ * special-cases exactly this divergence so an emptied `input.text` is not
5968
+ * mistaken for an unset one and does not fall back to the stale
5969
+ * `options.prompt` snapshot; a direct `options.input.text` read on the
5970
+ * stream paths, which have no such fallback to fall into). Comparing
5971
+ * against `options.input?.text` alone — ignoring `options.prompt` — would
5972
+ * make a prompt-only `generateText()` caller's untouched turn look
5973
+ * "changed" the first time middleware ran (nothing to compare `turn.text`
5974
+ * against but `""`), writing the real prompt into `input.text` as a
5975
+ * spurious edit on every such call even when no middleware did anything.
5976
+ *
5977
+ * Sampling params are written back the same way and can be removed as well
5978
+ * as changed: a middleware that drops `temperature` or `topP` from the
5979
+ * params must reach the wire as "not sent", not as the caller's original
5980
+ * value kept because the key went missing.
5981
+ *
5982
+ * `tools` is offered to middleware read-only — a middleware that rewrites
5983
+ * it gets a WARN rather than a silent drop, matching the OpenAI-compatible
5984
+ * streaming path's contract: native tool wiring is derived independently
5985
+ * of this V3 view, and re-deriving it here would diverge from it.
5986
+ */
5987
+ applyTurnParamsRewrite(options, params) {
5988
+ if (params.tools !== undefined) {
5989
+ logger.warn("[GoogleVertex] middleware rewrote 'tools' on the native path; tool rewrites are not applied to the native request — the original tool list was sent.");
5990
+ }
5991
+ const turn = this.readTurnFromPrompt(params.prompt);
5992
+ if (turn.unmapped > 0) {
5993
+ logger.warn(`[GoogleVertex] middleware added ${turn.unmapped} prompt item(s) the native request cannot carry (non-text user parts, assistant or tool messages); only system and user text edits are applied — those items were not sent.`);
5994
+ }
5995
+ const maxTokens = typeof params.maxOutputTokens === "number"
5996
+ ? params.maxOutputTokens
5997
+ : undefined;
5998
+ const temperature = typeof params.temperature === "number" ? params.temperature : undefined;
5999
+ const topP = typeof params.topP === "number" ? params.topP : undefined;
6000
+ return {
6001
+ ...options,
6002
+ ...(turn.systemPrompt !== (options.systemPrompt || undefined)
6003
+ ? { systemPrompt: turn.systemPrompt }
6004
+ : {}),
6005
+ ...(turn.text !== this.vertexTurnBaselineText(options)
6006
+ ? { input: { ...(options.input ?? {}), text: turn.text } }
6007
+ : {}),
6008
+ ...(maxTokens !== options.maxTokens ? { maxTokens } : {}),
6009
+ ...(temperature !== options.temperature ? { temperature } : {}),
6010
+ ...(topP !== options.topP ? { topP } : {}),
6011
+ };
6012
+ }
6013
+ /**
6014
+ * Strip the lifecycle middleware from the options handed to
6015
+ * `applyMiddlewareToModel` for a native Vertex call, when something else is
6016
+ * already firing the same callbacks.
6017
+ *
6018
+ * NeuroLink's `applyGenerateLifecycleMiddleware` /
6019
+ * `applyStreamLifecycleMiddleware` (neurolink.ts) mirror a caller's
6020
+ * top-level `onFinish` / `onError` / `onChunk` into
6021
+ * `options.middleware.middlewareConfig.lifecycle.config` — the exact same
6022
+ * callback references — before this provider sees `options`. Left enabled,
6023
+ * the built-in lifecycle middleware would fire them again from inside the
6024
+ * bridge, and with malformed payloads: it reads the legacy `textDelta` chunk
6025
+ * field and a bare `finishReason`, neither of which a V3 model produces.
6026
+ *
6027
+ * Who else fires them differs by path, so the exclusion does too:
6028
+ *
6029
+ * - generate: only `fireGenerateOnFinish` / `fireGenerateOnError`, which read
6030
+ * the TOP-LEVEL callbacks. The entry is stripped only when one is present.
6031
+ * A caller who configures `middlewareConfig.lifecycle` directly has no
6032
+ * other firer here, so that middleware applies exactly once, unmodified.
6033
+ * - stream: `BaseProvider.stream()` wraps every stream in
6034
+ * `wrapStreamWithLifecycleCallbacks`, which fires whatever sits in
6035
+ * `lifecycle.config` however it got there — top-level shorthand or direct
6036
+ * config. The entry is stripped whenever it exists, or a direct-config
6037
+ * caller would get every callback twice (and `onChunk` a third time, for
6038
+ * the `finish` part the middleware also reports).
6039
+ *
6040
+ * Only the `lifecycle` entry is disabled, so every other configured
6041
+ * middleware (guardrails, transformParams, custom hooks, analytics, ...)
6042
+ * applies normally.
6043
+ */
6044
+ excludeAutoLifecycleMiddleware(options, path) {
6045
+ const opts = options;
6046
+ const firedElsewhere = path === "stream" || !!(opts.onFinish || opts.onError || opts.onChunk);
6047
+ if (!firedElsewhere) {
6048
+ return options;
6049
+ }
6050
+ const lifecycle = opts.middleware?.middlewareConfig?.lifecycle;
6051
+ if (!lifecycle) {
6052
+ return options;
6053
+ }
6054
+ return {
6055
+ ...options,
6056
+ middleware: {
6057
+ ...opts.middleware,
6058
+ middlewareConfig: {
6059
+ ...opts.middleware?.middlewareConfig,
6060
+ lifecycle: { ...lifecycle, enabled: false },
6061
+ },
6062
+ },
6063
+ };
6064
+ }
6065
+ /**
6066
+ * Run the native Gemini generate loop and, when Vertex rejects an
6067
+ * over-constrained `responseSchema`, retry once without it.
6068
+ *
6069
+ * The retry sits inside the function `runNativeGenerateWithMiddleware` hands
6070
+ * to the model bridge rather than around the bridge, because it is
6071
+ * provider-internal recovery: model middleware must see one `doGenerate`
6072
+ * that succeeds. Retrying outside would show every middleware the failed
6073
+ * first call — the lifecycle middleware would report an `onError` for a
6074
+ * request that then succeeded, and `transformParams` would run twice.
6075
+ */
6076
+ async executeNativeGemini3GenerateWithSchemaFallback(options, modelName) {
6077
+ try {
6078
+ return await this.executeNativeGemini3Generate(options);
6079
+ }
6080
+ catch (nativeError) {
6081
+ // Vertex rejects over-constrained responseSchemas with a
6082
+ // deterministic 400 ("too many states" — constrained-decoding
6083
+ // state explosion). Re-sending the same schema can never
6084
+ // succeed, so retry ONCE with the schema dropped but JSON
6085
+ // mode kept: output.format "json" still sets
6086
+ // responseMimeType application/json on the no-tools path,
6087
+ // so the model is forced to emit JSON — just without the
6088
+ // offending schema constraints. The NeuroLink layer then
6089
+ // coerces the JSON text into the caller's original schema
6090
+ // (generate({schema}) guarantee holds).
6091
+ const requestedStructured = options.schema !== undefined || options.output?.format === "json";
6092
+ // Tools present → the executor skips responseMimeType, so a
6093
+ // schema-less retry would NOT actually run in JSON mode and
6094
+ // the structured-output contract would silently degrade to
6095
+ // prose. Only the no-tools case retries faithfully; with
6096
+ // tools, surface the 400 to the caller instead.
6097
+ const hasTools = !options.disableTools && Object.keys(options.tools ?? {}).length > 0;
6098
+ if (requestedStructured &&
6099
+ !hasTools &&
6100
+ isSchemaComplexityError(nativeError)) {
6101
+ logger.warn("[GoogleVertex] responseSchema too complex for constrained decoding — retrying native generate in schema-less JSON mode", {
6102
+ model: modelName,
6103
+ error: nativeError instanceof Error
6104
+ ? nativeError.message
6105
+ : String(nativeError),
6106
+ });
6107
+ return await this.executeNativeGemini3Generate({
6108
+ ...options,
6109
+ schema: undefined,
6110
+ output: { format: "json" },
6111
+ });
6112
+ }
6113
+ throw nativeError;
6114
+ }
6115
+ }
6116
+ /**
6117
+ * Apply the configured middleware chain around a native Vertex generate
6118
+ * loop. `callNative` is `executeNativeAnthropicGenerate` or
6119
+ * `executeNativeGemini3Generate` — a self-contained black box that runs
6120
+ * its own multi-step tool loop and returns a fully-resolved
6121
+ * `EnhancedGenerateResult`. Wrapping happens around that call, not inside
6122
+ * it: `transformParams` can rewrite the entry turn before the loop starts,
6123
+ * and `wrapGenerate` can observe the result or replace it outright (a
6124
+ * guardrail that blocks the call entirely never reaches `callNative`, so
6125
+ * `nativeResult` stays unset and a settled result is synthesized purely
6126
+ * from what middleware produced, then run through `enhanceResult` like any
6127
+ * other result so `enableAnalytics` / `enableEvaluation` still apply).
6128
+ */
6129
+ async runNativeGenerateWithMiddleware(options, modelName, callNative) {
6130
+ const startedAt = Date.now();
6131
+ const originalPrompt = this.buildTurnPromptForMiddleware(options);
6132
+ const providerName = this.providerName;
6133
+ let nativeResult;
6134
+ const baseModel = {
6135
+ specificationVersion: "v3",
6136
+ provider: providerName,
6137
+ modelId: modelName,
6138
+ supportedUrls: {},
6139
+ doGenerate: async (params) => {
6140
+ const effectiveOptions = this.applyTurnParamsRewrite(options, params);
6141
+ const result = await callNative(effectiveOptions);
6142
+ nativeResult = result;
6143
+ return {
6144
+ content: [{ type: "text", text: result.content }],
6145
+ finishReason: { unified: result.finishReason ?? "stop" },
6146
+ usage: {
6147
+ inputTokens: { total: result.usage?.input ?? 0 },
6148
+ outputTokens: { total: result.usage?.output ?? 0 },
6149
+ },
6150
+ };
6151
+ },
6152
+ doStream: () => {
6153
+ throw new Error("[GoogleVertex] doStream is not implemented on the native generate middleware model — this path only drives doGenerate.");
6154
+ },
6155
+ };
6156
+ const modelResult = await this.applyMiddlewareToModel(baseModel, this.excludeAutoLifecycleMiddleware(options, "generate"));
6157
+ if (typeof modelResult === "string") {
6158
+ throw new Error("[GoogleVertex] Native generate model handle required for middleware");
6159
+ }
6160
+ const wrappedModel = modelResult;
6161
+ const v3Params = this.buildTurnV3Params(options, originalPrompt);
6162
+ const v3Result = await wrappedModel.doGenerate(v3Params);
6163
+ const v3Text = v3Result.content
6164
+ .map((part) => (part.type === "text" ? part.text : ""))
6165
+ .join("");
6166
+ if (nativeResult) {
6167
+ // Reference/value equality against what the native call itself
6168
+ // returned is the "nothing changed" signal — return the native result
6169
+ // untouched (structuredData, toolCalls, analytics and all) rather than
6170
+ // a reconstruction that would drop fields this function doesn't know
6171
+ // about. Only a middleware that actually rewrote the text pays for a
6172
+ // merged object.
6173
+ return v3Text === nativeResult.content
6174
+ ? nativeResult
6175
+ : { ...nativeResult, content: v3Text };
6176
+ }
6177
+ // Guardrails (or any middleware) blocked the call before `doGenerate`
6178
+ // ran — synthesize a settled result purely from the V3 response
6179
+ // middleware produced, the same "no native call, no content" contract
6180
+ // the OpenAI-compatible path's blocking case already proves out. The
6181
+ // turn bookkeeping a real loop would have filled in takes short-circuit
6182
+ // defaults, and `enhanceResult` runs on it as on every unblocked result:
6183
+ // without it a blocked call silently loses `analytics` / `evaluation`
6184
+ // that the same call would have carried had the guardrail let it through.
6185
+ return this.enhanceResult({
6186
+ content: v3Text,
6187
+ provider: providerName,
6188
+ model: modelName,
6189
+ finishReason: v3Result.finishReason.unified,
6190
+ usage: {
6191
+ input: v3Result.usage.inputTokens.total ?? 0,
6192
+ output: v3Result.usage.outputTokens.total ?? 0,
6193
+ total: (v3Result.usage.inputTokens.total ?? 0) +
6194
+ (v3Result.usage.outputTokens.total ?? 0),
6195
+ },
6196
+ toolCalls: [],
6197
+ stopReason: resolveTurnStopReason({
6198
+ timedOut: false,
6199
+ stalled: false,
6200
+ wasAborted: false,
6201
+ cappedWithoutAnswer: false,
6202
+ finishReason: v3Result.finishReason.unified,
6203
+ }),
6204
+ stepsUsed: 0,
6205
+ responseTime: Date.now() - startedAt,
6206
+ toolsUsed: [],
6207
+ toolExecutions: [],
6208
+ enhancedWithTools: false,
6209
+ }, options, startedAt);
6210
+ }
6211
+ /**
6212
+ * Apply the configured middleware chain around a native Vertex stream
6213
+ * loop. `callNative` is `executeNativeAnthropicStream` or
6214
+ * `executeNativeGemini3Stream`. Both are bridged through the same lazy V3
6215
+ * `ReadableStream` (`nativeChunksToV3Stream` / `v3StreamToNativeChunks`)
6216
+ * regardless of whether the underlying loop is genuinely live (Anthropic)
6217
+ * or already fully resolved (Gemini3) by the time it returns — pulling one
6218
+ * item at a time is correct for both, and never turns a live stream into
6219
+ * collect-then-replay.
6220
+ *
6221
+ * A guardrail that blocks the call never invokes `doStream`, so
6222
+ * `nativeResult` stays unset; a `StreamResult` is synthesized instead,
6223
+ * with `usage` / `finishReason` resolving once the merged stream is fully
6224
+ * drained (the same late-resolving-getter contract
6225
+ * `preserveStreamResultAccessors` already exists for).
6226
+ */
6227
+ async runNativeStreamWithMiddleware(options, modelName, callNative) {
6228
+ const originalPrompt = this.buildTurnPromptForMiddleware(options);
6229
+ const providerName = this.providerName;
6230
+ let nativeResult;
6231
+ const baseModel = {
6232
+ specificationVersion: "v3",
6233
+ provider: providerName,
6234
+ modelId: modelName,
6235
+ supportedUrls: {},
6236
+ doGenerate: () => {
6237
+ throw new Error("[GoogleVertex] doGenerate is not implemented on the native stream middleware model — this path only drives doStream.");
6238
+ },
6239
+ doStream: async (params) => {
6240
+ const effectiveOptions = this.applyTurnParamsRewrite(options, params);
6241
+ const result = await callNative(effectiveOptions);
6242
+ nativeResult = result;
6243
+ return {
6244
+ stream: nativeChunksToV3Stream(result.stream, () => ({
6245
+ type: "finish",
6246
+ finishReason: { unified: result.finishReason ?? "stop" },
6247
+ usage: {
6248
+ inputTokens: { total: result.usage?.input ?? 0 },
6249
+ outputTokens: { total: result.usage?.output ?? 0 },
6250
+ },
6251
+ })),
6252
+ };
6253
+ },
6254
+ };
6255
+ const modelResult = await this.applyMiddlewareToModel(baseModel, this.excludeAutoLifecycleMiddleware(options, "stream"));
6256
+ if (typeof modelResult === "string") {
6257
+ throw new Error("[GoogleVertex] Native stream model handle required for middleware");
6258
+ }
6259
+ const wrappedModel = modelResult;
6260
+ const v3Params = this.buildTurnV3Params(options, originalPrompt);
6261
+ const { stream } = await wrappedModel.doStream(v3Params);
6262
+ let finishPart;
6263
+ const mergedStream = v3StreamToNativeChunks(stream, (part) => {
6264
+ finishPart = part;
6265
+ });
6266
+ if (nativeResult) {
6267
+ const settled = nativeResult;
6268
+ return this.preserveStreamResultAccessors(settled, {
6269
+ ...settled,
6270
+ stream: mergedStream,
6271
+ });
6272
+ }
6273
+ // Guardrails (or any middleware) blocked the call before `doStream`
6274
+ // ran — synthesize a settled StreamResult purely from the V3 stream
6275
+ // middleware produced. `usage` / `finishReason` are getters so a
6276
+ // consumer who reads them before draining `stream` doesn't just see a
6277
+ // permanent snapshot of "nothing yet" — same contract the native
6278
+ // Anthropic path's own getters already rely on.
6279
+ const blockedResult = {
6280
+ stream: mergedStream,
6281
+ provider: providerName,
6282
+ model: modelName,
6283
+ };
6284
+ Object.defineProperty(blockedResult, "usage", {
6285
+ enumerable: true,
6286
+ configurable: true,
6287
+ get: () => finishPart
6288
+ ? {
6289
+ input: finishPart.usage.inputTokens.total ?? 0,
6290
+ output: finishPart.usage.outputTokens.total ?? 0,
6291
+ total: (finishPart.usage.inputTokens.total ?? 0) +
6292
+ (finishPart.usage.outputTokens.total ?? 0),
6293
+ }
6294
+ : undefined,
6295
+ });
6296
+ Object.defineProperty(blockedResult, "finishReason", {
6297
+ enumerable: true,
6298
+ configurable: true,
6299
+ get: () => finishPart?.finishReason.unified,
6300
+ });
6301
+ return blockedResult;
6302
+ }
5734
6303
  /**
5735
6304
  * Re-apply getter-based accessor properties from a source StreamResult onto
5736
6305
  * a wrapper copy. Wrapper spreads (`{ ...result }`) invoke and SNAPSHOT
5737
6306
  * enumerable getters at wrap time — for background-loop streams (the native
5738
6307
  * Anthropic path) that resolve finishReason / structuredOutput / toolCalls
5739
- * only as the consumer drains, the snapshot is permanently undefined/empty.
5740
- * Copying the accessor descriptors keeps the wrapped result live. Results
5741
- * built from plain data properties (the buffered Gemini paths) have no
5742
- * getters and pass through untouched.
6308
+ * / usage only as the consumer drains, the snapshot is permanently
6309
+ * undefined/empty. Copying the accessor descriptors keeps the wrapped
6310
+ * result live. Results built from plain data properties (the buffered
6311
+ * Gemini paths, and the native Anthropic stream's own `usage` — a mutable
6312
+ * object reference rather than a getter, so a spread keeps its identity
6313
+ * and later mutations stay visible) have no matching getter here and pass
6314
+ * through untouched.
6315
+ *
6316
+ * The re-applied property also accepts a direct write: NeuroLink.stream()
6317
+ * backstops a missing `usage` from analytics once the outer stream drains
6318
+ * by assigning `mcpStreamOutcome.usage = resolvedUsage` on whatever result
6319
+ * object it was handed. Copying the bare getter-only descriptor verbatim
6320
+ * would turn that assignment into a TypeError ("Cannot set property usage
6321
+ * of #<Object> which has only a getter"), so each re-applied accessor is
6322
+ * rebuilt with a setter that remembers an override and prefers it over the
6323
+ * live source getter once one has been written.
5743
6324
  */
5744
6325
  preserveStreamResultAccessors(source, wrapped) {
5745
6326
  for (const key of [
@@ -5748,11 +6329,24 @@ export class GoogleVertexProvider extends BaseProvider {
5748
6329
  "toolCalls",
5749
6330
  "toolsUsed",
5750
6331
  "toolExecutions",
6332
+ "usage",
5751
6333
  ]) {
5752
6334
  const descriptor = Object.getOwnPropertyDescriptor(source, key);
5753
- if (descriptor?.get) {
5754
- Object.defineProperty(wrapped, key, descriptor);
6335
+ const liveGetter = descriptor?.get;
6336
+ if (!liveGetter) {
6337
+ continue;
5755
6338
  }
6339
+ let overridden = false;
6340
+ let overrideValue;
6341
+ Object.defineProperty(wrapped, key, {
6342
+ configurable: true,
6343
+ enumerable: descriptor.enumerable ?? true,
6344
+ get: () => (overridden ? overrideValue : liveGetter.call(source)),
6345
+ set: (value) => {
6346
+ overridden = true;
6347
+ overrideValue = value;
6348
+ },
6349
+ });
5756
6350
  }
5757
6351
  return wrapped;
5758
6352
  }