@juspay/neurolink 12.21.1 → 12.22.0
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 -4
- package/dist/browser/neurolink.min.js +398 -398
- package/dist/core/baseProvider.d.ts +18 -0
- package/dist/core/baseProvider.js +22 -0
- package/dist/core/modules/MessageBuilder.js +16 -0
- package/dist/factories/providerFactory.d.ts +20 -0
- package/dist/factories/providerFactory.js +100 -19
- package/dist/models/modelRegistry.d.ts +25 -0
- package/dist/models/modelRegistry.js +39 -0
- package/dist/providers/googleAiStudio/client.js +382 -50
- package/dist/providers/googleNativeGemini3/utils.d.ts +22 -0
- package/dist/providers/googleNativeGemini3/utils.js +24 -0
- package/dist/providers/googleVertex/client.d.ts +3 -12
- package/dist/providers/googleVertex/client.js +15 -34
- package/dist/providers/openaiChatCompletionsClient.js +8 -1
- package/dist/utils/stringDistance.d.ts +54 -0
- package/dist/utils/stringDistance.js +133 -0
- package/dist/utils/toolCallRepair.js +54 -55
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
9
9
|
import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
|
|
10
10
|
import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
|
|
11
11
|
import { runAgenticLoop } from "../../core/loopEngine.js";
|
|
12
|
-
import { DEFAULT_TOOL_MAX_RETRIES } from "../../core/constants.js";
|
|
12
|
+
import { DEFAULT_TOOL_MAX_RETRIES, resolveToolTimeoutMs, } from "../../core/constants.js";
|
|
13
13
|
import { isToolsSchemaExclusionInForce } from "../../core/modules/structuredOutputPolicy.js";
|
|
14
14
|
import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
|
|
15
15
|
import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
|
|
@@ -18,7 +18,7 @@ import { withTimeout } from "../../utils/async/index.js";
|
|
|
18
18
|
import { estimateTokens } from "../../utils/tokenEstimation.js";
|
|
19
19
|
import { transformToolExecutions } from "../../utils/transformationUtils.js";
|
|
20
20
|
import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
|
|
21
|
-
import { buildDedupedEngineTools, buildGeminiResponseSchema, buildNativeConfig, computeMaxSteps, createContextGuard, buildUserPartsWithMultimodal, extractThoughtSignature, handleMaxStepsTermination, prependConversationMessages, } from "../googleNativeGemini3/index.js";
|
|
21
|
+
import { buildDedupedEngineTools, buildGeminiResponseSchema, buildLoopExitMessage, buildNativeConfig, buildWrapupNudgeText, computeMaxSteps, createContextGuard, createTurnClock, buildUserPartsWithMultimodal, extractThoughtSignature, handleMaxStepsTermination, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, } from "../googleNativeGemini3/index.js";
|
|
22
22
|
import { createStreamChannel } from "../../core/streamChannel.js";
|
|
23
23
|
import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
|
|
24
24
|
import { warnGoogleSdkIgnoresProxy } from "../../proxy/proxyFetch.js";
|
|
@@ -687,8 +687,51 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
687
687
|
responseSchema: nativeResponseSchema,
|
|
688
688
|
}, toolsConfig);
|
|
689
689
|
const maxSteps = computeMaxSteps(options.maxSteps);
|
|
690
|
-
//
|
|
691
|
-
|
|
690
|
+
// Abort fan-in. The caller's signal, the pre-existing per-request
|
|
691
|
+
// timeout controller and the turn clock's watchdogs all trip one
|
|
692
|
+
// internal controller, and everything downstream (SDK request,
|
|
693
|
+
// engine, guarded tool executors) rides its signal — so a deadline
|
|
694
|
+
// reaches a tool mid-execution, not only the next step boundary.
|
|
695
|
+
const upstreamSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
|
|
696
|
+
const internalAbort = new AbortController();
|
|
697
|
+
const onUpstreamAbort = () => internalAbort.abort();
|
|
698
|
+
upstreamSignal?.addEventListener("abort", onUpstreamAbort);
|
|
699
|
+
if (upstreamSignal?.aborted) {
|
|
700
|
+
internalAbort.abort();
|
|
701
|
+
}
|
|
702
|
+
const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
|
|
703
|
+
// No `defaultTurnTimeoutMs`: this path already carries a whole-turn
|
|
704
|
+
// bound in `timeoutController`, and arming a second timer for the
|
|
705
|
+
// same instant would make which one fired a race. The clock owns an
|
|
706
|
+
// explicit `turnTimeoutMs` and the stall watchdog; the controller
|
|
707
|
+
// keeps the default deadline it always had, and `timedOut` below
|
|
708
|
+
// reads both so either one is reported as a time-limit exit.
|
|
709
|
+
const turnClock = createTurnClock({
|
|
710
|
+
...(options.turnTimeoutMs !== undefined
|
|
711
|
+
? { turnTimeoutMs: options.turnTimeoutMs }
|
|
712
|
+
: {}),
|
|
713
|
+
...(options.stallTimeoutMs !== undefined
|
|
714
|
+
? { stallTimeoutMs: options.stallTimeoutMs }
|
|
715
|
+
: {}),
|
|
716
|
+
...(options.wrapupTimeLeadMs !== undefined
|
|
717
|
+
? { wrapupTimeLeadMs: options.wrapupTimeLeadMs }
|
|
718
|
+
: {}),
|
|
719
|
+
onDeadline: (kind) => {
|
|
720
|
+
logger.warn(kind === "timeout"
|
|
721
|
+
? `[GoogleAIStudio] Native Gemini turn exceeded its ${options.turnTimeoutMs}ms time budget — aborting`
|
|
722
|
+
: `[GoogleAIStudio] Native Gemini turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
|
|
723
|
+
internalAbort.abort();
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
const composedSignal = internalAbort.signal;
|
|
727
|
+
/**
|
|
728
|
+
* True when a wall-clock bound ended the turn. Both the turn clock's
|
|
729
|
+
* explicit deadline and the pre-existing per-request controller are
|
|
730
|
+
* time limits, so an exit caused by either must not be reported as a
|
|
731
|
+
* bare caller abort.
|
|
732
|
+
*/
|
|
733
|
+
const hitTimeLimit = () => turnClock.timedOut ||
|
|
734
|
+
timeoutController?.controller.signal.aborted === true;
|
|
692
735
|
// Create a push-based text channel so the caller receives tokens as
|
|
693
736
|
// they arrive from the network rather than after full buffering.
|
|
694
737
|
const channel = createStreamChannel();
|
|
@@ -708,6 +751,9 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
708
751
|
});
|
|
709
752
|
// Shared metadata object mutated by the background loop so the
|
|
710
753
|
// returned object reflects the final values after stream completion.
|
|
754
|
+
// Typed against StreamResult so the turn-exit fields the loop fills
|
|
755
|
+
// in at close (stopReason / rawFinishReason / stepsUsed) are the
|
|
756
|
+
// declared ones rather than late additions to an inferred shape.
|
|
711
757
|
const metadata = {
|
|
712
758
|
streamId: `native-${Date.now()}`,
|
|
713
759
|
startTime,
|
|
@@ -723,6 +769,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
723
769
|
let totalCacheReadTokens = 0;
|
|
724
770
|
let totalReasoningTokens = 0;
|
|
725
771
|
let step = 0;
|
|
772
|
+
// Model calls the engine actually made, reported from the
|
|
773
|
+
// per-step request hook. `step` above counts only steps that
|
|
774
|
+
// produced tool calls, so it misses the final text-only step and
|
|
775
|
+
// would under-report `stepsUsed`, which is a public field.
|
|
776
|
+
let stepsTaken = 0;
|
|
777
|
+
let wasAborted = false;
|
|
778
|
+
let lastFinishReason;
|
|
726
779
|
// Cheap trigger for the in-turn reclaim, mirroring the Vertex twin.
|
|
727
780
|
// Planning serializes the WHOLE accumulated history to estimate it,
|
|
728
781
|
// so running it unconditionally charges that once per step for the
|
|
@@ -744,6 +797,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
744
797
|
// what makes an always-failing tool dispatch exactly twice.
|
|
745
798
|
toolFailureBreaker: { maxRetries: DEFAULT_TOOL_MAX_RETRIES },
|
|
746
799
|
liveTools: options.tools ?? {},
|
|
800
|
+
// A tool hydrated mid-turn gets the same bound, abort race
|
|
801
|
+
// and stall ping as one declared up front.
|
|
802
|
+
toolGuards: {
|
|
803
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
804
|
+
abortSignal: composedSignal,
|
|
805
|
+
onProgress: () => turnClock.noteProgress(),
|
|
806
|
+
},
|
|
747
807
|
...(declarationsResult
|
|
748
808
|
? { declarations: declarationsResult }
|
|
749
809
|
: {}),
|
|
@@ -751,11 +811,23 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
751
811
|
model: modelName,
|
|
752
812
|
contents,
|
|
753
813
|
config,
|
|
754
|
-
...(composedSignal
|
|
755
|
-
? { httpOptions: { signal: composedSignal } }
|
|
756
|
-
: {}),
|
|
757
814
|
}),
|
|
758
|
-
|
|
815
|
+
// `config.abortSignal` is the ONLY cancellation channel
|
|
816
|
+
// @google/genai reads (ApiClient forwards
|
|
817
|
+
// `params.config.abortSignal` to fetch). This loop previously
|
|
818
|
+
// passed a top-level `httpOptions.signal`, which is not a key
|
|
819
|
+
// the SDK looks at, so nothing could cancel an in-flight
|
|
820
|
+
// request — a caller abort or a blown deadline only took
|
|
821
|
+
// effect at the next step boundary, and never at all against a
|
|
822
|
+
// provider that had gone quiet mid-stream.
|
|
823
|
+
sendStep: async (request, signal) => {
|
|
824
|
+
turnClock.noteProgress();
|
|
825
|
+
const built = request;
|
|
826
|
+
return client.models.generateContentStream({
|
|
827
|
+
...built,
|
|
828
|
+
config: { ...(built.config ?? {}), abortSignal: signal },
|
|
829
|
+
});
|
|
830
|
+
},
|
|
759
831
|
noteUsage: (inputTokens, outputTokens) => {
|
|
760
832
|
contextGuard.noteUsage(inputTokens, outputTokens);
|
|
761
833
|
},
|
|
@@ -780,6 +852,15 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
780
852
|
// late write and lose the per-step thought signature.
|
|
781
853
|
const adapter = {
|
|
782
854
|
...baseAdapter,
|
|
855
|
+
// Counted HERE, not in buildToolResultMessages: this runs once
|
|
856
|
+
// per model call, including the final text-only step that asks
|
|
857
|
+
// for no tools. Counting in the tool-result hook reports one
|
|
858
|
+
// step fewer for every turn that ends normally.
|
|
859
|
+
buildStepRequest: (contents, engineStep) => {
|
|
860
|
+
stepsTaken = engineStep + 1;
|
|
861
|
+
turnClock.noteProgress();
|
|
862
|
+
return baseAdapter.buildStepRequest(contents, engineStep);
|
|
863
|
+
},
|
|
783
864
|
buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
|
|
784
865
|
// The engine's own step, not a count of times this hook ran.
|
|
785
866
|
// The two agree only while nothing skips the hook mid-turn:
|
|
@@ -832,6 +913,19 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
832
913
|
});
|
|
833
914
|
}
|
|
834
915
|
const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
|
|
916
|
+
// Time-budget wrap-up nudge (twin of the Vertex Gemini
|
|
917
|
+
// loops'): with the turn deadline approaching, tell the
|
|
918
|
+
// model to consolidate. Rides as a trailing text part on the
|
|
919
|
+
// tool-response user turn. Only fires against an EXPLICIT
|
|
920
|
+
// turnTimeoutMs — createTurnClock refuses to nudge against a
|
|
921
|
+
// defensive default, so no caller gains injected prompt text
|
|
922
|
+
// without asking for a time budget.
|
|
923
|
+
if (turnClock.shouldNudgeWrapup()) {
|
|
924
|
+
const last = next[next.length - 1];
|
|
925
|
+
if (last && Array.isArray(last.parts)) {
|
|
926
|
+
last.parts.push({ text: buildWrapupNudgeText(false) });
|
|
927
|
+
}
|
|
928
|
+
}
|
|
835
929
|
// Project this step's growth: the appended tool results ride
|
|
836
930
|
// the next prompt, which the provider has not reported on yet.
|
|
837
931
|
try {
|
|
@@ -847,45 +941,82 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
847
941
|
// Through the turn's DedupExecuteMap, NOT the raw executors:
|
|
848
942
|
// `.get()` returns the dedup wrapper that answers an identical
|
|
849
943
|
// repeated {name, args} from the per-turn cache (BZ-3327).
|
|
850
|
-
const engineTools = buildDedupedEngineTools(declarationsResult, options.tools
|
|
944
|
+
const engineTools = buildDedupedEngineTools(declarationsResult, options.tools, {
|
|
945
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
946
|
+
abortSignal: composedSignal,
|
|
947
|
+
onProgress: () => turnClock.noteProgress(),
|
|
948
|
+
});
|
|
851
949
|
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
|
|
852
950
|
tools: engineTools,
|
|
853
|
-
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
...(options.toolTimeoutMs !== undefined
|
|
859
|
-
? { toolTimeoutMs: options.toolTimeoutMs }
|
|
860
|
-
: {}),
|
|
951
|
+
abortSignal: composedSignal,
|
|
952
|
+
// The engine bounds every tool call itself. Passing the same
|
|
953
|
+
// value the guards above use keeps the engine's backstop from
|
|
954
|
+
// being tighter than what the caller asked for.
|
|
955
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
861
956
|
});
|
|
862
957
|
const pump = (async () => {
|
|
863
958
|
for await (const chunk of engineStream) {
|
|
959
|
+
// Every chunk is progress, which is what keeps a productive
|
|
960
|
+
// but slow turn away from the stall watchdog.
|
|
961
|
+
turnClock.noteProgress();
|
|
864
962
|
channel.push(chunk);
|
|
865
963
|
}
|
|
866
964
|
})();
|
|
867
965
|
let engineResult;
|
|
966
|
+
let turnFailure;
|
|
868
967
|
try {
|
|
869
968
|
engineResult = await resultPromise;
|
|
870
969
|
}
|
|
871
970
|
catch (error) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
971
|
+
turnFailure = error;
|
|
972
|
+
}
|
|
973
|
+
// Drained unconditionally and tolerantly: when the turn ends by
|
|
974
|
+
// abort the channel rejects too, and re-awaiting a settled
|
|
975
|
+
// rejection would rethrow the very error the branch below has
|
|
976
|
+
// already decided to absorb.
|
|
977
|
+
await drainDetachedPump(pump, "GoogleAIStudio");
|
|
978
|
+
if (turnFailure !== undefined) {
|
|
979
|
+
// A turn ended by its own time budget or by the caller is not
|
|
980
|
+
// a provider failure. Rethrowing one would surface a deadline
|
|
981
|
+
// as a network error and, worse, send the caller down an
|
|
982
|
+
// unbounded fallback path right after a blown budget. A
|
|
983
|
+
// provider timeout keeps throwing exactly as before, because
|
|
984
|
+
// classifying it is what produces the timeout error callers
|
|
985
|
+
// already handle.
|
|
986
|
+
if (turnClock.expired || options.abortSignal?.aborted) {
|
|
987
|
+
wasAborted = true;
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
logger.error("[GoogleAIStudio] Native SDK error", turnFailure);
|
|
991
|
+
throw this.handleProviderError(turnFailure);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (engineResult) {
|
|
995
|
+
totalInputTokens += engineResult.usage.inputTokens;
|
|
996
|
+
totalOutputTokens += engineResult.usage.outputTokens;
|
|
997
|
+
totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
|
|
998
|
+
totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
|
|
999
|
+
lastFinishReason =
|
|
1000
|
+
engineResult.rawStopReason ?? lastFinishReason;
|
|
1001
|
+
if (engineResult.aborted) {
|
|
1002
|
+
wasAborted = true;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (composedSignal.aborted) {
|
|
1006
|
+
wasAborted = true;
|
|
875
1007
|
}
|
|
876
|
-
await pump;
|
|
877
|
-
totalInputTokens += engineResult.usage.inputTokens;
|
|
878
|
-
totalOutputTokens += engineResult.usage.outputTokens;
|
|
879
|
-
totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
|
|
880
|
-
totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
|
|
881
1008
|
// The turn produced a final answer when the model stopped
|
|
882
1009
|
// calling tools of its own accord, rather than being cut off at
|
|
883
1010
|
// the cap.
|
|
884
|
-
const completedWithFinalAnswer = engineResult
|
|
885
|
-
engineResult.
|
|
1011
|
+
const completedWithFinalAnswer = engineResult !== undefined &&
|
|
1012
|
+
(engineResult.toolCalls.length === 0 ||
|
|
1013
|
+
engineResult.finishReason !== "tool-calls");
|
|
886
1014
|
// Handle max-steps termination: if the model was still calling
|
|
887
1015
|
// tools when we hit the limit, push a synthetic final message.
|
|
888
|
-
|
|
1016
|
+
// An aborted turn is NOT a step-cap turn — a killed 3-step turn
|
|
1017
|
+
// claiming it "reached the 200-step limit" is the exact
|
|
1018
|
+
// mislabeling this loop is being fixed for.
|
|
1019
|
+
const hitStepLimitWithoutFinalAnswer = !wasAborted && step >= maxSteps && !completedWithFinalAnswer;
|
|
889
1020
|
if (hitStepLimitWithoutFinalAnswer) {
|
|
890
1021
|
const fallback = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, "", // finalText is empty — model didn't stop on its own
|
|
891
1022
|
lastStepText);
|
|
@@ -893,10 +1024,57 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
893
1024
|
channel.push({ content: fallback });
|
|
894
1025
|
}
|
|
895
1026
|
}
|
|
1027
|
+
else if (wasAborted && !completedWithFinalAnswer) {
|
|
1028
|
+
// Exactly one honest terminal chunk, matching the actual exit
|
|
1029
|
+
// cause, and only when the consumer has not already been given
|
|
1030
|
+
// the model's own prose.
|
|
1031
|
+
logger.warn(`[GoogleAIStudio] Tool call loop ended mid-turn ` +
|
|
1032
|
+
`(${hitTimeLimit() ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
|
|
1033
|
+
`returning an honest terminal message.`);
|
|
1034
|
+
channel.push({
|
|
1035
|
+
content: buildLoopExitMessage({
|
|
1036
|
+
timedOut: hitTimeLimit(),
|
|
1037
|
+
stalled: turnClock.stalled,
|
|
1038
|
+
elapsedMs: turnClock.elapsedMs(),
|
|
1039
|
+
wasAborted,
|
|
1040
|
+
...(options.stallTimeoutMs !== undefined
|
|
1041
|
+
? { stallTimeoutMs: options.stallTimeoutMs }
|
|
1042
|
+
: {}),
|
|
1043
|
+
maxSteps,
|
|
1044
|
+
toolCallCount: allToolCalls.length,
|
|
1045
|
+
}),
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
896
1048
|
const responseTime = Date.now() - startTime;
|
|
1049
|
+
// Turn-exit discriminator, independent of the provider-shaped
|
|
1050
|
+
// finishReason — consumers branch on this instead of sniffing
|
|
1051
|
+
// strings. It reports only conditions this loop can actually
|
|
1052
|
+
// observe: it never claims a context-cap, because the reclaim
|
|
1053
|
+
// planner here has no stop-the-turn branch to report.
|
|
1054
|
+
const stopReason = resolveTurnStopReason({
|
|
1055
|
+
timedOut: hitTimeLimit(),
|
|
1056
|
+
stalled: turnClock.stalled,
|
|
1057
|
+
wasAborted,
|
|
1058
|
+
cappedWithoutAnswer: hitStepLimitWithoutFinalAnswer,
|
|
1059
|
+
finishReason: mapGeminiFinishReason(lastFinishReason),
|
|
1060
|
+
});
|
|
1061
|
+
if (stopReason !== "completed") {
|
|
1062
|
+
this.emitTurnEvent({
|
|
1063
|
+
phase: stopReason,
|
|
1064
|
+
step: stepsTaken,
|
|
1065
|
+
maxSteps,
|
|
1066
|
+
toolCallCount: allToolCalls.length,
|
|
1067
|
+
elapsedMs: turnClock.elapsedMs(),
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
897
1070
|
// Update shared metadata so the returned object reflects final values.
|
|
898
1071
|
metadata.responseTime = responseTime;
|
|
899
1072
|
metadata.totalToolExecutions = allToolCalls.length;
|
|
1073
|
+
metadata.stopReason = stopReason;
|
|
1074
|
+
metadata.stepsUsed = stepsTaken;
|
|
1075
|
+
if (lastFinishReason !== undefined) {
|
|
1076
|
+
metadata.rawFinishReason = lastFinishReason;
|
|
1077
|
+
}
|
|
900
1078
|
// Set token usage and finish reason on the span
|
|
901
1079
|
span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
|
|
902
1080
|
span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
|
|
@@ -929,6 +1107,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
929
1107
|
},
|
|
930
1108
|
requestDuration: responseTime,
|
|
931
1109
|
timestamp: new Date().toISOString(),
|
|
1110
|
+
// Turn-lifecycle telemetry. The generate path gets these for
|
|
1111
|
+
// free because createAnalytics reads them off the result;
|
|
1112
|
+
// this path settles its own analytics, so they are set here.
|
|
1113
|
+
stepsUsed: stepsTaken,
|
|
1114
|
+
toolCallCount: allToolCalls.length,
|
|
1115
|
+
stopReason,
|
|
1116
|
+
elapsedMs: turnClock.elapsedMs(),
|
|
1117
|
+
...(lastFinishReason !== undefined
|
|
1118
|
+
? { rawFinishReason: lastFinishReason }
|
|
1119
|
+
: {}),
|
|
932
1120
|
});
|
|
933
1121
|
channel.close();
|
|
934
1122
|
}
|
|
@@ -937,6 +1125,8 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
937
1125
|
analyticsReject(err);
|
|
938
1126
|
}
|
|
939
1127
|
finally {
|
|
1128
|
+
turnClock.dispose();
|
|
1129
|
+
upstreamSignal?.removeEventListener("abort", onUpstreamAbort);
|
|
940
1130
|
timeoutController?.cleanup();
|
|
941
1131
|
}
|
|
942
1132
|
})();
|
|
@@ -990,6 +1180,10 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
990
1180
|
const startTime = Date.now();
|
|
991
1181
|
const timeout = this.getTimeout(options);
|
|
992
1182
|
const timeoutController = createTimeoutController(timeout, this.providerName, "generate");
|
|
1183
|
+
// Declared out here so the finally below can release the watchdog
|
|
1184
|
+
// timers and the caller-signal listener on every exit, including the
|
|
1185
|
+
// throws inside the try that never reach the clock's own scope.
|
|
1186
|
+
let releaseTurnResources = () => { };
|
|
993
1187
|
try {
|
|
994
1188
|
const apiKey = this.getApiKey();
|
|
995
1189
|
const client = await createGoogleGenAIClient(apiKey, this.getBaseURL());
|
|
@@ -1064,7 +1258,44 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1064
1258
|
wantsJsonOutput: wantsNativeJson,
|
|
1065
1259
|
responseSchema: nativeResponseSchema,
|
|
1066
1260
|
}, toolsConfig);
|
|
1067
|
-
|
|
1261
|
+
// Abort fan-in — see the streaming twin for why the turn clock
|
|
1262
|
+
// drives an internal controller rather than composing a third
|
|
1263
|
+
// signal at each call site.
|
|
1264
|
+
const upstreamSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
|
|
1265
|
+
const internalAbort = new AbortController();
|
|
1266
|
+
const onUpstreamAbort = () => internalAbort.abort();
|
|
1267
|
+
upstreamSignal?.addEventListener("abort", onUpstreamAbort);
|
|
1268
|
+
if (upstreamSignal?.aborted) {
|
|
1269
|
+
internalAbort.abort();
|
|
1270
|
+
}
|
|
1271
|
+
const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
|
|
1272
|
+
// No `defaultTurnTimeoutMs`: the pre-existing whole-turn bound on
|
|
1273
|
+
// this path is `timeoutController`, and `hitTimeLimit()` reads it,
|
|
1274
|
+
// so no new default deadline is introduced by arming the clock.
|
|
1275
|
+
const turnClock = createTurnClock({
|
|
1276
|
+
...(options.turnTimeoutMs !== undefined
|
|
1277
|
+
? { turnTimeoutMs: options.turnTimeoutMs }
|
|
1278
|
+
: {}),
|
|
1279
|
+
...(options.stallTimeoutMs !== undefined
|
|
1280
|
+
? { stallTimeoutMs: options.stallTimeoutMs }
|
|
1281
|
+
: {}),
|
|
1282
|
+
...(options.wrapupTimeLeadMs !== undefined
|
|
1283
|
+
? { wrapupTimeLeadMs: options.wrapupTimeLeadMs }
|
|
1284
|
+
: {}),
|
|
1285
|
+
onDeadline: (kind) => {
|
|
1286
|
+
logger.warn(kind === "timeout"
|
|
1287
|
+
? `[GoogleAIStudio] Native Gemini generate turn exceeded its ${options.turnTimeoutMs}ms time budget — aborting`
|
|
1288
|
+
: `[GoogleAIStudio] Native Gemini generate turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
|
|
1289
|
+
internalAbort.abort();
|
|
1290
|
+
},
|
|
1291
|
+
});
|
|
1292
|
+
const composedSignal = internalAbort.signal;
|
|
1293
|
+
releaseTurnResources = () => {
|
|
1294
|
+
turnClock.dispose();
|
|
1295
|
+
upstreamSignal?.removeEventListener("abort", onUpstreamAbort);
|
|
1296
|
+
};
|
|
1297
|
+
const hitTimeLimit = () => turnClock.timedOut ||
|
|
1298
|
+
timeoutController?.controller.signal.aborted === true;
|
|
1068
1299
|
const maxSteps = computeMaxSteps(options.maxSteps);
|
|
1069
1300
|
let finalText = "";
|
|
1070
1301
|
let lastStepText = "";
|
|
@@ -1075,6 +1306,11 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1075
1306
|
const allToolCalls = [];
|
|
1076
1307
|
const toolExecutions = [];
|
|
1077
1308
|
let step = 0;
|
|
1309
|
+
// Model calls made — see the streaming twin for why this is not
|
|
1310
|
+
// `step`.
|
|
1311
|
+
let stepsTaken = 0;
|
|
1312
|
+
let wasAborted = false;
|
|
1313
|
+
let lastFinishReason;
|
|
1078
1314
|
// Cheap reclaim trigger — see the stream twin.
|
|
1079
1315
|
const contextGuard = createContextGuard(getContextWindowSize("googleAiStudio", modelName));
|
|
1080
1316
|
// Agentic loop for tool calling
|
|
@@ -1087,16 +1323,27 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1087
1323
|
maxSteps,
|
|
1088
1324
|
toolFailureBreaker: { maxRetries: DEFAULT_TOOL_MAX_RETRIES },
|
|
1089
1325
|
liveTools: options.tools ?? {},
|
|
1326
|
+
toolGuards: {
|
|
1327
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
1328
|
+
abortSignal: composedSignal,
|
|
1329
|
+
onProgress: () => turnClock.noteProgress(),
|
|
1330
|
+
},
|
|
1090
1331
|
...(declarationsResult ? { declarations: declarationsResult } : {}),
|
|
1091
1332
|
buildRequest: (contents) => ({
|
|
1092
1333
|
model: modelName,
|
|
1093
1334
|
contents,
|
|
1094
1335
|
config,
|
|
1095
|
-
...(composedSignal
|
|
1096
|
-
? { httpOptions: { signal: composedSignal } }
|
|
1097
|
-
: {}),
|
|
1098
1336
|
}),
|
|
1099
|
-
|
|
1337
|
+
// See the streaming twin: `config.abortSignal` is the only
|
|
1338
|
+
// cancellation channel the SDK reads.
|
|
1339
|
+
sendStep: async (request, signal) => {
|
|
1340
|
+
turnClock.noteProgress();
|
|
1341
|
+
const built = request;
|
|
1342
|
+
return client.models.generateContentStream({
|
|
1343
|
+
...built,
|
|
1344
|
+
config: { ...(built.config ?? {}), abortSignal: signal },
|
|
1345
|
+
});
|
|
1346
|
+
},
|
|
1100
1347
|
noteUsage: (inputTokens, outputTokens) => {
|
|
1101
1348
|
contextGuard.noteUsage(inputTokens, outputTokens);
|
|
1102
1349
|
},
|
|
@@ -1114,6 +1361,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1114
1361
|
});
|
|
1115
1362
|
const adapter = {
|
|
1116
1363
|
...baseAdapter,
|
|
1364
|
+
// Once per model call, including the final text-only step — see
|
|
1365
|
+
// the streaming twin.
|
|
1366
|
+
buildStepRequest: (contents, engineStep) => {
|
|
1367
|
+
stepsTaken = engineStep + 1;
|
|
1368
|
+
turnClock.noteProgress();
|
|
1369
|
+
return baseAdapter.buildStepRequest(contents, engineStep);
|
|
1370
|
+
},
|
|
1117
1371
|
buildToolResultMessages: (contents, stepResult, toolResults, engineStep) => {
|
|
1118
1372
|
// Same as the streaming twin: the engine's step, not a count of
|
|
1119
1373
|
// hook invocations. See the comment there.
|
|
@@ -1153,6 +1407,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1153
1407
|
});
|
|
1154
1408
|
}
|
|
1155
1409
|
const next = baseAdapter.buildToolResultMessages(contents, stepResult, toolResults, engineStep);
|
|
1410
|
+
// Wrap-up nudge — see the streaming twin.
|
|
1411
|
+
if (turnClock.shouldNudgeWrapup()) {
|
|
1412
|
+
const last = next[next.length - 1];
|
|
1413
|
+
if (last && Array.isArray(last.parts)) {
|
|
1414
|
+
last.parts.push({ text: buildWrapupNudgeText(false) });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1156
1417
|
try {
|
|
1157
1418
|
const appended = next[next.length - 1];
|
|
1158
1419
|
contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
|
|
@@ -1164,40 +1425,102 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1164
1425
|
},
|
|
1165
1426
|
};
|
|
1166
1427
|
// Same dedup routing as the streaming twin above.
|
|
1167
|
-
const engineTools = buildDedupedEngineTools(declarationsResult, options.tools
|
|
1428
|
+
const engineTools = buildDedupedEngineTools(declarationsResult, options.tools, {
|
|
1429
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
1430
|
+
abortSignal: composedSignal,
|
|
1431
|
+
onProgress: () => turnClock.noteProgress(),
|
|
1432
|
+
});
|
|
1168
1433
|
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentContents, {
|
|
1169
1434
|
tools: engineTools,
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
// path above for why the engine's bound has to be reachable.
|
|
1173
|
-
...(options.toolTimeoutMs !== undefined
|
|
1174
|
-
? { toolTimeoutMs: options.toolTimeoutMs }
|
|
1175
|
-
: {}),
|
|
1435
|
+
abortSignal: composedSignal,
|
|
1436
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
1176
1437
|
});
|
|
1177
1438
|
// Drained, not consumed: nothing streams out of generate(), but an
|
|
1178
1439
|
// undrained channel would stall the engine mid-turn.
|
|
1179
1440
|
const drain = (async () => {
|
|
1180
1441
|
for await (const chunk of engineStream) {
|
|
1181
1442
|
void chunk;
|
|
1443
|
+
turnClock.noteProgress();
|
|
1182
1444
|
}
|
|
1183
1445
|
})();
|
|
1184
1446
|
let engineResult;
|
|
1447
|
+
let turnFailure;
|
|
1185
1448
|
try {
|
|
1186
1449
|
engineResult = await resultPromise;
|
|
1187
1450
|
}
|
|
1188
1451
|
catch (error) {
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1452
|
+
turnFailure = error;
|
|
1453
|
+
}
|
|
1454
|
+
await drainDetachedPump(drain, "GoogleAIStudio");
|
|
1455
|
+
if (turnFailure !== undefined) {
|
|
1456
|
+
// Same split as the streaming twin: a blown time budget or a
|
|
1457
|
+
// caller abort ends the turn honestly, a provider failure still
|
|
1458
|
+
// throws.
|
|
1459
|
+
if (turnClock.expired || options.abortSignal?.aborted) {
|
|
1460
|
+
wasAborted = true;
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
logger.error("[GoogleAIStudio] Native SDK generate error", turnFailure);
|
|
1464
|
+
throw this.handleProviderError(turnFailure);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (engineResult) {
|
|
1468
|
+
totalInputTokens += engineResult.usage.inputTokens;
|
|
1469
|
+
totalOutputTokens += engineResult.usage.outputTokens;
|
|
1470
|
+
totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
|
|
1471
|
+
totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
|
|
1472
|
+
lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
|
|
1473
|
+
finalText = engineResult.text;
|
|
1474
|
+
if (engineResult.aborted) {
|
|
1475
|
+
wasAborted = true;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (composedSignal.aborted) {
|
|
1479
|
+
wasAborted = true;
|
|
1480
|
+
}
|
|
1481
|
+
const hitStepLimitWithoutAnswer = !wasAborted && step >= maxSteps && !finalText;
|
|
1482
|
+
if (wasAborted && !finalText) {
|
|
1483
|
+
// Never the step-cap text for a turn the budget killed: prefer
|
|
1484
|
+
// the prose the model already produced, else an honest message
|
|
1485
|
+
// naming the actual exit cause.
|
|
1486
|
+
logger.warn(`[GoogleAIStudio] Generate tool call loop ended mid-turn ` +
|
|
1487
|
+
`(${hitTimeLimit() ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
|
|
1488
|
+
`returning gathered text or an honest terminal message.`);
|
|
1489
|
+
finalText =
|
|
1490
|
+
lastStepText ||
|
|
1491
|
+
buildLoopExitMessage({
|
|
1492
|
+
timedOut: hitTimeLimit(),
|
|
1493
|
+
stalled: turnClock.stalled,
|
|
1494
|
+
elapsedMs: turnClock.elapsedMs(),
|
|
1495
|
+
wasAborted,
|
|
1496
|
+
...(options.stallTimeoutMs !== undefined
|
|
1497
|
+
? { stallTimeoutMs: options.stallTimeoutMs }
|
|
1498
|
+
: {}),
|
|
1499
|
+
maxSteps,
|
|
1500
|
+
toolCallCount: allToolCalls.length,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
finalText = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, finalText, lastStepText);
|
|
1192
1505
|
}
|
|
1193
|
-
await drain;
|
|
1194
|
-
totalInputTokens += engineResult.usage.inputTokens;
|
|
1195
|
-
totalOutputTokens += engineResult.usage.outputTokens;
|
|
1196
|
-
totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
|
|
1197
|
-
totalReasoningTokens += engineResult.usage.reasoningTokens ?? 0;
|
|
1198
|
-
finalText = engineResult.text;
|
|
1199
|
-
finalText = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, finalText, lastStepText);
|
|
1200
1506
|
const responseTime = Date.now() - startTime;
|
|
1507
|
+
// Turn-exit discriminator — see the streaming twin.
|
|
1508
|
+
const stopReason = resolveTurnStopReason({
|
|
1509
|
+
timedOut: hitTimeLimit(),
|
|
1510
|
+
stalled: turnClock.stalled,
|
|
1511
|
+
wasAborted,
|
|
1512
|
+
cappedWithoutAnswer: hitStepLimitWithoutAnswer,
|
|
1513
|
+
finishReason: mapGeminiFinishReason(lastFinishReason),
|
|
1514
|
+
});
|
|
1515
|
+
if (stopReason !== "completed") {
|
|
1516
|
+
this.emitTurnEvent({
|
|
1517
|
+
phase: stopReason,
|
|
1518
|
+
step: stepsTaken,
|
|
1519
|
+
maxSteps,
|
|
1520
|
+
toolCallCount: allToolCalls.length,
|
|
1521
|
+
elapsedMs: turnClock.elapsedMs(),
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1201
1524
|
// Set token usage and finish reason on the span
|
|
1202
1525
|
span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
|
|
1203
1526
|
span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
|
|
@@ -1214,6 +1537,14 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1214
1537
|
content: finalText,
|
|
1215
1538
|
provider: this.providerName,
|
|
1216
1539
|
model: modelName,
|
|
1540
|
+
// createAnalytics reads these off the result, so setting them
|
|
1541
|
+
// here is also what puts stepsUsed / stopReason / elapsedMs /
|
|
1542
|
+
// rawFinishReason on `result.analytics`.
|
|
1543
|
+
stopReason,
|
|
1544
|
+
stepsUsed: stepsTaken,
|
|
1545
|
+
...(lastFinishReason !== undefined
|
|
1546
|
+
? { rawFinishReason: lastFinishReason }
|
|
1547
|
+
: {}),
|
|
1217
1548
|
usage: {
|
|
1218
1549
|
input: adjustedInputTokens,
|
|
1219
1550
|
// Thinking tokens are billed at the output rate but Gemini
|
|
@@ -1243,6 +1574,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1243
1574
|
return this.enhanceResult(baseResult, options, startTime);
|
|
1244
1575
|
}
|
|
1245
1576
|
finally {
|
|
1577
|
+
releaseTurnResources();
|
|
1246
1578
|
timeoutController?.cleanup();
|
|
1247
1579
|
}
|
|
1248
1580
|
});
|