@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +355 -351
- package/dist/context/stages/structuredSummarizer.js +15 -4
- package/dist/core/modules/GenerationHandler.js +28 -10
- package/dist/core/modules/structuredOutputPolicy.d.ts +19 -0
- package/dist/core/modules/structuredOutputPolicy.js +26 -0
- package/dist/lib/context/stages/structuredSummarizer.js +15 -4
- package/dist/lib/core/modules/GenerationHandler.js +28 -10
- package/dist/lib/core/modules/structuredOutputPolicy.d.ts +19 -0
- package/dist/lib/core/modules/structuredOutputPolicy.js +26 -0
- package/dist/lib/mcp/externalServerManager.js +21 -6
- package/dist/lib/mcp/mcpClientFactory.js +5 -1
- package/dist/lib/neurolink.js +58 -25
- package/dist/lib/providers/googleVertex.d.ts +11 -0
- package/dist/lib/providers/googleVertex.js +706 -38
- package/dist/lib/types/generate.d.ts +13 -0
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/lib/utils/conversationMemory.js +19 -8
- package/dist/lib/utils/logSanitize.d.ts +26 -0
- package/dist/lib/utils/logSanitize.js +56 -0
- package/dist/mcp/externalServerManager.js +21 -6
- package/dist/mcp/mcpClientFactory.js +5 -1
- package/dist/neurolink.js +58 -25
- package/dist/providers/googleVertex.d.ts +11 -0
- package/dist/providers/googleVertex.js +706 -38
- package/dist/types/generate.d.ts +13 -0
- package/dist/types/stream.d.ts +1 -0
- package/dist/utils/conversationMemory.js +19 -8
- package/dist/utils/logSanitize.d.ts +26 -0
- package/dist/utils/logSanitize.js +56 -0
- 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,
|
|
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",
|
|
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:
|
|
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 <
|
|
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
|
-
|
|
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
|
-
: (
|
|
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:
|
|
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 <
|
|
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 ||
|
|
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
|
-
: (
|
|
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
|
-
//
|
|
3769
|
-
|
|
3770
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3784
|
-
//
|
|
3785
|
-
//
|
|
3786
|
-
|
|
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
|
-
|
|
4080
|
-
|
|
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
|
|
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
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
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") ||
|