@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
229 lines • 10.7 kB
JavaScript
/**
* Stream Handler Module
*
* Handles streaming-related validation, result creation, and analytics.
* Extracted from BaseProvider to follow Single Responsibility Principle.
*
* Responsibilities:
* - Stream options validation
* - Text stream creation
* - Stream result formatting
* - Stream analytics creation
*
* @module core/modules/StreamHandler
*/
import { trace, context as otelContext, SpanStatusCode, } from "@opentelemetry/api";
import { tracers, ATTR, withSpan } from "../../telemetry/index.js";
import { logger } from "../../utils/logger.js";
import { validateStreamOptions as validateStreamOpts, ValidationError, createValidationSummary, } from "../../utils/parameterValidation.js";
import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../../utils/noOutputSentinel.js";
import { STEP_LIMITS } from "../constants.js";
import { createAnalytics } from "../analytics.js";
import { nanoid } from "nanoid";
import { NoOutputGeneratedError } from "ai";
/**
* StreamHandler class - Handles streaming operations for AI providers
*/
export class StreamHandler {
providerName;
modelName;
constructor(providerName, modelName) {
this.providerName = providerName;
this.modelName = modelName;
}
/**
* Validate stream options - consolidates validation from 7/10 providers
*/
validateStreamOptions(options) {
const span = tracers.stream.startSpan("neurolink.stream.validate", {
attributes: {
[ATTR.NL_PROVIDER]: this.providerName,
[ATTR.NL_MODEL]: this.modelName,
"stream.has_max_steps": options.maxSteps !== undefined,
},
});
try {
const validation = validateStreamOpts(options);
if (!validation.isValid) {
const summary = createValidationSummary(validation);
span.setAttribute("stream.validation_errors", validation.errors?.length ?? 0);
throw new ValidationError(`Stream options validation failed: ${summary}`, "options", "VALIDATION_FAILED", validation.suggestions);
}
// Log warnings if any
if (validation.warnings.length > 0) {
logger.warn("Stream options validation warnings:", validation.warnings);
span.addEvent("stream.validation.warnings", {
"warning.count": validation.warnings.length,
warnings: validation.warnings.join("; ").substring(0, 500),
});
}
// Additional BaseProvider-specific validation
if (options.maxSteps !== undefined) {
if (options.maxSteps < STEP_LIMITS.min ||
options.maxSteps > STEP_LIMITS.max) {
throw new ValidationError(`maxSteps must be between ${STEP_LIMITS.min} and ${STEP_LIMITS.max}`, "maxSteps", "OUT_OF_RANGE", [
`Use a value between ${STEP_LIMITS.min} and ${STEP_LIMITS.max} for optimal performance`,
]);
}
}
}
catch (error) {
span.recordException(error instanceof Error ? error : new Error(String(error)));
// NLK-GAP-006 fix: set error status alongside recordException
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
finally {
span.end();
}
}
/**
* Create text stream transformation - consolidates identical logic from 7/10 providers
* Tracks TTFC (Time To First Chunk), chunk count, and total bytes streamed.
*/
createTextStream(result,
/**
* Reviewer follow-up: optional getter for the provider's captured
* upstream error (typically wired from `streamText`'s `onError`
* callback). When set, the sentinel's `providerError` /
* `modelResponseRaw` reflect the real upstream cause instead of the
* AI SDK's generic "No output generated" message. Callers that don't
* capture upstream errors can omit this — the sentinel still
* populates with the AI SDK error.
*/
getUnderlyingError) {
const providerName = this.providerName;
return (async function* () {
let chunkCount = 0;
let totalBytes = 0;
const streamStart = Date.now();
let firstChunkTime;
try {
for await (const chunk of result.textStream) {
chunkCount++;
totalBytes += chunk.length;
if (!firstChunkTime) {
firstChunkTime = Date.now();
const activeSpan = trace.getSpan(otelContext.active());
if (activeSpan) {
activeSpan.addEvent("stream.first_chunk", {
"stream.ttfc_ms": firstChunkTime - streamStart,
"stream.provider": providerName,
});
}
}
yield { content: chunk };
}
}
catch (error) {
// AI SDK v6 throws NoOutputGeneratedError when the stream produces no output
// (e.g. empty response, model refusal with no text). Treat as an empty stream
// rather than crashing the process with an unhandled rejection.
if (NoOutputGeneratedError.isInstance(error)) {
logger.warn(`${providerName}: Stream produced no output (NoOutputGeneratedError), returning empty stream`);
// Curator P3-6: build the enriched sentinel using the shared
// helper so every provider yields the same shape. Pass the
// captured upstream error (if any) so providerError /
// modelResponseRaw carry the real cause.
const sentinel = await buildNoOutputSentinel(error, result, getUnderlyingError?.());
// Curator P2-5 + P3-6: stamp the active OTel span so
// ContextEnricher.onEnd() surfaces a WARNING-level Langfuse
// observation with finishReason + token usage. Centralized in
// stampNoOutputSpan so every wired site stamps consistently.
stampNoOutputSpan(sentinel);
// S4 fix: yield a sentinel chunk so Pipeline B can detect the empty stream
// and set the span to WARNING status instead of OK
yield sentinel;
// Reviewer follow-up: must return here. Falling through to the
// post-stream detection block below would yield a SECOND sentinel
// chunk (verified with synthetic NoOutputGeneratedError stream:
// count=2 sentinels). The catch block's yield is sufficient.
return;
}
else {
throw error;
}
}
// Curator P3-6 (round-2 fix): the production trigger sets
// NoOutputGeneratedError on `result.finishReason` rejection — NOT
// thrown from textStream iteration. Surface that path here so the
// sentinel actually fires for real-world no-output streams. The
// catch above remains as a defensive path for failure modes that
// do throw from textStream.
if (chunkCount === 0) {
const detected = await detectPostStreamNoOutput(result, getUnderlyingError?.());
if (detected) {
logger.warn(`${providerName}: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection`);
stampNoOutputSpan(detected.sentinel);
yield detected.sentinel;
}
}
// Record completion metrics on the active span
const activeSpan = trace.getSpan(otelContext.active());
if (activeSpan) {
activeSpan.addEvent("stream.complete", {
"stream.chunk_count": chunkCount,
"stream.total_bytes": totalBytes,
"stream.duration_ms": Date.now() - streamStart,
"stream.ttfc_ms": firstChunkTime ? firstChunkTime - streamStart : -1,
});
}
})();
}
/**
* Create standardized stream result - consolidates result structure
*/
createStreamResult(stream, additionalProps = {}) {
return {
stream,
provider: this.providerName,
model: this.modelName,
...additionalProps,
};
}
/**
* Create stream analytics - consolidates analytics from 4/10 providers
*/
async createStreamAnalytics(result, startTime, options) {
return withSpan({
name: "neurolink.stream.analytics",
tracer: tracers.stream,
attributes: {
[ATTR.NL_PROVIDER]: this.providerName,
[ATTR.NL_MODEL]: this.modelName,
[ATTR.NL_STREAM_MODE]: true,
},
}, async (span) => {
try {
const durationMs = Date.now() - startTime;
span.setAttribute("stream.duration_ms", durationMs);
const analytics = createAnalytics(this.providerName, this.modelName, result, durationMs, {
requestId: `${this.providerName}-stream-${nanoid()}`,
streamingMode: true,
...options.context,
});
return analytics;
}
catch (error) {
logger.warn(`Analytics creation failed for ${this.providerName}:`, error);
return undefined;
}
});
}
/**
* Validate streaming-only options (called before executeStream)
* Simpler validation for options object structure
*/
validateStreamOptionsOnly(options) {
if (!options.input) {
throw new ValidationError("Stream options must include input", "input", "MISSING_REQUIRED", ["Provide options.input with text content"]);
}
if (!options.input.text && !options.input.images?.length) {
throw new ValidationError("Stream input must include either text or images", "input", "MISSING_REQUIRED", ["Provide options.input.text or options.input.images"]);
}
}
}
//# sourceMappingURL=StreamHandler.js.map