@juspay/neurolink 9.80.2 → 9.80.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +329 -325
- package/dist/lib/providers/googleVertex.d.ts +11 -0
- package/dist/lib/providers/googleVertex.js +590 -19
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/providers/googleVertex.d.ts +11 -0
- package/dist/providers/googleVertex.js +590 -19
- package/dist/types/stream.d.ts +1 -0
- package/package.json +2 -1
|
@@ -932,7 +932,10 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
932
932
|
this.emitStreamEnd(modelName, streamStartTime, false, error);
|
|
933
933
|
throw error;
|
|
934
934
|
}
|
|
935
|
-
}, (r) => r.stream,
|
|
935
|
+
}, (r) => r.stream,
|
|
936
|
+
// Preserve live getters (finishReason/structuredOutput/…) that the
|
|
937
|
+
// spread would otherwise snapshot before the background loop resolves.
|
|
938
|
+
(r, wrapped) => this.preserveStreamResultAccessors(r, { ...r, stream: wrapped }));
|
|
936
939
|
}
|
|
937
940
|
/**
|
|
938
941
|
* Emit `stream:end` so the Pipeline B observability listener creates a
|
|
@@ -2832,6 +2835,13 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2832
2835
|
// while generation is still in progress. Mirrors the executeStream
|
|
2833
2836
|
// pattern in googleAiStudio.ts.
|
|
2834
2837
|
const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
|
|
2838
|
+
// Reserve the LAST step of the budget for a forced final_result call when
|
|
2839
|
+
// structured output is active — see the generate twin for the full
|
|
2840
|
+
// rationale (tool_choice:"any" makes the text exit unreachable, so an
|
|
2841
|
+
// un-reserved budget deadlocks into an empty stream).
|
|
2842
|
+
const agenticStepBudget = useFinalResultTool
|
|
2843
|
+
? Math.max(maxSteps - 1, 0)
|
|
2844
|
+
: maxSteps;
|
|
2835
2845
|
const allToolCalls = [];
|
|
2836
2846
|
const toolExecutions = [];
|
|
2837
2847
|
const channel = createTextChannel();
|
|
@@ -2847,6 +2857,10 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2847
2857
|
};
|
|
2848
2858
|
const toolsUsedRef = [];
|
|
2849
2859
|
const structuredOutputRef = {};
|
|
2860
|
+
// Resolved after the background loop finishes; the StreamResult exposes it
|
|
2861
|
+
// via a getter (consumers read it after draining the stream), mirroring
|
|
2862
|
+
// the Gemini stream path's finishReason field.
|
|
2863
|
+
const finishReasonRef = {};
|
|
2850
2864
|
// Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
|
|
2851
2865
|
// experimental_telemetry, so emit spans manually — one turn span, one
|
|
2852
2866
|
// generation span per API call, one tool span per execution — all carrying
|
|
@@ -2883,6 +2897,11 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2883
2897
|
});
|
|
2884
2898
|
const turnContext = otelTrace.setSpan(otelContext.active(), turnSpan);
|
|
2885
2899
|
let aggregatedTurnText = "";
|
|
2900
|
+
// Count of characters ALREADY delivered to the consumer via live text
|
|
2901
|
+
// deltas. aggregatedTurnText only reflects completed finalMessage()
|
|
2902
|
+
// responses, so an aborted mid-answer call would otherwise look "empty"
|
|
2903
|
+
// to the terminal handling even though the consumer saw partial prose.
|
|
2904
|
+
let liveTextPushedLength = 0;
|
|
2886
2905
|
// Anthropic prompt-cache token accounting, aggregated across loop steps.
|
|
2887
2906
|
const turnCacheUsage = {
|
|
2888
2907
|
read: 0,
|
|
@@ -2913,10 +2932,23 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2913
2932
|
const loopPromise = (async () => {
|
|
2914
2933
|
let step = 0;
|
|
2915
2934
|
const currentMessages = [...messages];
|
|
2935
|
+
// Step-cap / abort bookkeeping (mirrors the generate twin): read by the
|
|
2936
|
+
// terminal recovery block after the loop and by the finishReason
|
|
2937
|
+
// mapping written into finishReasonRef.
|
|
2938
|
+
let wasAborted = false;
|
|
2939
|
+
let hitStepLimit = false;
|
|
2940
|
+
let synthesizedFinalAnswer = false;
|
|
2941
|
+
let modelFinished = false;
|
|
2942
|
+
let lastStopReason;
|
|
2916
2943
|
try {
|
|
2917
|
-
while (step <
|
|
2944
|
+
while (step < agenticStepBudget) {
|
|
2945
|
+
// Honor caller aborts BETWEEN steps: break into terminal handling
|
|
2946
|
+
// (one graceful cap chunk, clean close) instead of throwing —
|
|
2947
|
+
// channel.error would surface the caller's own abort as a stream
|
|
2948
|
+
// failure and route consumers into fallback retries.
|
|
2918
2949
|
if (options.abortSignal?.aborted) {
|
|
2919
|
-
|
|
2950
|
+
wasAborted = true;
|
|
2951
|
+
break;
|
|
2920
2952
|
}
|
|
2921
2953
|
step++;
|
|
2922
2954
|
// One generation observation per API call: request in, content + usage out.
|
|
@@ -2980,6 +3012,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2980
3012
|
generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
|
|
2981
3013
|
}
|
|
2982
3014
|
channel.push(delta);
|
|
3015
|
+
liveTextPushedLength += delta.length;
|
|
2983
3016
|
}
|
|
2984
3017
|
});
|
|
2985
3018
|
// finalMessage() resolves AFTER message_stop. By then the listener
|
|
@@ -2999,6 +3032,15 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2999
3032
|
generationSpan.recordException(modelCallError);
|
|
3000
3033
|
}
|
|
3001
3034
|
generationSpan.end();
|
|
3035
|
+
// A mid-flight abort (caller signal or the defensive timer
|
|
3036
|
+
// tripping abortHandler) rejects finalMessage() with an
|
|
3037
|
+
// abort-shaped error. Break gracefully into the terminal handling
|
|
3038
|
+
// instead of routing it through channel.error as a failure.
|
|
3039
|
+
if (options.abortSignal?.aborted || isAbortError(modelCallError)) {
|
|
3040
|
+
activeStream = undefined;
|
|
3041
|
+
wasAborted = true;
|
|
3042
|
+
break;
|
|
3043
|
+
}
|
|
3002
3044
|
throw modelCallError;
|
|
3003
3045
|
}
|
|
3004
3046
|
activeStream = undefined;
|
|
@@ -3016,6 +3058,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3016
3058
|
usage.input += response.usage?.input_tokens || 0;
|
|
3017
3059
|
usage.output += response.usage?.output_tokens || 0;
|
|
3018
3060
|
usage.total = usage.input + usage.output;
|
|
3061
|
+
lastStopReason = response.stop_reason;
|
|
3019
3062
|
for (const block of response.content) {
|
|
3020
3063
|
if (block.type === "text" && typeof block.text === "string") {
|
|
3021
3064
|
aggregatedTurnText += block.text;
|
|
@@ -3057,6 +3100,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3057
3100
|
if (finalResultCall) {
|
|
3058
3101
|
structuredOutputRef.value = finalResultCall.input;
|
|
3059
3102
|
channel.push(JSON.stringify(finalResultCall.input));
|
|
3103
|
+
modelFinished = true;
|
|
3060
3104
|
logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { keys: Object.keys(finalResultCall.input) });
|
|
3061
3105
|
break;
|
|
3062
3106
|
}
|
|
@@ -3064,11 +3108,14 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3064
3108
|
// No tools — pure text turn. Listener already pushed all deltas;
|
|
3065
3109
|
// loop terminates and channel.close() flushes the consumer.
|
|
3066
3110
|
if (toolUseBlocks.length === 0) {
|
|
3111
|
+
modelFinished = true;
|
|
3067
3112
|
break;
|
|
3068
3113
|
}
|
|
3069
3114
|
// Tool execution loop. tool:start / tool:end events fire from
|
|
3070
3115
|
// ToolsManager's wrapped execute (ToolsManager.ts:355) — no inline
|
|
3071
|
-
// emit needed.
|
|
3116
|
+
// emit needed. The array also carries the trailing soft-budget
|
|
3117
|
+
// nudge text block appended below (tool_result blocks stay first,
|
|
3118
|
+
// as the Anthropic API requires).
|
|
3072
3119
|
const toolResults = [];
|
|
3073
3120
|
// Per-step bookkeeping for conversation-memory storage.
|
|
3074
3121
|
const stepStorageCalls = [];
|
|
@@ -3156,6 +3203,23 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3156
3203
|
});
|
|
3157
3204
|
}
|
|
3158
3205
|
catch (err) {
|
|
3206
|
+
// An aborted tool call is a cancellation, not a tool failure —
|
|
3207
|
+
// end the span without recording an error execution/result and
|
|
3208
|
+
// break the turn.
|
|
3209
|
+
if (options.abortSignal?.aborted || isAbortError(err)) {
|
|
3210
|
+
endToolSpan({ aborted: true });
|
|
3211
|
+
// Keep persisted tool history paired: the call row was
|
|
3212
|
+
// already pushed above, so record a neutral cancellation
|
|
3213
|
+
// result (NOT an error) — an unpaired tool_use replayed on
|
|
3214
|
+
// the next turn would be rejected by the Anthropic API.
|
|
3215
|
+
stepStorageResults.push({
|
|
3216
|
+
toolCallId: toolUse.id,
|
|
3217
|
+
toolName: toolUse.name,
|
|
3218
|
+
output: { aborted: true },
|
|
3219
|
+
});
|
|
3220
|
+
wasAborted = true;
|
|
3221
|
+
break;
|
|
3222
|
+
}
|
|
3159
3223
|
const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
3160
3224
|
const errorPayload = { error: errMsg };
|
|
3161
3225
|
endToolSpan(errorPayload, errMsg);
|
|
@@ -3199,7 +3263,9 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3199
3263
|
}
|
|
3200
3264
|
// Persist this step's tool calls/results into conversation memory.
|
|
3201
3265
|
// Without this hook, tool rows never land in Redis and the
|
|
3202
|
-
// chat-history UI loses every tool invocation.
|
|
3266
|
+
// chat-history UI loses every tool invocation. Runs BEFORE the
|
|
3267
|
+
// abort break below so tools that DID complete in an aborted step
|
|
3268
|
+
// (real side effects) still reach the chat history.
|
|
3203
3269
|
if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
|
|
3204
3270
|
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
3271
|
logger.warn("[GoogleVertex] Failed to store native Anthropic stream tool executions", {
|
|
@@ -3207,6 +3273,26 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3207
3273
|
});
|
|
3208
3274
|
});
|
|
3209
3275
|
}
|
|
3276
|
+
// An abort inside the tool-exec loop only breaks that inner
|
|
3277
|
+
// for-loop. Break the while too so no further model call is issued
|
|
3278
|
+
// and control reaches the terminal step-cap handling below.
|
|
3279
|
+
if (wasAborted) {
|
|
3280
|
+
break;
|
|
3281
|
+
}
|
|
3282
|
+
// Soft budget nudge: with the step cap approaching, tell the model
|
|
3283
|
+
// to wrap up so the reserved forced-finalization call below stays a
|
|
3284
|
+
// fallback, not the norm. Rides as a trailing text block on the
|
|
3285
|
+
// tool_result user turn (cache-safe: it lives in the growing tail).
|
|
3286
|
+
const stepsRemaining = agenticStepBudget - step;
|
|
3287
|
+
if (stepsRemaining > 0 && stepsRemaining <= 3) {
|
|
3288
|
+
toolResults.push({
|
|
3289
|
+
type: "text",
|
|
3290
|
+
text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
|
|
3291
|
+
(useFinalResultTool
|
|
3292
|
+
? "call final_result with your best answer."
|
|
3293
|
+
: "provide your final answer."),
|
|
3294
|
+
});
|
|
3295
|
+
}
|
|
3210
3296
|
// Continue the loop: assistant turn + tool_result user turn.
|
|
3211
3297
|
// Filter server_tool_use blocks (Anthropic API rejects them in
|
|
3212
3298
|
// subsequent message turns).
|
|
@@ -3220,6 +3306,220 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3220
3306
|
content: toolResults,
|
|
3221
3307
|
});
|
|
3222
3308
|
}
|
|
3309
|
+
// Terminal handling — the loop exited without a model-initiated
|
|
3310
|
+
// finish (step budget exhausted, or the turn was aborted). Never end
|
|
3311
|
+
// the stream empty: force the reserved final_result step on
|
|
3312
|
+
// structured-output turns, synthesize a plain-text answer on tool
|
|
3313
|
+
// turns, or push one graceful cap chunk. Mirrors the generate twin
|
|
3314
|
+
// and the Gemini paths (ed289b7 / PR #1123). The finalization and
|
|
3315
|
+
// backstop calls emit no per-call generation span (matching the
|
|
3316
|
+
// Gemini synthesizeFinalAnswerWithoutTools precedent); their tokens
|
|
3317
|
+
// still land in `usage` and the turn-span metadata.
|
|
3318
|
+
if (!modelFinished) {
|
|
3319
|
+
// A caller abort that landed during the FINAL budgeted step's tool
|
|
3320
|
+
// execution leaves wasAborted=false (no loop-entry check runs after
|
|
3321
|
+
// a budget exit) — re-check before issuing any terminal model call.
|
|
3322
|
+
if (options.abortSignal?.aborted) {
|
|
3323
|
+
wasAborted = true;
|
|
3324
|
+
}
|
|
3325
|
+
const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
3326
|
+
if (wasAborted) {
|
|
3327
|
+
// Budget already blown — never issue another model call. Deliver
|
|
3328
|
+
// exactly one graceful cap chunk if nothing reached the consumer
|
|
3329
|
+
// (live deltas already delivered count as "something reached").
|
|
3330
|
+
logger.warn("[GoogleVertex] Native Anthropic stream loop aborted mid-turn; returning a graceful cap message.");
|
|
3331
|
+
if (aggregatedTurnText.length === 0 &&
|
|
3332
|
+
liveTextPushedLength === 0 &&
|
|
3333
|
+
!structuredOutputRef.value) {
|
|
3334
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
3335
|
+
channel.push(capMessage);
|
|
3336
|
+
aggregatedTurnText = capMessage;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
else if (useFinalResultTool) {
|
|
3340
|
+
hitStepLimit = true;
|
|
3341
|
+
logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
|
|
3342
|
+
try {
|
|
3343
|
+
// Reserved finalization step: identical request except
|
|
3344
|
+
// tool_choice pins final_result — Anthropic guarantees a
|
|
3345
|
+
// final_result tool_use. Cache treatment is byte-identical to
|
|
3346
|
+
// loop steps. No text listener: forced tool calls stream no
|
|
3347
|
+
// text deltas, and the structured JSON is pushed once below.
|
|
3348
|
+
const cachedFinal = applyVertexAnthropicCacheBreakpoints({
|
|
3349
|
+
system: systemPromptWithSchema,
|
|
3350
|
+
tools,
|
|
3351
|
+
messages: currentMessages,
|
|
3352
|
+
});
|
|
3353
|
+
const finalizationStream = await client.messages.stream({
|
|
3354
|
+
...requestParams,
|
|
3355
|
+
tool_choice: {
|
|
3356
|
+
type: "tool",
|
|
3357
|
+
name: "final_result",
|
|
3358
|
+
},
|
|
3359
|
+
...(cachedFinal.system !== undefined && {
|
|
3360
|
+
system: cachedFinal.system,
|
|
3361
|
+
}),
|
|
3362
|
+
...(cachedFinal.tools &&
|
|
3363
|
+
cachedFinal.tools.length > 0 && {
|
|
3364
|
+
tools: cachedFinal.tools,
|
|
3365
|
+
}),
|
|
3366
|
+
messages: cachedFinal.messages,
|
|
3367
|
+
});
|
|
3368
|
+
// Mid-flight aborts are covered by the shared abortHandler via
|
|
3369
|
+
// activeStream; already-fired aborts were handled by the
|
|
3370
|
+
// terminal-entry re-check above.
|
|
3371
|
+
activeStream = finalizationStream;
|
|
3372
|
+
const response = await finalizationStream.finalMessage();
|
|
3373
|
+
activeStream = undefined;
|
|
3374
|
+
usage.input += response.usage?.input_tokens || 0;
|
|
3375
|
+
usage.output += response.usage?.output_tokens || 0;
|
|
3376
|
+
usage.total = usage.input + usage.output;
|
|
3377
|
+
turnCacheUsage.read +=
|
|
3378
|
+
response.usage?.cache_read_input_tokens ?? 0;
|
|
3379
|
+
turnCacheUsage.creation +=
|
|
3380
|
+
response.usage?.cache_creation_input_tokens ?? 0;
|
|
3381
|
+
turnCacheUsage.creation5m +=
|
|
3382
|
+
response.usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
|
|
3383
|
+
turnCacheUsage.creation1h +=
|
|
3384
|
+
response.usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
3385
|
+
lastStopReason = response.stop_reason;
|
|
3386
|
+
const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
|
|
3387
|
+
if (forcedFinalResult) {
|
|
3388
|
+
structuredOutputRef.value = forcedFinalResult.input;
|
|
3389
|
+
channel.push(JSON.stringify(forcedFinalResult.input));
|
|
3390
|
+
synthesizedFinalAnswer = true;
|
|
3391
|
+
logger.debug("[GoogleVertex] Forced finalization returned structured output (stream)", { keys: Object.keys(forcedFinalResult.input) });
|
|
3392
|
+
}
|
|
3393
|
+
else {
|
|
3394
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
3395
|
+
channel.push(capMessage);
|
|
3396
|
+
aggregatedTurnText += capMessage;
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
catch (error) {
|
|
3400
|
+
activeStream = undefined;
|
|
3401
|
+
// An aborted finalization is a cancellation, not a step-cap
|
|
3402
|
+
// turn — keep finishReason mapping from lastStopReason.
|
|
3403
|
+
if (options.abortSignal?.aborted || isAbortError(error)) {
|
|
3404
|
+
hitStepLimit = false;
|
|
3405
|
+
}
|
|
3406
|
+
logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", {
|
|
3407
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3408
|
+
});
|
|
3409
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
3410
|
+
channel.push(capMessage);
|
|
3411
|
+
aggregatedTurnText += capMessage;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
else {
|
|
3415
|
+
hitStepLimit = true;
|
|
3416
|
+
if (aggregatedTurnText.length === 0) {
|
|
3417
|
+
// Pure tool-loop with no streamed text: one tools-disabled call
|
|
3418
|
+
// so the model answers from the gathered tool results, pushed
|
|
3419
|
+
// as a single chunk (matching the Gemini stream synth). NOTE:
|
|
3420
|
+
// the tools array must stay in the request — Anthropic rejects
|
|
3421
|
+
// histories containing tool_use/tool_result blocks without a
|
|
3422
|
+
// tools param — so "tools disabled" is tool_choice:"none",
|
|
3423
|
+
// which also keeps the cached tools prefix byte-identical.
|
|
3424
|
+
logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
|
|
3425
|
+
try {
|
|
3426
|
+
const backstopSystem = (systemPromptWithSchema
|
|
3427
|
+
? systemPromptWithSchema + "\n\n"
|
|
3428
|
+
: "") +
|
|
3429
|
+
"Tool calling is no longer available for this turn. " +
|
|
3430
|
+
"Provide your final answer directly as plain text now, " +
|
|
3431
|
+
"using the information gathered so far.";
|
|
3432
|
+
const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
|
|
3433
|
+
system: backstopSystem,
|
|
3434
|
+
tools,
|
|
3435
|
+
messages: currentMessages,
|
|
3436
|
+
});
|
|
3437
|
+
const backstopStream = await client.messages.stream({
|
|
3438
|
+
...requestParams,
|
|
3439
|
+
tool_choice: { type: "none" },
|
|
3440
|
+
system: cachedBackstop.system,
|
|
3441
|
+
...(cachedBackstop.tools &&
|
|
3442
|
+
cachedBackstop.tools.length > 0 && {
|
|
3443
|
+
tools: cachedBackstop.tools,
|
|
3444
|
+
}),
|
|
3445
|
+
messages: cachedBackstop.messages,
|
|
3446
|
+
});
|
|
3447
|
+
activeStream = backstopStream;
|
|
3448
|
+
const response = await backstopStream.finalMessage();
|
|
3449
|
+
activeStream = undefined;
|
|
3450
|
+
usage.input += response.usage?.input_tokens || 0;
|
|
3451
|
+
usage.output += response.usage?.output_tokens || 0;
|
|
3452
|
+
usage.total = usage.input + usage.output;
|
|
3453
|
+
turnCacheUsage.read +=
|
|
3454
|
+
response.usage?.cache_read_input_tokens ?? 0;
|
|
3455
|
+
turnCacheUsage.creation +=
|
|
3456
|
+
response.usage?.cache_creation_input_tokens ?? 0;
|
|
3457
|
+
turnCacheUsage.creation5m +=
|
|
3458
|
+
response.usage?.cache_creation?.ephemeral_5m_input_tokens ??
|
|
3459
|
+
0;
|
|
3460
|
+
turnCacheUsage.creation1h +=
|
|
3461
|
+
response.usage?.cache_creation?.ephemeral_1h_input_tokens ??
|
|
3462
|
+
0;
|
|
3463
|
+
lastStopReason = response.stop_reason;
|
|
3464
|
+
const backstopText = response.content
|
|
3465
|
+
.filter((block) => block.type === "text")
|
|
3466
|
+
.map((b) => b.text)
|
|
3467
|
+
.join("");
|
|
3468
|
+
if (backstopText) {
|
|
3469
|
+
synthesizedFinalAnswer = true;
|
|
3470
|
+
channel.push(backstopText);
|
|
3471
|
+
aggregatedTurnText = backstopText;
|
|
3472
|
+
}
|
|
3473
|
+
else {
|
|
3474
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
3475
|
+
channel.push(capMessage);
|
|
3476
|
+
aggregatedTurnText = capMessage;
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
catch (error) {
|
|
3480
|
+
activeStream = undefined;
|
|
3481
|
+
// An aborted backstop is a cancellation, not a step-cap
|
|
3482
|
+
// turn — keep finishReason mapping from lastStopReason.
|
|
3483
|
+
if (options.abortSignal?.aborted || isAbortError(error)) {
|
|
3484
|
+
hitStepLimit = false;
|
|
3485
|
+
}
|
|
3486
|
+
logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
|
|
3487
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3488
|
+
});
|
|
3489
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
3490
|
+
channel.push(capMessage);
|
|
3491
|
+
aggregatedTurnText = capMessage;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
// else: prose already streamed to the consumer across steps —
|
|
3495
|
+
// add nothing; finishReason "tool-calls" flags the capped turn.
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
// Absolute backstop — never close the stream empty on a turn that
|
|
3499
|
+
// ran tools (mirrors the generate twin's guard). Catches the
|
|
3500
|
+
// model-finished-with-empty-content exit, which skips the terminal
|
|
3501
|
+
// recovery above.
|
|
3502
|
+
const deliveredNothing = aggregatedTurnText.length === 0 &&
|
|
3503
|
+
liveTextPushedLength === 0 &&
|
|
3504
|
+
!structuredOutputRef.value;
|
|
3505
|
+
const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
3506
|
+
if (deliveredNothing && externalToolCallTotal > 0) {
|
|
3507
|
+
const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallTotal);
|
|
3508
|
+
channel.push(capMessage);
|
|
3509
|
+
aggregatedTurnText = capMessage;
|
|
3510
|
+
}
|
|
3511
|
+
// Honest finish reason (same mapping as the generate twin): "length"
|
|
3512
|
+
// takes precedence, a capped turn without a clean/forced answer is
|
|
3513
|
+
// "tool-calls", everything else is "stop". Mirrored onto metadata
|
|
3514
|
+
// (a mutable reference) because wrapper spreads snapshot the
|
|
3515
|
+
// top-level getter before this line runs.
|
|
3516
|
+
finishReasonRef.value =
|
|
3517
|
+
lastStopReason === "max_tokens"
|
|
3518
|
+
? "length"
|
|
3519
|
+
: hitStepLimit && !synthesizedFinalAnswer
|
|
3520
|
+
? "tool-calls"
|
|
3521
|
+
: "stop";
|
|
3522
|
+
metadata.finishReason = finishReasonRef.value;
|
|
3223
3523
|
metadata.responseTime = Date.now() - startTime;
|
|
3224
3524
|
metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
3225
3525
|
// Surface cache metrics once, after the agentic loop, from the
|
|
@@ -3315,6 +3615,13 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3315
3615
|
configurable: true,
|
|
3316
3616
|
get: () => structuredOutputRef.value,
|
|
3317
3617
|
});
|
|
3618
|
+
// Resolved by the background loop right before channel.close(); consumers
|
|
3619
|
+
// read it after draining the stream (same contract as usage/metadata).
|
|
3620
|
+
Object.defineProperty(result, "finishReason", {
|
|
3621
|
+
enumerable: true,
|
|
3622
|
+
configurable: true,
|
|
3623
|
+
get: () => finishReasonRef.value,
|
|
3624
|
+
});
|
|
3318
3625
|
return result;
|
|
3319
3626
|
}
|
|
3320
3627
|
/**
|
|
@@ -3578,8 +3885,21 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3578
3885
|
};
|
|
3579
3886
|
// Handle tool calling loop with max steps
|
|
3580
3887
|
const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
|
|
3888
|
+
// Reserve the LAST step of the budget for a forced final_result call when
|
|
3889
|
+
// structured output is active. tool_choice:"any" (set above) forces a tool
|
|
3890
|
+
// call on every assistant turn, so the toolUseBlocks.length===0 text exit
|
|
3891
|
+
// is structurally unreachable — without a reserved finalization step, a
|
|
3892
|
+
// turn whose budget is consumed by other tool calls (runaway loop, or all
|
|
3893
|
+
// tools failing) ends with content:"" and a masking finishReason "stop".
|
|
3894
|
+
const agenticStepBudget = useFinalResultTool
|
|
3895
|
+
? Math.max(maxSteps - 1, 0)
|
|
3896
|
+
: maxSteps;
|
|
3581
3897
|
let step = 0;
|
|
3582
3898
|
let finalText = "";
|
|
3899
|
+
// Prose produced mid-loop alongside tool calls, accumulated across steps
|
|
3900
|
+
// via appendStepText — surfaced if the step budget runs out (mirrors the
|
|
3901
|
+
// Gemini paths' accumulatedText; last-step-only would drop earlier prose).
|
|
3902
|
+
let accumulatedStepText = "";
|
|
3583
3903
|
let structuredOutput;
|
|
3584
3904
|
const allToolCalls = [];
|
|
3585
3905
|
let totalInputTokens = 0;
|
|
@@ -3594,8 +3914,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3594
3914
|
// (notably "length" on token truncation) — the legacy native path always
|
|
3595
3915
|
// reported "stop", hiding truncation from callers.
|
|
3596
3916
|
let lastStopReason;
|
|
3917
|
+
// Step-cap / abort bookkeeping (mirrors the native Gemini loops): the
|
|
3918
|
+
// terminal recovery block below and the finishReason mapping both read
|
|
3919
|
+
// these to distinguish clean finishes, capped turns, and aborted turns.
|
|
3920
|
+
let wasAborted = false;
|
|
3921
|
+
let hitStepLimit = false;
|
|
3922
|
+
let synthesizedFinalAnswer = false;
|
|
3923
|
+
let modelFinished = false;
|
|
3597
3924
|
const currentMessages = [...messages];
|
|
3598
|
-
while (step <
|
|
3925
|
+
while (step < agenticStepBudget) {
|
|
3926
|
+
// Honor caller aborts BETWEEN steps: break into terminal handling
|
|
3927
|
+
// instead of throwing (a throw routes consumers into abortSignal-less
|
|
3928
|
+
// fallback retries — observed in production as a 600s abort no-op).
|
|
3929
|
+
if (options.abortSignal?.aborted) {
|
|
3930
|
+
wasAborted = true;
|
|
3931
|
+
break;
|
|
3932
|
+
}
|
|
3599
3933
|
step++;
|
|
3600
3934
|
try {
|
|
3601
3935
|
// Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
|
|
@@ -3612,6 +3946,9 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3612
3946
|
tools,
|
|
3613
3947
|
messages: currentMessages,
|
|
3614
3948
|
});
|
|
3949
|
+
// The caller's abortSignal rides as an SDK request option so a
|
|
3950
|
+
// mid-flight abort cancels the HTTP call itself (the SDK rejects with
|
|
3951
|
+
// an abort-shaped error the per-step catch below turns into a break).
|
|
3615
3952
|
const response = await withTimeout(client.messages.create({
|
|
3616
3953
|
...requestParams,
|
|
3617
3954
|
...(cachedGenerate.system !== undefined && {
|
|
@@ -3622,7 +3959,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3622
3959
|
tools: cachedGenerate.tools,
|
|
3623
3960
|
}),
|
|
3624
3961
|
messages: cachedGenerate.messages,
|
|
3625
|
-
}), generateTimeoutMs, "Anthropic generate timed out");
|
|
3962
|
+
}, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
|
|
3626
3963
|
// Update token counts. input_tokens is the uncached remainder; cache
|
|
3627
3964
|
// reads/writes are reported separately and accumulated here so the
|
|
3628
3965
|
// result reflects the full picture.
|
|
@@ -3641,6 +3978,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3641
3978
|
// Extract structured output and convert to JSON string for finalText
|
|
3642
3979
|
structuredOutput = finalResultCall.input;
|
|
3643
3980
|
finalText = JSON.stringify(structuredOutput);
|
|
3981
|
+
modelFinished = true;
|
|
3644
3982
|
logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { keys: Object.keys(structuredOutput) });
|
|
3645
3983
|
break; // We have the structured output, we're done
|
|
3646
3984
|
}
|
|
@@ -3650,10 +3988,13 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3650
3988
|
const responseText = textBlocks.map((b) => b.text).join("");
|
|
3651
3989
|
if (toolUseBlocks.length === 0) {
|
|
3652
3990
|
// No tool calls, we're done
|
|
3653
|
-
finalText = responseText ||
|
|
3991
|
+
finalText = responseText || accumulatedStepText;
|
|
3992
|
+
modelFinished = true;
|
|
3654
3993
|
break;
|
|
3655
3994
|
}
|
|
3656
|
-
// Handle tool calls
|
|
3995
|
+
// Handle tool calls. The array also carries the trailing soft-budget
|
|
3996
|
+
// nudge text block appended below (tool_result blocks stay first, as
|
|
3997
|
+
// the Anthropic API requires).
|
|
3657
3998
|
const toolResults = [];
|
|
3658
3999
|
// Per-step bookkeeping for conversation-memory storage. Tracks calls
|
|
3659
4000
|
// and results for ONLY the tools fired in this step so the storage
|
|
@@ -3704,6 +4045,21 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3704
4045
|
});
|
|
3705
4046
|
}
|
|
3706
4047
|
catch (err) {
|
|
4048
|
+
// An aborted tool call is a cancellation, not a tool failure —
|
|
4049
|
+
// break the turn without recording an error execution/result.
|
|
4050
|
+
if (options.abortSignal?.aborted || isAbortError(err)) {
|
|
4051
|
+
// Keep persisted tool history paired: the call row was
|
|
4052
|
+
// already pushed above, so record a neutral cancellation
|
|
4053
|
+
// result (NOT an error) — an unpaired tool_use replayed on
|
|
4054
|
+
// the next turn would be rejected by the Anthropic API.
|
|
4055
|
+
stepStorageResults.push({
|
|
4056
|
+
toolCallId: toolUse.id,
|
|
4057
|
+
toolName: toolUse.name,
|
|
4058
|
+
output: { aborted: true },
|
|
4059
|
+
});
|
|
4060
|
+
wasAborted = true;
|
|
4061
|
+
break;
|
|
4062
|
+
}
|
|
3707
4063
|
const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
3708
4064
|
const errorPayload = { error: errMsg };
|
|
3709
4065
|
toolExecutions.push({
|
|
@@ -3747,6 +4103,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3747
4103
|
// Without this, tool_call / tool_result rows never reach Redis and
|
|
3748
4104
|
// the chat-history UI loses every tool invocation.
|
|
3749
4105
|
// Fire-and-forget — storage failures must not break generation.
|
|
4106
|
+
// Runs BEFORE the abort break below so tools that DID complete in an
|
|
4107
|
+
// aborted step (real side effects) still reach the chat history.
|
|
3750
4108
|
if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
|
|
3751
4109
|
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
4110
|
logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
|
|
@@ -3754,6 +4112,26 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3754
4112
|
});
|
|
3755
4113
|
});
|
|
3756
4114
|
}
|
|
4115
|
+
// An abort inside the tool-exec loop only breaks that inner for-loop.
|
|
4116
|
+
// Break the while too so no further model call is issued and control
|
|
4117
|
+
// reaches the terminal step-cap handling below.
|
|
4118
|
+
if (wasAborted) {
|
|
4119
|
+
break;
|
|
4120
|
+
}
|
|
4121
|
+
// Soft budget nudge: with the step cap approaching, tell the model to
|
|
4122
|
+
// wrap up so the reserved forced-finalization call below stays a
|
|
4123
|
+
// fallback, not the norm. Rides as a trailing text block on the
|
|
4124
|
+
// tool_result user turn (cache-safe: it lives in the growing tail).
|
|
4125
|
+
const stepsRemaining = agenticStepBudget - step;
|
|
4126
|
+
if (stepsRemaining > 0 && stepsRemaining <= 3) {
|
|
4127
|
+
toolResults.push({
|
|
4128
|
+
type: "text",
|
|
4129
|
+
text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
|
|
4130
|
+
(useFinalResultTool
|
|
4131
|
+
? "call final_result with your best answer."
|
|
4132
|
+
: "provide your final answer."),
|
|
4133
|
+
});
|
|
4134
|
+
}
|
|
3757
4135
|
// Add assistant message and tool results to continue the loop
|
|
3758
4136
|
// Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
|
|
3759
4137
|
const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
|
|
@@ -3765,25 +4143,193 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3765
4143
|
role: "user",
|
|
3766
4144
|
content: toolResults,
|
|
3767
4145
|
});
|
|
3768
|
-
//
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
}
|
|
4146
|
+
// Accumulate the step's prose so a capped turn can still surface it —
|
|
4147
|
+
// finalText is reserved for model-initiated finishes.
|
|
4148
|
+
accumulatedStepText = appendStepText(accumulatedStepText, responseText);
|
|
3772
4149
|
}
|
|
3773
4150
|
catch (error) {
|
|
4151
|
+
// A mid-request abort surfaces as an abort-shaped SDK rejection (we
|
|
4152
|
+
// pass options.abortSignal as the request signal above). Break
|
|
4153
|
+
// gracefully into the terminal handling instead of re-throwing — a
|
|
4154
|
+
// re-throw routes the caller's abort into abortSignal-less fallback
|
|
4155
|
+
// retries. Dual check as in the Gemini loops: the caller's signal OR
|
|
4156
|
+
// an abort-shaped error either way means "stop", not a real failure.
|
|
4157
|
+
if (options.abortSignal?.aborted || isAbortError(error)) {
|
|
4158
|
+
wasAborted = true;
|
|
4159
|
+
break;
|
|
4160
|
+
}
|
|
3774
4161
|
logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
|
|
3775
4162
|
throw this.handleProviderError(error);
|
|
3776
4163
|
}
|
|
3777
4164
|
}
|
|
4165
|
+
// Terminal handling — the loop exited without a model-initiated finish
|
|
4166
|
+
// (step budget exhausted, or the turn was aborted). Never return "":
|
|
4167
|
+
// force the reserved final_result step on structured-output turns,
|
|
4168
|
+
// synthesize a plain-text answer on tool turns, or fall back to a
|
|
4169
|
+
// graceful cap message. Mirrors the Gemini paths (ed289b7 / PR #1123).
|
|
4170
|
+
if (!modelFinished && !finalText) {
|
|
4171
|
+
// A caller abort that landed during the FINAL budgeted step's tool
|
|
4172
|
+
// execution leaves wasAborted=false (no loop-entry check runs after a
|
|
4173
|
+
// budget exit) — re-check before issuing any terminal model call.
|
|
4174
|
+
if (options.abortSignal?.aborted) {
|
|
4175
|
+
wasAborted = true;
|
|
4176
|
+
}
|
|
4177
|
+
const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
4178
|
+
if (wasAborted) {
|
|
4179
|
+
// Budget already blown — never issue another model call; prefer the
|
|
4180
|
+
// prose the model already produced (mirrors the Gemini abort path),
|
|
4181
|
+
// else answer with a graceful cap message. finishReason keeps mapping
|
|
4182
|
+
// from lastStopReason (an aborted turn is not a step-cap turn).
|
|
4183
|
+
logger.warn("[GoogleVertex] Native Anthropic generate loop aborted mid-turn; returning gathered text or a graceful cap message.");
|
|
4184
|
+
finalText =
|
|
4185
|
+
accumulatedStepText ||
|
|
4186
|
+
buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
4187
|
+
}
|
|
4188
|
+
else if (useFinalResultTool) {
|
|
4189
|
+
hitStepLimit = true;
|
|
4190
|
+
logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
|
|
4191
|
+
try {
|
|
4192
|
+
// Reserved finalization step: identical request except tool_choice
|
|
4193
|
+
// pins final_result, so Anthropic guarantees the response is a
|
|
4194
|
+
// final_result tool_use. Cache treatment is byte-identical to loop
|
|
4195
|
+
// steps (same applyVertexAnthropicCacheBreakpoints on the same
|
|
4196
|
+
// stable system/tools prefix), so caching is unaffected.
|
|
4197
|
+
const cachedFinal = applyVertexAnthropicCacheBreakpoints({
|
|
4198
|
+
system: systemPromptWithSchema,
|
|
4199
|
+
tools,
|
|
4200
|
+
messages: currentMessages,
|
|
4201
|
+
});
|
|
4202
|
+
const response = await withTimeout(client.messages.create({
|
|
4203
|
+
...requestParams,
|
|
4204
|
+
tool_choice: {
|
|
4205
|
+
type: "tool",
|
|
4206
|
+
name: "final_result",
|
|
4207
|
+
},
|
|
4208
|
+
...(cachedFinal.system !== undefined && {
|
|
4209
|
+
system: cachedFinal.system,
|
|
4210
|
+
}),
|
|
4211
|
+
...(cachedFinal.tools &&
|
|
4212
|
+
cachedFinal.tools.length > 0 && {
|
|
4213
|
+
tools: cachedFinal.tools,
|
|
4214
|
+
}),
|
|
4215
|
+
messages: cachedFinal.messages,
|
|
4216
|
+
}, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic finalization call timed out");
|
|
4217
|
+
totalInputTokens += response.usage?.input_tokens || 0;
|
|
4218
|
+
totalOutputTokens += response.usage?.output_tokens || 0;
|
|
4219
|
+
totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
|
|
4220
|
+
totalCacheCreationTokens +=
|
|
4221
|
+
response.usage?.cache_creation_input_tokens || 0;
|
|
4222
|
+
lastStopReason = response.stop_reason;
|
|
4223
|
+
const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
|
|
4224
|
+
if (forcedFinalResult) {
|
|
4225
|
+
structuredOutput = forcedFinalResult.input;
|
|
4226
|
+
finalText = JSON.stringify(structuredOutput);
|
|
4227
|
+
synthesizedFinalAnswer = true;
|
|
4228
|
+
logger.debug("[GoogleVertex] Forced finalization returned structured output (generate)", { keys: Object.keys(structuredOutput) });
|
|
4229
|
+
}
|
|
4230
|
+
else {
|
|
4231
|
+
finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
catch (error) {
|
|
4235
|
+
// An aborted finalization is a cancellation, not a step-cap turn —
|
|
4236
|
+
// keep finishReason mapping from lastStopReason, not "tool-calls".
|
|
4237
|
+
if (options.abortSignal?.aborted || isAbortError(error)) {
|
|
4238
|
+
hitStepLimit = false;
|
|
4239
|
+
}
|
|
4240
|
+
logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", { error: error instanceof Error ? error.message : String(error) });
|
|
4241
|
+
finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
else {
|
|
4245
|
+
hitStepLimit = true;
|
|
4246
|
+
if (accumulatedStepText) {
|
|
4247
|
+
// Prefer the prose the model already produced across steps.
|
|
4248
|
+
logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}); returning text already gathered from prior steps.`);
|
|
4249
|
+
finalText = accumulatedStepText;
|
|
4250
|
+
}
|
|
4251
|
+
else {
|
|
4252
|
+
// Pure tool-loop with no text: one tools-disabled call so the model
|
|
4253
|
+
// answers from the gathered tool results (Anthropic twin of the
|
|
4254
|
+
// Gemini synthesizeFinalAnswerWithoutTools). NOTE: the tools array
|
|
4255
|
+
// must stay in the request — Anthropic rejects requests whose
|
|
4256
|
+
// history contains tool_use/tool_result blocks without a tools
|
|
4257
|
+
// param — so "tools disabled" is expressed as tool_choice:"none",
|
|
4258
|
+
// which also keeps the cached tools prefix byte-identical.
|
|
4259
|
+
logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
|
|
4260
|
+
try {
|
|
4261
|
+
const backstopSystem = (systemPromptWithSchema ? systemPromptWithSchema + "\n\n" : "") +
|
|
4262
|
+
"Tool calling is no longer available for this turn. Provide " +
|
|
4263
|
+
"your final answer directly as plain text now, using the " +
|
|
4264
|
+
"information gathered so far.";
|
|
4265
|
+
const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
|
|
4266
|
+
system: backstopSystem,
|
|
4267
|
+
tools,
|
|
4268
|
+
messages: currentMessages,
|
|
4269
|
+
});
|
|
4270
|
+
const response = await withTimeout(client.messages.create({
|
|
4271
|
+
...requestParams,
|
|
4272
|
+
tool_choice: { type: "none" },
|
|
4273
|
+
system: cachedBackstop.system,
|
|
4274
|
+
...(cachedBackstop.tools &&
|
|
4275
|
+
cachedBackstop.tools.length > 0 && {
|
|
4276
|
+
tools: cachedBackstop.tools,
|
|
4277
|
+
}),
|
|
4278
|
+
messages: cachedBackstop.messages,
|
|
4279
|
+
}, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic backstop call timed out");
|
|
4280
|
+
totalInputTokens += response.usage?.input_tokens || 0;
|
|
4281
|
+
totalOutputTokens += response.usage?.output_tokens || 0;
|
|
4282
|
+
totalCacheReadTokens +=
|
|
4283
|
+
response.usage?.cache_read_input_tokens || 0;
|
|
4284
|
+
totalCacheCreationTokens +=
|
|
4285
|
+
response.usage?.cache_creation_input_tokens || 0;
|
|
4286
|
+
lastStopReason = response.stop_reason;
|
|
4287
|
+
const backstopText = response.content
|
|
4288
|
+
.filter((block) => block.type === "text")
|
|
4289
|
+
.map((b) => b.text)
|
|
4290
|
+
.join("");
|
|
4291
|
+
if (backstopText) {
|
|
4292
|
+
synthesizedFinalAnswer = true;
|
|
4293
|
+
finalText = backstopText;
|
|
4294
|
+
}
|
|
4295
|
+
else {
|
|
4296
|
+
finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
catch (error) {
|
|
4300
|
+
// An aborted backstop is a cancellation, not a step-cap turn —
|
|
4301
|
+
// keep finishReason mapping from lastStopReason, not "tool-calls".
|
|
4302
|
+
if (options.abortSignal?.aborted || isAbortError(error)) {
|
|
4303
|
+
hitStepLimit = false;
|
|
4304
|
+
}
|
|
4305
|
+
logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
|
|
4306
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4307
|
+
});
|
|
4308
|
+
finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
3778
4313
|
const responseTime = Date.now() - startTime;
|
|
3779
4314
|
const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
|
|
3780
4315
|
const externalToolExecutions = toolExecutions.filter((te) => te.name !== "final_result");
|
|
4316
|
+
// Absolute backstop — no code path may surface empty content on a turn
|
|
4317
|
+
// that ran tools (the consumer-facing "empty response" incident shape).
|
|
4318
|
+
if (!finalText && externalToolCalls.length > 0) {
|
|
4319
|
+
finalText = buildToolLoopCapMessage(maxSteps, externalToolCalls.length);
|
|
4320
|
+
}
|
|
3781
4321
|
const result = {
|
|
3782
4322
|
content: finalText,
|
|
3783
|
-
//
|
|
3784
|
-
//
|
|
3785
|
-
//
|
|
3786
|
-
|
|
4323
|
+
// Honest finish reason. "length" (token truncation) takes precedence; a
|
|
4324
|
+
// step-cap exhaustion without a clean/forced answer surfaces as
|
|
4325
|
+
// "tool-calls" (aligned with the Gemini paths, so consumers detect
|
|
4326
|
+
// capped turns uniformly); everything else — including a successful
|
|
4327
|
+
// forced finalization or backstop synthesis — is a normal "stop".
|
|
4328
|
+
finishReason: lastStopReason === "max_tokens"
|
|
4329
|
+
? "length"
|
|
4330
|
+
: hitStepLimit && !synthesizedFinalAnswer
|
|
4331
|
+
? "tool-calls"
|
|
4332
|
+
: "stop",
|
|
3787
4333
|
provider: this.providerName,
|
|
3788
4334
|
model: modelName,
|
|
3789
4335
|
usage: {
|
|
@@ -4252,10 +4798,35 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4252
4798
|
}
|
|
4253
4799
|
},
|
|
4254
4800
|
};
|
|
4255
|
-
return {
|
|
4801
|
+
return this.preserveStreamResultAccessors(result, {
|
|
4256
4802
|
...result,
|
|
4257
4803
|
stream: wrappedIterable,
|
|
4258
|
-
};
|
|
4804
|
+
});
|
|
4805
|
+
}
|
|
4806
|
+
/**
|
|
4807
|
+
* Re-apply getter-based accessor properties from a source StreamResult onto
|
|
4808
|
+
* a wrapper copy. Wrapper spreads (`{ ...result }`) invoke and SNAPSHOT
|
|
4809
|
+
* enumerable getters at wrap time — for background-loop streams (the native
|
|
4810
|
+
* Anthropic path) that resolve finishReason / structuredOutput / toolCalls
|
|
4811
|
+
* only as the consumer drains, the snapshot is permanently undefined/empty.
|
|
4812
|
+
* Copying the accessor descriptors keeps the wrapped result live. Results
|
|
4813
|
+
* built from plain data properties (the buffered Gemini paths) have no
|
|
4814
|
+
* getters and pass through untouched.
|
|
4815
|
+
*/
|
|
4816
|
+
preserveStreamResultAccessors(source, wrapped) {
|
|
4817
|
+
for (const key of [
|
|
4818
|
+
"finishReason",
|
|
4819
|
+
"structuredOutput",
|
|
4820
|
+
"toolCalls",
|
|
4821
|
+
"toolsUsed",
|
|
4822
|
+
"toolExecutions",
|
|
4823
|
+
]) {
|
|
4824
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
|
4825
|
+
if (descriptor?.get) {
|
|
4826
|
+
Object.defineProperty(wrapped, key, descriptor);
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
return wrapped;
|
|
4259
4830
|
}
|
|
4260
4831
|
/**
|
|
4261
4832
|
* Attach `gen_ai.usage.*` and `neurolink.cost` attributes to a span.
|