@juspay/neurolink 9.59.2 → 9.59.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +355 -355
  3. package/dist/core/baseProvider.d.ts +10 -3
  4. package/dist/core/baseProvider.js +8 -3
  5. package/dist/core/modules/StreamHandler.d.ts +22 -3
  6. package/dist/core/modules/StreamHandler.js +42 -20
  7. package/dist/lib/core/baseProvider.d.ts +10 -3
  8. package/dist/lib/core/baseProvider.js +8 -3
  9. package/dist/lib/core/modules/StreamHandler.d.ts +22 -3
  10. package/dist/lib/core/modules/StreamHandler.js +42 -20
  11. package/dist/lib/neurolink.js +57 -3
  12. package/dist/lib/providers/anthropic.js +13 -1
  13. package/dist/lib/providers/anthropicBaseProvider.js +30 -2
  14. package/dist/lib/providers/azureOpenai.js +12 -1
  15. package/dist/lib/providers/googleAiStudio.js +12 -1
  16. package/dist/lib/providers/googleVertex.js +11 -1
  17. package/dist/lib/providers/huggingFace.js +29 -2
  18. package/dist/lib/providers/litellm.js +44 -4
  19. package/dist/lib/providers/mistral.js +12 -1
  20. package/dist/lib/providers/openAI.js +34 -3
  21. package/dist/lib/providers/openRouter.js +33 -2
  22. package/dist/lib/providers/openaiCompatible.js +34 -2
  23. package/dist/lib/services/server/ai/observability/instrumentation.js +7 -2
  24. package/dist/lib/types/index.d.ts +1 -0
  25. package/dist/lib/types/index.js +2 -0
  26. package/dist/lib/types/noOutputSentinel.d.ts +26 -0
  27. package/dist/lib/types/noOutputSentinel.js +2 -0
  28. package/dist/lib/types/stream.d.ts +2 -1
  29. package/dist/lib/utils/noOutputSentinel.d.ts +80 -0
  30. package/dist/lib/utils/noOutputSentinel.js +193 -0
  31. package/dist/neurolink.js +57 -3
  32. package/dist/providers/anthropic.js +13 -1
  33. package/dist/providers/anthropicBaseProvider.js +30 -2
  34. package/dist/providers/azureOpenai.js +12 -1
  35. package/dist/providers/googleAiStudio.js +12 -1
  36. package/dist/providers/googleVertex.js +11 -1
  37. package/dist/providers/huggingFace.js +29 -2
  38. package/dist/providers/litellm.js +44 -4
  39. package/dist/providers/mistral.js +12 -1
  40. package/dist/providers/openAI.js +34 -3
  41. package/dist/providers/openRouter.js +33 -2
  42. package/dist/providers/openaiCompatible.js +34 -2
  43. package/dist/services/server/ai/observability/instrumentation.js +7 -2
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +2 -0
  46. package/dist/types/noOutputSentinel.d.ts +26 -0
  47. package/dist/types/noOutputSentinel.js +1 -0
  48. package/dist/types/stream.d.ts +2 -1
  49. package/dist/utils/noOutputSentinel.d.ts +80 -0
  50. package/dist/utils/noOutputSentinel.js +192 -0
  51. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Curator P3-6: shared builder for the `NoOutputGeneratedError` sentinel
3
+ * chunk. Each provider's stream-transformation generator catches the AI
4
+ * SDK's `NoOutputGeneratedError` and yields this sentinel so downstream
5
+ * telemetry has finish reason + token usage + provider error context
6
+ * instead of just `{ noOutput: true, errorType: "..." }`.
7
+ *
8
+ * The AI SDK rejects `result.finishReason` / `result.totalUsage` in this
9
+ * branch today (see `ai/src/generate-text/stream-text.ts` ~L1078); we
10
+ * still attempt to await them so a future SDK version surfacing partial
11
+ * values populates the sentinel automatically. When they reject we keep
12
+ * conservative defaults (`finishReason: "error"`, zero usage).
13
+ */
14
+ import type { StreamNoOutputSentinel, StreamNoOutputSentinelResultLike } from "../types/index.js";
15
+ export declare function buildNoOutputSentinel(error: unknown, result?: StreamNoOutputSentinelResultLike,
16
+ /**
17
+ * Reviewer follow-up: AI SDK v6 wraps the AI SDK's
18
+ * `NoOutputGeneratedError` without preserving the underlying provider
19
+ * error in `error.cause`, and rejects `result.finishReason` /
20
+ * `result.totalUsage` with the wrapped error too. To differentiate
21
+ * content-filter / stop-sequence / provider-crash, providers can
22
+ * capture the upstream error (e.g. via streamText's `onError`
23
+ * callback) and pass it here. When provided, it takes precedence
24
+ * over the AI SDK error for `providerError` and `modelResponseRaw`.
25
+ */
26
+ underlyingError?: unknown): Promise<StreamNoOutputSentinel>;
27
+ /**
28
+ * Curator P3-6 (round-2): the AI SDK v6 path that sets
29
+ * `NoOutputGeneratedError` does NOT throw it from `result.textStream`
30
+ * iteration — it sets the error as a *promise rejection* on
31
+ * `result.finishReason` / `result.totalUsage` / `result.steps` (see
32
+ * `ai/src/generate-text/stream-text.ts` ~L1078). Providers that only
33
+ * catch errors thrown from `for await (chunk of result.textStream)` will
34
+ * miss the production trigger entirely: the stream completes silently
35
+ * with 0 chunks and the rejection bubbles as an unhandled rejection.
36
+ *
37
+ * This helper surfaces the rejection by awaiting `result.finishReason`
38
+ * after the stream completes. Providers must call this AFTER iterating
39
+ * the textStream when 0 chunks were yielded — the returned sentinel
40
+ * (if non-null) carries the enriched metadata Curator's report needed.
41
+ */
42
+ export declare function detectPostStreamNoOutput(result: StreamNoOutputSentinelResultLike,
43
+ /**
44
+ * Optional provider-captured underlying error (e.g. from streamText's
45
+ * `onError` callback). When provided, the resulting sentinel will carry
46
+ * the real provider error in `providerError` / `modelResponseRaw`
47
+ * instead of the AI SDK's generic "No output generated" message.
48
+ */
49
+ underlyingError?: unknown): Promise<{
50
+ sentinel: StreamNoOutputSentinel;
51
+ error: Error;
52
+ } | null>;
53
+ /**
54
+ * Reviewer follow-up: every provider's post-stream NoOutput detect must
55
+ * stamp the active OTel span so Pipeline B (`ContextEnricher.onEnd()` →
56
+ * `applyNonErrorLangfuseLevel`) surfaces a WARNING-level Langfuse
57
+ * observation with the enriched status message. Without this, only
58
+ * `StreamHandler`-based providers produced the rich telemetry; the
59
+ * provider-specific paths (openAI, openaiCompatible, litellm,
60
+ * huggingFace, openRouter, anthropicBaseProvider) yielded the sentinel
61
+ * to direct stream consumers but Pipeline B saw nothing.
62
+ *
63
+ * Stamps three attributes:
64
+ * - `neurolink.no_output = true` (Pipeline B trigger)
65
+ * - `langfuse.status_message` (enriched, with finishReason + tokens)
66
+ * - `neurolink.no_output.finish_reason` (raw finish reason)
67
+ *
68
+ * Safe to call when tracing isn't initialized — silently no-ops.
69
+ */
70
+ export declare function stampNoOutputSpan(sentinel: StreamNoOutputSentinel): void;
71
+ /**
72
+ * Build the OTel `langfuse.status_message` summary string for a no-output
73
+ * stream. Used by `StreamHandler.createTextStream` and any future provider
74
+ * that wants to stamp the active span with the same enriched message.
75
+ *
76
+ * Reviewer follow-up: AI SDK v4 used `promptTokens` / `completionTokens`,
77
+ * v6 uses `inputTokens` / `outputTokens`. Read both shapes so the message
78
+ * is correct whichever version surfaced partial usage data.
79
+ */
80
+ export declare function buildNoOutputStatusMessage(finishReason: unknown, usage: unknown): string;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Curator P3-6: shared builder for the `NoOutputGeneratedError` sentinel
3
+ * chunk. Each provider's stream-transformation generator catches the AI
4
+ * SDK's `NoOutputGeneratedError` and yields this sentinel so downstream
5
+ * telemetry has finish reason + token usage + provider error context
6
+ * instead of just `{ noOutput: true, errorType: "..." }`.
7
+ *
8
+ * The AI SDK rejects `result.finishReason` / `result.totalUsage` in this
9
+ * branch today (see `ai/src/generate-text/stream-text.ts` ~L1078); we
10
+ * still attempt to await them so a future SDK version surfacing partial
11
+ * values populates the sentinel automatically. When they reject we keep
12
+ * conservative defaults (`finishReason: "error"`, zero usage).
13
+ */
14
+ import { NoOutputGeneratedError } from "ai";
15
+ import { trace, context as otelContext } from "@opentelemetry/api";
16
+ export async function buildNoOutputSentinel(error, result,
17
+ /**
18
+ * Reviewer follow-up: AI SDK v6 wraps the AI SDK's
19
+ * `NoOutputGeneratedError` without preserving the underlying provider
20
+ * error in `error.cause`, and rejects `result.finishReason` /
21
+ * `result.totalUsage` with the wrapped error too. To differentiate
22
+ * content-filter / stop-sequence / provider-crash, providers can
23
+ * capture the upstream error (e.g. via streamText's `onError`
24
+ * callback) and pass it here. When provided, it takes precedence
25
+ * over the AI SDK error for `providerError` and `modelResponseRaw`.
26
+ */
27
+ underlyingError) {
28
+ let finishReason = "error";
29
+ // Reviewer follow-up: include both AI SDK v4 (promptTokens /
30
+ // completionTokens) and v6 (inputTokens / outputTokens) keys in the
31
+ // default usage so downstream consumers reading either shape see
32
+ // correct zeros instead of `undefined`. Also keep `totalTokens` for
33
+ // back-compat.
34
+ let usage = {
35
+ promptTokens: 0,
36
+ completionTokens: 0,
37
+ inputTokens: 0,
38
+ outputTokens: 0,
39
+ totalTokens: 0,
40
+ };
41
+ if (result) {
42
+ try {
43
+ if (result.finishReason !== undefined) {
44
+ finishReason = await Promise.resolve(result.finishReason);
45
+ }
46
+ }
47
+ catch {
48
+ // Expected: AI SDK rejects with the same NoOutputGeneratedError.
49
+ }
50
+ try {
51
+ if (result.totalUsage !== undefined) {
52
+ usage = await Promise.resolve(result.totalUsage);
53
+ }
54
+ }
55
+ catch {
56
+ // Expected: AI SDK rejects with the same NoOutputGeneratedError.
57
+ }
58
+ }
59
+ // Prefer the provider-captured underlying error for `providerError` /
60
+ // `modelResponseRaw` since the AI SDK NoOutputGeneratedError doesn't
61
+ // carry the actual upstream cause. Fall back to the AI SDK error.
62
+ const messageSource = underlyingError instanceof Error
63
+ ? underlyingError
64
+ : underlyingError !== undefined
65
+ ? new Error(String(underlyingError))
66
+ : error instanceof Error
67
+ ? error
68
+ : new Error(String(error));
69
+ const providerError = messageSource.message;
70
+ const causeFromSource = messageSource.cause;
71
+ // Reviewer follow-up: guard the `error.cause` access so it doesn't
72
+ // throw a TypeError when `error` is null/undefined (only valid object
73
+ // values can be indexed safely).
74
+ const causeFromError = error !== null && typeof error === "object"
75
+ ? error.cause
76
+ : undefined;
77
+ const cause = causeFromSource !== undefined ? causeFromSource : causeFromError;
78
+ // Reviewer follow-up: always populate `modelResponseRaw` so downstream
79
+ // telemetry consumers can rely on the field being a string. When neither
80
+ // an `underlyingError` nor a `cause` is available, fall back to error
81
+ // name + message so we still carry *something* about what the provider
82
+ // returned.
83
+ const modelResponseRaw = cause !== undefined
84
+ ? String(cause).slice(0, 500)
85
+ : `${messageSource.name}: ${messageSource.message}`.slice(0, 500);
86
+ return {
87
+ content: "",
88
+ metadata: {
89
+ noOutput: true,
90
+ errorType: "NoOutputGeneratedError",
91
+ finishReason,
92
+ usage,
93
+ providerError,
94
+ modelResponseRaw,
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Curator P3-6 (round-2): the AI SDK v6 path that sets
100
+ * `NoOutputGeneratedError` does NOT throw it from `result.textStream`
101
+ * iteration — it sets the error as a *promise rejection* on
102
+ * `result.finishReason` / `result.totalUsage` / `result.steps` (see
103
+ * `ai/src/generate-text/stream-text.ts` ~L1078). Providers that only
104
+ * catch errors thrown from `for await (chunk of result.textStream)` will
105
+ * miss the production trigger entirely: the stream completes silently
106
+ * with 0 chunks and the rejection bubbles as an unhandled rejection.
107
+ *
108
+ * This helper surfaces the rejection by awaiting `result.finishReason`
109
+ * after the stream completes. Providers must call this AFTER iterating
110
+ * the textStream when 0 chunks were yielded — the returned sentinel
111
+ * (if non-null) carries the enriched metadata Curator's report needed.
112
+ */
113
+ export async function detectPostStreamNoOutput(result,
114
+ /**
115
+ * Optional provider-captured underlying error (e.g. from streamText's
116
+ * `onError` callback). When provided, the resulting sentinel will carry
117
+ * the real provider error in `providerError` / `modelResponseRaw`
118
+ * instead of the AI SDK's generic "No output generated" message.
119
+ */
120
+ underlyingError) {
121
+ if (result.finishReason === undefined) {
122
+ return null;
123
+ }
124
+ try {
125
+ await Promise.resolve(result.finishReason);
126
+ // No rejection — the stream completed normally with a valid finish
127
+ // reason; this is the empty-but-not-erroring case (e.g. AI SDK
128
+ // recorded a step with no text), not the no-output failure.
129
+ return null;
130
+ }
131
+ catch (err) {
132
+ if (NoOutputGeneratedError.isInstance(err)) {
133
+ return {
134
+ sentinel: await buildNoOutputSentinel(err, result, underlyingError),
135
+ error: err,
136
+ };
137
+ }
138
+ // Other rejection types (network errors, parse errors) are not the
139
+ // bug-confirmed scenario — let the caller's existing error handling
140
+ // surface them.
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * Reviewer follow-up: every provider's post-stream NoOutput detect must
146
+ * stamp the active OTel span so Pipeline B (`ContextEnricher.onEnd()` →
147
+ * `applyNonErrorLangfuseLevel`) surfaces a WARNING-level Langfuse
148
+ * observation with the enriched status message. Without this, only
149
+ * `StreamHandler`-based providers produced the rich telemetry; the
150
+ * provider-specific paths (openAI, openaiCompatible, litellm,
151
+ * huggingFace, openRouter, anthropicBaseProvider) yielded the sentinel
152
+ * to direct stream consumers but Pipeline B saw nothing.
153
+ *
154
+ * Stamps three attributes:
155
+ * - `neurolink.no_output = true` (Pipeline B trigger)
156
+ * - `langfuse.status_message` (enriched, with finishReason + tokens)
157
+ * - `neurolink.no_output.finish_reason` (raw finish reason)
158
+ *
159
+ * Safe to call when tracing isn't initialized — silently no-ops.
160
+ */
161
+ export function stampNoOutputSpan(sentinel) {
162
+ try {
163
+ const activeSpan = trace.getSpan(otelContext.active());
164
+ if (!activeSpan) {
165
+ return;
166
+ }
167
+ activeSpan.setAttribute("neurolink.no_output", true);
168
+ activeSpan.setAttribute("langfuse.status_message", buildNoOutputStatusMessage(sentinel.metadata.finishReason, sentinel.metadata.usage));
169
+ activeSpan.setAttribute("neurolink.no_output.finish_reason", String(sentinel.metadata.finishReason));
170
+ }
171
+ catch {
172
+ // Tracing not initialized — ignore.
173
+ }
174
+ }
175
+ /**
176
+ * Build the OTel `langfuse.status_message` summary string for a no-output
177
+ * stream. Used by `StreamHandler.createTextStream` and any future provider
178
+ * that wants to stamp the active span with the same enriched message.
179
+ *
180
+ * Reviewer follow-up: AI SDK v4 used `promptTokens` / `completionTokens`,
181
+ * v6 uses `inputTokens` / `outputTokens`. Read both shapes so the message
182
+ * is correct whichever version surfaced partial usage data.
183
+ */
184
+ export function buildNoOutputStatusMessage(finishReason, usage) {
185
+ const u = usage;
186
+ const inputTokens = u?.inputTokens ?? u?.promptTokens ?? 0;
187
+ const outputTokens = u?.outputTokens ?? u?.completionTokens ?? 0;
188
+ return (`Stream produced no output (NoOutputGeneratedError): ` +
189
+ `finishReason=${String(finishReason)}, ` +
190
+ `inputTokens=${inputTokens}, ` +
191
+ `outputTokens=${outputTokens}`);
192
+ }
193
+ //# sourceMappingURL=noOutputSentinel.js.map
package/dist/neurolink.js CHANGED
@@ -5218,9 +5218,36 @@ Current user's request: ${currentInput}`;
5218
5218
  // single `generation:end` event with cost data. Cost listeners
5219
5219
  // subscribe here; previously the stream path never fired it.
5220
5220
  let resolvedUsage;
5221
+ // Reviewer follow-up: track *non-sentinel output chunks* (text,
5222
+ // audio, image — anything the SDK considers real output) so the
5223
+ // fallback gate fires only when the stream produced nothing
5224
+ // useful. Counting only text content here would have spuriously
5225
+ // triggered fallback for valid audio-only (Google Live) and
5226
+ // image-only streams. The sentinel is the only thing we exclude
5227
+ // — that path can mask real provider failures (DNS, auth,
5228
+ // retry-exhaustion) that AI SDK rejects with
5229
+ // NoOutputGeneratedError, and we want fallback to fire there.
5230
+ let realOutputChunks = 0;
5221
5231
  try {
5222
5232
  for await (const chunk of mcpStream) {
5223
5233
  chunkCount++;
5234
+ const isNoOutputSentinel = chunk !== null &&
5235
+ typeof chunk === "object" &&
5236
+ "metadata" in chunk &&
5237
+ chunk.metadata
5238
+ ?.noOutput === true;
5239
+ const hasTextContent = chunk &&
5240
+ "content" in chunk &&
5241
+ typeof chunk.content === "string" &&
5242
+ chunk.content.length > 0;
5243
+ const hasMediaPayload = chunk !== null &&
5244
+ typeof chunk === "object" &&
5245
+ "type" in chunk &&
5246
+ (chunk.type === "audio" ||
5247
+ chunk.type === "image");
5248
+ if (!isNoOutputSentinel && (hasTextContent || hasMediaPayload)) {
5249
+ realOutputChunks++;
5250
+ }
5224
5251
  if (chunk &&
5225
5252
  "content" in chunk &&
5226
5253
  typeof chunk.content === "string") {
@@ -5232,13 +5259,17 @@ Current user's request: ${currentInput}`;
5232
5259
  metadata: {
5233
5260
  chunkIndex: chunkCount,
5234
5261
  totalLength: accumulatedContent.length,
5262
+ ...(isNoOutputSentinel && { noOutput: true }),
5235
5263
  },
5236
5264
  timestamp: Date.now(),
5237
5265
  });
5238
5266
  }
5239
5267
  yield chunk;
5240
5268
  }
5241
- if (chunkCount === 0 &&
5269
+ // Reviewer follow-up: fire fallback when no *non-sentinel*
5270
+ // output was produced — sentinel-only and truly empty streams
5271
+ // both qualify, but media-only streams (audio/image) do not.
5272
+ if (realOutputChunks === 0 &&
5242
5273
  !metadata.fallbackAttempted &&
5243
5274
  !enhancedOptions.disableInternalFallback &&
5244
5275
  streamState.toolCalls.length === 0 &&
@@ -5735,9 +5766,32 @@ Current user's request: ${currentInput}`;
5735
5766
  streamState.finishReason =
5736
5767
  fallbackResult.finishReason ?? streamState.finishReason;
5737
5768
  }
5769
+ // Reviewer follow-up: count *real* output chunks for the fallback
5770
+ // success gate, mirroring the primary stream wrapper. A fallback
5771
+ // that yields only the NoOutputSentinel must not be treated as
5772
+ // success — that's the same masked-failure scenario as the primary.
5738
5773
  let fallbackChunkCount = 0;
5774
+ let fallbackRealOutputChunks = 0;
5739
5775
  for await (const fallbackChunk of fallbackResult.stream) {
5740
5776
  fallbackChunkCount++;
5777
+ const isFallbackNoOutputSentinel = fallbackChunk !== null &&
5778
+ typeof fallbackChunk === "object" &&
5779
+ "metadata" in fallbackChunk &&
5780
+ fallbackChunk.metadata
5781
+ ?.noOutput === true;
5782
+ const fallbackHasTextContent = fallbackChunk &&
5783
+ "content" in fallbackChunk &&
5784
+ typeof fallbackChunk.content === "string" &&
5785
+ fallbackChunk.content.length > 0;
5786
+ const fallbackHasMediaPayload = fallbackChunk !== null &&
5787
+ typeof fallbackChunk === "object" &&
5788
+ "type" in fallbackChunk &&
5789
+ (fallbackChunk.type === "audio" ||
5790
+ fallbackChunk.type === "image");
5791
+ if (!isFallbackNoOutputSentinel &&
5792
+ (fallbackHasTextContent || fallbackHasMediaPayload)) {
5793
+ fallbackRealOutputChunks++;
5794
+ }
5741
5795
  if (fallbackChunk &&
5742
5796
  "content" in fallbackChunk &&
5743
5797
  typeof fallbackChunk.content === "string") {
@@ -5746,10 +5800,10 @@ Current user's request: ${currentInput}`;
5746
5800
  }
5747
5801
  yield fallbackChunk;
5748
5802
  }
5749
- if (fallbackChunkCount === 0 &&
5803
+ if (fallbackRealOutputChunks === 0 &&
5750
5804
  fallbackToolCalls.length === 0 &&
5751
5805
  fallbackToolResults.length === 0) {
5752
- throw new Error(`Fallback provider ${fallbackRoute.provider} also returned 0 chunks`);
5806
+ throw new Error(`Fallback provider ${fallbackRoute.provider} also returned 0 real output chunks (chunkCount=${fallbackChunkCount}, sentinel-only or empty)`);
5753
5807
  }
5754
5808
  // Fallback succeeded - likely guardrails blocked primary
5755
5809
  metadata.fallbackProvider = fallbackRoute.provider;
@@ -790,6 +790,10 @@ export class AnthropicProvider extends BaseProvider {
790
790
  "gen_ai.request.model": getModelId(model, this.modelName || "unknown"),
791
791
  },
792
792
  });
793
+ // Reviewer follow-up: capture upstream provider errors via onError
794
+ // so the post-stream NoOutput sentinel carries the real cause in
795
+ // providerError / modelResponseRaw.
796
+ let capturedProviderError;
793
797
  let result;
794
798
  try {
795
799
  result = streamText({
@@ -802,6 +806,14 @@ export class AnthropicProvider extends BaseProvider {
802
806
  stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS),
803
807
  toolChoice: resolveToolChoice(options, tools, shouldUseTools),
804
808
  abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
809
+ onError: (event) => {
810
+ capturedProviderError = event.error;
811
+ logger.error("Anthropic: Stream error", {
812
+ error: event.error instanceof Error
813
+ ? event.error.message
814
+ : String(event.error),
815
+ });
816
+ },
805
817
  experimental_repairToolCall: this.getToolCallRepairFn(options),
806
818
  experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
807
819
  onStepFinish: ({ toolCalls, toolResults }) => {
@@ -868,7 +880,7 @@ export class AnthropicProvider extends BaseProvider {
868
880
  streamSpan.end();
869
881
  });
870
882
  timeoutController?.cleanup();
871
- const transformedStream = this.createTextStream(result);
883
+ const transformedStream = this.createTextStream(result, () => capturedProviderError);
872
884
  // ✅ Note: Vercel AI SDK's streamText() method limitations with tools
873
885
  // The streamText() function doesn't provide the same tool result access as generateText()
874
886
  // Full tool support is now available with real streaming
@@ -5,6 +5,7 @@ import { AnthropicModels } from "../constants/enums.js";
5
5
  import { BaseProvider } from "../core/baseProvider.js";
6
6
  import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
7
7
  import { logger } from "../utils/logger.js";
8
+ import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
8
9
  import { calculateCost } from "../utils/pricing.js";
9
10
  import { createAnthropicBaseConfig, validateApiKey, } from "../utils/providerConfig.js";
10
11
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
@@ -81,6 +82,10 @@ export class AnthropicProviderV2 extends BaseProvider {
81
82
  "gen_ai.request.model": getModelId(model, this.modelName || "unknown"),
82
83
  },
83
84
  });
85
+ // Reviewer follow-up: capture upstream provider errors via onError
86
+ // so the post-stream NoOutput detect can propagate the real cause
87
+ // into the sentinel's providerError / modelResponseRaw.
88
+ let capturedProviderError;
84
89
  let result;
85
90
  try {
86
91
  result = streamText({
@@ -95,6 +100,14 @@ export class AnthropicProviderV2 extends BaseProvider {
95
100
  abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
96
101
  experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
97
102
  experimental_repairToolCall: this.getToolCallRepairFn(options),
103
+ onError: (event) => {
104
+ capturedProviderError = event.error;
105
+ logger.error("AnthropicBaseProvider: Stream error", {
106
+ error: event.error instanceof Error
107
+ ? event.error.message
108
+ : String(event.error),
109
+ });
110
+ },
98
111
  onStepFinish: ({ toolCalls, toolResults }) => {
99
112
  this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
100
113
  logger.warn("[AnthropicBaseProvider] Failed to store tool executions", {
@@ -153,19 +166,34 @@ export class AnthropicProviderV2 extends BaseProvider {
153
166
  timeoutController?.cleanup();
154
167
  // Transform string stream to content object stream (match Google AI pattern)
155
168
  const transformedStream = async function* () {
169
+ let chunkCount = 0;
156
170
  try {
157
171
  for await (const chunk of result.textStream) {
172
+ chunkCount++;
158
173
  yield { content: chunk };
159
174
  }
160
175
  }
161
176
  catch (streamError) {
162
- // AI SDK v6 throws NoOutputGeneratedError when the stream produced no output.
163
177
  if (NoOutputGeneratedError.isInstance(streamError)) {
164
- logger.warn("AnthropicBaseProvider: Stream produced no output (NoOutputGeneratedError)");
178
+ logger.warn("AnthropicBaseProvider: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
179
+ const sentinel = await buildNoOutputSentinel(streamError, result, capturedProviderError);
180
+ stampNoOutputSpan(sentinel);
181
+ yield sentinel;
165
182
  return;
166
183
  }
167
184
  throw streamError;
168
185
  }
186
+ // Curator P3-6 (round-2 fix): production trigger sets the error
187
+ // on result.finishReason rejection, not on textStream iteration.
188
+ // Surface that path here so the sentinel actually fires.
189
+ if (chunkCount === 0) {
190
+ const detected = await detectPostStreamNoOutput(result, capturedProviderError);
191
+ if (detected) {
192
+ logger.warn("AnthropicBaseProvider: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
193
+ stampNoOutputSpan(detected.sentinel);
194
+ yield detected.sentinel;
195
+ }
196
+ }
169
197
  };
170
198
  return {
171
199
  stream: transformedStream(),
@@ -111,6 +111,9 @@ export class AzureOpenAIProvider extends BaseProvider {
111
111
  // Using protected helper from BaseProvider to eliminate code duplication
112
112
  const messages = await this.buildMessagesForStream(options);
113
113
  const model = await this.getAISDKModelWithMiddleware(options);
114
+ // Reviewer follow-up: capture upstream provider errors via onError
115
+ // so the post-stream NoOutput sentinel carries the real cause.
116
+ let capturedProviderError;
114
117
  const stream = await streamText({
115
118
  model,
116
119
  messages: messages,
@@ -126,6 +129,14 @@ export class AzureOpenAIProvider extends BaseProvider {
126
129
  abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
127
130
  experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
128
131
  experimental_repairToolCall: this.getToolCallRepairFn(options),
132
+ onError: (event) => {
133
+ capturedProviderError = event.error;
134
+ logger.error("AzureOpenAI: Stream error", {
135
+ error: event.error instanceof Error
136
+ ? event.error.message
137
+ : String(event.error),
138
+ });
139
+ },
129
140
  onStepFinish: (event) => {
130
141
  emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), event.toolResults);
131
142
  this.handleToolExecutionStorage([...event.toolCalls], [...event.toolResults], options, new Date()).catch((error) => {
@@ -138,7 +149,7 @@ export class AzureOpenAIProvider extends BaseProvider {
138
149
  });
139
150
  timeoutController?.cleanup();
140
151
  // Transform string stream to content object stream using BaseProvider method
141
- const transformedStream = this.createTextStream(stream);
152
+ const transformedStream = this.createTextStream(stream, () => capturedProviderError);
142
153
  return {
143
154
  stream: transformedStream,
144
155
  provider: "azure",
@@ -466,6 +466,9 @@ export class GoogleAIStudioProvider extends BaseProvider {
466
466
  const messages = await this.buildMessagesForStream(options);
467
467
  const collectedToolCalls = [];
468
468
  const collectedToolResults = [];
469
+ // Reviewer follow-up: capture upstream provider errors via onError
470
+ // so the post-stream NoOutput sentinel carries the real cause.
471
+ let capturedProviderError;
469
472
  const result = await streamText({
470
473
  model,
471
474
  messages: messages,
@@ -477,6 +480,14 @@ export class GoogleAIStudioProvider extends BaseProvider {
477
480
  abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
478
481
  experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
479
482
  experimental_repairToolCall: this.getToolCallRepairFn(options),
483
+ onError: (event) => {
484
+ capturedProviderError = event.error;
485
+ logger.error("GoogleAiStudio: Stream error", {
486
+ error: event.error instanceof Error
487
+ ? event.error.message
488
+ : String(event.error),
489
+ });
490
+ },
480
491
  // Gemini 3: use thinkingLevel via providerOptions
481
492
  // Gemini 2.5: use thinkingBudget via providerOptions
482
493
  ...(options.thinkingConfig?.enabled && {
@@ -540,7 +551,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
540
551
  })
541
552
  .finally(() => timeoutController?.cleanup());
542
553
  // Transform string stream to content object stream using BaseProvider method
543
- const transformedStream = this.createTextStream(result);
554
+ const transformedStream = this.createTextStream(result, () => capturedProviderError);
544
555
  // Create analytics promise that resolves after stream completion
545
556
  const analyticsPromise = streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, toAnalyticsStreamResult(result), Date.now() - startTime, {
546
557
  requestId: `google-ai-stream-${Date.now()}`,
@@ -877,10 +877,16 @@ export class GoogleVertexProvider extends BaseProvider {
877
877
  }
878
878
  async executeAISDKStream(options, analysisSchema, modelName) {
879
879
  const functionTag = "GoogleVertexProvider.executeStream";
880
+ // Reviewer follow-up: include `capturedProviderError` in the
881
+ // tracking object so the streamText `onError` callback (in
882
+ // buildAISDKStreamOptions) can write to it; the post-stream
883
+ // NoOutput sentinel reads it via the `getUnderlyingError` getter
884
+ // passed to createTextStream.
880
885
  const tracking = {
881
886
  chunkCount: 0,
882
887
  collectedToolCalls: [],
883
888
  collectedToolResults: [],
889
+ capturedProviderError: undefined,
884
890
  };
885
891
  const timeoutController = createTimeoutController(this.getTimeout(options), this.providerName, "stream");
886
892
  try {
@@ -909,7 +915,7 @@ export class GoogleVertexProvider extends BaseProvider {
909
915
  timeoutController,
910
916
  });
911
917
  return {
912
- stream: this.createTextStream(result),
918
+ stream: this.createTextStream(result, () => tracking.capturedProviderError),
913
919
  provider: this.providerName,
914
920
  model: this.modelName,
915
921
  ...(shouldUseTools && {
@@ -1011,6 +1017,10 @@ export class GoogleVertexProvider extends BaseProvider {
1011
1017
  const errorMessage = event.error instanceof Error
1012
1018
  ? event.error.message
1013
1019
  : String(event.error);
1020
+ // Reviewer follow-up: capture the upstream error so the
1021
+ // post-stream NoOutput sentinel can surface it via
1022
+ // providerError / modelResponseRaw.
1023
+ tracking.capturedProviderError = event.error;
1014
1024
  logger.error(`${functionTag}: Stream error`, {
1015
1025
  provider: this.providerName,
1016
1026
  modelName: this.modelName,
@@ -5,6 +5,7 @@ import { DEFAULT_MAX_STEPS } from "../core/constants.js";
5
5
  import { createProxyFetch } from "../proxy/proxyFetch.js";
6
6
  import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
7
7
  import { logger } from "../utils/logger.js";
8
+ import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
8
9
  import { createHuggingFaceConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
9
10
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
10
11
  import { resolveToolChoice } from "../utils/toolChoice.js";
@@ -128,6 +129,10 @@ export class HuggingFaceProvider extends BaseProvider {
128
129
  ? { ...options, systemPrompt: streamOptions.system }
129
130
  : options;
130
131
  const messages = await this.buildMessagesForStream(messagesOptions);
132
+ // Reviewer follow-up: capture upstream provider errors via onError
133
+ // so the post-stream NoOutput detect can propagate the real cause
134
+ // into the sentinel's providerError / modelResponseRaw.
135
+ let capturedProviderError;
131
136
  const result = await streamText({
132
137
  model: this.model,
133
138
  messages: messages,
@@ -141,6 +146,14 @@ export class HuggingFaceProvider extends BaseProvider {
141
146
  abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
142
147
  experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
143
148
  experimental_repairToolCall: this.getToolCallRepairFn(options),
149
+ onError: (event) => {
150
+ capturedProviderError = event.error;
151
+ logger.error("HuggingFace: Stream error", {
152
+ error: event.error instanceof Error
153
+ ? event.error.message
154
+ : String(event.error),
155
+ });
156
+ },
144
157
  onStepFinish: ({ toolCalls, toolResults }) => {
145
158
  emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults);
146
159
  this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
@@ -154,19 +167,33 @@ export class HuggingFaceProvider extends BaseProvider {
154
167
  timeoutController?.cleanup();
155
168
  // Transform stream to match StreamResult interface with enhanced tool call parsing
156
169
  const transformedStream = async function* () {
170
+ let chunkCount = 0;
157
171
  try {
158
172
  for await (const chunk of result.textStream) {
173
+ chunkCount++;
159
174
  yield { content: chunk };
160
175
  }
161
176
  }
162
177
  catch (streamError) {
163
- // AI SDK v6 throws NoOutputGeneratedError when the stream produced no output.
164
178
  if (NoOutputGeneratedError.isInstance(streamError)) {
165
- logger.warn("HuggingFace: Stream produced no output (NoOutputGeneratedError)");
179
+ logger.warn("HuggingFace: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
180
+ const sentinel = await buildNoOutputSentinel(streamError, result, capturedProviderError);
181
+ stampNoOutputSpan(sentinel);
182
+ yield sentinel;
166
183
  return;
167
184
  }
168
185
  throw streamError;
169
186
  }
187
+ // Curator P3-6 (round-2 fix): production trigger comes through
188
+ // the result.finishReason rejection, not textStream throws.
189
+ if (chunkCount === 0) {
190
+ const detected = await detectPostStreamNoOutput(result, capturedProviderError);
191
+ if (detected) {
192
+ logger.warn("HuggingFace: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
193
+ stampNoOutputSpan(detected.sentinel);
194
+ yield detected.sentinel;
195
+ }
196
+ }
170
197
  };
171
198
  return {
172
199
  stream: transformedStream(),