@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
193 lines (192 loc) • 8.49 kB
JavaScript
/**
* Curator P3-6: shared builder for the `NoOutputGeneratedError` sentinel
* chunk. Each provider's stream-transformation generator catches the AI
* SDK's `NoOutputGeneratedError` and yields this sentinel so downstream
* telemetry has finish reason + token usage + provider error context
* instead of just `{ noOutput: true, errorType: "..." }`.
*
* The AI SDK rejects `result.finishReason` / `result.totalUsage` in this
* branch today (see `ai/src/generate-text/stream-text.ts` ~L1078); we
* still attempt to await them so a future SDK version surfacing partial
* values populates the sentinel automatically. When they reject we keep
* conservative defaults (`finishReason: "error"`, zero usage).
*/
import { NoOutputGeneratedError } from "ai";
import { trace, context as otelContext } from "@opentelemetry/api";
export async function buildNoOutputSentinel(error, result,
/**
* Reviewer follow-up: AI SDK v6 wraps the AI SDK's
* `NoOutputGeneratedError` without preserving the underlying provider
* error in `error.cause`, and rejects `result.finishReason` /
* `result.totalUsage` with the wrapped error too. To differentiate
* content-filter / stop-sequence / provider-crash, providers can
* capture the upstream error (e.g. via streamText's `onError`
* callback) and pass it here. When provided, it takes precedence
* over the AI SDK error for `providerError` and `modelResponseRaw`.
*/
underlyingError) {
let finishReason = "error";
// Reviewer follow-up: include both AI SDK v4 (promptTokens /
// completionTokens) and v6 (inputTokens / outputTokens) keys in the
// default usage so downstream consumers reading either shape see
// correct zeros instead of `undefined`. Also keep `totalTokens` for
// back-compat.
let usage = {
promptTokens: 0,
completionTokens: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
if (result) {
try {
if (result.finishReason !== undefined) {
finishReason = await Promise.resolve(result.finishReason);
}
}
catch {
// Expected: AI SDK rejects with the same NoOutputGeneratedError.
}
try {
if (result.totalUsage !== undefined) {
usage = await Promise.resolve(result.totalUsage);
}
}
catch {
// Expected: AI SDK rejects with the same NoOutputGeneratedError.
}
}
// Prefer the provider-captured underlying error for `providerError` /
// `modelResponseRaw` since the AI SDK NoOutputGeneratedError doesn't
// carry the actual upstream cause. Fall back to the AI SDK error.
const messageSource = underlyingError instanceof Error
? underlyingError
: underlyingError !== undefined
? new Error(String(underlyingError))
: error instanceof Error
? error
: new Error(String(error));
const providerError = messageSource.message;
const causeFromSource = messageSource.cause;
// Reviewer follow-up: guard the `error.cause` access so it doesn't
// throw a TypeError when `error` is null/undefined (only valid object
// values can be indexed safely).
const causeFromError = error !== null && typeof error === "object"
? error.cause
: undefined;
const cause = causeFromSource !== undefined ? causeFromSource : causeFromError;
// Reviewer follow-up: always populate `modelResponseRaw` so downstream
// telemetry consumers can rely on the field being a string. When neither
// an `underlyingError` nor a `cause` is available, fall back to error
// name + message so we still carry *something* about what the provider
// returned.
const modelResponseRaw = cause !== undefined
? String(cause).slice(0, 500)
: `${messageSource.name}: ${messageSource.message}`.slice(0, 500);
return {
content: "",
metadata: {
noOutput: true,
errorType: "NoOutputGeneratedError",
finishReason,
usage,
providerError,
modelResponseRaw,
},
};
}
/**
* Curator P3-6 (round-2): the AI SDK v6 path that sets
* `NoOutputGeneratedError` does NOT throw it from `result.textStream`
* iteration — it sets the error as a *promise rejection* on
* `result.finishReason` / `result.totalUsage` / `result.steps` (see
* `ai/src/generate-text/stream-text.ts` ~L1078). Providers that only
* catch errors thrown from `for await (chunk of result.textStream)` will
* miss the production trigger entirely: the stream completes silently
* with 0 chunks and the rejection bubbles as an unhandled rejection.
*
* This helper surfaces the rejection by awaiting `result.finishReason`
* after the stream completes. Providers must call this AFTER iterating
* the textStream when 0 chunks were yielded — the returned sentinel
* (if non-null) carries the enriched metadata Curator's report needed.
*/
export async function detectPostStreamNoOutput(result,
/**
* Optional provider-captured underlying error (e.g. from streamText's
* `onError` callback). When provided, the resulting sentinel will carry
* the real provider error in `providerError` / `modelResponseRaw`
* instead of the AI SDK's generic "No output generated" message.
*/
underlyingError) {
if (result.finishReason === undefined) {
return null;
}
try {
await Promise.resolve(result.finishReason);
// No rejection — the stream completed normally with a valid finish
// reason; this is the empty-but-not-erroring case (e.g. AI SDK
// recorded a step with no text), not the no-output failure.
return null;
}
catch (err) {
if (NoOutputGeneratedError.isInstance(err)) {
return {
sentinel: await buildNoOutputSentinel(err, result, underlyingError),
error: err,
};
}
// Other rejection types (network errors, parse errors) are not the
// bug-confirmed scenario — let the caller's existing error handling
// surface them.
return null;
}
}
/**
* Reviewer follow-up: every provider's post-stream NoOutput detect must
* stamp the active OTel span so Pipeline B (`ContextEnricher.onEnd()` →
* `applyNonErrorLangfuseLevel`) surfaces a WARNING-level Langfuse
* observation with the enriched status message. Without this, only
* `StreamHandler`-based providers produced the rich telemetry; the
* provider-specific paths (openAI, openaiCompatible, litellm,
* huggingFace, openRouter, anthropicBaseProvider) yielded the sentinel
* to direct stream consumers but Pipeline B saw nothing.
*
* Stamps three attributes:
* - `neurolink.no_output = true` (Pipeline B trigger)
* - `langfuse.status_message` (enriched, with finishReason + tokens)
* - `neurolink.no_output.finish_reason` (raw finish reason)
*
* Safe to call when tracing isn't initialized — silently no-ops.
*/
export function stampNoOutputSpan(sentinel) {
try {
const activeSpan = trace.getSpan(otelContext.active());
if (!activeSpan) {
return;
}
activeSpan.setAttribute("neurolink.no_output", true);
activeSpan.setAttribute("langfuse.status_message", buildNoOutputStatusMessage(sentinel.metadata.finishReason, sentinel.metadata.usage));
activeSpan.setAttribute("neurolink.no_output.finish_reason", String(sentinel.metadata.finishReason));
}
catch {
// Tracing not initialized — ignore.
}
}
/**
* Build the OTel `langfuse.status_message` summary string for a no-output
* stream. Used by `StreamHandler.createTextStream` and any future provider
* that wants to stamp the active span with the same enriched message.
*
* Reviewer follow-up: AI SDK v4 used `promptTokens` / `completionTokens`,
* v6 uses `inputTokens` / `outputTokens`. Read both shapes so the message
* is correct whichever version surfaced partial usage data.
*/
export function buildNoOutputStatusMessage(finishReason, usage) {
const u = usage;
const inputTokens = u?.inputTokens ?? u?.promptTokens ?? 0;
const outputTokens = u?.outputTokens ?? u?.completionTokens ?? 0;
return (`Stream produced no output (NoOutputGeneratedError): ` +
`finishReason=${String(finishReason)}, ` +
`inputTokens=${inputTokens}, ` +
`outputTokens=${outputTokens}`);
}