UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

761 lines (760 loc) • 39 kB
import type { AIProviderName } from "../constants/enums.js"; import type { EvaluationData } from "../index.js"; import type { NeuroLink } from "../neurolink.js"; import type { FileToolRootPolicy, UnknownRecord, MiddlewareFactoryOptions, StreamOptions, StreamResult, AIProvider, AnalyticsData, EnhancedGenerateResult, TextGenerationOptions, TextGenerationResult, ValidationSchema, EmbedInput, DecisionRequest, DecisionResult } from "../types/index.js"; import { TelemetryHandler } from "./modules/TelemetryHandler.js"; import type { LanguageModel, ModelMessage, RawUsageObject, Tool, ToolCallRepairFunction, ToolSet } from "../types/index.js"; export declare abstract class BaseProvider implements AIProvider { protected modelName: string; protected readonly providerName: AIProviderName; protected readonly defaultTimeout: number; protected middlewareOptions?: MiddlewareFactoryOptions; protected readonly directTools: {}; protected mcpTools?: Record<string, Tool>; private fileToolRootsBound; protected customTools?: Map<string, unknown>; protected toolExecutor?: (toolName: string, params: unknown) => Promise<unknown>; protected sessionId?: string; protected userId?: string; protected neurolink?: NeuroLink; setTraceContext(ctx: { traceId: string; parentSpanId: string; } | null): void; private messageBuilder; private streamHandler; protected telemetryHandler: TelemetryHandler; private utilities; private readonly toolsManager; constructor(modelName?: string, providerName?: AIProviderName, neurolink?: NeuroLink, middleware?: MiddlewareFactoryOptions); /** * Update modelName and rebuild composition handlers with the new value. * * Auto-discovery providers (lm-studio, llamacpp) call this once they have * resolved the loaded model from `/v1/models`. Without this, handlers * (TelemetryHandler, MessageBuilder, ...) keep the pre-discovery name and * pricing / span / log metadata reports the stale value. */ protected refreshHandlersForModel(model: string): void; /** * Check if this provider supports tool/function calling * Override in subclasses to disable tools for specific providers or models * @returns the current model's registered capability, or true when unknown */ supportsTools(): boolean; /** * Whether this provider implements the opt-in `executionControl` contract. * * Default false, and that default is load-bearing: a provider that has not * implemented the contract must REJECT it, not ignore it. Silently dropping * a caller-set execution policy is invisible until a long turn dies at a * ceiling its owner believed had been removed. Overridden only where the * control is genuinely honoured end to end. */ supportsExecutionControl(): boolean; /** * Apply the shared tool gate and optionally report registry-backed * suppression at the request entry point. */ private shouldUseTools; /** * Primary streaming method - implements AIProvider interface * When tools are involved, falls back to generate() with synthetic streaming */ stream(optionsOrPrompt: StreamOptions | string, analysisSchema?: ValidationSchema): Promise<StreamResult>; /** * Swap in a fallback model when a stream dies on a retired id before it has * produced anything. * * OpenAI-compatible streaming is lazy: executeStream() returns a * StreamResult without touching the network, and the request only goes out * on the consumer's first pull. A retired model therefore does NOT fail in * stream()'s try/catch — it fails deep inside iteration. This wrapper sits * UNDER wrapStreamWithLifecycleCallbacks precisely so that a failed first * attempt is invisible: onChunk, onFinish and onError all belong to the * layer above and never observe it. * * The `yielded` guards are the safety property. A stream is only retried * while it has emitted nothing; once a single chunk has reached the * consumer the output is committed, and any later failure propagates * untouched rather than replaying a half-delivered response. */ private withStreamModelFallback; /** * The eager half of the retired-model stream fallback, for providers whose * executeStream() reaches the network before returning (see * runGenerateWithModelFallback for the generate() half and the reasoning). * * Returns a working StreamResult when a fallback model succeeds, or * undefined to mean "not recoverable — carry on with the original error". * Returning rather than throwing is deliberate: every existing path in the * caller's catch (the broad fake-streaming fallback, the terminal-error * re-throw, the onError firing) is left exactly as it was, so behaviour * changes only when a fallback actually succeeds. */ private retryStreamWithFallbackModel; /** * Wrap a StreamResult with consumer-facing lifecycle callbacks. * * `options.onChunk`, `options.onFinish`, `options.onError` are translated * by NeuroLink.applyStreamLifecycleMiddleware() into * `options.middleware.middlewareConfig.lifecycle.config`. The AI SDK's * lifecycle middleware only sees these via the wrapped LanguageModel — * which is bypassed by providers that stream via raw HTTP fetch (Ollama * over /api/chat, custom OpenAI-compatible servers, etc). Wrapping the * user-facing stream here ensures the callbacks fire regardless of the * underlying transport. * * `teardownController`, when supplied, is the call's own abort source (see * `stream()`): the cancel hook fires it on abandonment so the transport's * in-flight HTTP request is cancelled, not just this iterator chain. */ private wrapStreamWithLifecycleCallbacks; /** * Fire the consumer-supplied onError callback before throwing. Used in * error branches inside stream() that re-throw without emitting any * stream chunks (which would otherwise hide the failure from a caller * that supplied `onError`). */ private fireLifecycleErrorCallback; /** * Build the fake-stream output and apply the same incremental TTS wrapper * used by the standard NeuroLink stream path. */ private createFakeStreamingOutput; /** * Execute fake streaming - extracted method for reusability */ private executeFakeStreaming; /** * Apply per-call tool filtering (whitelist/blacklist) to a tools record. * * All filtering surfaces are merged into one ResolvedToolPolicy by * `resolveToolPolicy()` — per-call `toolFilter` (whitelist), * `enabledToolNames` (merged into the whitelist, as its docs always * promised), `excludeTools` (denylist, applied after the whitelist), and * the instance-level `tools` config (enabled/include/exclude, `*` globs) — * then applied by `applyToolGate()`. This is the single filter semantics * for every generate/stream path. */ private getToolPolicy; private applyToolFiltering; /** * Deterministic name-sorted key order. External MCP servers connect and * discover in parallel, so insertion order varies across process restarts; * providers serialize this record in key order (and Anthropic pins its * cache_control breakpoint to the LAST tool), so an unstable order silently * busts provider prompt caches. Runs AFTER the dedup pass — dedup's * keep-first policy must see phase order (built-ins first) so duplicate * winners don't flip when an MCP tool name sorts earlier. */ private sortToolRecord; /** * On-demand discovery (`tools.discovery: true`): defer external MCP tool * schemas behind one `search_tools` meta-tool. Built-in tools, per-call * tools, explicitly whitelisted tools, and session-pinned (previously * discovered) tools always stay hot. No-op when discovery is off — with a * one-time WARN when the catalog is large enough that selection accuracy * measurably degrades. */ private applyToolDiscovery; private static warnedLargeCatalog; private warnLargeCatalogOnce; /** * Opt-in signature dedup — runs AFTER whitelist/blacklist filtering and * BEFORE the tool set reaches the provider call. Fails open: any error * inside dedupeTools returns the original filtered set unchanged. */ private applyDedupPass; /** * Prepare generation context including tools and model */ private prepareGenerationContext; /** * Get merged tools for streaming: combines base tools (MCP/built-in) with * user-provided tools (e.g., RAG tools passed via options.tools). * * This is the canonical tool-merge pattern for executeStream() implementations. * All providers should call this instead of getAllTools() directly. */ protected getToolsForStream(options: StreamOptions | TextGenerationOptions): Promise<Record<string, Tool>>; /** * Create (or reuse) the per-call ToolExecutionRecorder and wrap the final * tool record with it. The recorder rides on the options object so provider * loops and result assembly observe the same capture state; wrapping is * idempotent, so paths that re-enter (stream→generate fallback) never * double-record. */ protected wrapToolsForExecutionCapture(tools: Record<string, Tool>, options: StreamOptions | TextGenerationOptions): Record<string, Tool>; /** * Build messages array for generation - delegated to MessageBuilder */ private buildMessages; /** * Build messages array for streaming operations - delegated to MessageBuilder * This is a protected helper method that providers can use to build messages * with automatic multimodal detection, eliminating code duplication * * @param options - Stream options or text generation options * @returns Promise resolving to ModelMessage array ready for AI SDK */ protected buildMessagesForStream(options: StreamOptions | TextGenerationOptions): Promise<ModelMessage[]>; /** * Record performance metrics - delegated to TelemetryHandler */ protected recordPerformanceMetrics(usage: RawUsageObject | undefined, responseTime: number): Promise<void>; /** * Text generation method - implements AIProvider interface * Tools are always available unless explicitly disabled * * Supports Text-to-Speech (TTS) audio generation in two modes: * 1. Direct synthesis (default): TTS synthesizes the input text without AI generation * 2. AI response synthesis: TTS synthesizes the AI-generated response after generation * * When TTS is enabled with useAiResponse=false (default), the method returns early with * only the audio result, skipping AI generation entirely for optimal performance. * * When TTS is enabled with useAiResponse=true, the method performs full AI generation * and then synthesizes the AI response to audio. * * @param optionsOrPrompt - Generation options or prompt string * @param _analysisSchema - Optional analysis schema (not used) * @returns Enhanced result with optional audio field containing TTSResult * * IMPLEMENTATION NOTE: Uses streamText() under the hood and accumulates results * for consistency and better performance */ /** * Ensure runtime-discovered model limits (context window, output-token * ceiling) are registered before any budget math runs. Generation * pipelines await this BEFORE `checkContextBudget`, and generate()/stream() * await it before options normalization so `getSafeMaxTokens` sees the * discovered output ceiling. * * Default no-op. Providers with a runtime discovery source override it * (e.g. LiteLLM's `/model/info`). Implementations must NEVER reject — * discovery failure degrades to the static defaults. */ ensureModelLimits(): Promise<void>; generate(optionsOrPrompt: TextGenerationOptions | string, _analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>; /** * Models the catalog names are the models a vendor served when the entry was * last verified. Vendors retire them without warning, and until this existed * the runtime had no answer for that: an InvalidModelError is classified * non-retryable (correctly — another *provider* cannot fix a bad model id), * so the fallback chain stopped dead and the caller got an error even though * the same provider was still serving four other models listed right there * in the catalog's `fallbacks`. * * So on an invalid-model error only, walk this provider's fallbacks. Every * switch is a loud WARN: the caller asked for one model and is getting * another, which they must be able to see in the logs. Any other error type * propagates untouched on the first attempt. * * stream() gets the same treatment via retryStreamWithFallbackModel, which * is safe for the same reason it is cheap: the retry happens before any * lifecycle callback has fired and before a chunk has reached the consumer, * so there is no observable output to replay. */ /** * Protected rather than private so a provider with a native generate path can * reuse it. Overriding `generate()` otherwise skips this wrapper silently, * and a retired default model stops degrading to the next live one in the * catalog — which is exactly what the provider contract gate checks. */ protected runGenerateWithModelFallback(attempt: () => Promise<EnhancedGenerateResult | null>, callerOwnsFallback: boolean, options?: TextGenerationOptions): Promise<EnhancedGenerateResult | null>; /** * Model ids to try when this provider rejects its current model as invalid. * Empty by default; catalog-driven providers return their `fallbacks`. */ protected getModelFallbacks(): string[]; /** * The fallbacks an invalid-model retry may try for this request. A catalog * orders `fallbacks` for text, so a request carrying an image keeps only the * vision-capable ones: a text-only candidate fails the vision guard, and * that error names the fallback instead of the retired model the caller * asked for. With no vision-capable candidate left, the caller gets the * original invalid-model error. */ protected modelFallbacksFor(options: TextGenerationOptions | StreamOptions, requestedModel: string): string[]; /** * Alias for generate method - implements AIProvider interface */ gen(optionsOrPrompt: TextGenerationOptions | string, analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>; private runGenerateInActiveContext; protected handleDirectTTSSynthesis(options: TextGenerationOptions, startTime: number): Promise<EnhancedGenerateResult>; private handleVideoFrameGeneration; /** * Close out a turn produced by a provider's own native generate loop. * * The native loops bypass `executeGeneration`, and with it every post-call * step the standard path runs. Each one that was missed had to be found * separately — `onFinish` stopped firing, TTS synthesis silently produced no * audio, and OTEL saw no usage for any native provider. Routing all of them * through one method is what stops the next native path from rediscovering * the same list. * * Order matters and mirrors the standard path: metrics are recorded before * synthesis so telemetry sees the model's own usage, and `enhanceResult` * runs last so analytics and evaluation observe the final content. */ protected finalizeNativeGenerate(result: EnhancedGenerateResult, options: TextGenerationOptions, startTime: number): Promise<EnhancedGenerateResult>; /** * Invoke a caller's `onFinish` for a native turn. * * On the standard path `onFinish` is converted into lifecycle middleware and * applied by wrapping the model — a step the native loops skip, so they have * to call it themselves. Failures are logged and swallowed: a caller's * callback must not be able to fail their generation. */ protected fireGenerateOnFinish(options: TextGenerationOptions, result: EnhancedGenerateResult | null, startTime: number): void; protected synthesizeAIResponseIfNeeded(enhancedResult: EnhancedGenerateResult, options: TextGenerationOptions): Promise<EnhancedGenerateResult>; /** * Build the public TTS failure detail. * * The message is redacted through the shared `sanitizeErrorCause` before it * leaves this method. It used to be an internal value that only reached the * logger; it is now carried on `result.ttsMetadata` and therefore reaches * SDK callers, who may forward it to an end user. Provider errors quote the * request URL often enough that a key in a query string is a real vector — * `redactUrlsInText` strips exactly that while leaving a bare URL readable, * so the diagnosis survives and the credential does not. */ private getTTSErrorDetails; /** * BACKWARD COMPATIBILITY: Legacy generateText method * Converts EnhancedGenerateResult to TextGenerationResult format * Ensures existing scripts using createAIProvider().generateText() continue to work */ generateText(options: TextGenerationOptions): Promise<TextGenerationResult>; /** * Generate embeddings for text * * This is a default implementation that throws an error. * Providers that support embeddings (OpenAI, Google Vertex, Amazon Bedrock) * should override this method with their specific implementation. * * @param input - Text string or EmbedInput with text/image/mimeType * @param _modelName - Optional embedding model name (provider-specific) * @returns Promise resolving to the embedding vector (array of numbers) * @throws Error if the provider does not support embeddings * * @example * ```typescript * const provider = await ProviderFactory.createProvider('openai', 'text-embedding-3-small'); * const embedding = await provider.embed('Hello world'); * console.log(embedding); // [0.123, -0.456, ...] * * // Multi-modal embedding (Bedrock Titan Image / Nova) * const multiEmbedding = await provider.embed({ text: "a photo", image: imageBuffer, mimeType: "image/png" }); * ``` */ embed(input: string | EmbedInput, _modelName?: string): Promise<number[]>; /** * Generate embeddings for multiple texts in a single batch * * This is a default implementation that throws an error. * Providers that support embeddings should override this method. * The AI SDK's embedMany automatically handles chunking for models with batch limits. * * @param texts - The texts to embed * @param _modelName - Optional embedding model name (provider-specific) * @returns Promise resolving to an array of embedding vectors * @throws Error if the provider does not support embeddings */ embedMany(texts: string[], _modelName?: string): Promise<number[][]>; /** * Get the default embedding model for this provider * * Override in subclasses to provide provider-specific defaults. * Returns undefined for providers that don't support embeddings. * * @returns The default embedding model name, or undefined if not supported */ protected getDefaultEmbeddingModel(): string | undefined; /** * Evaluate a state against a batch of typed questions. * * Default implementation throws, exactly as `embed()` does: `decide` is an * optional capability, declared per provider by `"decide"` in its * descriptor's `inferenceKinds`. Callers that want a silent no-op on an * unsupporting provider should check the descriptor first rather than * catching this. * * Latency on a decision model is flat in question count while concurrent * requests queue, so this method is deliberately batch-shaped: one call * carries every question a caller needs. There is no single-question * convenience wrapper, because it would invite fanning out. * * @throws Error if the provider does not serve the `decide` inference type */ decide(_request: DecisionRequest): Promise<DecisionResult>; /** * Get the default decision model for this provider. * * Mirrors {@link getDefaultEmbeddingModel}. Returns undefined for providers * that do not serve the `decide` inference type. */ protected getDefaultDecisionModel(): string | undefined; /** * Create an `experimental_repairToolCall` handler for streamText/generateText. * Dynamically reads the tool's JSON schema to repair wrong names and params. * Returns undefined when repair is disabled via options. */ protected getToolCallRepairFn(options?: StreamOptions | TextGenerationOptions): ToolCallRepairFunction<ToolSet> | undefined; /** * Opt-in streaming hook. A provider that produces an async iterable of text * chunks plus promises for how the turn ended implements this and gets a * working `executeStream` for free. * * Deliberately optional rather than abstract: every provider that already * overrides `executeStream` directly — the older and still perfectly valid * pattern — never needs it. * * `finishReason` and `usage` are promises because both are only knowable * once the underlying stream has finished. Resolve them when it does; the * default `executeStream` chains off them rather than waiting for a * consumer, so they must settle even if nobody drains the stream. */ protected doStream?(options: StreamOptions): Promise<{ stream: AsyncIterable<{ content: string; }>; finishReason: Promise<string>; usage: Promise<{ inputTokens: number; outputTokens: number; }>; warnings?: string[]; }>; /** * Provider-specific streaming implementation (only used when tools are * disabled). * * This used to be `protected abstract`, which meant a provider had exactly * two options: write the whole adapter by hand, or not stream at all. The * failure mode that produced was SageMaker's — a complete, working * `doStream` sitting one property access away from an `executeStream` that * unconditionally threw "not yet fully implemented". Providers that * implement `doStream` now inherit a correct implementation, and providers * that override this method are unaffected. */ protected executeStream(options: StreamOptions, _analysisSchema?: ValidationSchema): Promise<StreamResult>; /** * Get the provider name */ protected abstract getProviderName(): AIProviderName; /** * Get the default model for this provider */ protected abstract getDefaultModel(): string; /** * REQUIRED: Every provider MUST implement this method * Returns the Vercel AI SDK model instance for this provider */ /** * Run one whole turn under the caller's timeout contract. * * Every generate path must go through this, including the native loops that * bypass `executeGeneration`. When the Anthropic native path was first added * it built its own call and never composed a timer, so `timeout: 1000` * against a three-second upstream simply returned the late response — the * turn budget silently stopped existing for that provider. * * An explicit, valid `turnTimeoutMs` is the caller's whole-turn contract and * owns this hard abort; `timeout` then keeps its per-model-call meaning (it * reaches the model layer via `providerOptions.neurolink`). Before that * split, `timeout` alone bounded the ENTIRE multi-step loop, so a caller * asking for a 40-minute turn of 5-minute calls was killed at 5 minutes * flat — mid-loop, dressed as "Request was aborted.". * * `descriptorGenerateMs` only ever RAISES the 3-minute floor, never lowers * it: several descriptors carry aspirational sub-180s numbers (openai 30s, * bedrock 45s) that were never enforced, and enforcing them now would break * long-running generations that have always been allowed. */ protected withTurnTimeout<T>(options: TextGenerationOptions, descriptorGenerateMs: number | undefined, run: (timedOptions: TextGenerationOptions) => Promise<T>): Promise<T>; /** * Emit a `turn:lifecycle` event on the SDK emitter (alongside * tool:start/tool:end) so loop conditions that previously only reached * process logs — step-cap, time-limit, stall, abort, tool timeouts, * malformed-call retries — are observable by consumers' own pipelines. * * Lives here rather than on one provider because every native agentic loop * has the same conditions to report, and a second copy is how two loops * come to emit subtly different payloads for the same event name. */ protected emitTurnEvent(payload: { phase: "step-cap" | "context-cap" | "time-limit" | "stalled" | "aborted" | "provider-error" | "tool-timeout" | "malformed-retry"; step?: number; maxSteps?: number; toolName?: string; toolCallCount?: number; elapsedMs?: number; }): void; /** * The per-provider generate budget from the descriptor, for native paths * that call `withTurnTimeout` directly. */ protected getDescriptorGenerateMs(): number | undefined; /** Per-provider `decide` budget from the descriptor, when one is declared. */ protected getDescriptorDecideMs(): number | undefined; protected abstract getAISDKModel(): LanguageModel | Promise<LanguageModel>; /** * Public handle on this provider's model object. * * `getAISDKModel()` is protected because only the generation pipeline should * drive it. The browser bundle needs the same handle to back its public * provider factories without reaching into provider internals, so this is the * one sanctioned way out. The union return lets providers that resolve their * model synchronously (Anthropic) and asynchronously (the OpenAI-compatible * family) both satisfy it. */ getModel(): LanguageModel | Promise<LanguageModel>; /** * Get AI SDK model with middleware applied * This method wraps the base model with any configured middleware * TODO(#1576): Implement global level middlewares that can be used */ protected getAISDKModelWithMiddleware(options?: TextGenerationOptions | StreamOptions): Promise<LanguageModel>; /** * Apply the configured middleware chain to a caller-supplied base model. * * `getAISDKModelWithMiddleware()` always wraps `getAISDKModel()`, which is * the model the non-streaming path drives. Streaming paths build a * different base — one whose `doStream` starts the provider's own stream * loop — and need the same chain applied to it, so the wrapping is split * out here rather than duplicated per provider. */ protected applyMiddlewareToModel(baseModel: LanguageModel, options?: TextGenerationOptions | StreamOptions): Promise<LanguageModel>; /** * Extract middleware options - delegated to Utilities */ private extractMiddlewareOptions; /** * Check if a schema is a Zod schema - delegated to Utilities */ private isZodSchema; /** * Convert tool execution result - delegated to Utilities */ private convertToolResult; /** * Fix JSON Schema for OpenAI strict mode - delegated to Utilities */ private fixSchemaForOpenAIStrictMode; /** * Get all available tools - delegated to ToolsManager */ /** * Bind the built-in file tools to one request's root policy. NeuroLink * calls this before every provider call that carries the caller's options, * because several providers override generate()/stream() and assemble * their tools without passing through BaseProvider's entry points. * Providers are created per request, so a bound policy never reaches * another request. */ setFileToolRootPolicy(policy: FileToolRootPolicy | undefined): void; /** * Resolve roots for a provider called directly, without NeuroLink. A * policy NeuroLink already bound is left alone unless these options carry * their own. An invalid root throws, before any model request. */ private applyFileToolRoots; protected getAllTools(): Promise<Record<string, Tool>>; /** * Calculate actual cost - delegated to TelemetryHandler */ private calculateActualCost; /** * Create a permissive Zod schema - delegated to Utilities */ private createPermissiveZodSchema; /** * Set session context for MCP tools - delegated to ToolsManager */ setSessionContext(sessionId?: string, userId?: string): void; /** * Provider-specific error formatting. * Subclasses implement this to produce human-readable error messages * (e.g., "❌ Google Vertex AI Provider Error\n\n..."). */ protected abstract formatProviderError(error: unknown): Error; /** * Handle provider errors with abort passthrough. * AbortErrors are never wrapped — they must propagate with their * original identity so that isAbortError() can detect them in * retry/fallback loops (directProviderGeneration, performMCPGenerationRetries). */ /** * Classify an error that escaped while the consumer was ITERATING a stream. * * `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider * that discovers its failure lazily throws on first pull instead — so the raw upstream * error reached the consumer with no provider tag and no classification, while the same * failure through `generate()` was classified normally. Measured on OpenAI: * * streaming "You have no credits remaining." * non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..." * * Three guards. Only the third is load-bearing against today's code; the first two are * deliberate depth against a hazard that is real but not currently reachable. Measured, * rather than assumed — see the note after the list. * * 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark, * including the two formatters whose results escape the ProviderError hierarchy * (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both * extend Error directly). Without this they would be classified a second time and * degraded. * 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY, * bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its * own streaming catch (`anthropic/client.ts`), and its result is a ProviderError. * 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider * failure. `classifyProviderError` ends in an unconditional catch-all * (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not * gated on the error having come off the wire. Measured with the guard absent: * TypeError "Cannot read properties of undefined (reading 'content')" * became ProviderError "[openai] openai error: Cannot read properties of undefined..." * which would hide a real defect behind a plausible provider message. * * WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is * attached to a formatted error in exactly one place — `handleProviderError` below — and * that same method applies the stamp. So a ProviderError carrying a status is always * stamped (caught by guard 1's own condition), and a ProviderError produced by a direct * `formatProviderError` call carries no status, so guard 3 already returns it untouched. * Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked * provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was * byte-identical (`RateLimitError`, one `[anthropic]` prefix). * * They remain because the hazard they cover is measured and real: `handleProviderError` * is NOT idempotent — it copies statusCode onto its own output, so a second pass * re-matches the bare 429 rule and DEGRADES the classification: * pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..." * pass 2 RateLimitError "...rate limit exceeded..." * The day any provider attaches a status to an error it formats itself, guard 3 stops * covering that case and this degradation becomes live. Cheap insurance, not dead code — * but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is. */ protected classifyStreamError(error: unknown): Error; protected handleProviderError(error: unknown): Error; /** * Image generation method. Providers that support it should override this. * By default, it throws an error indicating that the functionality is not supported. * @param _options The generation options. * @returns A promise that resolves to the generation result. */ protected executeImageGeneration(_options: TextGenerationOptions): Promise<EnhancedGenerateResult>; /** * Execute operation with timeout and proper cleanup * Consolidates identical timeout handling from 8/10 providers */ protected executeWithTimeout<T>(operation: () => Promise<T>, options: { timeout?: number | string; operationType?: string; }): Promise<T>; /** * Validate stream options - delegated to StreamHandler */ protected validateStreamOptions(options: StreamOptions): void; /** * Create text stream transformation - delegated to StreamHandler. * Reviewer follow-up: forwards the optional `getUnderlyingError` * callback so providers can capture upstream errors via * `streamText`'s `onError` and have them flow into the * NoOutputGeneratedError sentinel's `providerError` / * `modelResponseRaw`. */ protected createTextStream(result: { textStream: AsyncIterable<string>; finishReason?: Promise<unknown> | unknown; totalUsage?: Promise<unknown> | unknown; }, getUnderlyingError?: () => unknown): AsyncGenerator<{ content: string; } | import("../types/index.js").StreamNoOutputSentinel>; /** * Create standardized stream result - delegated to StreamHandler */ protected createStreamResult(stream: AsyncGenerator<{ content: string; }>, additionalProps?: Partial<StreamResult>): StreamResult; /** * Create stream analytics - delegated to StreamHandler */ protected createStreamAnalytics(result: UnknownRecord, startTime: number, options: StreamOptions): Promise<UnknownRecord | undefined>; /** * Handle common error patterns - delegated to Utilities */ protected handleCommonErrors(error: unknown): Error | null; /** * Set up tool executor - delegated to ToolsManager * @param sdk - The NeuroLinkSDK instance for tool execution * @param functionTag - Function name for logging */ setupToolExecutor(sdk: { customTools: Map<string, unknown>; executeTool: (toolName: string, params: unknown) => Promise<unknown>; }, functionTag: string): void; /** * Normalize text generation options - delegated to Utilities */ protected normalizeTextOptions(optionsOrPrompt: TextGenerationOptions | string): TextGenerationOptions; /** * Normalize stream options - delegated to Utilities */ protected normalizeStreamOptions(optionsOrPrompt: StreamOptions | string): StreamOptions; protected enhanceResult(result: EnhancedGenerateResult, options: TextGenerationOptions, startTime: number): Promise<EnhancedGenerateResult>; /** * Handle video generation mode * * Generates video from input image + text prompt using Vertex AI Veo 3.1. * * @param options - Text generation options with video configuration * @param startTime - Generation start timestamp for metrics * @returns Enhanced result with video data * * @example * ```typescript * const result = await provider.generate({ * input: { text: "Product showcase", images: [imageBuffer] }, * output: { mode: "video", video: { resolution: "1080p" } } * }); * // result.video contains the generated video * ``` */ protected handleVideoGeneration(options: TextGenerationOptions, startTime: number): Promise<EnhancedGenerateResult>; /** * Create analytics - delegated to TelemetryHandler */ protected createAnalytics(result: EnhancedGenerateResult, responseTime: number, options: TextGenerationOptions): Promise<AnalyticsData>; /** * Create evaluation - delegated to TelemetryHandler */ protected createEvaluation(result: EnhancedGenerateResult, options: TextGenerationOptions): Promise<EvaluationData>; /** * Validate text generation options - delegated to Utilities */ protected validateOptions(options: TextGenerationOptions): void; /** * Get provider information - delegated to Utilities */ protected getProviderInfo(): { provider: string; model: string; }; /** * Get timeout value in milliseconds - delegated to Utilities */ getTimeout(options: TextGenerationOptions | StreamOptions): number; /** * Check if tool executions should be stored and handle storage */ protected handleToolExecutionStorage(toolCalls: unknown[], toolResults: unknown[], options: TextGenerationOptions | StreamOptions, currentTime: Date): Promise<void>; /** * Utility method to chunk large prompts into smaller pieces * @param prompt The prompt to chunk * @param maxChunkSize Maximum size per chunk (default: 900,000 characters) * @param overlap Overlap between chunks to maintain context (default: 100 characters) * @returns Array of prompt chunks */ static chunkPrompt(prompt: string, maxChunkSize?: number, overlap?: number): string[]; }