@juspay/neurolink 9.80.2 → 9.80.4

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +355 -351
  3. package/dist/context/stages/structuredSummarizer.js +15 -4
  4. package/dist/core/modules/GenerationHandler.js +28 -10
  5. package/dist/core/modules/structuredOutputPolicy.d.ts +19 -0
  6. package/dist/core/modules/structuredOutputPolicy.js +26 -0
  7. package/dist/lib/context/stages/structuredSummarizer.js +15 -4
  8. package/dist/lib/core/modules/GenerationHandler.js +28 -10
  9. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +19 -0
  10. package/dist/lib/core/modules/structuredOutputPolicy.js +26 -0
  11. package/dist/lib/mcp/externalServerManager.js +21 -6
  12. package/dist/lib/mcp/mcpClientFactory.js +5 -1
  13. package/dist/lib/neurolink.js +58 -25
  14. package/dist/lib/providers/googleVertex.d.ts +11 -0
  15. package/dist/lib/providers/googleVertex.js +706 -38
  16. package/dist/lib/types/generate.d.ts +13 -0
  17. package/dist/lib/types/stream.d.ts +1 -0
  18. package/dist/lib/utils/conversationMemory.js +19 -8
  19. package/dist/lib/utils/logSanitize.d.ts +26 -0
  20. package/dist/lib/utils/logSanitize.js +56 -0
  21. package/dist/mcp/externalServerManager.js +21 -6
  22. package/dist/mcp/mcpClientFactory.js +5 -1
  23. package/dist/neurolink.js +58 -25
  24. package/dist/providers/googleVertex.d.ts +11 -0
  25. package/dist/providers/googleVertex.js +706 -38
  26. package/dist/types/generate.d.ts +13 -0
  27. package/dist/types/stream.d.ts +1 -0
  28. package/dist/utils/conversationMemory.js +19 -8
  29. package/dist/utils/logSanitize.d.ts +26 -0
  30. package/dist/utils/logSanitize.js +56 -0
  31. package/package.json +2 -1
@@ -7,6 +7,8 @@ import { ErrorCategory, ErrorSeverity, } from "../constants/enums.js";
7
7
  import { BaseProvider } from "../core/baseProvider.js";
8
8
  import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
9
9
  import { ModelConfigurationManager } from "../core/modelConfiguration.js";
10
+ import { isSchemaComplexityError } from "../core/modules/structuredOutputPolicy.js";
11
+ import { stringifyContentSafe } from "../utils/logSanitize.js";
10
12
  import { createProxyFetch } from "../proxy/proxyFetch.js";
11
13
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
12
14
  import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
@@ -932,7 +934,10 @@ export class GoogleVertexProvider extends BaseProvider {
932
934
  this.emitStreamEnd(modelName, streamStartTime, false, error);
933
935
  throw error;
934
936
  }
935
- }, (r) => r.stream, (r, wrapped) => ({ ...r, stream: wrapped }));
937
+ }, (r) => r.stream,
938
+ // Preserve live getters (finishReason/structuredOutput/…) that the
939
+ // spread would otherwise snapshot before the background loop resolves.
940
+ (r, wrapped) => this.preserveStreamResultAccessors(r, { ...r, stream: wrapped }));
936
941
  }
937
942
  /**
938
943
  * Emit `stream:end` so the Pipeline B observability listener creates a
@@ -2348,7 +2353,25 @@ export class GoogleVertexProvider extends BaseProvider {
2348
2353
  wasAborted = true;
2349
2354
  break;
2350
2355
  }
2351
- logger.error("[GoogleVertex] Native SDK generate error", error);
2356
+ logger.error("[GoogleVertex] Native SDK generate error", {
2357
+ error,
2358
+ model: modelName,
2359
+ location: effectiveLocation,
2360
+ status: error?.status,
2361
+ });
2362
+ // Best-effort request context for formatProviderError —
2363
+ // this.modelName can be stale when options.model overrides the
2364
+ // instance default.
2365
+ try {
2366
+ if (error && typeof error === "object") {
2367
+ const e = error;
2368
+ e.requestModel = modelName;
2369
+ e.requestRegion = effectiveLocation;
2370
+ }
2371
+ }
2372
+ catch {
2373
+ /* frozen/sealed error — context stays best-effort */
2374
+ }
2352
2375
  throw this.handleProviderError(error);
2353
2376
  }
2354
2377
  }
@@ -2582,9 +2605,7 @@ export class GoogleVertexProvider extends BaseProvider {
2582
2605
  if (msg.role === "user" || msg.role === "assistant") {
2583
2606
  messages.push({
2584
2607
  role: msg.role,
2585
- content: typeof msg.content === "string"
2586
- ? msg.content
2587
- : JSON.stringify(msg.content),
2608
+ content: stringifyContentSafe(msg.content),
2588
2609
  });
2589
2610
  }
2590
2611
  }
@@ -2832,6 +2853,13 @@ export class GoogleVertexProvider extends BaseProvider {
2832
2853
  // while generation is still in progress. Mirrors the executeStream
2833
2854
  // pattern in googleAiStudio.ts.
2834
2855
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
2856
+ // Reserve the LAST step of the budget for a forced final_result call when
2857
+ // structured output is active — see the generate twin for the full
2858
+ // rationale (tool_choice:"any" makes the text exit unreachable, so an
2859
+ // un-reserved budget deadlocks into an empty stream).
2860
+ const agenticStepBudget = useFinalResultTool
2861
+ ? Math.max(maxSteps - 1, 0)
2862
+ : maxSteps;
2835
2863
  const allToolCalls = [];
2836
2864
  const toolExecutions = [];
2837
2865
  const channel = createTextChannel();
@@ -2847,6 +2875,10 @@ export class GoogleVertexProvider extends BaseProvider {
2847
2875
  };
2848
2876
  const toolsUsedRef = [];
2849
2877
  const structuredOutputRef = {};
2878
+ // Resolved after the background loop finishes; the StreamResult exposes it
2879
+ // via a getter (consumers read it after draining the stream), mirroring
2880
+ // the Gemini stream path's finishReason field.
2881
+ const finishReasonRef = {};
2850
2882
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
2851
2883
  // experimental_telemetry, so emit spans manually — one turn span, one
2852
2884
  // generation span per API call, one tool span per execution — all carrying
@@ -2883,6 +2915,11 @@ export class GoogleVertexProvider extends BaseProvider {
2883
2915
  });
2884
2916
  const turnContext = otelTrace.setSpan(otelContext.active(), turnSpan);
2885
2917
  let aggregatedTurnText = "";
2918
+ // Count of characters ALREADY delivered to the consumer via live text
2919
+ // deltas. aggregatedTurnText only reflects completed finalMessage()
2920
+ // responses, so an aborted mid-answer call would otherwise look "empty"
2921
+ // to the terminal handling even though the consumer saw partial prose.
2922
+ let liveTextPushedLength = 0;
2886
2923
  // Anthropic prompt-cache token accounting, aggregated across loop steps.
2887
2924
  const turnCacheUsage = {
2888
2925
  read: 0,
@@ -2913,10 +2950,23 @@ export class GoogleVertexProvider extends BaseProvider {
2913
2950
  const loopPromise = (async () => {
2914
2951
  let step = 0;
2915
2952
  const currentMessages = [...messages];
2953
+ // Step-cap / abort bookkeeping (mirrors the generate twin): read by the
2954
+ // terminal recovery block after the loop and by the finishReason
2955
+ // mapping written into finishReasonRef.
2956
+ let wasAborted = false;
2957
+ let hitStepLimit = false;
2958
+ let synthesizedFinalAnswer = false;
2959
+ let modelFinished = false;
2960
+ let lastStopReason;
2916
2961
  try {
2917
- while (step < maxSteps) {
2962
+ while (step < agenticStepBudget) {
2963
+ // Honor caller aborts BETWEEN steps: break into terminal handling
2964
+ // (one graceful cap chunk, clean close) instead of throwing —
2965
+ // channel.error would surface the caller's own abort as a stream
2966
+ // failure and route consumers into fallback retries.
2918
2967
  if (options.abortSignal?.aborted) {
2919
- throw new Error("Stream aborted by caller");
2968
+ wasAborted = true;
2969
+ break;
2920
2970
  }
2921
2971
  step++;
2922
2972
  // One generation observation per API call: request in, content + usage out.
@@ -2980,6 +3030,7 @@ export class GoogleVertexProvider extends BaseProvider {
2980
3030
  generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
2981
3031
  }
2982
3032
  channel.push(delta);
3033
+ liveTextPushedLength += delta.length;
2983
3034
  }
2984
3035
  });
2985
3036
  // finalMessage() resolves AFTER message_stop. By then the listener
@@ -2999,6 +3050,15 @@ export class GoogleVertexProvider extends BaseProvider {
2999
3050
  generationSpan.recordException(modelCallError);
3000
3051
  }
3001
3052
  generationSpan.end();
3053
+ // A mid-flight abort (caller signal or the defensive timer
3054
+ // tripping abortHandler) rejects finalMessage() with an
3055
+ // abort-shaped error. Break gracefully into the terminal handling
3056
+ // instead of routing it through channel.error as a failure.
3057
+ if (options.abortSignal?.aborted || isAbortError(modelCallError)) {
3058
+ activeStream = undefined;
3059
+ wasAborted = true;
3060
+ break;
3061
+ }
3002
3062
  throw modelCallError;
3003
3063
  }
3004
3064
  activeStream = undefined;
@@ -3016,6 +3076,7 @@ export class GoogleVertexProvider extends BaseProvider {
3016
3076
  usage.input += response.usage?.input_tokens || 0;
3017
3077
  usage.output += response.usage?.output_tokens || 0;
3018
3078
  usage.total = usage.input + usage.output;
3079
+ lastStopReason = response.stop_reason;
3019
3080
  for (const block of response.content) {
3020
3081
  if (block.type === "text" && typeof block.text === "string") {
3021
3082
  aggregatedTurnText += block.text;
@@ -3057,6 +3118,7 @@ export class GoogleVertexProvider extends BaseProvider {
3057
3118
  if (finalResultCall) {
3058
3119
  structuredOutputRef.value = finalResultCall.input;
3059
3120
  channel.push(JSON.stringify(finalResultCall.input));
3121
+ modelFinished = true;
3060
3122
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { keys: Object.keys(finalResultCall.input) });
3061
3123
  break;
3062
3124
  }
@@ -3064,11 +3126,14 @@ export class GoogleVertexProvider extends BaseProvider {
3064
3126
  // No tools — pure text turn. Listener already pushed all deltas;
3065
3127
  // loop terminates and channel.close() flushes the consumer.
3066
3128
  if (toolUseBlocks.length === 0) {
3129
+ modelFinished = true;
3067
3130
  break;
3068
3131
  }
3069
3132
  // Tool execution loop. tool:start / tool:end events fire from
3070
3133
  // ToolsManager's wrapped execute (ToolsManager.ts:355) — no inline
3071
- // emit needed.
3134
+ // emit needed. The array also carries the trailing soft-budget
3135
+ // nudge text block appended below (tool_result blocks stay first,
3136
+ // as the Anthropic API requires).
3072
3137
  const toolResults = [];
3073
3138
  // Per-step bookkeeping for conversation-memory storage.
3074
3139
  const stepStorageCalls = [];
@@ -3143,7 +3208,7 @@ export class GoogleVertexProvider extends BaseProvider {
3143
3208
  // so coerce defensively to keep the follow-up turn valid.
3144
3209
  const resultContent = typeof result === "string"
3145
3210
  ? result
3146
- : (JSON.stringify(result ?? null) ?? String(result));
3211
+ : stringifyContentSafe(result ?? null);
3147
3212
  toolResults.push({
3148
3213
  type: "tool_result",
3149
3214
  tool_use_id: toolUse.id,
@@ -3156,6 +3221,23 @@ export class GoogleVertexProvider extends BaseProvider {
3156
3221
  });
3157
3222
  }
3158
3223
  catch (err) {
3224
+ // An aborted tool call is a cancellation, not a tool failure —
3225
+ // end the span without recording an error execution/result and
3226
+ // break the turn.
3227
+ if (options.abortSignal?.aborted || isAbortError(err)) {
3228
+ endToolSpan({ aborted: true });
3229
+ // Keep persisted tool history paired: the call row was
3230
+ // already pushed above, so record a neutral cancellation
3231
+ // result (NOT an error) — an unpaired tool_use replayed on
3232
+ // the next turn would be rejected by the Anthropic API.
3233
+ stepStorageResults.push({
3234
+ toolCallId: toolUse.id,
3235
+ toolName: toolUse.name,
3236
+ output: { aborted: true },
3237
+ });
3238
+ wasAborted = true;
3239
+ break;
3240
+ }
3159
3241
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3160
3242
  const errorPayload = { error: errMsg };
3161
3243
  endToolSpan(errorPayload, errMsg);
@@ -3199,7 +3281,9 @@ export class GoogleVertexProvider extends BaseProvider {
3199
3281
  }
3200
3282
  // Persist this step's tool calls/results into conversation memory.
3201
3283
  // Without this hook, tool rows never land in Redis and the
3202
- // chat-history UI loses every tool invocation.
3284
+ // chat-history UI loses every tool invocation. Runs BEFORE the
3285
+ // abort break below so tools that DID complete in an aborted step
3286
+ // (real side effects) still reach the chat history.
3203
3287
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3204
3288
  withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c) => ({ ...c, stepIndex: step })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
3205
3289
  logger.warn("[GoogleVertex] Failed to store native Anthropic stream tool executions", {
@@ -3207,6 +3291,26 @@ export class GoogleVertexProvider extends BaseProvider {
3207
3291
  });
3208
3292
  });
3209
3293
  }
3294
+ // An abort inside the tool-exec loop only breaks that inner
3295
+ // for-loop. Break the while too so no further model call is issued
3296
+ // and control reaches the terminal step-cap handling below.
3297
+ if (wasAborted) {
3298
+ break;
3299
+ }
3300
+ // Soft budget nudge: with the step cap approaching, tell the model
3301
+ // to wrap up so the reserved forced-finalization call below stays a
3302
+ // fallback, not the norm. Rides as a trailing text block on the
3303
+ // tool_result user turn (cache-safe: it lives in the growing tail).
3304
+ const stepsRemaining = agenticStepBudget - step;
3305
+ if (stepsRemaining > 0 && stepsRemaining <= 3) {
3306
+ toolResults.push({
3307
+ type: "text",
3308
+ text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
3309
+ (useFinalResultTool
3310
+ ? "call final_result with your best answer."
3311
+ : "provide your final answer."),
3312
+ });
3313
+ }
3210
3314
  // Continue the loop: assistant turn + tool_result user turn.
3211
3315
  // Filter server_tool_use blocks (Anthropic API rejects them in
3212
3316
  // subsequent message turns).
@@ -3220,6 +3324,220 @@ export class GoogleVertexProvider extends BaseProvider {
3220
3324
  content: toolResults,
3221
3325
  });
3222
3326
  }
3327
+ // Terminal handling — the loop exited without a model-initiated
3328
+ // finish (step budget exhausted, or the turn was aborted). Never end
3329
+ // the stream empty: force the reserved final_result step on
3330
+ // structured-output turns, synthesize a plain-text answer on tool
3331
+ // turns, or push one graceful cap chunk. Mirrors the generate twin
3332
+ // and the Gemini paths (ed289b7 / PR #1123). The finalization and
3333
+ // backstop calls emit no per-call generation span (matching the
3334
+ // Gemini synthesizeFinalAnswerWithoutTools precedent); their tokens
3335
+ // still land in `usage` and the turn-span metadata.
3336
+ if (!modelFinished) {
3337
+ // A caller abort that landed during the FINAL budgeted step's tool
3338
+ // execution leaves wasAborted=false (no loop-entry check runs after
3339
+ // a budget exit) — re-check before issuing any terminal model call.
3340
+ if (options.abortSignal?.aborted) {
3341
+ wasAborted = true;
3342
+ }
3343
+ const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3344
+ if (wasAborted) {
3345
+ // Budget already blown — never issue another model call. Deliver
3346
+ // exactly one graceful cap chunk if nothing reached the consumer
3347
+ // (live deltas already delivered count as "something reached").
3348
+ logger.warn("[GoogleVertex] Native Anthropic stream loop aborted mid-turn; returning a graceful cap message.");
3349
+ if (aggregatedTurnText.length === 0 &&
3350
+ liveTextPushedLength === 0 &&
3351
+ !structuredOutputRef.value) {
3352
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3353
+ channel.push(capMessage);
3354
+ aggregatedTurnText = capMessage;
3355
+ }
3356
+ }
3357
+ else if (useFinalResultTool) {
3358
+ hitStepLimit = true;
3359
+ logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
3360
+ try {
3361
+ // Reserved finalization step: identical request except
3362
+ // tool_choice pins final_result — Anthropic guarantees a
3363
+ // final_result tool_use. Cache treatment is byte-identical to
3364
+ // loop steps. No text listener: forced tool calls stream no
3365
+ // text deltas, and the structured JSON is pushed once below.
3366
+ const cachedFinal = applyVertexAnthropicCacheBreakpoints({
3367
+ system: systemPromptWithSchema,
3368
+ tools,
3369
+ messages: currentMessages,
3370
+ });
3371
+ const finalizationStream = await client.messages.stream({
3372
+ ...requestParams,
3373
+ tool_choice: {
3374
+ type: "tool",
3375
+ name: "final_result",
3376
+ },
3377
+ ...(cachedFinal.system !== undefined && {
3378
+ system: cachedFinal.system,
3379
+ }),
3380
+ ...(cachedFinal.tools &&
3381
+ cachedFinal.tools.length > 0 && {
3382
+ tools: cachedFinal.tools,
3383
+ }),
3384
+ messages: cachedFinal.messages,
3385
+ });
3386
+ // Mid-flight aborts are covered by the shared abortHandler via
3387
+ // activeStream; already-fired aborts were handled by the
3388
+ // terminal-entry re-check above.
3389
+ activeStream = finalizationStream;
3390
+ const response = await finalizationStream.finalMessage();
3391
+ activeStream = undefined;
3392
+ usage.input += response.usage?.input_tokens || 0;
3393
+ usage.output += response.usage?.output_tokens || 0;
3394
+ usage.total = usage.input + usage.output;
3395
+ turnCacheUsage.read +=
3396
+ response.usage?.cache_read_input_tokens ?? 0;
3397
+ turnCacheUsage.creation +=
3398
+ response.usage?.cache_creation_input_tokens ?? 0;
3399
+ turnCacheUsage.creation5m +=
3400
+ response.usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
3401
+ turnCacheUsage.creation1h +=
3402
+ response.usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
3403
+ lastStopReason = response.stop_reason;
3404
+ const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
3405
+ if (forcedFinalResult) {
3406
+ structuredOutputRef.value = forcedFinalResult.input;
3407
+ channel.push(JSON.stringify(forcedFinalResult.input));
3408
+ synthesizedFinalAnswer = true;
3409
+ logger.debug("[GoogleVertex] Forced finalization returned structured output (stream)", { keys: Object.keys(forcedFinalResult.input) });
3410
+ }
3411
+ else {
3412
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3413
+ channel.push(capMessage);
3414
+ aggregatedTurnText += capMessage;
3415
+ }
3416
+ }
3417
+ catch (error) {
3418
+ activeStream = undefined;
3419
+ // An aborted finalization is a cancellation, not a step-cap
3420
+ // turn — keep finishReason mapping from lastStopReason.
3421
+ if (options.abortSignal?.aborted || isAbortError(error)) {
3422
+ hitStepLimit = false;
3423
+ }
3424
+ logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", {
3425
+ error: error instanceof Error ? error.message : String(error),
3426
+ });
3427
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3428
+ channel.push(capMessage);
3429
+ aggregatedTurnText += capMessage;
3430
+ }
3431
+ }
3432
+ else {
3433
+ hitStepLimit = true;
3434
+ if (aggregatedTurnText.length === 0) {
3435
+ // Pure tool-loop with no streamed text: one tools-disabled call
3436
+ // so the model answers from the gathered tool results, pushed
3437
+ // as a single chunk (matching the Gemini stream synth). NOTE:
3438
+ // the tools array must stay in the request — Anthropic rejects
3439
+ // histories containing tool_use/tool_result blocks without a
3440
+ // tools param — so "tools disabled" is tool_choice:"none",
3441
+ // which also keeps the cached tools prefix byte-identical.
3442
+ logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
3443
+ try {
3444
+ const backstopSystem = (systemPromptWithSchema
3445
+ ? systemPromptWithSchema + "\n\n"
3446
+ : "") +
3447
+ "Tool calling is no longer available for this turn. " +
3448
+ "Provide your final answer directly as plain text now, " +
3449
+ "using the information gathered so far.";
3450
+ const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
3451
+ system: backstopSystem,
3452
+ tools,
3453
+ messages: currentMessages,
3454
+ });
3455
+ const backstopStream = await client.messages.stream({
3456
+ ...requestParams,
3457
+ tool_choice: { type: "none" },
3458
+ system: cachedBackstop.system,
3459
+ ...(cachedBackstop.tools &&
3460
+ cachedBackstop.tools.length > 0 && {
3461
+ tools: cachedBackstop.tools,
3462
+ }),
3463
+ messages: cachedBackstop.messages,
3464
+ });
3465
+ activeStream = backstopStream;
3466
+ const response = await backstopStream.finalMessage();
3467
+ activeStream = undefined;
3468
+ usage.input += response.usage?.input_tokens || 0;
3469
+ usage.output += response.usage?.output_tokens || 0;
3470
+ usage.total = usage.input + usage.output;
3471
+ turnCacheUsage.read +=
3472
+ response.usage?.cache_read_input_tokens ?? 0;
3473
+ turnCacheUsage.creation +=
3474
+ response.usage?.cache_creation_input_tokens ?? 0;
3475
+ turnCacheUsage.creation5m +=
3476
+ response.usage?.cache_creation?.ephemeral_5m_input_tokens ??
3477
+ 0;
3478
+ turnCacheUsage.creation1h +=
3479
+ response.usage?.cache_creation?.ephemeral_1h_input_tokens ??
3480
+ 0;
3481
+ lastStopReason = response.stop_reason;
3482
+ const backstopText = response.content
3483
+ .filter((block) => block.type === "text")
3484
+ .map((b) => b.text)
3485
+ .join("");
3486
+ if (backstopText) {
3487
+ synthesizedFinalAnswer = true;
3488
+ channel.push(backstopText);
3489
+ aggregatedTurnText = backstopText;
3490
+ }
3491
+ else {
3492
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3493
+ channel.push(capMessage);
3494
+ aggregatedTurnText = capMessage;
3495
+ }
3496
+ }
3497
+ catch (error) {
3498
+ activeStream = undefined;
3499
+ // An aborted backstop is a cancellation, not a step-cap
3500
+ // turn — keep finishReason mapping from lastStopReason.
3501
+ if (options.abortSignal?.aborted || isAbortError(error)) {
3502
+ hitStepLimit = false;
3503
+ }
3504
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
3505
+ error: error instanceof Error ? error.message : String(error),
3506
+ });
3507
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3508
+ channel.push(capMessage);
3509
+ aggregatedTurnText = capMessage;
3510
+ }
3511
+ }
3512
+ // else: prose already streamed to the consumer across steps —
3513
+ // add nothing; finishReason "tool-calls" flags the capped turn.
3514
+ }
3515
+ }
3516
+ // Absolute backstop — never close the stream empty on a turn that
3517
+ // ran tools (mirrors the generate twin's guard). Catches the
3518
+ // model-finished-with-empty-content exit, which skips the terminal
3519
+ // recovery above.
3520
+ const deliveredNothing = aggregatedTurnText.length === 0 &&
3521
+ liveTextPushedLength === 0 &&
3522
+ !structuredOutputRef.value;
3523
+ const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3524
+ if (deliveredNothing && externalToolCallTotal > 0) {
3525
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallTotal);
3526
+ channel.push(capMessage);
3527
+ aggregatedTurnText = capMessage;
3528
+ }
3529
+ // Honest finish reason (same mapping as the generate twin): "length"
3530
+ // takes precedence, a capped turn without a clean/forced answer is
3531
+ // "tool-calls", everything else is "stop". Mirrored onto metadata
3532
+ // (a mutable reference) because wrapper spreads snapshot the
3533
+ // top-level getter before this line runs.
3534
+ finishReasonRef.value =
3535
+ lastStopReason === "max_tokens"
3536
+ ? "length"
3537
+ : hitStepLimit && !synthesizedFinalAnswer
3538
+ ? "tool-calls"
3539
+ : "stop";
3540
+ metadata.finishReason = finishReasonRef.value;
3223
3541
  metadata.responseTime = Date.now() - startTime;
3224
3542
  metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3225
3543
  // Surface cache metrics once, after the agentic loop, from the
@@ -3315,6 +3633,13 @@ export class GoogleVertexProvider extends BaseProvider {
3315
3633
  configurable: true,
3316
3634
  get: () => structuredOutputRef.value,
3317
3635
  });
3636
+ // Resolved by the background loop right before channel.close(); consumers
3637
+ // read it after draining the stream (same contract as usage/metadata).
3638
+ Object.defineProperty(result, "finishReason", {
3639
+ enumerable: true,
3640
+ configurable: true,
3641
+ get: () => finishReasonRef.value,
3642
+ });
3318
3643
  return result;
3319
3644
  }
3320
3645
  /**
@@ -3344,9 +3669,7 @@ export class GoogleVertexProvider extends BaseProvider {
3344
3669
  if (msg.role === "user" || msg.role === "assistant") {
3345
3670
  messages.push({
3346
3671
  role: msg.role,
3347
- content: typeof msg.content === "string"
3348
- ? msg.content
3349
- : JSON.stringify(msg.content),
3672
+ content: stringifyContentSafe(msg.content),
3350
3673
  });
3351
3674
  }
3352
3675
  }
@@ -3578,8 +3901,21 @@ export class GoogleVertexProvider extends BaseProvider {
3578
3901
  };
3579
3902
  // Handle tool calling loop with max steps
3580
3903
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
3904
+ // Reserve the LAST step of the budget for a forced final_result call when
3905
+ // structured output is active. tool_choice:"any" (set above) forces a tool
3906
+ // call on every assistant turn, so the toolUseBlocks.length===0 text exit
3907
+ // is structurally unreachable — without a reserved finalization step, a
3908
+ // turn whose budget is consumed by other tool calls (runaway loop, or all
3909
+ // tools failing) ends with content:"" and a masking finishReason "stop".
3910
+ const agenticStepBudget = useFinalResultTool
3911
+ ? Math.max(maxSteps - 1, 0)
3912
+ : maxSteps;
3581
3913
  let step = 0;
3582
3914
  let finalText = "";
3915
+ // Prose produced mid-loop alongside tool calls, accumulated across steps
3916
+ // via appendStepText — surfaced if the step budget runs out (mirrors the
3917
+ // Gemini paths' accumulatedText; last-step-only would drop earlier prose).
3918
+ let accumulatedStepText = "";
3583
3919
  let structuredOutput;
3584
3920
  const allToolCalls = [];
3585
3921
  let totalInputTokens = 0;
@@ -3594,8 +3930,22 @@ export class GoogleVertexProvider extends BaseProvider {
3594
3930
  // (notably "length" on token truncation) — the legacy native path always
3595
3931
  // reported "stop", hiding truncation from callers.
3596
3932
  let lastStopReason;
3933
+ // Step-cap / abort bookkeeping (mirrors the native Gemini loops): the
3934
+ // terminal recovery block below and the finishReason mapping both read
3935
+ // these to distinguish clean finishes, capped turns, and aborted turns.
3936
+ let wasAborted = false;
3937
+ let hitStepLimit = false;
3938
+ let synthesizedFinalAnswer = false;
3939
+ let modelFinished = false;
3597
3940
  const currentMessages = [...messages];
3598
- while (step < maxSteps) {
3941
+ while (step < agenticStepBudget) {
3942
+ // Honor caller aborts BETWEEN steps: break into terminal handling
3943
+ // instead of throwing (a throw routes consumers into abortSignal-less
3944
+ // fallback retries — observed in production as a 600s abort no-op).
3945
+ if (options.abortSignal?.aborted) {
3946
+ wasAborted = true;
3947
+ break;
3948
+ }
3599
3949
  step++;
3600
3950
  try {
3601
3951
  // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
@@ -3612,6 +3962,9 @@ export class GoogleVertexProvider extends BaseProvider {
3612
3962
  tools,
3613
3963
  messages: currentMessages,
3614
3964
  });
3965
+ // The caller's abortSignal rides as an SDK request option so a
3966
+ // mid-flight abort cancels the HTTP call itself (the SDK rejects with
3967
+ // an abort-shaped error the per-step catch below turns into a break).
3615
3968
  const response = await withTimeout(client.messages.create({
3616
3969
  ...requestParams,
3617
3970
  ...(cachedGenerate.system !== undefined && {
@@ -3622,7 +3975,7 @@ export class GoogleVertexProvider extends BaseProvider {
3622
3975
  tools: cachedGenerate.tools,
3623
3976
  }),
3624
3977
  messages: cachedGenerate.messages,
3625
- }), generateTimeoutMs, "Anthropic generate timed out");
3978
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
3626
3979
  // Update token counts. input_tokens is the uncached remainder; cache
3627
3980
  // reads/writes are reported separately and accumulated here so the
3628
3981
  // result reflects the full picture.
@@ -3641,6 +3994,7 @@ export class GoogleVertexProvider extends BaseProvider {
3641
3994
  // Extract structured output and convert to JSON string for finalText
3642
3995
  structuredOutput = finalResultCall.input;
3643
3996
  finalText = JSON.stringify(structuredOutput);
3997
+ modelFinished = true;
3644
3998
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { keys: Object.keys(structuredOutput) });
3645
3999
  break; // We have the structured output, we're done
3646
4000
  }
@@ -3650,10 +4004,13 @@ export class GoogleVertexProvider extends BaseProvider {
3650
4004
  const responseText = textBlocks.map((b) => b.text).join("");
3651
4005
  if (toolUseBlocks.length === 0) {
3652
4006
  // No tool calls, we're done
3653
- finalText = responseText || finalText;
4007
+ finalText = responseText || accumulatedStepText;
4008
+ modelFinished = true;
3654
4009
  break;
3655
4010
  }
3656
- // Handle tool calls
4011
+ // Handle tool calls. The array also carries the trailing soft-budget
4012
+ // nudge text block appended below (tool_result blocks stay first, as
4013
+ // the Anthropic API requires).
3657
4014
  const toolResults = [];
3658
4015
  // Per-step bookkeeping for conversation-memory storage. Tracks calls
3659
4016
  // and results for ONLY the tools fired in this step so the storage
@@ -3691,7 +4048,7 @@ export class GoogleVertexProvider extends BaseProvider {
3691
4048
  // so coerce defensively to keep the follow-up turn valid.
3692
4049
  const resultContent = typeof result === "string"
3693
4050
  ? result
3694
- : (JSON.stringify(result ?? null) ?? String(result));
4051
+ : stringifyContentSafe(result ?? null);
3695
4052
  toolResults.push({
3696
4053
  type: "tool_result",
3697
4054
  tool_use_id: toolUse.id,
@@ -3704,6 +4061,21 @@ export class GoogleVertexProvider extends BaseProvider {
3704
4061
  });
3705
4062
  }
3706
4063
  catch (err) {
4064
+ // An aborted tool call is a cancellation, not a tool failure —
4065
+ // break the turn without recording an error execution/result.
4066
+ if (options.abortSignal?.aborted || isAbortError(err)) {
4067
+ // Keep persisted tool history paired: the call row was
4068
+ // already pushed above, so record a neutral cancellation
4069
+ // result (NOT an error) — an unpaired tool_use replayed on
4070
+ // the next turn would be rejected by the Anthropic API.
4071
+ stepStorageResults.push({
4072
+ toolCallId: toolUse.id,
4073
+ toolName: toolUse.name,
4074
+ output: { aborted: true },
4075
+ });
4076
+ wasAborted = true;
4077
+ break;
4078
+ }
3707
4079
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3708
4080
  const errorPayload = { error: errMsg };
3709
4081
  toolExecutions.push({
@@ -3747,6 +4119,8 @@ export class GoogleVertexProvider extends BaseProvider {
3747
4119
  // Without this, tool_call / tool_result rows never reach Redis and
3748
4120
  // the chat-history UI loses every tool invocation.
3749
4121
  // Fire-and-forget — storage failures must not break generation.
4122
+ // Runs BEFORE the abort break below so tools that DID complete in an
4123
+ // aborted step (real side effects) still reach the chat history.
3750
4124
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3751
4125
  withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c) => ({ ...c, stepIndex: step })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
3752
4126
  logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
@@ -3754,6 +4128,26 @@ export class GoogleVertexProvider extends BaseProvider {
3754
4128
  });
3755
4129
  });
3756
4130
  }
4131
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
4132
+ // Break the while too so no further model call is issued and control
4133
+ // reaches the terminal step-cap handling below.
4134
+ if (wasAborted) {
4135
+ break;
4136
+ }
4137
+ // Soft budget nudge: with the step cap approaching, tell the model to
4138
+ // wrap up so the reserved forced-finalization call below stays a
4139
+ // fallback, not the norm. Rides as a trailing text block on the
4140
+ // tool_result user turn (cache-safe: it lives in the growing tail).
4141
+ const stepsRemaining = agenticStepBudget - step;
4142
+ if (stepsRemaining > 0 && stepsRemaining <= 3) {
4143
+ toolResults.push({
4144
+ type: "text",
4145
+ text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
4146
+ (useFinalResultTool
4147
+ ? "call final_result with your best answer."
4148
+ : "provide your final answer."),
4149
+ });
4150
+ }
3757
4151
  // Add assistant message and tool results to continue the loop
3758
4152
  // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
3759
4153
  const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
@@ -3765,25 +4159,208 @@ export class GoogleVertexProvider extends BaseProvider {
3765
4159
  role: "user",
3766
4160
  content: toolResults,
3767
4161
  });
3768
- // Store last text in case we hit max steps
3769
- if (responseText) {
3770
- finalText = responseText;
3771
- }
4162
+ // Accumulate the step's prose so a capped turn can still surface it —
4163
+ // finalText is reserved for model-initiated finishes.
4164
+ accumulatedStepText = appendStepText(accumulatedStepText, responseText);
3772
4165
  }
3773
4166
  catch (error) {
3774
- logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
4167
+ // A mid-request abort surfaces as an abort-shaped SDK rejection (we
4168
+ // pass options.abortSignal as the request signal above). Break
4169
+ // gracefully into the terminal handling instead of re-throwing — a
4170
+ // re-throw routes the caller's abort into abortSignal-less fallback
4171
+ // retries. Dual check as in the Gemini loops: the caller's signal OR
4172
+ // an abort-shaped error either way means "stop", not a real failure.
4173
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4174
+ wasAborted = true;
4175
+ break;
4176
+ }
4177
+ logger.error("[GoogleVertex] Native Anthropic SDK generate error", {
4178
+ error,
4179
+ model: modelName,
4180
+ step,
4181
+ status: error?.status,
4182
+ });
4183
+ // Best-effort request context for formatProviderError — see the
4184
+ // native Gemini catch for rationale.
4185
+ try {
4186
+ if (error && typeof error === "object") {
4187
+ error.requestModel = modelName;
4188
+ }
4189
+ }
4190
+ catch {
4191
+ /* frozen/sealed error — context stays best-effort */
4192
+ }
3775
4193
  throw this.handleProviderError(error);
3776
4194
  }
3777
4195
  }
4196
+ // Terminal handling — the loop exited without a model-initiated finish
4197
+ // (step budget exhausted, or the turn was aborted). Never return "":
4198
+ // force the reserved final_result step on structured-output turns,
4199
+ // synthesize a plain-text answer on tool turns, or fall back to a
4200
+ // graceful cap message. Mirrors the Gemini paths (ed289b7 / PR #1123).
4201
+ if (!modelFinished && !finalText) {
4202
+ // A caller abort that landed during the FINAL budgeted step's tool
4203
+ // execution leaves wasAborted=false (no loop-entry check runs after a
4204
+ // budget exit) — re-check before issuing any terminal model call.
4205
+ if (options.abortSignal?.aborted) {
4206
+ wasAborted = true;
4207
+ }
4208
+ const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
4209
+ if (wasAborted) {
4210
+ // Budget already blown — never issue another model call; prefer the
4211
+ // prose the model already produced (mirrors the Gemini abort path),
4212
+ // else answer with a graceful cap message. finishReason keeps mapping
4213
+ // from lastStopReason (an aborted turn is not a step-cap turn).
4214
+ logger.warn("[GoogleVertex] Native Anthropic generate loop aborted mid-turn; returning gathered text or a graceful cap message.");
4215
+ finalText =
4216
+ accumulatedStepText ||
4217
+ buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4218
+ }
4219
+ else if (useFinalResultTool) {
4220
+ hitStepLimit = true;
4221
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
4222
+ try {
4223
+ // Reserved finalization step: identical request except tool_choice
4224
+ // pins final_result, so Anthropic guarantees the response is a
4225
+ // final_result tool_use. Cache treatment is byte-identical to loop
4226
+ // steps (same applyVertexAnthropicCacheBreakpoints on the same
4227
+ // stable system/tools prefix), so caching is unaffected.
4228
+ const cachedFinal = applyVertexAnthropicCacheBreakpoints({
4229
+ system: systemPromptWithSchema,
4230
+ tools,
4231
+ messages: currentMessages,
4232
+ });
4233
+ const response = await withTimeout(client.messages.create({
4234
+ ...requestParams,
4235
+ tool_choice: {
4236
+ type: "tool",
4237
+ name: "final_result",
4238
+ },
4239
+ ...(cachedFinal.system !== undefined && {
4240
+ system: cachedFinal.system,
4241
+ }),
4242
+ ...(cachedFinal.tools &&
4243
+ cachedFinal.tools.length > 0 && {
4244
+ tools: cachedFinal.tools,
4245
+ }),
4246
+ messages: cachedFinal.messages,
4247
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic finalization call timed out");
4248
+ totalInputTokens += response.usage?.input_tokens || 0;
4249
+ totalOutputTokens += response.usage?.output_tokens || 0;
4250
+ totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
4251
+ totalCacheCreationTokens +=
4252
+ response.usage?.cache_creation_input_tokens || 0;
4253
+ lastStopReason = response.stop_reason;
4254
+ const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
4255
+ if (forcedFinalResult) {
4256
+ structuredOutput = forcedFinalResult.input;
4257
+ finalText = JSON.stringify(structuredOutput);
4258
+ synthesizedFinalAnswer = true;
4259
+ logger.debug("[GoogleVertex] Forced finalization returned structured output (generate)", { keys: Object.keys(structuredOutput) });
4260
+ }
4261
+ else {
4262
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4263
+ }
4264
+ }
4265
+ catch (error) {
4266
+ // An aborted finalization is a cancellation, not a step-cap turn —
4267
+ // keep finishReason mapping from lastStopReason, not "tool-calls".
4268
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4269
+ hitStepLimit = false;
4270
+ }
4271
+ logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", { error: error instanceof Error ? error.message : String(error) });
4272
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4273
+ }
4274
+ }
4275
+ else {
4276
+ hitStepLimit = true;
4277
+ if (accumulatedStepText) {
4278
+ // Prefer the prose the model already produced across steps.
4279
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}); returning text already gathered from prior steps.`);
4280
+ finalText = accumulatedStepText;
4281
+ }
4282
+ else {
4283
+ // Pure tool-loop with no text: one tools-disabled call so the model
4284
+ // answers from the gathered tool results (Anthropic twin of the
4285
+ // Gemini synthesizeFinalAnswerWithoutTools). NOTE: the tools array
4286
+ // must stay in the request — Anthropic rejects requests whose
4287
+ // history contains tool_use/tool_result blocks without a tools
4288
+ // param — so "tools disabled" is expressed as tool_choice:"none",
4289
+ // which also keeps the cached tools prefix byte-identical.
4290
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
4291
+ try {
4292
+ const backstopSystem = (systemPromptWithSchema ? systemPromptWithSchema + "\n\n" : "") +
4293
+ "Tool calling is no longer available for this turn. Provide " +
4294
+ "your final answer directly as plain text now, using the " +
4295
+ "information gathered so far.";
4296
+ const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
4297
+ system: backstopSystem,
4298
+ tools,
4299
+ messages: currentMessages,
4300
+ });
4301
+ const response = await withTimeout(client.messages.create({
4302
+ ...requestParams,
4303
+ tool_choice: { type: "none" },
4304
+ system: cachedBackstop.system,
4305
+ ...(cachedBackstop.tools &&
4306
+ cachedBackstop.tools.length > 0 && {
4307
+ tools: cachedBackstop.tools,
4308
+ }),
4309
+ messages: cachedBackstop.messages,
4310
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic backstop call timed out");
4311
+ totalInputTokens += response.usage?.input_tokens || 0;
4312
+ totalOutputTokens += response.usage?.output_tokens || 0;
4313
+ totalCacheReadTokens +=
4314
+ response.usage?.cache_read_input_tokens || 0;
4315
+ totalCacheCreationTokens +=
4316
+ response.usage?.cache_creation_input_tokens || 0;
4317
+ lastStopReason = response.stop_reason;
4318
+ const backstopText = response.content
4319
+ .filter((block) => block.type === "text")
4320
+ .map((b) => b.text)
4321
+ .join("");
4322
+ if (backstopText) {
4323
+ synthesizedFinalAnswer = true;
4324
+ finalText = backstopText;
4325
+ }
4326
+ else {
4327
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4328
+ }
4329
+ }
4330
+ catch (error) {
4331
+ // An aborted backstop is a cancellation, not a step-cap turn —
4332
+ // keep finishReason mapping from lastStopReason, not "tool-calls".
4333
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4334
+ hitStepLimit = false;
4335
+ }
4336
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
4337
+ error: error instanceof Error ? error.message : String(error),
4338
+ });
4339
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4340
+ }
4341
+ }
4342
+ }
4343
+ }
3778
4344
  const responseTime = Date.now() - startTime;
3779
4345
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
3780
4346
  const externalToolExecutions = toolExecutions.filter((te) => te.name !== "final_result");
4347
+ // Absolute backstop — no code path may surface empty content on a turn
4348
+ // that ran tools (the consumer-facing "empty response" incident shape).
4349
+ if (!finalText && externalToolCalls.length > 0) {
4350
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCalls.length);
4351
+ }
3781
4352
  const result = {
3782
4353
  content: finalText,
3783
- // Surface truncation: Anthropic "max_tokens" unified "length" so the
3784
- // SDK boundary can flag/observe incomplete structured output. Anything
3785
- // else (end_turn / stop_sequence / tool_use) is a normal stop.
3786
- finishReason: lastStopReason === "max_tokens" ? "length" : "stop",
4354
+ // Honest finish reason. "length" (token truncation) takes precedence; a
4355
+ // step-cap exhaustion without a clean/forced answer surfaces as
4356
+ // "tool-calls" (aligned with the Gemini paths, so consumers detect
4357
+ // capped turns uniformly); everything else including a successful
4358
+ // forced finalization or backstop synthesis — is a normal "stop".
4359
+ finishReason: lastStopReason === "max_tokens"
4360
+ ? "length"
4361
+ : hitStepLimit && !synthesizedFinalAnswer
4362
+ ? "tool-calls"
4363
+ : "stop",
3787
4364
  provider: this.providerName,
3788
4365
  model: modelName,
3789
4366
  usage: {
@@ -4076,8 +4653,49 @@ export class GoogleVertexProvider extends BaseProvider {
4076
4653
  model: modelName,
4077
4654
  totalToolCount: Object.keys(mergedOptions.tools).length,
4078
4655
  });
4079
- nativeResult =
4080
- await this.executeNativeGemini3Generate(mergedOptions);
4656
+ try {
4657
+ nativeResult =
4658
+ await this.executeNativeGemini3Generate(mergedOptions);
4659
+ }
4660
+ catch (nativeError) {
4661
+ // Vertex rejects over-constrained responseSchemas with a
4662
+ // deterministic 400 ("too many states" — constrained-decoding
4663
+ // state explosion). Re-sending the same schema can never
4664
+ // succeed, so retry ONCE with the schema dropped but JSON
4665
+ // mode kept: output.format "json" still sets
4666
+ // responseMimeType application/json on the no-tools path,
4667
+ // so the model is forced to emit JSON — just without the
4668
+ // offending schema constraints. The NeuroLink layer then
4669
+ // coerces the JSON text into the caller's original schema
4670
+ // (generate({schema}) guarantee holds).
4671
+ const requestedStructured = mergedOptions.schema !== undefined ||
4672
+ mergedOptions.output?.format === "json";
4673
+ // Tools present → the executor skips responseMimeType, so a
4674
+ // schema-less retry would NOT actually run in JSON mode and
4675
+ // the structured-output contract would silently degrade to
4676
+ // prose. Only the no-tools case retries faithfully; with
4677
+ // tools, surface the 400 to the caller instead.
4678
+ const hasTools = !mergedOptions.disableTools &&
4679
+ Object.keys(mergedOptions.tools ?? {}).length > 0;
4680
+ if (requestedStructured &&
4681
+ !hasTools &&
4682
+ isSchemaComplexityError(nativeError)) {
4683
+ logger.warn("[GoogleVertex] responseSchema too complex for constrained decoding — retrying native generate in schema-less JSON mode", {
4684
+ model: modelName,
4685
+ error: nativeError instanceof Error
4686
+ ? nativeError.message
4687
+ : String(nativeError),
4688
+ });
4689
+ nativeResult = await this.executeNativeGemini3Generate({
4690
+ ...mergedOptions,
4691
+ schema: undefined,
4692
+ output: { format: "json" },
4693
+ });
4694
+ }
4695
+ else {
4696
+ throw nativeError;
4697
+ }
4698
+ }
4081
4699
  }
4082
4700
  executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(nativeResult?.content ?? ""));
4083
4701
  return nativeResult;
@@ -4252,10 +4870,35 @@ export class GoogleVertexProvider extends BaseProvider {
4252
4870
  }
4253
4871
  },
4254
4872
  };
4255
- return {
4873
+ return this.preserveStreamResultAccessors(result, {
4256
4874
  ...result,
4257
4875
  stream: wrappedIterable,
4258
- };
4876
+ });
4877
+ }
4878
+ /**
4879
+ * Re-apply getter-based accessor properties from a source StreamResult onto
4880
+ * a wrapper copy. Wrapper spreads (`{ ...result }`) invoke and SNAPSHOT
4881
+ * enumerable getters at wrap time — for background-loop streams (the native
4882
+ * Anthropic path) that resolve finishReason / structuredOutput / toolCalls
4883
+ * only as the consumer drains, the snapshot is permanently undefined/empty.
4884
+ * Copying the accessor descriptors keeps the wrapped result live. Results
4885
+ * built from plain data properties (the buffered Gemini paths) have no
4886
+ * getters and pass through untouched.
4887
+ */
4888
+ preserveStreamResultAccessors(source, wrapped) {
4889
+ for (const key of [
4890
+ "finishReason",
4891
+ "structuredOutput",
4892
+ "toolCalls",
4893
+ "toolsUsed",
4894
+ "toolExecutions",
4895
+ ]) {
4896
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
4897
+ if (descriptor?.get) {
4898
+ Object.defineProperty(wrapped, key, descriptor);
4899
+ }
4900
+ }
4901
+ return wrapped;
4259
4902
  }
4260
4903
  /**
4261
4904
  * Attach `gen_ai.usage.*` and `neurolink.cost` attributes to a span.
@@ -4389,17 +5032,42 @@ export class GoogleVertexProvider extends BaseProvider {
4389
5032
  `3. Ensure your project has access to the model ` +
4390
5033
  `4. For Claude models, enable Anthropic integration in Google Cloud Console`, this.providerName);
4391
5034
  }
4392
- // Rate limit and quota errors
5035
+ // Rate limit / quota / capacity errors. Anthropic-on-Vertex capacity
5036
+ // exhaustion surfaces as overloaded_error (HTTP 529) — same operational
5037
+ // meaning as a 429, so classify it here instead of the generic 5xx branch.
4393
5038
  if (message.includes("QUOTA_EXCEEDED") ||
4394
5039
  message.includes("RATE_LIMIT_EXCEEDED") ||
4395
5040
  message.includes("rate limit") ||
4396
5041
  message.includes("429") ||
4397
- statusCode === 429) {
4398
- return new RateLimitError(`Google Vertex AI quota/rate limit exceeded. ` +
4399
- `Solutions: 1. Check your Vertex AI quotas in Google Cloud Console ` +
4400
- `2. Request quota increase if needed ` +
4401
- `3. Try a different model or reduce request frequency ` +
4402
- `4. Consider using a different region`, this.providerName);
5042
+ statusCode === 429 ||
5043
+ statusCode === 529 ||
5044
+ /overloaded/i.test(message)) {
5045
+ // Surface retry guidance when the SDK error carries it. @google/genai
5046
+ // ApiError nests RetryInfo inside the JSON error body's details array,
5047
+ // so fall back to scraping retryDelay out of the raw message.
5048
+ const retryDelay = typeof errorRecord?.retryDelay === "string"
5049
+ ? errorRecord.retryDelay
5050
+ : (/["']?retryDelay["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?s)/.exec(message)?.[1] ?? undefined);
5051
+ // Prefer the per-request context the native catches attach to the
5052
+ // error (this.modelName can be stale when options.model overrides the
5053
+ // instance default). Gemini models are force-routed to the "global"
5054
+ // endpoint regardless of configured location — report the region the
5055
+ // request actually hit.
5056
+ const requestModel = typeof errorRecord?.requestModel === "string"
5057
+ ? errorRecord.requestModel
5058
+ : this.modelName;
5059
+ const effectiveRegion = typeof errorRecord?.requestRegion === "string"
5060
+ ? errorRecord.requestRegion
5061
+ : resolveVertexRegionForModel(requestModel, this.location);
5062
+ return new RateLimitError(`Google Vertex AI rate limit / shared-capacity exhausted (429 RESOURCE_EXHAUSTED / overloaded) ` +
5063
+ `for model '${requestModel}' in region '${effectiveRegion}'.` +
5064
+ (retryDelay
5065
+ ? ` Upstream suggests retrying after ${retryDelay}.`
5066
+ : "") +
5067
+ ` Solutions: 1. Retry with backoff ` +
5068
+ `2. Check your Vertex AI quotas in Google Cloud Console (shared-capacity 429s can occur below quota) ` +
5069
+ `3. Try a different region or model ` +
5070
+ `4. Request provisioned throughput for sustained load`, this.providerName);
4403
5071
  }
4404
5072
  // Network connectivity errors
4405
5073
  if (message.includes("ECONNRESET") ||