@juspay/neurolink 11.24.1 → 11.25.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.
@@ -247,8 +247,14 @@ export class BaseProvider {
247
247
  return this.wrapStreamWithLifecycleCallbacks(realStreamResult, options);
248
248
  }
249
249
  catch (realStreamError) {
250
- // Don't retry on terminal/abort errors only fall back for
251
- // "real streaming with tools is unsupported" style failures.
250
+ // The fallback is BROAD, not narrow: only the terminal errors listed
251
+ // below (abort, timeout, 401/403, quota, rate limit, authentication)
252
+ // re-throw. Every other failure — including a genuine configuration or
253
+ // programming error — is masked as a degraded fake stream whenever
254
+ // tools are enabled. Narrowing this to "streaming with tools is
255
+ // unsupported" failures would change behaviour for every provider at
256
+ // once, so it needs its own characterization PR first; until then this
257
+ // comment records what the code does, not what a narrower design would.
252
258
  const errMsg = realStreamError instanceof Error
253
259
  ? realStreamError.message
254
260
  : String(realStreamError);
package/dist/neurolink.js CHANGED
@@ -7354,7 +7354,17 @@ Current user's request: ${currentInput}`;
7354
7354
  // Reviewer follow-up: fire fallback when no *non-sentinel*
7355
7355
  // output was produced — sentinel-only and truly empty streams
7356
7356
  // both qualify, but media-only streams (audio/image) do not.
7357
+ //
7358
+ // fallbackOnMaxSteps: false exempts one no-output shape — a turn
7359
+ // the provider reports as ended at the caller's own maxSteps bound
7360
+ // (metadata.stopReason "step-cap", a mutable reference the native
7361
+ // loops fill by drain time). That bound is the caller's budget,
7362
+ // not a provider failure, and retrying it on another provider
7363
+ // spends that provider's tokens to exceed a budget the caller set.
7364
+ const cappedByCallerBudget = enhancedOptions.fallbackOnMaxSteps === false &&
7365
+ providerStreamMetadata?.stopReason === "step-cap";
7357
7366
  if (realOutputChunks === 0 &&
7367
+ !cappedByCallerBudget &&
7358
7368
  !metadata.fallbackAttempted &&
7359
7369
  !enhancedOptions.disableInternalFallback &&
7360
7370
  streamState.toolCalls.length === 0 &&
@@ -1455,6 +1455,13 @@ export class AnthropicProvider extends BaseProvider {
1455
1455
  const client = this.client;
1456
1456
  const toolsUsed = [];
1457
1457
  const streamStartTime = Date.now();
1458
+ // Mutable-reference contract from StreamResult.metadata: created before
1459
+ // the loop and filled once the turn drains, because wrapper spreads
1460
+ // snapshot top-level result fields before the loop resolves. This is how
1461
+ // the Gemini/Vertex native paths already report their resolved outcome;
1462
+ // without it a consumer (e.g. the SDK's no-output fallback gate) cannot
1463
+ // tell a step-capped turn from a failed one.
1464
+ const turnMetadata = {};
1458
1465
  // Hoisted out of runLoop so the error path can resolve the usage
1459
1466
  // accumulated by steps that completed BEFORE the failure — those steps
1460
1467
  // were billed and must not be reported as zero.
@@ -1738,6 +1745,20 @@ export class AnthropicProvider extends BaseProvider {
1738
1745
  totalCacheRead += result.usage.cacheReadTokens ?? 0;
1739
1746
  totalCacheWrite += result.usage.cacheWriteTokens ?? 0;
1740
1747
  lastStop = result.rawStopReason ?? lastStop;
1748
+ turnMetadata.finishReason = result.finishReason;
1749
+ if (result.rawStopReason) {
1750
+ turnMetadata.rawFinishReason = result.rawStopReason;
1751
+ }
1752
+ // "tool-calls" after a drained turn means the model still wanted tools
1753
+ // when the step budget ran out — the engine breaks at maxSteps, and a
1754
+ // model turn that finished normally maps to "stop". The one other
1755
+ // producer of stop_reason "tool_use" at turn end is a final_result
1756
+ // call (structured output), which `finalResultText` identifies, so it
1757
+ // must not read as a capped turn.
1758
+ if (result.finishReason === "tool-calls" &&
1759
+ finalResultText === undefined) {
1760
+ turnMetadata.stopReason = "step-cap";
1761
+ }
1741
1762
  resolveUsage(buildDeferredUsage());
1742
1763
  resolveFinish(lastStop ?? "stop");
1743
1764
  };
@@ -1822,6 +1843,7 @@ export class AnthropicProvider extends BaseProvider {
1822
1843
  model: this.modelName,
1823
1844
  toolCalls: [],
1824
1845
  toolResults: [],
1846
+ metadata: turnMetadata,
1825
1847
  // Wire the deferred usage/finish promises into the analytics collector
1826
1848
  // (mirrors openaiChatCompletionsBase). Without this the loop computed a
1827
1849
  // fully correct aggregate that was consumed only by the OTel span —
@@ -3337,6 +3337,55 @@ export class GoogleVertexProvider extends BaseProvider {
3337
3337
  const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
3338
3338
  const failedTools = new Map();
3339
3339
  try {
3340
+ // Restores the per-tool observation the hand-rolled dispatch had.
3341
+ // The execution runs INSIDE the span's context so spans the tool opens
3342
+ // itself nest under this call rather than dangling beside the turn, and
3343
+ // the settled RESULT is inspected — an MCP tool reports failure in its
3344
+ // payload, so an observation that only watches for throws records a
3345
+ // failed call as successful.
3346
+ //
3347
+ // Stream path only: the generate twin never had per-tool spans, and
3348
+ // giving it them here would be behaviour GAINED under cover of a
3349
+ // migration. Shared with resolveToolOnMiss below: the old dispatch
3350
+ // opened the span before the executor lookup, so a tool hydrated
3351
+ // mid-turn was observed exactly like one declared up front.
3352
+ const withToolSpan = (toolCallName, run) => {
3353
+ const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
3354
+ kind: SpanKind.INTERNAL,
3355
+ attributes: {
3356
+ [LANGFUSE_ATTR.OBSERVATION_TYPE]: "tool",
3357
+ [ATTR.GEN_AI_TOOL_NAME]: toolCallName,
3358
+ "ai.toolCall.name": toolCallName,
3359
+ },
3360
+ }, turnContext);
3361
+ const finish = (output, errorMessage) => {
3362
+ toolSpan.setAttribute("ai.toolCall.result", spanJsonAttribute(output));
3363
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(output));
3364
+ if (errorMessage) {
3365
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_LEVEL, "ERROR");
3366
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_STATUS_MESSAGE, errorMessage);
3367
+ toolSpan.setStatus({
3368
+ code: SpanStatusCode.ERROR,
3369
+ message: errorMessage,
3370
+ });
3371
+ }
3372
+ else {
3373
+ toolSpan.setStatus({ code: SpanStatusCode.OK });
3374
+ }
3375
+ toolSpan.end();
3376
+ };
3377
+ return otelContext.with(otelTrace.setSpan(turnContext, toolSpan), async () => {
3378
+ try {
3379
+ const result = await run();
3380
+ finish(result, extractMcpToolErrorMessage(result));
3381
+ return result;
3382
+ }
3383
+ catch (error) {
3384
+ finish({ error: true }, error instanceof Error ? error.message : String(error));
3385
+ throw error;
3386
+ }
3387
+ });
3388
+ };
3340
3389
  // Executors handed to the engine, taken through the turn's
3341
3390
  // DedupExecuteMap so an identical repeated call is answered from the
3342
3391
  // per-turn cache rather than run again (BZ-3327). `.get()` returns the
@@ -3358,53 +3407,7 @@ export class GoogleVertexProvider extends BaseProvider {
3358
3407
  toolTimeoutMs: toolExecTimeoutMs,
3359
3408
  abortSignal: internalAbort.signal,
3360
3409
  onProgress: () => turnClock.noteProgress(),
3361
- // Restores the per-tool observation the hand-rolled dispatch had.
3362
- // The execution runs INSIDE the span's context so spans the tool opens
3363
- // itself nest under this call rather than dangling beside the turn, and
3364
- // the settled RESULT is inspected — an MCP tool reports failure in its
3365
- // payload, so an observation that only watches for throws records a
3366
- // failed call as successful.
3367
- //
3368
- // Stream path only: the generate twin never had per-tool spans, and
3369
- // giving it them here would be behaviour GAINED under cover of a
3370
- // migration.
3371
- withToolSpan: (toolCallName, run) => {
3372
- const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
3373
- kind: SpanKind.INTERNAL,
3374
- attributes: {
3375
- [LANGFUSE_ATTR.OBSERVATION_TYPE]: "tool",
3376
- [ATTR.GEN_AI_TOOL_NAME]: toolCallName,
3377
- "ai.toolCall.name": toolCallName,
3378
- },
3379
- }, turnContext);
3380
- const finish = (output, errorMessage) => {
3381
- toolSpan.setAttribute("ai.toolCall.result", spanJsonAttribute(output));
3382
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(output));
3383
- if (errorMessage) {
3384
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_LEVEL, "ERROR");
3385
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_STATUS_MESSAGE, errorMessage);
3386
- toolSpan.setStatus({
3387
- code: SpanStatusCode.ERROR,
3388
- message: errorMessage,
3389
- });
3390
- }
3391
- else {
3392
- toolSpan.setStatus({ code: SpanStatusCode.OK });
3393
- }
3394
- toolSpan.end();
3395
- };
3396
- return otelContext.with(otelTrace.setSpan(turnContext, toolSpan), async () => {
3397
- try {
3398
- const result = await run();
3399
- finish(result, extractMcpToolErrorMessage(result));
3400
- return result;
3401
- }
3402
- catch (error) {
3403
- finish({ error: true }, error instanceof Error ? error.message : String(error));
3404
- throw error;
3405
- }
3406
- });
3407
- },
3410
+ withToolSpan,
3408
3411
  }),
3409
3412
  };
3410
3413
  }
@@ -3538,6 +3541,10 @@ export class GoogleVertexProvider extends BaseProvider {
3538
3541
  toolTimeoutMs: toolExecTimeoutMs,
3539
3542
  abortSignal: internalAbort.signal,
3540
3543
  onProgress: () => turnClock.noteProgress(),
3544
+ // Same observation as an up-front executor: the hand-rolled
3545
+ // dispatch spanned at the call, after the executor lookup, so
3546
+ // a hydrated tool was never the one unobserved call in a turn.
3547
+ withToolSpan,
3541
3548
  }),
3542
3549
  };
3543
3550
  },
@@ -409,6 +409,17 @@ export type StreamOptions = {
409
409
  * Used by the Claude proxy so the proxy itself can own fallback order.
410
410
  */
411
411
  disableInternalFallback?: boolean;
412
+ /**
413
+ * Whether a turn that ended at the caller's own `maxSteps` bound may still
414
+ * trigger the internal no-output provider fallback. Reaching `maxSteps` is
415
+ * a budget the caller set, not a provider failure, but such a turn can end
416
+ * with no text output and would otherwise be retried on a different
417
+ * provider — spending that provider's tokens because the caller's own
418
+ * budget ran out. Set `false` to surface the capped turn as-is
419
+ * (`metadata.stopReason === "step-cap"`). Default (unset/true) preserves
420
+ * the existing fallback behaviour.
421
+ */
422
+ fallbackOnMaxSteps?: boolean;
412
423
  /**
413
424
  * Skip injecting tool schemas into the system prompt.
414
425
  * When true, tools are ONLY passed natively via the provider's `tools` parameter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.24.1",
3
+ "version": "11.25.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {