@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.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +355 -355
- package/dist/core/baseProvider.d.ts +10 -3
- package/dist/core/baseProvider.js +8 -3
- package/dist/core/modules/StreamHandler.d.ts +22 -3
- package/dist/core/modules/StreamHandler.js +42 -20
- package/dist/lib/core/baseProvider.d.ts +10 -3
- package/dist/lib/core/baseProvider.js +8 -3
- package/dist/lib/core/modules/StreamHandler.d.ts +22 -3
- package/dist/lib/core/modules/StreamHandler.js +42 -20
- package/dist/lib/neurolink.js +57 -3
- package/dist/lib/providers/anthropic.js +13 -1
- package/dist/lib/providers/anthropicBaseProvider.js +30 -2
- package/dist/lib/providers/azureOpenai.js +12 -1
- package/dist/lib/providers/googleAiStudio.js +12 -1
- package/dist/lib/providers/googleVertex.js +11 -1
- package/dist/lib/providers/huggingFace.js +29 -2
- package/dist/lib/providers/litellm.js +44 -4
- package/dist/lib/providers/mistral.js +12 -1
- package/dist/lib/providers/openAI.js +34 -3
- package/dist/lib/providers/openRouter.js +33 -2
- package/dist/lib/providers/openaiCompatible.js +34 -2
- package/dist/lib/services/server/ai/observability/instrumentation.js +7 -2
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +2 -0
- package/dist/lib/types/noOutputSentinel.d.ts +26 -0
- package/dist/lib/types/noOutputSentinel.js +2 -0
- package/dist/lib/types/stream.d.ts +2 -1
- package/dist/lib/utils/noOutputSentinel.d.ts +80 -0
- package/dist/lib/utils/noOutputSentinel.js +193 -0
- package/dist/neurolink.js +57 -3
- package/dist/providers/anthropic.js +13 -1
- package/dist/providers/anthropicBaseProvider.js +30 -2
- package/dist/providers/azureOpenai.js +12 -1
- package/dist/providers/googleAiStudio.js +12 -1
- package/dist/providers/googleVertex.js +11 -1
- package/dist/providers/huggingFace.js +29 -2
- package/dist/providers/litellm.js +44 -4
- package/dist/providers/mistral.js +12 -1
- package/dist/providers/openAI.js +34 -3
- package/dist/providers/openRouter.js +33 -2
- package/dist/providers/openaiCompatible.js +34 -2
- package/dist/services/server/ai/observability/instrumentation.js +7 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/noOutputSentinel.d.ts +26 -0
- package/dist/types/noOutputSentinel.js +1 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/utils/noOutputSentinel.d.ts +80 -0
- package/dist/utils/noOutputSentinel.js +192 -0
- 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
|
-
|
|
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 (
|
|
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(),
|