@juspay/neurolink 9.26.1 → 9.26.2

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.
@@ -11,7 +11,7 @@ import { logger } from "../utils/logger.js";
11
11
  import { isGemini3Model } from "../utils/modelDetection.js";
12
12
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
13
13
  import { estimateTokens } from "../utils/tokenEstimation.js";
14
- import { buildNativeConfig, buildNativeToolDeclarations, collectStreamChunks, computeMaxSteps, executeNativeToolCalls, extractTextFromParts, handleMaxStepsTermination, pushModelResponseToHistory, sanitizeToolsForGemini, } from "./googleNativeGemini3.js";
14
+ import { buildNativeConfig, buildNativeToolDeclarations, collectStreamChunks, collectStreamChunksIncremental, computeMaxSteps, createTextChannel, executeNativeToolCalls, extractTextFromParts, handleMaxStepsTermination, pushModelResponseToHistory, sanitizeToolsForGemini, } from "./googleNativeGemini3.js";
15
15
  // Google AI Live API types now imported from ../types/providerSpecific.js
16
16
  // Import proper types for multimodal message handling
17
17
  // Create Google GenAI client
@@ -578,107 +578,151 @@ export class GoogleAIStudioProvider extends BaseProvider {
578
578
  }
579
579
  const config = buildNativeConfig(options, toolsConfig);
580
580
  const maxSteps = computeMaxSteps(options.maxSteps);
581
- let finalText = "";
582
- let lastStepText = "";
583
- let totalInputTokens = 0;
584
- let totalOutputTokens = 0;
585
- const allToolCalls = [];
586
- let step = 0;
587
- const failedTools = new Map();
588
581
  // Compose abort signal from user signal + timeout
589
582
  const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
590
- // Agentic loop for tool calling
591
- while (step < maxSteps) {
592
- if (composedSignal?.aborted) {
593
- throw composedSignal.reason instanceof Error
594
- ? composedSignal.reason
595
- : new Error("Request aborted");
596
- }
597
- step++;
598
- logger.debug(`[GoogleAIStudio] Native SDK step ${step}/${maxSteps}`);
583
+ // Create a push-based text channel so the caller receives tokens as
584
+ // they arrive from the network rather than after full buffering.
585
+ const channel = createTextChannel();
586
+ // Shared mutable state updated by the background agentic loop.
587
+ const allToolCalls = [];
588
+ // analyticsResolvers lets the background loop settle the analytics
589
+ // promise once token counts are known (after the loop completes).
590
+ let analyticsResolve;
591
+ let analyticsReject;
592
+ const analyticsPromise = new Promise((res, rej) => {
593
+ analyticsResolve = res;
594
+ analyticsReject = rej;
595
+ });
596
+ // Shared metadata object mutated by the background loop so the
597
+ // returned object reflects the final values after stream completion.
598
+ const metadata = {
599
+ streamId: `native-${Date.now()}`,
600
+ startTime,
601
+ responseTime: 0,
602
+ totalToolExecutions: 0,
603
+ };
604
+ // Run the agentic loop in the background without awaiting it here,
605
+ // so we can return the StreamResult (with channel.iterable) immediately.
606
+ const loopPromise = (async () => {
607
+ let lastStepText = "";
608
+ let totalInputTokens = 0;
609
+ let totalOutputTokens = 0;
610
+ let step = 0;
611
+ let completedWithFinalAnswer = false;
612
+ const failedTools = new Map();
599
613
  try {
600
- const stream = await client.models.generateContentStream({
601
- model: modelName,
602
- contents: currentContents,
603
- config,
604
- ...(composedSignal
605
- ? { httpOptions: { signal: composedSignal } }
606
- : {}),
607
- });
608
- const chunkResult = await collectStreamChunks(stream);
609
- totalInputTokens += chunkResult.inputTokens;
610
- totalOutputTokens += chunkResult.outputTokens;
611
- const stepText = extractTextFromParts(chunkResult.rawResponseParts);
612
- // If no function calls, we're done
613
- if (chunkResult.stepFunctionCalls.length === 0) {
614
- finalText = stepText;
615
- break;
614
+ // Agentic loop for tool calling
615
+ while (step < maxSteps) {
616
+ if (composedSignal?.aborted) {
617
+ throw composedSignal.reason instanceof Error
618
+ ? composedSignal.reason
619
+ : new Error("Request aborted");
620
+ }
621
+ step++;
622
+ logger.debug(`[GoogleAIStudio] Native SDK step ${step}/${maxSteps}`);
623
+ try {
624
+ const rawStream = await client.models.generateContentStream({
625
+ model: modelName,
626
+ contents: currentContents,
627
+ config,
628
+ ...(composedSignal
629
+ ? { httpOptions: { signal: composedSignal } }
630
+ : {}),
631
+ });
632
+ // For every step, use incremental collection so text parts
633
+ // are pushed to the channel as they arrive. For intermediate
634
+ // steps (those that produce function calls) we still need the
635
+ // complete rawResponseParts for pushModelResponseToHistory,
636
+ // which collectStreamChunksIncremental provides at stream end.
637
+ const chunkResult = await collectStreamChunksIncremental(rawStream, channel);
638
+ totalInputTokens += chunkResult.inputTokens;
639
+ totalOutputTokens += chunkResult.outputTokens;
640
+ const stepText = extractTextFromParts(chunkResult.rawResponseParts);
641
+ // If no function calls, this was the final step — channel
642
+ // already received all text parts incrementally.
643
+ if (chunkResult.stepFunctionCalls.length === 0) {
644
+ completedWithFinalAnswer = true;
645
+ break;
646
+ }
647
+ lastStepText = stepText;
648
+ // Record tool call events on the span
649
+ for (const fc of chunkResult.stepFunctionCalls) {
650
+ span.addEvent("gen_ai.tool_call", {
651
+ "tool.name": fc.name,
652
+ "tool.step": step,
653
+ });
654
+ }
655
+ logger.debug(`[GoogleAIStudio] Executing ${chunkResult.stepFunctionCalls.length} function calls`);
656
+ // Add model response with ALL parts (including thoughtSignature) to history
657
+ pushModelResponseToHistory(currentContents, chunkResult.rawResponseParts, chunkResult.stepFunctionCalls);
658
+ const functionResponses = await executeNativeToolCalls("[GoogleAIStudio]", chunkResult.stepFunctionCalls, executeMap, failedTools, allToolCalls, { abortSignal: composedSignal });
659
+ // Add function responses to history — the @google/genai SDK
660
+ // only accepts "user" and "model" as valid roles in contents.
661
+ // Function/tool responses must use role: "user" (matching the
662
+ // SDK's own automaticFunctionCalling implementation).
663
+ currentContents.push({
664
+ role: "user",
665
+ parts: functionResponses,
666
+ });
667
+ }
668
+ catch (error) {
669
+ logger.error("[GoogleAIStudio] Native SDK error", error);
670
+ throw this.handleProviderError(error);
671
+ }
616
672
  }
617
- lastStepText = stepText;
618
- // Record tool call events on the span
619
- for (const fc of chunkResult.stepFunctionCalls) {
620
- span.addEvent("gen_ai.tool_call", {
621
- "tool.name": fc.name,
622
- "tool.step": step,
623
- });
673
+ // Handle max-steps termination: if the model was still calling
674
+ // tools when we hit the limit, push a synthetic final message.
675
+ const hitStepLimitWithoutFinalAnswer = step >= maxSteps && !completedWithFinalAnswer;
676
+ if (hitStepLimitWithoutFinalAnswer) {
677
+ const fallback = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, "", // finalText is empty — model didn't stop on its own
678
+ lastStepText);
679
+ if (fallback) {
680
+ channel.push(fallback);
681
+ }
624
682
  }
625
- logger.debug(`[GoogleAIStudio] Executing ${chunkResult.stepFunctionCalls.length} function calls`);
626
- // Add model response with ALL parts (including thoughtSignature) to history
627
- pushModelResponseToHistory(currentContents, chunkResult.rawResponseParts, chunkResult.stepFunctionCalls);
628
- const functionResponses = await executeNativeToolCalls("[GoogleAIStudio]", chunkResult.stepFunctionCalls, executeMap, failedTools, allToolCalls, { abortSignal: composedSignal });
629
- // Add function responses to history the @google/genai SDK
630
- // only accepts "user" and "model" as valid roles in contents.
631
- // Function/tool responses must use role: "user" (matching the
632
- // SDK's own automaticFunctionCalling implementation).
633
- currentContents.push({
634
- role: "user",
635
- parts: functionResponses,
683
+ const responseTime = Date.now() - startTime;
684
+ // Update shared metadata so the returned object reflects final values.
685
+ metadata.responseTime = responseTime;
686
+ metadata.totalToolExecutions = allToolCalls.length;
687
+ // Set token usage and finish reason on the span
688
+ span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
689
+ span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
690
+ span.setAttribute(ATTR.GEN_AI_FINISH_REASON, hitStepLimitWithoutFinalAnswer ? "max_steps" : "stop");
691
+ analyticsResolve({
692
+ provider: this.providerName,
693
+ model: modelName,
694
+ tokenUsage: {
695
+ input: totalInputTokens,
696
+ output: totalOutputTokens,
697
+ total: totalInputTokens + totalOutputTokens,
698
+ },
699
+ requestDuration: responseTime,
700
+ timestamp: new Date().toISOString(),
636
701
  });
702
+ channel.close();
637
703
  }
638
- catch (error) {
639
- logger.error("[GoogleAIStudio] Native SDK error", error);
640
- throw this.handleProviderError(error);
704
+ catch (err) {
705
+ channel.error(err);
706
+ analyticsReject(err);
641
707
  }
642
- }
643
- finalText = handleMaxStepsTermination("[GoogleAIStudio]", step, maxSteps, finalText, lastStepText);
644
- const responseTime = Date.now() - startTime;
645
- // Set token usage and finish reason on the span
646
- span.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, totalInputTokens);
647
- span.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, totalOutputTokens);
648
- span.setAttribute(ATTR.GEN_AI_FINISH_REASON, step >= maxSteps ? "max_steps" : "stop");
649
- // Create async iterable for streaming result
650
- async function* createTextStream() {
651
- yield { content: finalText };
652
- }
708
+ finally {
709
+ timeoutController?.cleanup();
710
+ }
711
+ })();
712
+ // Suppress unhandled-rejection warnings on loopPromise — errors are
713
+ // forwarded to the channel and will surface when the caller iterates.
714
+ loopPromise.catch(() => undefined);
653
715
  return {
654
- stream: createTextStream(),
716
+ stream: channel.iterable,
655
717
  provider: this.providerName,
656
718
  model: modelName,
657
- toolCalls: allToolCalls.map((tc) => ({
658
- toolName: tc.toolName,
659
- args: tc.args,
660
- })),
661
- analytics: Promise.resolve({
662
- provider: this.providerName,
663
- model: modelName,
664
- tokenUsage: {
665
- input: totalInputTokens,
666
- output: totalOutputTokens,
667
- total: totalInputTokens + totalOutputTokens,
668
- },
669
- requestDuration: responseTime,
670
- timestamp: new Date().toISOString(),
671
- }),
672
- metadata: {
673
- streamId: `native-${Date.now()}`,
674
- startTime,
675
- responseTime,
676
- totalToolExecutions: allToolCalls.length,
677
- },
719
+ toolCalls: allToolCalls,
720
+ analytics: analyticsPromise,
721
+ metadata,
678
722
  };
679
723
  }
680
724
  finally {
681
- timeoutController?.cleanup();
725
+ // Timeout controller cleanup is managed inside the background loop
682
726
  }
683
727
  });
684
728
  }
@@ -709,7 +753,9 @@ export class GoogleAIStudioProvider extends BaseProvider {
709
753
  hasTools: !!options.tools && Object.keys(options.tools).length > 0,
710
754
  });
711
755
  // Build contents from input
712
- const promptText = options.prompt || options.input?.text || "";
756
+ // Prefer input.text over prompt processCSVFilesForNativeSDK enriches
757
+ // input.text with inlined CSV data, so using prompt first would discard it.
758
+ const promptText = options.input?.text || options.prompt || "";
713
759
  const currentContents = [{ role: "user", parts: [{ text: promptText }] }];
714
760
  // Convert tools (merge SDK tools with options.tools)
715
761
  let toolsConfig;
@@ -98,6 +98,49 @@ export declare function collectStreamChunks(stream: AsyncIterable<{
98
98
  functionCalls?: NativeFunctionCall[];
99
99
  [key: string]: unknown;
100
100
  }>): Promise<CollectedChunkResult>;
101
+ /**
102
+ * A push-based text channel that decouples producers (agentic loop) from
103
+ * consumers (the caller's async iterable).
104
+ *
105
+ * The producer calls `push(text)` for each chunk and `close()` / `error(err)`
106
+ * when done. The consumer iterates the `iterable` async generator.
107
+ */
108
+ export type TextChannel = {
109
+ /** Push a text chunk to the consumer. */
110
+ push(text: string): void;
111
+ /** Signal that no more chunks will arrive. */
112
+ close(): void;
113
+ /** Signal that the producer encountered a fatal error. */
114
+ error(err: unknown): void;
115
+ /** Async iterable consumed by the StreamResult. */
116
+ iterable: AsyncIterable<{
117
+ content: string;
118
+ }>;
119
+ };
120
+ /**
121
+ * Create a push-based text channel that bridges a background producer
122
+ * (the agentic tool-calling loop) with an async-iterable consumer.
123
+ *
124
+ * This enables truly incremental streaming: text parts are yielded to the
125
+ * caller as they arrive from the network, rather than being buffered until
126
+ * the model finishes generating.
127
+ */
128
+ export declare function createTextChannel(): TextChannel;
129
+ /**
130
+ * Iterate a single stream step incrementally, pushing text parts to `channel`
131
+ * as they arrive from the network while simultaneously accumulating the full
132
+ * `CollectedChunkResult` needed for history and token accounting.
133
+ *
134
+ * Used for all steps (both intermediate tool-calling steps and the final
135
+ * text-only step). Text parts are pushed to the channel as they arrive,
136
+ * enabling truly incremental streaming. The complete `rawResponseParts`
137
+ * (including thoughtSignature) are still returned at the end for use by
138
+ * `pushModelResponseToHistory`.
139
+ */
140
+ export declare function collectStreamChunksIncremental(stream: AsyncIterable<{
141
+ functionCalls?: NativeFunctionCall[];
142
+ [key: string]: unknown;
143
+ }>, channel: TextChannel): Promise<CollectedChunkResult>;
101
144
  /**
102
145
  * Extract text from raw response parts, filtering out non-text parts
103
146
  * (thoughtSignature, functionCall) to avoid SDK warnings.
@@ -158,7 +158,6 @@ export function sanitizeToolsForGemini(tools) {
158
158
  logger.warn(`[Gemini] Failed to sanitize tool "${name}", skipping: ${error instanceof Error ? error.message : String(error)}`);
159
159
  // Don't fall back to the original tool — an incompatible schema would fail the Gemini request
160
160
  dropped.push(name);
161
- continue;
162
161
  }
163
162
  }
164
163
  return { tools: sanitized, dropped };
@@ -171,29 +170,45 @@ export function sanitizeToolsForGemini(tools) {
171
170
  export function buildNativeToolDeclarations(tools) {
172
171
  const functionDeclarations = [];
173
172
  const executeMap = new Map();
173
+ const skippedTools = [];
174
174
  for (const [name, tool] of Object.entries(tools)) {
175
- const decl = {
176
- name,
177
- description: tool.description || `Tool: ${name}`,
178
- };
179
- if (tool.parameters) {
180
- let rawSchema;
181
- if (isZodSchema(tool.parameters)) {
182
- rawSchema = convertZodToJsonSchema(tool.parameters);
183
- }
184
- else if (typeof tool.parameters === "object") {
185
- rawSchema = tool.parameters;
175
+ try {
176
+ const decl = {
177
+ name,
178
+ description: tool.description || `Tool: ${name}`,
179
+ };
180
+ if (tool.parameters) {
181
+ let rawSchema;
182
+ if (isZodSchema(tool.parameters)) {
183
+ rawSchema = convertZodToJsonSchema(tool.parameters);
184
+ }
185
+ else if (typeof tool.parameters === "object") {
186
+ rawSchema = tool.parameters;
187
+ }
188
+ else {
189
+ rawSchema = { type: "object", properties: {} };
190
+ }
191
+ // Unwrap Vercel AI SDK's jsonSchema() wrapper: { jsonSchema: { type: "object", ... } }
192
+ if (rawSchema.jsonSchema &&
193
+ typeof rawSchema.jsonSchema === "object" &&
194
+ !rawSchema.type) {
195
+ rawSchema = rawSchema.jsonSchema;
196
+ }
197
+ decl.parametersJsonSchema = sanitizeSchemaForGemini(inlineJsonSchema(rawSchema));
186
198
  }
187
- else {
188
- rawSchema = { type: "object", properties: {} };
199
+ functionDeclarations.push(decl);
200
+ if (tool.execute) {
201
+ executeMap.set(name, tool.execute);
189
202
  }
190
- decl.parametersJsonSchema = sanitizeSchemaForGemini(inlineJsonSchema(rawSchema));
191
203
  }
192
- functionDeclarations.push(decl);
193
- if (tool.execute) {
194
- executeMap.set(name, tool.execute);
204
+ catch (err) {
205
+ skippedTools.push(name);
206
+ logger.error(`[buildNativeToolDeclarations] Failed to convert tool "${name}":`, err);
195
207
  }
196
208
  }
209
+ if (skippedTools.length > 0) {
210
+ logger.warn(`[buildNativeToolDeclarations] ${skippedTools.length} tool(s) skipped due to schema errors: ${skippedTools.join(", ")}`);
211
+ }
197
212
  return { toolsConfig: [{ functionDeclarations }], executeMap };
198
213
  }
199
214
  /**
@@ -265,6 +280,121 @@ export async function collectStreamChunks(stream) {
265
280
  }
266
281
  return { rawResponseParts, stepFunctionCalls, inputTokens, outputTokens };
267
282
  }
283
+ /**
284
+ * Create a push-based text channel that bridges a background producer
285
+ * (the agentic tool-calling loop) with an async-iterable consumer.
286
+ *
287
+ * This enables truly incremental streaming: text parts are yielded to the
288
+ * caller as they arrive from the network, rather than being buffered until
289
+ * the model finishes generating.
290
+ */
291
+ export function createTextChannel() {
292
+ const queue = [];
293
+ let done = false;
294
+ let fatalError = undefined;
295
+ // Resolve the current "wait for data" promise when new data arrives
296
+ let notify = null;
297
+ function wake() {
298
+ if (notify) {
299
+ const fn = notify;
300
+ notify = null;
301
+ fn();
302
+ }
303
+ }
304
+ function push(text) {
305
+ if (done) {
306
+ return;
307
+ }
308
+ queue.push({ content: text });
309
+ wake();
310
+ }
311
+ function close() {
312
+ done = true;
313
+ wake();
314
+ }
315
+ function error(err) {
316
+ done = true;
317
+ fatalError = err;
318
+ wake();
319
+ }
320
+ let readIndex = 0;
321
+ async function* iterable() {
322
+ try {
323
+ while (true) {
324
+ if (readIndex < queue.length) {
325
+ yield queue[readIndex++];
326
+ // Periodically compact consumed chunks to avoid unbounded retention
327
+ if (readIndex > 1024 && readIndex * 2 >= queue.length) {
328
+ queue.splice(0, readIndex);
329
+ readIndex = 0;
330
+ }
331
+ }
332
+ else if (done) {
333
+ if (fatalError !== undefined) {
334
+ throw fatalError instanceof Error
335
+ ? fatalError
336
+ : new Error(String(fatalError));
337
+ }
338
+ return;
339
+ }
340
+ else {
341
+ // Wait until the producer pushes data or signals completion
342
+ await new Promise((resolve) => {
343
+ notify = resolve;
344
+ });
345
+ }
346
+ }
347
+ }
348
+ finally {
349
+ // Consumer stopped reading (e.g. disconnect/cancel): stop buffering.
350
+ done = true;
351
+ queue.length = 0;
352
+ notify?.();
353
+ }
354
+ }
355
+ return { push, close, error, iterable: iterable() };
356
+ }
357
+ /**
358
+ * Iterate a single stream step incrementally, pushing text parts to `channel`
359
+ * as they arrive from the network while simultaneously accumulating the full
360
+ * `CollectedChunkResult` needed for history and token accounting.
361
+ *
362
+ * Used for all steps (both intermediate tool-calling steps and the final
363
+ * text-only step). Text parts are pushed to the channel as they arrive,
364
+ * enabling truly incremental streaming. The complete `rawResponseParts`
365
+ * (including thoughtSignature) are still returned at the end for use by
366
+ * `pushModelResponseToHistory`.
367
+ */
368
+ export async function collectStreamChunksIncremental(stream, channel) {
369
+ const rawResponseParts = [];
370
+ const stepFunctionCalls = [];
371
+ let inputTokens = 0;
372
+ let outputTokens = 0;
373
+ for await (const chunk of stream) {
374
+ const chunkRecord = chunk;
375
+ const candidates = chunkRecord.candidates;
376
+ const firstCandidate = candidates?.[0];
377
+ const chunkContent = firstCandidate?.content;
378
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
379
+ for (const part of chunkContent.parts) {
380
+ rawResponseParts.push(part);
381
+ // Forward text parts to the consumer immediately
382
+ if (typeof part.text === "string" && part.text.length > 0) {
383
+ channel.push(part.text);
384
+ }
385
+ }
386
+ }
387
+ if (chunk.functionCalls) {
388
+ stepFunctionCalls.push(...chunk.functionCalls);
389
+ }
390
+ const usage = chunkRecord.usageMetadata;
391
+ if (usage) {
392
+ inputTokens = Math.max(inputTokens, usage.promptTokenCount || 0);
393
+ outputTokens = Math.max(outputTokens, usage.candidatesTokenCount || 0);
394
+ }
395
+ }
396
+ return { rawResponseParts, stepFunctionCalls, inputTokens, outputTokens };
397
+ }
268
398
  /**
269
399
  * Extract text from raw response parts, filtering out non-text parts
270
400
  * (thoughtSignature, functionCall) to avoid SDK warnings.