UNPKG

@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
/** * 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