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

3,059 lines • 154 kB
import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import { createDirectAgentTools, directAgentTools, } from "../agent/directTools.js";
import { boundFileToolRoots, resolveFileToolRootPolicy, } from "../utils/fileToolRoots.js";
import { defaultProviderFor } from "../factories/mediaHandlerCatalog.js";
import { DECISION_PROVIDERS, PROVIDER_DESCRIPTORS_BY_NAME, } from "../factories/providerDescriptors.js";
import { MiddlewareFactory } from "../middleware/factory.js";
import { modelSupports } from "../models/modelRegistry.js";
import { resolveRequestKind } from "./resolveRequestKind.js";
import { ATTR, tracers } from "../telemetry/index.js";
import { ERROR_CODES, isAbortError, NeuroLinkError, } from "../utils/errorHandling.js";
import { InvalidModelError, ProviderError } from "../types/index.js";
/**
 * `instanceof` is not reliable here. The published bundle and the source tree
 * are separate module graphs, so an InvalidModelError built by the error
 * classifier can fail an `instanceof` against the class this file imported —
 * silently, with a clean typecheck. The constructor-name check is the same
 * approach the OTel error-type mapping below already uses.
 */
function isInvalidModelError(error) {
    return (error instanceof InvalidModelError ||
        (error instanceof Error && error.constructor.name === "InvalidModelError"));
}
import { sanitizeErrorCause } from "../utils/logSanitize.js";
import { createAnalytics as buildAnalytics } from "./analytics.js";
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/providerRetry.js";
import { getLifecycleMiddlewareConfig, hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
import { logger } from "../utils/logger.js";
import { interleaveTTSStream } from "../utils/ttsStream.js";
import { attachStreamCancel, cancelStream, releaseIterator, } from "../utils/streamCancellation.js";
import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
import { composeAbortSignals, composeAbortSignalsScoped, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
import { getKeyCount, getKeysAsString, transformToolExecutions, } from "../utils/transformationUtils.js";
import { ToolExecutionRecorder } from "./toolExecutionRecorder.js";
import { TTS_ERROR_CODES, TTSProcessor } from "../utils/ttsProcessor.js";
import { executeVideoAnalysis, hasVideoFrames, } from "../utils/videoAnalysisProcessor.js";
import { dedupeTools } from "./toolDedup.js";
import { resolveToolPolicy, toolNameMatcher } from "../tools/toolPolicy.js";
import { applyToolGate } from "../tools/toolGate.js";
import { partitionToolsForDiscovery, isDiscoveryMetaTool, LARGE_CATALOG_WARN_THRESHOLD, } from "../tools/toolDiscovery.js";
// Import modules for composition
import { MessageBuilder } from "./modules/MessageBuilder.js";
import { StreamHandler } from "./modules/StreamHandler.js";
import { TelemetryHandler } from "./modules/TelemetryHandler.js";
import { ToolsManager } from "./modules/ToolsManager.js";
import { Utilities } from "./modules/Utilities.js";
import { ProviderImageAdapter } from "../adapters/providerImageAdapter.js";
import { generateOnceNative } from "../utils/nativeSingleShot.js";
import { validateExecutionControl } from "../utils/parameterValidation.js";
import { extractTokenUsage } from "../utils/tokenUtils.js";
import { preserveLiveStreamAccessors } from "../utils/streamResultAccessors.js";
/**
 * Abstract base class for all AI providers
 * Tools are integrated as first-class citizens - always available by default
 */
/**
 * Marks an error as already run through `formatProviderError`.
 *
 * `handleProviderError` is NOT idempotent — measured: feeding its own output back in
 * degrades a specific classification into a generic one, because it copies `statusCode`
 * onto the formatted error, so a second pass re-matches the bare 429 rule while the more
 * specific rule (which keyed on the raw body) no longer can:
 *
 *   pass 1  ProviderError  "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
 *   pass 2  RateLimitError "[openai] OpenAI rate limit exceeded. Please try again later."
 *
 * Since the stream path can now reach a classifier that other paths already reached, that
 * second pass became possible and had to be made impossible.
 *
 * Same `Symbol.for` stamping technique as `utils/lifecycleCallbacks.ts` and for the same
 * reason: it survives across module copies where a closed-over WeakSet would not, and a
 * frozen error degrades to one extra classification rather than a throw.
 */
const PROVIDER_ERROR_CLASSIFIED = Symbol.for("neurolink.providerErrorClassified");
function markProviderErrorClassified(error) {
    if (error === null || typeof error !== "object") {
        return;
    }
    try {
        Object.defineProperty(error, PROVIDER_ERROR_CLASSIFIED, {
            value: true,
            enumerable: false,
            writable: false,
            configurable: false,
        });
    }
    catch {
        // Non-extensible error — worst case is one redundant classification.
    }
}
function isProviderErrorClassified(error) {
    if (error === null || typeof error !== "object") {
        return false;
    }
    return error[PROVIDER_ERROR_CLASSIFIED] === true;
}
function requestCarriesImages(options) {
    const input = options.input;
    if (!input) {
        return false;
    }
    if ((input.images?.length ?? 0) > 0) {
        return true;
    }
    return ("content" in input &&
        Array.isArray(input.content) &&
        input.content.some((part) => part.type === "image"));
}
export class BaseProvider {
    // Not `readonly` because providers that auto-discover the model from a
    // /v1/models endpoint (lm-studio, llamacpp) need to update modelName after
    // construction so handlers (TelemetryHandler, MessageBuilder) cache the
    // resolved name. All other providers treat this as effectively readonly.
    modelName;
    providerName;
    defaultTimeout = 30000; // 30 seconds
    middlewareOptions; // TODO(#1576): Implement global level middlewares that can be used
    // Tools are conditionally included based on centralized configuration
    directTools = shouldDisableBuiltinTools()
        ? {}
        : directAgentTools;
    mcpTools; // MCP tools loaded dynamically when available
    fileToolRootsBound = false;
    customTools; // Custom tools from registerTool()
    toolExecutor; // Tool executor from setupToolExecutor
    sessionId;
    userId;
    neurolink; // Reference to actual NeuroLink instance for MCP tools
    /** @internal Trace context propagated from NeuroLink SDK for span hierarchy */
    _traceContext = null;
    setTraceContext(ctx) {
        this._traceContext = ctx;
    }
    // Composition modules - Single Responsibility Principle
    // Handlers below are not `readonly` so that providers which auto-discover
    // their model after construction (lm-studio, llamacpp) can rebuild them
    // via `refreshHandlersForModel(...)` and propagate the resolved name into
    // pricing / telemetry / span attributes. All other providers leave these
    // alone.
    messageBuilder;
    streamHandler;
    telemetryHandler;
    utilities;
    toolsManager;
    constructor(modelName, providerName, neurolink, middleware) {
        this.modelName = modelName || this.getDefaultModel();
        this.providerName = providerName || this.getProviderName();
        this.neurolink = neurolink;
        this.middlewareOptions = middleware;
        // Initialize composition modules
        this.messageBuilder = new MessageBuilder(this.providerName, this.modelName);
        this.streamHandler = new StreamHandler(this.providerName, this.modelName);
        this.telemetryHandler = new TelemetryHandler(this.providerName, this.modelName, this.neurolink);
        this.utilities = new Utilities(this.providerName, this.modelName, this.defaultTimeout, this.middlewareOptions);
        this.toolsManager = new ToolsManager(this.providerName, this.directTools, this.neurolink, {
            isZodSchema: (schema) => this.isZodSchema(schema),
            convertToolResult: (result) => this.convertToolResult(result),
            createPermissiveZodSchema: () => this.createPermissiveZodSchema(),
            fixSchemaForOpenAIStrictMode: (schema) => this.fixSchemaForOpenAIStrictMode(schema),
        });
    }
    /**
     * 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.
     */
    refreshHandlersForModel(model) {
        this.modelName = model;
        trace
            .getSpan(context.active())
            ?.setAttribute(ATTR.GEN_AI_MODEL, this.modelName);
        this.messageBuilder = new MessageBuilder(this.providerName, this.modelName);
        this.streamHandler = new StreamHandler(this.providerName, this.modelName);
        this.telemetryHandler = new TelemetryHandler(this.providerName, this.modelName, this.neurolink);
        this.utilities = new Utilities(this.providerName, this.modelName, this.defaultTimeout, this.middlewareOptions);
    }
    /**
     * 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() {
        return modelSupports("functionCalling", this.providerName, this.modelName);
    }
    /**
     * 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() {
        return false;
    }
    /**
     * Apply the shared tool gate and optionally report registry-backed
     * suppression at the request entry point.
     */
    shouldUseTools(options, warnWhenUnsupported = false) {
        if (options.disableTools) {
            return false;
        }
        const supportsTools = this.supportsTools();
        if (!supportsTools && warnWhenUnsupported) {
            logger.warn(`Tools disabled for ${this.providerName}/${this.modelName} because the model does not support function calling`, {
                provider: this.providerName,
                model: this.modelName,
            });
        }
        return supportsTools;
    }
    // ===================
    // PUBLIC API METHODS
    // ===================
    /**
     * Primary streaming method - implements AIProvider interface
     * When tools are involved, falls back to generate() with synthetic streaming
     */
    async stream(optionsOrPrompt, analysisSchema) {
        // Runtime model limits must land before normalizeStreamOptions resolves
        // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
        await this.ensureModelLimits();
        let options = this.normalizeStreamOptions(optionsOrPrompt);
        this.applyFileToolRoots(options);
        // Own an abort signal for this call so teardown can reach the transport.
        //
        // #1550 gave teardown a way to close the iterator chain, but closing an
        // iterator does not cancel the HTTP request underneath it — the provider
        // read stays pending and the connection stays open. Providers already
        // honour `options.abortSignal` and pass it to their transport; nothing
        // ever fired one on abandonment, because the only signal available was
        // the caller's and the caller had simply walked away.
        //
        // Composed rather than substituted, so a caller's own signal keeps
        // working exactly as before and either source can end the request.
        // Scoped, not plain `composeAbortSignals`: that one is `AbortSignal.any`,
        // whose registration on a source survives until the DERIVED signal is
        // collected. A caller that reuses one long-lived `abortSignal` across many
        // stream calls would accumulate a dependent per call on it, released only
        // by GC — the exact hazard composeAbortSignalsScoped was written for (see
        // its docstring: MaxListenersExceededWarning at 10+ compositions). It
        // registers removable listeners and hands back a `dispose()`, which
        // `teardown()` calls when the stream settles.
        const teardownController = new AbortController();
        const { signal: composedStreamSignal, dispose: disposeComposedSignal } = composeAbortSignalsScoped(options.abortSignal, teardownController.signal);
        if (composedStreamSignal !== options.abortSignal) {
            options = { ...options, abortSignal: composedStreamSignal };
        }
        // Ownership of the composed signal transfers to the returned wrapper,
        // whose teardown() disposes it. Until that wrapper exists, this method
        // owns it — and every throw between here and there (an execution-control
        // rejection, a provider error with no tool fallback) would otherwise
        // leave composeAbortSignalsScoped's once-listeners attached to a caller
        // signal that may outlive this call by many more.
        //
        // catch, deliberately, not finally: dispose() only detaches listeners, so
        // running it on the success path would sever the caller's abort from the
        // composed signal the stream is still using.
        try {
            // Before anything else, and before a single byte leaves the process: an
            // execution policy this provider cannot honour is an error, and a policy
            // whose shape could be read two ways is an error. Both are silent bugs at
            // the point they would otherwise matter.
            validateExecutionControl(options.executionControl, this.providerName, this.supportsExecutionControl(), {
                turnTimeoutMs: options.turnTimeoutMs,
                toolTimeoutMs: options.toolTimeoutMs,
            });
            logger.info(`Starting stream`, {
                provider: this.providerName,
                hasTools: !options.disableTools && this.supportsTools(),
                disableTools: !!options.disableTools,
                supportsTools: this.supportsTools(),
                inputLength: options.input?.text?.length || 0,
                maxTokens: options.maxTokens,
                temperature: options.temperature,
                timestamp: Date.now(),
            });
            // ===== EARLY MULTIMODAL DETECTION =====
            // #1259: audioFiles was missing here while videoFiles was present, so an
            // audio-only stream skipped this branch entirely.
            const hasFileInput = !!options.input?.files?.length ||
                !!options.input?.videoFiles?.length ||
                !!options.input?.audioFiles?.length;
            if (hasFileInput) {
                // ===== VIDEO ANALYSIS DETECTION =====
                // Check if video frames are present and handle with fake streaming
                const messages = await this.buildMessagesForStream(options);
                if (hasVideoFrames(messages)) {
                    logger.info(`Video frames detected in stream, using fake streaming for video analysis`, {
                        provider: this.providerName,
                        model: this.modelName,
                    });
                    // Note: executeFakeStreaming() owns its own catch that fires the
                    // consumer-supplied onError before re-throwing through
                    // handleProviderError(), so we do not need to wrap again here —
                    // doing so would route the error through handleProviderError()
                    // twice (and risk a double-fire onError without the shared
                    // lifecycle-fired WeakSet mark).
                    const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
                    return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
                }
            }
            // CRITICAL: Image generation models don't support real streaming
            // Force fake streaming for image models to ensure image output is yielded.
            // resolveRequestKind() skips this path when the caller explicitly requests
            // non-image output (e.g. JSON analysis) so dual-mode models like
            // gemini-3.1-flash-image-preview can still perform text/structured
            // generation — see its doc comment for the full precedence table.
            const requestKind = resolveRequestKind(options, this.modelName);
            if (requestKind === "image") {
                logger.info(`Image model detected, forcing fake streaming`, {
                    provider: this.providerName,
                    model: this.modelName,
                    reason: "Image generation requires fake streaming to yield image output",
                });
                // Skip real streaming, go directly to fake streaming.
                // executeFakeStreaming() owns its own catch + lifecycle fire, so
                // wrapping again here would double-route through handleProviderError().
                const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
                return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
            }
            // Central tool merge: Pre-merge base tools (MCP/built-in) with user-provided
            // tools (e.g. RAG tools) into options.tools. This way, every provider's
            // executeStream() can simply use options.tools (or getAllTools() + options.tools)
            // and get the complete tool set without needing per-provider merge logic.
            if (this.shouldUseTools(options, true)) {
                const mergedTools = await this.getToolsForStream(options);
                options = { ...options, tools: mergedTools };
            }
            else {
                options = { ...options, tools: {} };
            }
            // CRITICAL FIX: Always prefer real streaming over fake streaming
            // Try real streaming first, use fake streaming only as fallback
            try {
                logger.debug(`Attempting real streaming`, {
                    provider: this.providerName,
                    timestamp: Date.now(),
                });
                const realStreamResult = await this.executeStream(options, analysisSchema);
                logger.info(`Real streaming succeeded`, {
                    provider: this.providerName,
                    timestamp: Date.now(),
                });
                // Wire lifecycle callbacks (onChunk/onFinish/onError) on the user-
                // facing StreamResult.stream. The AI-SDK lifecycle middleware only
                // sees AI-SDK-internal chunks via streamText/wrapLanguageModel, so
                // providers with custom HTTP streaming (Ollama, llama.cpp's /api,
                // anything that doesn't go through streamText) bypass it. Wrapping
                // here makes the callbacks fire for every provider, regardless of
                // streaming implementation.
                return this.wrapStreamWithLifecycleCallbacks(this.withStreamModelFallback(realStreamResult, options, analysisSchema), options, teardownController, disposeComposedSignal);
            }
            catch (realStreamError) {
                // Retired-model fallback runs FIRST, before any lifecycle callback has
                // fired and before a single chunk has reached the consumer: onChunk and
                // onFinish are only wired on the success path above, and onError fires
                // further down this same catch. That ordering is what makes retrying a
                // stream safe at all — nothing observable has happened yet.
                const recovered = await this.retryStreamWithFallbackModel(realStreamError, options, analysisSchema, teardownController, disposeComposedSignal);
                if (recovered) {
                    return recovered;
                }
                // The fallback is BROAD, not narrow: only the terminal errors listed
                // below (abort, timeout, 401/403, quota, rate limit, authentication)
                // re-throw. Every other failure — including a genuine configuration or
                // programming error — is masked as a degraded fake stream whenever
                // tools are enabled. Narrowing this to "streaming with tools is
                // unsupported" failures would change behaviour for every provider at
                // once, so it needs its own characterization PR first; until then this
                // comment records what the code does, not what a narrower design would.
                const errMsg = realStreamError instanceof Error
                    ? realStreamError.message
                    : String(realStreamError);
                const errName = realStreamError instanceof Error ? realStreamError.name : "";
                if (errName === "AbortError" ||
                    errMsg.includes("abort") ||
                    errMsg.includes("timeout") ||
                    errMsg.includes("401") ||
                    errMsg.includes("403") ||
                    errMsg.includes("quota") ||
                    errMsg.includes("rate limit") ||
                    errMsg.includes("authentication")) {
                    await this.fireLifecycleErrorCallback(options, realStreamError);
                    throw this.handleProviderError(realStreamError);
                }
                logger.warn(`Real streaming failed for ${this.providerName}, falling back to fake streaming:`, {
                    error: errMsg,
                    timestamp: Date.now(),
                });
                // Fallback to fake streaming only if real streaming fails AND tools
                // are enabled. executeFakeStreaming() owns its own catch + lifecycle
                // fire, so a fake-streaming failure here surfaces through that path
                // without needing an outer wrap (which would double-route through
                // handleProviderError()).
                if (!options.disableTools && this.supportsTools()) {
                    const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
                    return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
                }
                else {
                    // If real streaming failed and no tools are enabled, fire onError
                    // before re-throwing so consumer-supplied callbacks see the failure.
                    await this.fireLifecycleErrorCallback(options, realStreamError);
                    // If real streaming failed and no tools are enabled, re-throw the original error
                    logger.error(`Real streaming failed for ${this.providerName}:`, realStreamError);
                    throw this.handleProviderError(realStreamError);
                }
            }
        }
        catch (streamSetupError) {
            disposeComposedSignal();
            throw streamSetupError;
        }
    }
    /**
     * 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.
     */
    withStreamModelFallback(result, options, analysisSchema) {
        // The caller owns fallback order (the Claude proxy sets this): hand the
        // stream back untouched so an invalid model surfaces as exactly that.
        if (options.disableInternalFallback === true) {
            return result;
        }
        const provider = this;
        const source = result.stream;
        // Iterate through explicit iterators so a consumer break can be forwarded
        // to the one that is actually in flight (see the cancel hook below): a
        // generator's own return() is queued behind a pending next(), and a
        // source that is waiting on a slow vendor would never see the break.
        const sourceIterator = source[Symbol.asyncIterator]();
        let activeStream = source;
        let activeIterator = sourceIterator;
        // The StreamResult whose non-stream fields (model, provider, metadata,
        // analytics, tool*) the caller should see. Starts as the first attempt
        // and is reassigned to a fallback candidate's own result the moment that
        // candidate actually commits real output (see the retry loop below) —
        // never to a candidate that failed before producing anything. Read by
        // the live getters built at the bottom of this function, so a caller
        // that reads a field AFTER a fallback takes over sees the attempt that
        // actually served the stream, not the abandoned one.
        let activeResult = result;
        // Some provider results expose getter-only properties. The forwarding
        // result below must still be writable because NeuroLink's stream pipeline
        // assigns final fields after constructing it. Keep assignment overrides
        // scoped to the result that was active when the assignment happened, so a
        // value written to a rejected attempt cannot shadow a later retry.
        const assignedValues = new WeakMap();
        const iterableOf = (iterator) => ({
            [Symbol.asyncIterator]: () => iterator,
        });
        // A failing stream still emits one chunk before it throws: the no-output
        // sentinel, {content: "", metadata: {noOutput: true, ...}}. That is a
        // marker, not output, so it must not count as committed — otherwise the
        // guard below blocks every retry it exists to allow. Only that sentinel
        // and a bare empty text chunk are withheld; everything else — reasoning
        // deltas ({content: "", reasoning}), tool-call deltas, audio and image
        // chunks — IS output, commits the stream, and is yielded immediately so
        // real-time streaming is untouched. Withheld chunks are released in order
        // once real output arrives, at end of stream, or before any error that is
        // not retried; they are dropped only when a fallback model takes over.
        const isWithheld = (chunk) => {
            if (typeof chunk === "string") {
                return chunk.length === 0;
            }
            if (typeof chunk !== "object" || chunk === null) {
                return false;
            }
            const shape = chunk;
            if (shape.metadata?.noOutput === true) {
                return true;
            }
            return shape.content === "" && Object.keys(shape).length === 1;
        };
        async function* withFallback() {
            let committed = false;
            const held = [];
            try {
                for await (const chunk of iterableOf(sourceIterator)) {
                    if (isWithheld(chunk)) {
                        held.push(chunk);
                        continue;
                    }
                    committed = true;
                    while (held.length > 0) {
                        yield held.shift();
                    }
                    yield chunk;
                }
                while (held.length > 0) {
                    yield held.shift();
                }
                return;
            }
            catch (error) {
                if (committed ||
                    !isInvalidModelError(provider.formatProviderError(error))) {
                    // Not retrying: release what was withheld — the sentinel included —
                    // so the consumer sees exactly what the unwrapped stream produced
                    // before the error.
                    while (held.length > 0) {
                        yield held.shift();
                    }
                    throw error;
                }
                const requestedModel = provider.modelName;
                const candidates = provider.modelFallbacksFor(options, requestedModel);
                for (const candidate of candidates) {
                    logger.warn(`[${provider.providerName}] model "${requestedModel}" was rejected as invalid — retrying stream with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
                    provider.refreshHandlersForModel(candidate);
                    let retryCommitted = false;
                    const retryHeld = [];
                    try {
                        const retry = await provider.executeStream(options, analysisSchema);
                        const retryIterator = retry.stream[Symbol.asyncIterator]();
                        activeStream = retry.stream;
                        activeIterator = retryIterator;
                        for await (const chunk of iterableOf(retryIterator)) {
                            if (isWithheld(chunk)) {
                                retryHeld.push(chunk);
                                continue;
                            }
                            if (!retryCommitted) {
                                retryCommitted = true;
                                // This candidate is now the attempt serving the stream:
                                // every metadata/analytics/tool field the caller reads must
                                // resolve from here, never from the abandoned first attempt.
                                activeResult = retry;
                            }
                            while (retryHeld.length > 0) {
                                yield retryHeld.shift();
                            }
                            yield chunk;
                        }
                        while (retryHeld.length > 0) {
                            yield retryHeld.shift();
                        }
                        return;
                    }
                    catch (retryError) {
                        // Same rule as above: once this candidate has emitted real
                        // content its output is committed, and moving to another model
                        // would splice two different responses together.
                        if (retryCommitted) {
                            throw retryError;
                        }
                    }
                }
                provider.refreshHandlersForModel(requestedModel);
                // Every fallback failed too: release the original attempt's withheld
                // chunks, then surface the ORIGINAL error — it names the model the
                // caller asked for.
                while (held.length > 0) {
                    yield held.shift();
                }
                throw error;
            }
        }
        const wrapped = withFallback();
        // Same contract as wrapStreamWithLifecycleCallbacks: a consumer break
        // (cancelStream on the outer stream) must close the live upstream
        // iterator directly, not wait for this generator to reach its next
        // yield. Without this, the TTS early-break path — and any consumer that
        // stops mid-stream — would leave the vendor request open until the next
        // chunk arrived.
        attachStreamCancel(wrapped, () => {
            cancelStream(activeStream);
            releaseIterator(activeIterator);
        });
        // A naked spread would read (and thereby snapshot) every field on
        // `result` immediately — before the generator above has even started —
        // permanently freezing the first attempt's values. Define every declared
        // StreamResult field instead of only the keys present on `result`: retry
        // providers may expose optional fields the rejected attempt omitted.
        const target = {
            stream: wrapped,
        };
        const resultKeys = [
            "knowledge",
            "agentModeVersion",
            "provider",
            "model",
            "usage",
            "finishReason",
            "stopReason",
            "rawFinishReason",
            "toolCalls",
            "toolResults",
            "toolEvents",
            "toolExecutions",
            "toolsUsed",
            "metadata",
            "analytics",
            "evaluation",
            "events",
            "workflow",
            "transcription",
            "audio",
            "ttsMetadata",
        ];
        for (const key of resultKeys) {
            Object.defineProperty(target, key, {
                enumerable: true,
                configurable: true,
                get: () => {
                    const assigned = assignedValues.get(activeResult);
                    return assigned?.has(key) ? assigned.get(key) : activeResult[key];
                },
                set: (value) => {
                    const descriptor = Object.getOwnPropertyDescriptor(activeResult, key);
                    if (descriptor?.set) {
                        descriptor.set.call(activeResult, value);
                        return;
                    }
                    if (!descriptor || "value" in descriptor) {
                        Reflect.set(activeResult, key, value);
                        return;
                    }
                    let assigned = assignedValues.get(activeResult);
                    if (!assigned) {
                        assigned = new Map();
                        assignedValues.set(activeResult, assigned);
                    }
                    assigned.set(key, value);
                },
            });
        }
        return target;
    }
    /**
     * 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.
     */
    async retryStreamWithFallbackModel(error, options, analysisSchema, teardownController, disposeComposedSignal) {
        if (options.disableInternalFallback === true) {
            return undefined;
        }
        // executeStream() surfaces the raw transport error, so classify it the
        // way this provider would before deciding. formatProviderError is
        // contractually return-only, never throw.
        if (!isInvalidModelError(this.formatProviderError(error))) {
            return undefined;
        }
        const requestedModel = this.modelName;
        const candidates = this.modelFallbacksFor(options, requestedModel);
        for (const candidate of candidates) {
            logger.warn(`[${this.providerName}] model "${requestedModel}" was rejected as invalid — retrying stream with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
            this.refreshHandlersForModel(candidate);
            try {
                const result = await this.executeStream(options, analysisSchema);
                return this.wrapStreamWithLifecycleCallbacks(result, options, teardownController, disposeComposedSignal);
            }
            catch {
                // Any failure on a candidate — stale id or otherwise — just moves to
                // the next one. Nothing is reported from here: if none succeed the
                // caller still handles the ORIGINAL error, which names the model the
                // caller actually asked for.
            }
        }
        // Restore the caller's model so a failed request does not leave this
        // instance silently pointing at the last fallback it tried.
        this.refreshHandlersForModel(requestedModel);
        return undefined;
    }
    /**
     * 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.
     */
    wrapStreamWithLifecycleCallbacks(result, options, teardownController, 
    // Releases the scoped composition in stream() that put this call's
    // teardown signal alongside the caller's. Runs from teardown(), so the
    // listeners come off the caller's (possibly long-lived) signal the
    // moment this stream settles rather than whenever GC gets to it.
    disposeComposedSignal) {
        const lifecycle = getLifecycleMiddlewareConfig(options);
        // No early return when there are no callbacks. This wrapper is the only point
        // every provider's stream passes through unconditionally (the real-streaming path
        // plus all three fake-streaming paths), and returning `result` untouched here is
        // exactly why a provider error that surfaces during ITERATION reached the consumer
        // unclassified: the object handed back was the provider's own generator, by
        // reference, and every layer below is a proven passthrough. The callbacks below are
        // each individually guarded, so with none registered this only adds the catch.
        const { onChunk, onFinish, onError } = lifecycle ?? {};
        const startTime = Date.now();
        const originalStream = result.stream;
        // Lifecycle callbacks are awaited with a bounded deadline so callers
        // observe ordering guarantees (onChunk/onFinish/onError have all
        // settled by the time `for await` returns / throws). The previous
        // fire-and-forget pattern left async work running past stream close,
        // creating races during cleanup. The deadline is configurable via
        // `lifecycle.timeoutMs` (per-call) or `NEUROLINK_LIFECYCLE_TIMEOUT_MS`
        // (env / CLI surface) — see `resolveLifecycleTimeoutMs`.
        const timeoutMs = resolveLifecycleTimeoutMs(lifecycle);
        const safeFire = async (fn, label) => {
            try {
                await withTimeoutFn(async () => {
                    const ret = fn();
                    if (ret && typeof ret.then === "function") {
                        await ret;
                    }
                }, timeoutMs, `[lifecycle] ${label} callback exceeded ${timeoutMs}ms`);
            }
            catch (e) {
                logger.warn(`[lifecycle] ${label} callback error:`, e);
            }
        };
        // Arrow, like `safeFire` above: the generator is a plain function expression, so
        // `this` is not bound inside it.
        const classifyStreamError = (e) => this.classifyStreamError(e);
        // Hold the upstream iterator rather than letting `for await` create one
        // internally, so the cancel hook below can close it directly. Iterating
        // `upstreamIterable` is equivalent to iterating `originalStream` — same
        // iterator, same early-exit `return()` semantics — it just leaves a handle
        // reachable from outside the generator.
        const upstreamIterator = originalStream[Symbol.asyncIterator]();
        const upstreamIterable = {
            [Symbol.asyncIterator]: () => upstreamIterator,
        };
        // Set by teardown, read by the generator's catch. Distinguishes "the
        // provider failed" from "we aborted the provider because the consumer
        // abandoned this stream", which must not be reported as a failure.
        let teardownRequested = false;
        let teardownDone = false;
        // Shared by both teardown entry points below (native `.return()` via the
        // `finally`, and the `attachStreamCancel` hook for a consumer parked
        // mid-`await` that a queued `.return()` cannot reach promptly). Guarded
        // so whichever fires first does the work exactly once.
        const teardown = () => {
            if (teardownDone) {
                return;
            }
            teardownDone = true;
            teardownRequested = true;
            // Abort before closing the iterators. The iterator close releases our
            // side of the chain; this releases the provider's — the in-flight HTTP
            // request that a closed iterator leaves running.
            teardownController?.abort();
            disposeComposedSignal?.();
            cancelStream(originalStream);
            releaseIterator(upstreamIterator);
        };
        const wrappedStream = (async function* () {
            let accumulated = "";
            let seq = 0;
            try {
                for await (const chunk of upstreamIterable) {
                    const textPart = chunk &&
                        typeof chunk === "object" &&
                        "content" in chunk &&
                        typeof chunk.content === "string"
                        ? chunk.content
                        : "";
                    // Only fire onChunk for actual text deltas. Non-text chunks
                    // (image, tts_audio) would otherwise produce empty text-delta
                    // events that consumers must filter out themselves.
                    if (onChunk && textPart) {
                        const currentSeq = seq++;
                        await safeFire(() => onChunk({
                            type: "text-delta",
                            textDelta: textPart,
                            sequenceNumber: currentSeq,
                        }), "onChunk");
                    }
                    if (textPart) {
                        accumulated += textPart;
                    }
                    yield chunk;
                }
                if (onFinish) {
                    // Read `result.usage` / `result.finishReason` here, after the
                    // drain loop above has fully exhausted `upstreamIterable` — the
                    // same point native background-loop providers (Vertex's Gemini3
                    // and Claude paths) resolve these fields by, whether they are
                    // plain values set before `result` was returned or live getters
                    // preserved through `withStreamModelFallback`
                    // (`preserveLiveStreamAccessors`). Both fields are generic
                    // `StreamResult` members for every provider, not Vertex-specific,
                    // so this enrichment is additive for all of them: only present
                    // when `result` actually carries a value, never a synthesized
                    // default (a provider that never populated these fields keeps
                    // getting the exact same bare `{text, duration}` payload as
                    // before).
                    const { usage, finishReason } = result;
                    await safeFire(() => onFinish({
                        text: accumulated,
                        duration: Date.now() - startTime,
                        ...(usage
                            ? {
                                usage: {
                                    promptTokens: usage.input ?? 0,
                                    completionTokens: usage.output ?? 0,
                                },
                            }
                            : {}),
                        ...(finishReason ? { finishReason } : {}),
                    }), "onFinish");
                }
            }
            catch (error) {
                const err = error instanceof Error ? error : new Error(String(error));
                if (teardownRequested) {
                    // Our own abort, from a consumer that already broke out of this
                    // stream. Firing onError would report cleanup as a provider failure,
                    // and rethrowing would surface an error nobody is left to catch —
                    // an unhandled rejection, which terminates the process. Ending the
                    // generator quietly is the correct outcome for an abandoned stream.
                    return;
                }
                if (onError && !hasLifecycleErrorFired(err)) {
                    // Mark before firing so a higher layer that also routes through
                    // fireLifecycleErrorCallback (or its own lifecycle wrapper) with
                    // the same error instance won't double-fire onError. Mirrors the
                    // pattern in fireLifecycleErrorCallback below.
                    markLifecycleErrorFired(err);
                    await safeFire(() => onError({
                        error: err,
                        duration: Date.now() - startTime,
                        recoverable: false,
                    }), "onError");
                }
                throw classifyStreamError(err);
            }
            finally {
                // Unconditional and safe: `teardown()` is idempotent (guarded by
                // `teardownDone`), and `cancelStream`/`releaseIterator` are
                // documented as no-ops on an already-finished stream/iterator, so
                // calling it again after normal completion or a real provider error
                // (both of which reach here too) costs nothing. The path that
                // actually needs it is a consumer breaking out of `for await` while
                // this generator is parked right after a `yield` — that unwinds
                // straight here, bypassing both the rest of the `try` and the
                // `catch` above, since a native `.return()` is a "return"
                // completion, not a thrown error. Closing the iterator chain (done
                // automatically by `for-await-of`'s own `IteratorClose` on
                // `upstreamIterable`) does not cancel the HTTP request underneath
                // it, so the transport is aborted here explicitly.
                teardown();
            }
        })();
        // A consumer that breaks out of the stream cannot reach this generator
        // through `.return()` while it is parked awaiting the provider — that
        // request queues behind the in-flight `next()`. The hook closes the
        // upstream directly and forwards the request to any wrapper below, so
        // abandoning a stream really does release the provider connection. This
        // is the fallback path for that case; the common case (a consumer that
        // breaks right after receiving a chunk, so this generator is parked at
        // the `yield` rather than mid-`await`) is handled by the `finally`
        // above, which reaches `.return()` synchronously.
        attachStreamCancel(wrappedStream, teardown);
        // See the comment in withStreamModelFallback above: this spread must not
        // be allowed to freeze a provider's lazy toolsUsed/toolExecutions getters.
        return preserveLiveStreamAccessors(result, {
            ...result,
            stream: wrappedStream,
        });
    }
    /**
     * 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`).
     */
    async fireLifecycleErrorCallback(options, error) {
        const err = error instanceof Error ? error : new Error(String(error));
        // The AI-SDK lifecycle middleware stamps errors it has already
        // surfaced (Symbol.for("neurolink.onErrorFired"); see
        // utils/lifecycleCallbacks.ts). Skip here so consumers don't receive
        // duplicate onError events for the same failure.
        if (hasLifecycleErrorFired(err)) {
            return;
        }
        const lifecycle = getLifecycleMiddlewareConfig(options);
        const onError = lifecycle?.onError;
        if (!onError) {
            return;
        }
        // Set the marker before invoking so a sync re-entry (or a concurrent
        // dispatch path) can't double-fire onError for the same error object.
        markLifecycleErrorFired(err);
        // Fire the consumer's onError with a bounded deadline AND await its
        // completion — callers can now `await fireLifecycleErrorCallback(...)`
        // to guarantee the consumer's async onError settles before the
        // surrounding stream() / executeFakeStreaming() rethrows. Deadline is
        // configurable via `lifecycle.timeoutMs` or the
        // `NEUROLINK_LIFECYCLE_TIMEOUT_MS` env var.
        const timeoutMs = resolveLifecycleTimeoutMs(lifecycle);
        try {
            await withTimeoutFn(async () => {
                // Capturing `onError` into a const above means TypeScript sees the
                // narrowing past the early-return, so no non-null assertion needed
                // here — and the callback identity is stable across the timeout
                // boundary even if the caller mutates `lifecycle.onError` mid-call.
                const ret = onError({
                    error: err,
                    duration: 0,
                    recoverable: false,
                });
                if (ret && typeof ret.then === "function") {
                    await ret;
                }
            }, timeoutMs, `[lifecycle] onError callback exceeded ${timeoutMs}ms`);
        }
        catch (e) {
            logger.warn("[lifecycle] onError callback error:", e);
        }
    }
    /**
     * Build the fake-stream output and apply the same incremental TTS wrapper
     * used by the standard NeuroLink stream path.
     */
    createFakeStreamingOutput(result, options, onTTSComplete) {
        const incrementalTTS = options.tts?.enabled === true;
        const source = (async function* () {
            if (result?.content) {
                const words = result.content.split(/(\s+)/);
                let buffer = "";
                for (let i = 0; i < words.length; i++) {
                    buffer += words[i];
                    const shouldYield = i === words.length - 1 ||
                        buffer.length > 50 ||
                        /[.!?;,]\s*$/.test(buffer);
                    if (shouldYield && buffer.trim()) {
                        yield { content: buffer };
                        buffer = "";
                        await new Promise((resolve) => {
                            setTimeout(resolve, Math.random() * 9 + 1);
                        });
                    }
                }
                if (buffer.trim()) {
                    yield { content: buffer };
                }
            }
            if (result?.imageOutput) {
                yield { type: "image", imageOutput: result.imageOutput };
            }
            if (result?.audio && !incrementalTTS) {
                yield {
                    type: "tts_audio",
                    audio: {
                        data: result.audio.buffer,
                        format: result.audio.format,
                        index: 0,
                        isFinal: true,
                        cumulativeSize: result.audio.size,
                        voice: result.audio.voice,
                        sampleRate: result.audio.sampleRate,
                    },
                };
            }
        })();
        if (!incrementalTTS || !options.tts) {
            return source;
        }
        return interleaveTTSStream({
            stream: source,
            provider: options.tts.provider ?? options.provider ?? this.providerName,
            options: options.tts,
            onComplete: onTTSComplete,
        });
    }
    /**
     * Execute fake streaming - extracted method for reusability
     */
    async executeFakeStreaming(options, analysisSchema) {
        try {
            logger.info(`Starting fake streaming with tools`, {
                provider: this.providerName,
                supportsTools: this.supportsTools(),
                timestamp: Date.now(),
            });
            // Convert stream options to text generation options
            const textOptions = {
                prompt: options.input?.text || "",
                input: options.input,
                systemPrompt: options.systemPrompt,
                temperature: options.temperature,
                maxTokens: options.maxTokens,
                tools: options.tools, // 🔧 FIX: Pass user-provided tools (including RAG tools) to generation pipeline
                disableTools: !!options.disableTools,
                maxSteps: options.maxSteps || 5,
                provider: options.provider,
                model: options.model,
                region: options.region, // Pass region for Vertex AI
                // 🔧 FIX: Include analytics and evaluation options from stream options
                enableAnalytics: options.enableAnalytics,
                enableEvaluation: options.enableEvaluation,
                evaluationDomain: options.evaluationDomain,
                toolUsageContext: options.toolUsageContext,
                context: options.context,
                csvOptions: options.csvOptions,
                pdfOptions: options.pdfOptions,
                videoOptions: options.videoOptions,
                officeOptions: options.officeOptions,
                // Forward abort, tool filtering, and timeout options to prevent
                // silent bypass when falling back from real streaming to fake streaming
                abortSignal: options.abortSignal,
                toolFilter: options.toolFilter,
                excludeTools: options.excludeTools,
                skipToolPromptInjection: options.skipToolPromptInjection,
                timeout: options.timeout,
                stt: options.stt,
                // Streaming TTS is synthesized incrementally by
                // createFakeStreamingOutput; do not let generate() perform a duplicate
                // input- or whole-response synthesis first.
                tts: options.tts?.enabled ? undefined : options.tts,
            };
            logger.debug(`Calling generate for fake streaming`, {
                provider: this.providerName,
                maxSteps: textOptions.maxSteps,
                disableTools: textOptions.disableTools,
                timestamp: Date.now(),
            });
            const result = await this.generate(textOptions, analysisSchema);
            logger.info(`Generate completed for fake streaming`, {
                provider: this.providerName,
                hasContent: !!result?.content,
                contentLength: result?.content?.length || 0,
                toolsUsed: result?.toolsUsed?.length || 0,
                hasImageOutput: !!result?.imageOutput,
                timestamp: Date.now(),
            });
            const incrementalTTS = options.tts?.enabled === true;
            const ttsProvider = options.tts?.provider ?? options.provider ?? this.providerName;
            const ttsStartedAt = Date.now();
            let resolveAudio;
            let audioSettled = false;
            const audio = incrementalTTS
                ? new Promise((resolve) => {
                    resolveAudio = resolve;
                }).catch(() => undefined)
                : undefined;
            const ttsMetadata = incrementalTTS
                ? {
                    attempted: TTSProcessor.supports(ttsProvider),
                    success: false,
                }
                : result?.ttsMetadata;
            const onTTSComplete = incrementalTTS
                ? (ttsResult, error) => {
                    if (audioSettled) {
                        return;
                    }
                    audioSettled = true;
                    if (ttsMetadata) {
                        ttsMetadata.success =
                            error === undefined && ttsResult !== undefined;
                        if (error) {
                            ttsMetadata.error = error;
                        }
                        else {
                            delete ttsMetadata.error;
                        }
                        ttsMetadata.latency = Date.now() - ttsStartedAt;
                    }
                    resolveAudio?.(ttsResult);
                }
                : undefined;
            // Create a synthetic stream from the generate result that simulates progressive delivery
            //
            // toolsUsed/toolExecutions: generate()'s result carries both once the
            // tool round trip settles, but a plain field list (like every other
            // field above) would silently drop them from the fake-streaming
            // StreamResult. toolsUsed overlaps generate()'s and StreamResult's
            // declared types exactly, so it is assigned directly; toolExecutions
            // does not (GenerateResult carries ToolExecutionRecord[], StreamResult
            // declares ToolExecutionSummary[] but every other live producer of this
            // field actually returns transformToolExecutions()'s
            // {name,input,output,duration} shape) — attached via defineProperty,
            // the same bypass `createMCPStream()` uses, rather than a literal that
            // would fail the structural check or need a banned double-cast.
            return Object.defineProperty({
                stream: this.createFakeStreamingOutput(result, options, onTTSComplete),
                usage: result?.usage,
                provider: result?.provider,
                model: result?.model,
                toolCalls: result?.toolCalls?.map((call) => ({
                    toolName: call.toolName,
                    parameters: call.args,
                    id: call.toolCallId,
                })),
                toolResults: result?.toolResults
                    ? result.toolResults.map((tr) => ({
                        toolName: tr.toolName || "unknown",
                        status: (tr.status === "error"
                            ? "failure"
                            : "success"),
                        result: tr.output ?? tr.result,
                        error: tr.error,
                    }))
                    : undefined,
                // 🔧 FIX: Include analytics and evaluation from generate result
                analytics: result?.analytics,
                evaluation: result?.evaluation,
                audio,
                ttsMetadata,
                toolsUsed: result?.toolsUsed,
            }, "toolExecutions", {
                value: transformToolExecutions(result?.toolExecutions),
                enumerable: true,
                configurable: true,
                writable: true,
            });
        }
        catch (error) {
            logger.error(`Fake streaming fallback failed for ${this.providerName}:`, error);
            // Fire the consumer-supplied onError BEFORE re-throwing through
            // handleProviderError() so callers using onChunk/onFinish/onError
            // get notified even when fake-streaming setup (message build, image
            // adapter, etc.) fails synchronously. Awaited so the consumer's
            // async onError fully settles before we rethrow. The shared
            // lifecycle-fired WeakSet mark prevents double-fire if a wrapper
            // layer also handles this.
            await this.fireLifecycleErrorCallback(options, error);
            throw this.handleProviderError(error);
        }
    }
    /**
     * 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.
     */
    getToolPolicy(options) {
        return resolveToolPolicy({
            options: {
                // Defense-in-depth: both current call sites already zero the tool
                // record via shouldUseTools before the gate runs, but forwarding
                // disableTools makes the gate self-sufficient for any future call
                // site that forgets the upstream check.
                disableTools: options.disableTools,
                toolFilter: options.toolFilter,
                excludeTools: options.excludeTools,
                enabledToolNames: options.enabledToolNames,
            },
            instanceConfig: this.neurolink?.getToolsConfig(),
            builtinToolNames: Object.keys(this.directTools ?? {}),
        });
    }
    applyToolFiltering(tools, options) {
        const policy = this.getToolPolicy(options);
        // Check whether the dedup pass is requested — even when no whitelist/
        // denylist is set we still need to run the dedup pass if enabled.
        const dedupConfig = this.neurolink?.getToolDedupConfig();
        const hasDedupEnabled = dedupConfig !== undefined && dedupConfig.enabled === true;
        const beforeCount = Object.keys(tools).length;
        const filtered = applyToolGate(tools, policy);
        const afterCount = Object.keys(filtered).length;
        if (beforeCount !== afterCount) {
            logger.debug(`Tool filtering applied`, {
                provider: this.providerName,
                beforeCount,
                afterCount,
                policySources: policy.sources,
                toolFilter: options.toolFilter,
                excludeTools: options.excludeTools,
                enabledToolNames: options.enabledToolNames,
            });
        }
        if (!hasDedupEnabled || dedupConfig === undefined) {
            return this.sortToolRecord(filtered);
        }
        const deduped = this.applyDedupPass(filtered, dedupConfig);
        // A forced toolChoice must survive dedup: keep-first can collapse the
        // forced tool into an earlier near-identical signature, and the provider
        // would then reject the request for naming an unknown tool. Restore it
        // from the pre-dedup record (whitelisted names are already safe — the
        // gate runs before dedup, so a whitelist leaves no duplicate to lose to).
        const forcedName = options.toolChoice?.toolName;
        if (typeof forcedName === "string" &&
            !Object.hasOwn(deduped, forcedName) &&
            Object.hasOwn(filtered, forcedName)) {
            deduped[forcedName] = filtered[forcedName];
            logger.debug(`Restored toolChoice-forced tool removed by signature dedup`, { provider: this.providerName, toolName: forcedName });
        }
        return this.sortToolRecord(deduped);
    }
    /**
     * 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.
     */
    sortToolRecord(tools) {
        // Null prototype: a tool named "__proto__" must become an own entry, not
        // a prototype mutation that silently drops the tool.
        const sorted = Object.create(null);
        for (const name of Object.keys(tools).sort()) {
            sorted[name] = tools[name];
        }
        return sorted;
    }
    /**
     * 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.
     */
    async applyToolDiscovery(toolsInput, options) {
        // Strip a stale meta-tool left by a previous resolution pass (the
        // stream → generate fallback re-enters resolution with options.tools
        // already partitioned). Discovery re-partitions against the fresh merged
        // record below; without this, the collision guard would see our own
        // meta-tool and skip partitioning, shipping the full catalog AND a stale
        // search_tools closure. Real user tools named "search_tools" are not
        // marked and are left untouched.
        let tools = toolsInput;
        if (isDiscoveryMetaTool(tools["search_tools"])) {
            const { search_tools: _stale, ...rest } = tools;
            tools = rest;
        }
        const toolCount = Object.keys(tools).length;
        const policy = this.getToolPolicy(options);
        if (!policy.discovery) {
            if (toolCount > LARGE_CATALOG_WARN_THRESHOLD) {
                this.warnLargeCatalogOnce(toolCount);
            }
            return tools;
        }
        const externalTools = this.neurolink?.getExternalMCPTools() ?? [];
        if (externalTools.length === 0) {
            return tools;
        }
        // Session pinning requires a caller-provided sessionId. Without one there
        // is no session identity — pinning to a shared fallback key would leak
        // one caller's discoveries into every other caller of a shared instance
        // and monotonically defeat deferral, so pins are simply not persisted.
        const rawSessionId = options.context?.sessionId;
        const sessionKey = typeof rawSessionId === "string" && rawSessionId.length > 0
            ? rawSessionId
            : typeof rawSessionId === "number"
                ? String(rawSessionId)
                : undefined;
        const pinnedNames = (sessionKey ? this.neurolink?.getDiscoveryPins(sessionKey) : undefined) ??
            new Set();
        const perCallNames = new Set(Object.keys(options.tools ?? {}));
        // Explicitly requested tools stay hot. Allowlist entries may be globs
        // (e.g. toolFilter: ["github*"]), so match with the same pattern matcher
        // the gate uses — a Set of pattern STRINGS would defer glob-whitelisted
        // tools the gate deliberately kept.
        const explicitPatterns = [
            ...(options.toolFilter ?? []),
            ...(options.enabledToolNames ?? []),
        ];
        // A toolChoice that forces a named tool must never see that tool
        // deferred — the provider would reject the request (unknown tool name).
        const toolChoice = options.toolChoice;
        if (toolChoice && typeof toolChoice.toolName === "string") {
            explicitPatterns.push(toolChoice.toolName);
        }
        const explicitMatcher = explicitPatterns.length > 0 ? toolNameMatcher(explicitPatterns) : null;
        const deferrableNames = externalTools
            .map((t) => t.name)
            .filter((name) => name in tools &&
            !perCallNames.has(name) &&
            !(explicitMatcher ? explicitMatcher(name) : false));
        return partitionToolsForDiscovery(tools, {
            deferrableNames,
            pinnedNames,
            onHydrate: (names) => {
                if (sessionKey) {
                    this.neurolink?.pinDiscoveredTools(sessionKey, names);
                }
            },
        });
    }
    static warnedLargeCatalog = false;
    warnLargeCatalogOnce(toolCount) {
        if (BaseProvider.warnedLargeCatalog) {
            return;
        }
        BaseProvider.warnedLargeCatalog = true;
        logger.warn(`[ToolDiscovery] ${toolCount} tools are being sent in full on every request (~${Math.round((toolCount * 175) / 100) / 10}K tokens). Tool-selection accuracy degrades past 30-50 tools — consider enabling on-demand discovery: new NeuroLink({ tools: { discovery: true } })`, { toolCount, provider: this.providerName });
    }
    /**
     * 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.
     */
    applyDedupPass(filtered, dedupConfig) {
        const { tools: dedupedTools, removed } = dedupeTools(filtered, dedupConfig);
        if (removed.length > 0 && logger.shouldLog("debug")) {
            logger.debug(`Tool signature dedup removed duplicates`, {
                provider: this.providerName,
                removedCount: removed.length,
                removed: removed.map((r) => ({
                    name: r.name,
                    duplicateOf: r.duplicateOf,
                    similarity: r.similarity,
                })),
            });
        }
        return dedupedTools;
    }
    /**
     * Prepare generation context including tools and model
     */
    async prepareGenerationContext(options) {
        const shouldUseTools = this.shouldUseTools(options, true);
        const baseTools = shouldUseTools ? await this.getAllTools() : {};
        let tools = shouldUseTools
            ? {
                ...baseTools,
                ...(options.tools || {}),
            }
            : {};
        // Apply per-call tool filtering (whitelist/blacklist)
        tools = this.applyToolFiltering(tools, options);
        // Per-call execution capture: wrap every executable tool so real
        // params/results/timing surface on result.toolExecutions. Must run
        // BEFORE discovery — search_tools hydration mutates the discovery
        // record in place, so tools hydrated mid-turn stay wrapped.
        tools = this.wrapToolsForExecutionCapture(tools, options);
        // On-demand discovery: defer external MCP schemas behind search_tools
        tools = await this.applyToolDiscovery(tools, options);
        logger.debug(`Final tools prepared for AI`, {
            provider: this.providerName,
            directTools: getKeyCount(baseTools),
            directToolNames: getKeysAsString(baseTools),
            externalTools: getKeyCount(options.tools || {}),
            externalToolNames: getKeysAsString(options.tools || {}),
            totalTools: getKeyCount(tools),
            totalToolNames: getKeysAsString(tools),
            shouldUseTools,
            timestamp: Date.now(),
        });
        const model = await this.getAISDKModelWithMiddleware(options);
        return { tools, model };
    }
    /**
     * 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.
     */
    async getToolsForStream(options) {
        const shouldUseTools = this.shouldUseTools(options);
        if (!shouldUseTools) {
            return {};
        }
        const baseTools = await this.getAllTools();
        const externalTools = (options.tools || {});
        let merged = { ...baseTools, ...externalTools };
        // Apply per-call tool filtering (whitelist/blacklist)
        merged = this.applyToolFiltering(merged, options);
        // Per-call execution capture (native loops obtain their tools here, so
        // this single wrap covers the Gemini/Anthropic native paths too). Must
        // run BEFORE discovery so tools hydrated mid-turn stay wrapped.
        merged = this.wrapToolsForExecutionCapture(merged, options);
        // On-demand discovery: defer external MCP schemas behind search_tools
        merged = await this.applyToolDiscovery(merged, options);
        logger.debug(`Tools prepared for streaming`, {
            provider: this.providerName,
            baseToolCount: Object.keys(baseTools).length,
            externalToolCount: Object.keys(externalTools).length,
            totalToolCount: Object.keys(merged).length,
        });
        return merged;
    }
    /**
     * 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.
     */
    wrapToolsForExecutionCapture(tools, options) {
        if (Object.keys(tools).length === 0) {
            return tools;
        }
        let recorder = ToolExecutionRecorder.from(options);
        if (!recorder) {
            recorder = new ToolExecutionRecorder(options.toolExecutionCapture);
            recorder.attachTo(options);
        }
        return recorder.wrapTools(tools);
    }
    /**
     * Build messages array for generation - delegated to MessageBuilder
     */
    async buildMessages(options) {
        return this.messageBuilder.buildMessages(options);
    }
    /**
     * 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
     */
    async buildMessagesForStream(options) {
        return this.messageBuilder.buildMessagesForStream(options);
    }
    /**
     * Record performance metrics - delegated to TelemetryHandler
     */
    async recordPerformanceMetrics(usage, responseTime) {
        await this.telemetryHandler.recordPerformanceMetrics(usage, responseTime);
    }
    /**
     * 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.
     */
    async ensureModelLimits() { }
    async generate(optionsOrPrompt, _analysisSchema) {
        // Runtime model limits must land before normalizeTextOptions resolves
        // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
        await this.ensureModelLimits();
        const options = this.normalizeTextOptions(optionsOrPrompt);
        this.validateOptions(options);
        this.applyFileToolRoots(options);
        const startTime = Date.now();
        // One span per attempt. runGenerateInActiveContext ends the span on the
        // way out (success or failure), so a model-fallback retry must not reuse
        // it — an ended span would swallow the retry's attributes and report the
        // model that failed rather than the one that served.
        const attempt = async () => {
            // OTEL span for provider-level generate tracing
            // Use startActiveSpan pattern via context.with() so child spans become descendants
            const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", {
                kind: SpanKind.CLIENT,
                attributes: {
                    [ATTR.GEN_AI_SYSTEM]: this.providerName || "unknown",
                    [ATTR.GEN_AI_MODEL]: this.modelName || options.model || "unknown",
                    [ATTR.GEN_AI_OPERATION]: "generate",
                    [ATTR.NL_PROVIDER]: this.providerName || "unknown",
                },
            });
            // Set this span as the active context so child spans (provider calls, tool executions) become descendants
            const activeCtx = trace.setSpan(context.active(), otelSpan);
            const otelSpanState = { ended: false };
            return await context.with(activeCtx, async () => this.runGenerateInActiveContext(options, startTime, otelSpan, otelSpanState));
        };
        // Callers that own fallback order (providerFallback / modelChain callers,
        // or a router that retries on its own) pass the flag on both paths;
        // TextGenerationOptions declares it, so a plain read is enough here.
        const callerOwnsFallback = options.disableInternalFallback === true;
        return await this.runGenerateWithModelFallback(attempt, callerOwnsFallback, options);
    }
    /**
     * 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.
     */
    async runGenerateWithModelFallback(attempt, callerOwnsFallback, options) {
        const requestedModel = this.modelName;
        try {
            return await attempt();
        }
        catch (error) {
            if (callerOwnsFallback || !isInvalidModelError(error)) {
                throw error;
            }
            const candidates = options
                ? this.modelFallbacksFor(options, requestedModel)
                : this.getModelFallbacks().filter((model) => model !== requestedModel);
            if (candidates.length === 0) {
                throw error;
            }
            for (const candidate of candidates) {
                logger.warn(`[${this.providerName}] model "${requestedModel}" was rejected as invalid — retrying with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
                this.refreshHandlersForModel(candidate);
                try {
                    return await attempt();
                }
                catch (retryError) {
                    if (!isInvalidModelError(retryError)) {
                        // Restore the caller's model: this failure is unrelated to the
                        // model id, so the instance must not be left on a fallback.
                        this.refreshHandlersForModel(requestedModel);
                        throw retryError;
                    }
                    // This fallback is stale too — keep walking the list.
                }
            }
            // Every fallback was rejected as well. Restore the caller's model so a
            // failed request does not leave this instance silently repointed, and
            // surface the ORIGINAL error: it names the model the caller asked for.
            this.refreshHandlersForModel(requestedModel);
            throw error;
        }
    }
    /**
     * Model ids to try when this provider rejects its current model as invalid.
     * Empty by default; catalog-driven providers return their `fallbacks`.
     */
    getModelFallbacks() {
        return [];
    }
    /**
     * 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.
     */
    modelFallbacksFor(options, requestedModel) {
        const candidates = this.getModelFallbacks().filter((model) => model !== requestedModel);
        if (!requestCarriesImages(options)) {
            return candidates;
        }
        return candidates.filter((model) => ProviderImageAdapter.supportsVision(this.providerName, model));
    }
    /**
     * Alias for generate method - implements AIProvider interface
     */
    async gen(optionsOrPrompt, analysisSchema) {
        return this.generate(optionsOrPrompt, analysisSchema);
    }
    async runGenerateInActiveContext(options, startTime, otelSpan, otelSpanState) {
        try {
            // Single source of truth for "what kind of request is this" — see
            // resolveRequestKind's doc comment for the full precedence table.
            const requestKind = resolveRequestKind(options, this.modelName);
            if (requestKind === "video") {
                return await this.handleVideoGeneration(options, startTime);
            }
            if (requestKind === "image") {
                logger.info(`Image generation model detected, routing to executeImageGeneration`, {
                    provider: this.providerName,
                    model: this.modelName,
                });
                const imageResult = await this.executeImageGeneration(options);
                return await this.enhanceResult(imageResult, options, startTime);
            }
            if (requestKind === "tts-direct") {
                return this.handleDirectTTSSynthesis(options, startTime);
            }
            // Only `model` is used now — the video-frame path needs it. `tools`
            // fed the standard generate flow, which no longer exists.
            const { model } = await this.prepareGenerationContext(options);
            const messages = await this.buildMessages(options);
            const videoFrameResult = await this.handleVideoFrameGeneration(options, messages, model, startTime);
            if (videoFrameResult) {
                return videoFrameResult;
            }
            // Every provider that generates text overrides `generate()` and runs a
            // native loop. There is no shared fallback any more: the standard flow
            // called the ai package's `generateText`, and once that was gone the
            // flow could only throw. Reaching here means a provider was asked for
            // text without implementing it — an image or embedding provider handed
            // a text model, or a new subclass with no `generate()` yet.
            throw new NeuroLinkError({
                code: ERROR_CODES.INVALID_CONFIGURATION,
                message: `${this.providerName} cannot generate text: it does not override generate(). Every text provider implements a native generate() — see docs/plans/2026-09-03-completing-the-ai-sdk-removal.md`,
                category: ErrorCategory.CONFIGURATION,
                severity: ErrorSeverity.CRITICAL,
                retriable: false,
                context: { provider: this.providerName, model: this.modelName },
            });
        }
        catch (error) {
            otelSpan.setStatus({
                code: SpanStatusCode.ERROR,
                message: error instanceof Error ? error.message : String(error),
            });
            otelSpan.end();
            otelSpanState.ended = true;
            if (isAbortError(error)) {
                logger.info(`Generate aborted for ${this.providerName}`, {
                    error: error instanceof Error ? error.message : String(error),
                });
            }
            else {
                logger.error(`Generate failed for ${this.providerName}:`, error);
            }
            throw this.handleProviderError(error);
        }
        finally {
            if (!otelSpanState.ended) {
                otelSpan.setStatus({ code: SpanStatusCode.OK });
                otelSpan.end();
            }
        }
    }
    async handleDirectTTSSynthesis(options, startTime) {
        const textToSynthesize = options.prompt ?? options.input?.text ?? "";
        const baseResult = {
            content: textToSynthesize,
            provider: options.provider ?? this.providerName,
            model: this.modelName,
            usage: { input: 0, output: 0, total: 0 },
        };
        const ttsOptions = options.tts;
        if (!ttsOptions) {
            return this.enhanceResult(baseResult, options, startTime);
        }
        const ttsStartTime = Date.now();
        const ttsProvider = ttsOptions.provider ?? options.provider ?? this.providerName;
        const ttsTimeout = this.getTimeout(options);
        try {
            baseResult.audio = await withTimeoutFn(() => TTSProcessor.synthesize(textToSynthesize, ttsProvider, ttsOptions), ttsTimeout, `TTS synthesis timed out after ${ttsTimeout}ms for provider "${ttsProvider}"`);
            baseResult.ttsMetadata = {
                attempted: true,
                success: true,
                latency: Date.now() - ttsStartTime,
            };
        }
        catch (ttsError) {
            const latency = Date.now() - ttsStartTime;
            const error = this.getTTSErrorDetails(ttsError);
            baseResult.ttsMetadata = {
                attempted: true,
                success: false,
                error,
                latency,
            };
            this.telemetryHandler.recordTTSFailure(ttsProvider, error, latency);
            logger.error(`TTS synthesis failed in Mode 1 (direct input synthesis):`, ttsError);
        }
        return this.enhanceResult(baseResult, options, startTime);
    }
    async handleVideoFrameGeneration(options, messages, model, startTime) {
        if (!hasVideoFrames(messages)) {
            return null;
        }
        // Bug 2 fix: callers requesting structured output (schema or explicit
        // output.format) must NOT be hijacked into the prose-returning video
        // analysis path. Without this gate, schema/format are silently dropped
        // whenever messages contain >=3 image parts.
        if (options.schema !== undefined || options.output?.format !== undefined) {
            logger.info("[VideoFrameGen] Skipping video-frame analysis route; caller requested structured output", {
                provider: this.providerName,
                model: this.modelName,
                hasSchema: options.schema !== undefined,
                outputFormat: options.output?.format,
            });
            return null;
        }
        const videoAnalysisResult = await executeVideoAnalysis(messages, {
            provider: options.provider,
            providerName: this.providerName,
            region: options.region,
        });
        const userText = messages
            .filter((m) => m.role === "user")
            .flatMap((m) => Array.isArray(m.content)
            ? m.content
                .filter((p) => p.type === "text")
                .map((p) => p.text)
            : [typeof m.content === "string" ? m.content : ""])
            .filter(Boolean)
            .join("\n")
            .trim();
        let formattedContent = videoAnalysisResult;
        let usage = { input: 0, output: 0, total: 0 };
        if (options.systemPrompt) {
            try {
                const formattingPrompt = userText
                    ? `The user asked: "${userText}"\n\nHere is the video/image analysis result from the visual analysis system:\n\n${videoAnalysisResult}\n\nBased on this analysis, provide your response.`
                    : `Here is a video/image analysis result from the visual analysis system:\n\n${videoAnalysisResult}\n\nBased on this analysis, provide your response.`;
                logger.debug("[VideoAnalysis] Formatting via Claude", {
                    userTextLength: userText.length,
                    analysisLength: videoAnalysisResult.length,
                });
                const formattedResult = await generateOnceNative(model, {
                    ...(options.systemPrompt ? { system: options.systemPrompt } : {}),
                    prompt: formattingPrompt,
                    maxOutputTokens: options.maxTokens || 8192,
                    temperature: 0.3,
                    ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
                });
                formattedContent = formattedResult.text;
                usage = extractTokenUsage(formattedResult.usage);
                logger.debug("[VideoAnalysis] Claude formatting complete", {
                    formattedLength: formattedContent.length,
                    usage,
                });
            }
            catch (error) {
                logger.warn("[VideoAnalysis] Claude formatting failed, using raw Gemini output", {
                    error: error instanceof Error ? error.message : String(error),
                });
            }
        }
        return this.enhanceResult({
            content: formattedContent,
            provider: options.provider ?? this.providerName,
            model: this.modelName,
            usage,
        }, options, startTime);
    }
    /**
     * 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.
     */
    async finalizeNativeGenerate(result, options, startTime) {
        // onFinish is NOT fired here. `applyGenerateLifecycleMiddleware` folds it
        // into `options.middleware.middlewareConfig.lifecycle`, and the native
        // paths now wrap their model, so the lifecycle middleware fires it.
        // Firing it here too would deliver every callback twice.
        await this.recordPerformanceMetrics(result.usage, Date.now() - startTime);
        const synthesized = await this.synthesizeAIResponseIfNeeded(result, options);
        return this.enhanceResult(synthesized, options, startTime);
    }
    /**
     * 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.
     */
    fireGenerateOnFinish(options, result, startTime) {
        const onFinish = options
            .onFinish;
        if (typeof onFinish !== "function") {
            return;
        }
        try {
            const usage = result?.usage;
            const cb = onFinish({
                text: result?.content || "",
                usage: usage
                    ? {
                        promptTokens: usage.input ?? 0,
                        completionTokens: usage.output ?? 0,
                    }
                    : undefined,
                duration: Date.now() - startTime,
                finishReason: result?.finishReason ?? "stop",
            });
            Promise.resolve(cb).catch((err) => logger.warn(`[${this.providerName}] onFinish callback rejected: ${err instanceof Error ? err.message : String(err)}`));
        }
        catch (err) {
            logger.warn(`[${this.providerName}] onFinish callback threw: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    async synthesizeAIResponseIfNeeded(enhancedResult, options) {
        if (!options.tts?.enabled || !options.tts?.useAiResponse) {
            return enhancedResult;
        }
        const ttsOptions = options.tts;
        const aiResponse = enhancedResult.content;
        const ttsProvider = ttsOptions.provider ?? options.provider ?? this.providerName;
        if (!aiResponse || !ttsProvider) {
            logger.warn(`TTS synthesis skipped despite being enabled`, {
                provider: this.providerName,
                hasAiResponse: !!aiResponse,
                aiResponseLength: aiResponse?.length ?? 0,
                hasProvider: !!ttsProvider,
                ttsConfig: {
                    enabled: options.tts?.enabled,
                    useAiResponse: options.tts?.useAiResponse,
                },
                reason: !aiResponse
                    ? "AI response is empty or undefined"
                    : "Provider is missing",
            });
            return {
                ...enhancedResult,
                ttsMetadata: {
                    attempted: false,
                    success: false,
                },
            };
        }
        const ttsStartTime = Date.now();
        const ttsTimeout = this.getTimeout(options);
        try {
            const ttsResult = await withTimeoutFn(() => TTSProcessor.synthesize(aiResponse, ttsProvider, ttsOptions), ttsTimeout, `TTS synthesis timed out after ${ttsTimeout}ms for provider "${ttsProvider}"`);
            return {
                ...enhancedResult,
                audio: ttsResult,
                ttsMetadata: {
                    attempted: true,
                    success: true,
                    latency: Date.now() - ttsStartTime,
                },
            };
        }
        catch (ttsError) {
            const latency = Date.now() - ttsStartTime;
            const error = this.getTTSErrorDetails(ttsError);
            this.telemetryHandler.recordTTSFailure(ttsProvider, error, latency);
            logger.error(`TTS synthesis failed in Mode 2 (AI response synthesis):`, ttsError);
            return {
                ...enhancedResult,
                ttsMetadata: {
                    attempted: true,
                    success: false,
                    error,
                    latency,
                },
            };
        }
    }
    /**
     * 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.
     */
    getTTSErrorDetails(error) {
        const safeMessage = sanitizeErrorCause(error).message;
        if (error instanceof AsyncTimeoutError) {
            return {
                code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
                message: safeMessage,
                retriable: true,
            };
        }
        if (error instanceof NeuroLinkError) {
            return {
                code: error.code,
                message: safeMessage,
                retriable: error.retriable,
            };
        }
        return {
            code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
            message: safeMessage,
        };
    }
    /**
     * BACKWARD COMPATIBILITY: Legacy generateText method
     * Converts EnhancedGenerateResult to TextGenerationResult format
     * Ensures existing scripts using createAIProvider().generateText() continue to work
     */
    async generateText(options) {
        // Validate required parameters for backward compatibility - support both prompt and input.text
        const promptText = options.prompt || options.input?.text;
        if (!promptText ||
            typeof promptText !== "string" ||
            promptText.trim() === "") {
            throw new Error("GenerateText options must include prompt or input.text as a non-empty string");
        }
        // Call the main generate method
        const result = await this.generate(options);
        if (!result) {
            throw new Error("Generation failed: No result returned");
        }
        // Convert EnhancedGenerateResult to TextGenerationResult format
        return {
            content: result.content || "",
            provider: result.provider || this.providerName,
            model: result.model || this.modelName,
            usage: result.usage || {
                input: 0,
                output: 0,
                total: 0,
            },
            responseTime: 0, // BaseProvider doesn't track response time directly
            toolsUsed: result.toolsUsed || [],
            // Map ToolExecutionRecord entries to the legacy TextGenerationResult
            // shape: real timing and error status now come from the records.
            toolExecutions: result.toolExecutions?.map((te) => ({
                toolName: te.toolName,
                executionTime: te.durationMs,
                success: !te.isError,
            })),
            enhancedWithTools: !!(result.toolsUsed && result.toolsUsed.length > 0),
            analytics: result.analytics,
            evaluation: result.evaluation,
            audio: result.audio,
            // Forward reasoning fields. They are populated by the shared native
            // generate loop, which serves the OpenAI-compatible, Anthropic and
            // SageMaker providers (DeepSeek `reasoning_content`, gateway
            // `reasoning`, Anthropic thinking, OpenAI o-series). Vertex and AI Studio
            // override generate() and never reach that loop; they report a numeric
            // `usage.reasoning` token count and leave this text field undefined.
            reasoning: result.reasoning,
            reasoningTokens: result.reasoningTokens,
        };
    }
    /**
     * 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" });
     * ```
     */
    async embed(input, _modelName) {
        const textLength = typeof input === "string" ? input.length : (input.text?.length ?? 0);
        logger.warn(`embed() called on ${this.providerName} which does not have a native implementation`, {
            textLength,
        });
        throw new Error(`Embedding generation is not supported by the ${this.providerName} provider. ` +
            `Supported providers: openai, vertex/google, bedrock, cohere, voyage, jina. ` +
            `Use an embedding model like text-embedding-3-small (OpenAI), text-embedding-004 (Vertex), ` +
            `embed-english-v3.0 (Cohere), voyage-3 (Voyage), jina-embeddings-v3 (Jina), ` +
            `or amazon.titan-embed-text-v2:0 (Bedrock).`);
    }
    /**
     * 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
     */
    async embedMany(texts, _modelName) {
        logger.warn(`embedMany() called on ${this.providerName} which does not have a native implementation`, {
            count: texts.length,
        });
        throw new Error(`Batch embedding generation is not supported by the ${this.providerName} provider. ` +
            `Supported providers: openai, googleAiStudio, vertex/google, bedrock, cohere, voyage, jina. ` +
            `Use an embedding model like text-embedding-3-small (OpenAI), gemini-embedding-001 (Google AI), ` +
            `text-embedding-004 (Vertex), embed-english-v3.0 (Cohere), voyage-3 (Voyage), ` +
            `jina-embeddings-v3 (Jina), or amazon.titan-embed-text-v2:0 (Bedrock).`);
    }
    /**
     * 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
     */
    getDefaultEmbeddingModel() {
        // Default implementation returns undefined - providers override this
        return undefined;
    }
    // ===================
    // `decide` inference type
    // ===================
    /**
     * 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
     */
    async decide(_request) {
        logger.warn(`decide() called on ${this.providerName}, which does not serve the decide inference type`);
        throw new Error(`The decide inference type is not supported by the ${this.providerName} provider. ` +
            `Supported providers: ${DECISION_PROVIDERS.map((d) => d.name).join(", ")}. A provider declares this capability with ` +
            `"decide" in its descriptor's inferenceKinds.`);
    }
    /**
     * Get the default decision model for this provider.
     *
     * Mirrors {@link getDefaultEmbeddingModel}. Returns undefined for providers
     * that do not serve the `decide` inference type.
     */
    getDefaultDecisionModel() {
        return undefined;
    }
    // ===================
    // ===================
    // BZ-665: Schema-driven tool call repair
    // ===================
    /**
     * 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.
     */
    getToolCallRepairFn(options) {
        if (options?.disableToolCallRepair) {
            return undefined;
        }
        // Lazy import to avoid circular dependency at module load time
        return (async (...args) => {
            const { createToolCallRepair } = await import("../utils/toolCallRepair.js");
            return createToolCallRepair()(...args);
        });
    }
    /**
     * 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.
     */
    async executeStream(options, _analysisSchema) {
        if (!this.doStream) {
            throw new NeuroLinkError({
                code: ERROR_CODES.INVALID_CONFIGURATION,
                message: `${this.providerName} cannot stream: it neither implements doStream() nor overrides executeStream()`,
                category: ErrorCategory.CONFIGURATION,
                severity: ErrorSeverity.CRITICAL,
                retriable: false,
                context: { provider: this.providerName, model: this.modelName },
            });
        }
        const startTime = Date.now();
        const { stream, finishReason, usage, warnings } = await this.doStream(options);
        if (warnings?.length) {
            logger.warn(`[${this.providerName}] doStream reported warnings`, {
                provider: this.providerName,
                count: warnings.length,
            });
        }
        // `metadata` is handed back by reference and filled in when the turn
        // ends — the documented contract for background-loop streams, since a
        // result-object spread would snapshot a top-level getter before the
        // stream has produced anything.
        const metadata = {
            startTime,
            streamId: `${this.providerName}-${startTime}`,
        };
        // Chained off the provider's own promises rather than off the consumer
        // draining the stream. Binding analytics to a stream iterator's finally
        // block means a caller that awaits analytics without iterating waits
        // forever, because a generator body does not run until it is iterated.
        const analytics = (async () => {
            const [resolvedFinishReason, resolvedUsage] = await Promise.all([
                finishReason,
                usage,
            ]);
            metadata.finishReason = resolvedFinishReason;
            metadata.rawFinishReason = resolvedFinishReason;
            return buildAnalytics(this.providerName, this.modelName || this.getDefaultModel(), {
                usage: {
                    input: resolvedUsage.inputTokens,
                    output: resolvedUsage.outputTokens,
                    total: resolvedUsage.inputTokens + resolvedUsage.outputTokens,
                },
                stopReason: resolvedFinishReason,
            }, Date.now() - startTime, { streamingMode: true });
        })();
        return {
            stream,
            model: this.modelName || this.getDefaultModel(),
            provider: this.getProviderName(),
            analytics,
            metadata,
        };
    }
    /**
     * 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.
     */
    async withTurnTimeout(options, descriptorGenerateMs, run) {
        const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
            Number.isFinite(options.turnTimeoutMs) &&
            options.turnTimeoutMs > 0;
        const effectiveTimeout = hasValidTurnTimeout
            ? options.turnTimeoutMs
            : (options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000));
        const timeoutController = createTimeoutController(effectiveTimeout, this.providerName, "generate");
        const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
        const timedOptions = composedSignal
            ? { ...options, abortSignal: composedSignal }
            : options;
        try {
            return await run(timedOptions);
        }
        catch (error) {
            // When OUR timer fired, provider SDKs typically normalize the abort
            // into their own generic cancel shape (e.g. Anthropic's
            // APIUserAbortError, "Request was aborted.") and discard the signal's
            // reason. The TimeoutError on the signal is the honest identity —
            // rethrow it so logs and abort classification see a timeout, not a
            // caller cancel. A genuine caller abort (their signal fired) keeps its
            // original shape even if our timer also expired in the race window.
            const reason = timeoutController?.controller.signal.aborted
                ? timeoutController.controller.signal.reason
                : undefined;
            if (reason instanceof TimeoutError &&
                isAbortError(error) &&
                options.abortSignal?.aborted !== true) {
                throw reason;
            }
            throw error;
        }
        finally {
            timeoutController?.cleanup();
        }
    }
    /**
     * 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.
     */
    emitTurnEvent(payload) {
        try {
            this.neurolink?.getEventEmitter()?.emit("turn:lifecycle", {
                provider: this.providerName,
                timestamp: Date.now(),
                ...payload,
            });
        }
        catch {
            /* listener errors are non-fatal */
        }
    }
    /**
     * The per-provider generate budget from the descriptor, for native paths
     * that call `withTurnTimeout` directly.
     */
    getDescriptorGenerateMs() {
        return PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts
            ?.generateMs;
    }
    /** Per-provider `decide` budget from the descriptor, when one is declared. */
    getDescriptorDecideMs() {
        return PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts
            ?.decideMs;
    }
    /**
     * 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() {
        return this.getAISDKModel();
    }
    /**
     * 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
     */
    async getAISDKModelWithMiddleware(options = {}) {
        return this.applyMiddlewareToModel(await this.getAISDKModel(), options);
    }
    /**
     * 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.
     */
    async applyMiddlewareToModel(baseModel, options = {}) {
        logger.debug(`Retrieved base model for ${this.providerName}`, {
            provider: this.providerName,
            model: this.modelName,
            hasMiddlewareConfig: !!this.middlewareOptions,
            timestamp: Date.now(),
        });
        // Check if middleware should be applied
        const middlewareOptions = this.extractMiddlewareOptions(options);
        logger.debug(`Middleware extraction result`, {
            provider: this.providerName,
            model: this.modelName,
            middlewareOptions,
        });
        if (!middlewareOptions) {
            return baseModel;
        }
        try {
            logger.debug(`Applying middleware to ${this.providerName} model`, {
                provider: this.providerName,
                model: this.modelName,
                middlewareOptions,
            });
            // Create a new factory instance with the specified options
            const factory = new MiddlewareFactory(middlewareOptions);
            // Create middleware context
            const context = factory.createContext(this.providerName, this.modelName, options, {
                sessionId: this.sessionId,
                userId: this.userId,
            });
            // Apply middleware to the model
            const wrappedModel = factory.applyMiddleware(baseModel, context, middlewareOptions);
            logger.debug(`Applied middleware to ${this.providerName} model`, {
                provider: this.providerName,
                model: this.modelName,
                hasMiddleware: true,
            });
            return wrappedModel;
        }
        catch (error) {
            logger.warn(`Failed to apply middleware to ${this.providerName}, using base model`, {
                error: error instanceof Error ? error.message : String(error),
            });
            // Return base model on middleware failure to maintain functionality
            return baseModel;
        }
    }
    /**
     * Extract middleware options - delegated to Utilities
     */
    extractMiddlewareOptions(options) {
        return this.utilities.extractMiddlewareOptions(options);
    }
    // ===================
    // TOOL MANAGEMENT
    // ===================
    /**
     * Check if a schema is a Zod schema - delegated to Utilities
     */
    isZodSchema(schema) {
        return this.utilities.isZodSchema(schema);
    }
    /**
     * Convert tool execution result - delegated to Utilities
     */
    async convertToolResult(result) {
        return this.utilities.convertToolResult(result);
    }
    /**
     * Fix JSON Schema for OpenAI strict mode - delegated to Utilities
     */
    fixSchemaForOpenAIStrictMode(schema) {
        return this.utilities.fixSchemaForOpenAIStrictMode(schema);
    }
    /**
     * 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) {
        if (policy === undefined || Object.keys(this.directTools).length === 0) {
            return;
        }
        this.fileToolRootsBound = true;
        this.toolsManager.setDirectTools(policy.roots === null ? this.directTools : createDirectAgentTools(policy));
    }
    /**
     * 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.
     */
    applyFileToolRoots(options) {
        if (Object.keys(this.directTools).length === 0) {
            return;
        }
        const bound = boundFileToolRoots(options);
        if (bound !== undefined) {
            this.setFileToolRootPolicy(bound);
            return;
        }
        if (this.fileToolRootsBound && options.toolRoots === undefined) {
            return;
        }
        this.setFileToolRootPolicy(resolveFileToolRootPolicy({
            perCall: options.toolRoots,
            instance: this.neurolink?.getToolsConfig()?.fileRoots,
        }));
    }
    async getAllTools() {
        // Any path that reaches tool assembly without a bound policy — an internal
        // caller, or a provider whose generate()/stream() skips the base entry
        // points — gets the instance's roots rather than the working directory.
        if (!this.fileToolRootsBound) {
            this.applyFileToolRoots({});
        }
        return this.toolsManager.getAllTools();
    }
    /**
     * Calculate actual cost - delegated to TelemetryHandler
     */
    async calculateActualCost(usage) {
        return this.telemetryHandler.calculateActualCost(usage);
    }
    /**
     * Create a permissive Zod schema - delegated to Utilities
     */
    createPermissiveZodSchema() {
        return this.utilities.createPermissiveZodSchema();
    }
    /**
     * Set session context for MCP tools - delegated to ToolsManager
     */
    setSessionContext(sessionId, userId) {
        this.sessionId = sessionId;
        this.userId = userId;
        this.toolsManager.setSessionContext(sessionId, userId);
    }
    /**
     * 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.
     */
    classifyStreamError(error) {
        const err = error instanceof Error ? error : new Error(String(error));
        if (isProviderErrorClassified(err) || err instanceof ProviderError) {
            return err;
        }
        if (duckTypedStatusCode(err) === undefined) {
            return err;
        }
        return this.handleProviderError(err);
    }
    handleProviderError(error) {
        if (isAbortError(error)) {
            // Preserve AbortError identity — never wrap in provider-specific formatting
            return error instanceof Error
                ? error
                : new DOMException("The operation was aborted", "AbortError");
        }
        // Already formatted by this method — hand it straight back. Formatting is
        // NOT idempotent: formatProviderError prepends the provider tag every time,
        // so a second pass produces
        //   "[vertex] Google Vertex AI error: [vertex] Google Vertex AI error: ..."
        // and, when a rule matched on statusCode the first time, can also DEGRADE
        // the classification (a specific "quota exhausted" ProviderError re-matching
        // the bare 429 rule as a generic RateLimitError) because the block below
        // copies statusCode onto its own output.
        //
        // A single Vertex failure reaches here FIVE times for one logical error;
        // this makes calls 2..5 cheap pass-throughs. `instanceof Error` rather than
        // a cast: nothing but an Error is ever stamped, and an unstamped value
        // simply falls through to formatting, which is the safe direction.
        if (error instanceof Error && isProviderErrorClassified(error)) {
            return error;
        }
        const formatted = this.formatProviderError(error);
        // Preserve transport retry metadata across formatting. Provider
        // formatters return fresh Error instances (RateLimitError, NetworkError,
        // …) that would otherwise destroy the classification upper layers need:
        // performMCPGenerationRetries' isRetryable/status checks and
        // providerRetry's Retry-After extraction. Copied generically so every
        // provider's 429/5xx keeps its status and server-requested delay.
        if (error && typeof error === "object" && formatted !== error) {
            const src = error;
            const dst = formatted;
            const statusCode = duckTypedStatusCode(error);
            if (statusCode !== undefined && dst.statusCode === undefined) {
                dst.statusCode = statusCode;
            }
            if (typeof src.isRetryable === "boolean" &&
                dst.isRetryable === undefined) {
                dst.isRetryable = src.isRetryable;
            }
            if (dst.retryAfterMs === undefined) {
                const retryAfterMs = extractRetryAfterMsFromError(error);
                if (retryAfterMs !== undefined) {
                    dst.retryAfterMs = retryAfterMs;
                }
            }
        }
        // Preserve the lifecycle-fired mark across formatting:
        // fireLifecycleErrorCallback() marks the ORIGINAL error in the shared
        // WeakSet, but formatProviderError() typically returns a new Error
        // instance. Re-mark the formatted error so a higher layer (e.g.
        // NeuroLink.stream()'s top-level catch + applyStreamLifecycleMiddleware)
        // doesn't fire onError a second time for the same failure.
        if (hasLifecycleErrorFired(error)) {
            markLifecycleErrorFired(formatted);
        }
        // P3 fix: Classify error and set error.type on the active OTel span
        try {
            const activeSpan = trace.getSpan(context.active());
            if (activeSpan) {
                let errorType = "provider_error";
                const errName = formatted?.constructor?.name ?? "";
                if (errName === "RateLimitError") {
                    errorType = "rate_limit";
                }
                else if (errName === "AuthenticationError") {
                    errorType = "auth_failure";
                }
                else if (errName === "NetworkError") {
                    errorType = "network";
                }
                else if (errName === "InvalidModelError") {
                    errorType = "invalid_model";
                }
                else if (errName === "TimeoutError") {
                    errorType = "timeout";
                }
                activeSpan.setAttribute("error.type", errorType);
                if (formatted instanceof Error) {
                    activeSpan.setAttribute("error.message", formatted.message.substring(0, 500));
                }
            }
        }
        catch {
            // Non-blocking — telemetry failures shouldn't mask the original error
        }
        // Stamp AFTER formatting so any later catch site can tell this error has
        // already been classified. Deliberately not applied to the AbortError
        // passthrough above — that returns the original error untouched.
        markProviderErrorClassified(formatted);
        return formatted;
    }
    /**
     * 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.
     */
    async executeImageGeneration(_options) {
        throw new Error(`Image generation is not supported by the ${this.providerName} provider or the selected model.`);
    }
    // ===================
    // CONSOLIDATED PROVIDER METHODS - MOVED FROM INDIVIDUAL PROVIDERS
    // ===================
    /**
     * Execute operation with timeout and proper cleanup
     * Consolidates identical timeout handling from 8/10 providers
     */
    async executeWithTimeout(operation, options) {
        const timeout = this.getTimeout(options);
        const timeoutController = createTimeoutController(timeout, this.providerName, options.operationType || "generate");
        try {
            if (timeoutController) {
                return await Promise.race([
                    operation(),
                    new Promise((_, reject) => {
                        timeoutController.controller.signal.addEventListener("abort", () => {
                            reject(new TimeoutError(`${this.providerName} operation timed out`, timeoutController.timeoutMs, this.providerName, options.operationType ||
                                "generate"));
                        });
                    }),
                ]);
            }
            else {
                return await operation();
            }
        }
        finally {
            timeoutController?.cleanup();
        }
    }
    /**
     * Validate stream options - delegated to StreamHandler
     */
    validateStreamOptions(options) {
        this.streamHandler.validateStreamOptions(options);
    }
    /**
     * 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`.
     */
    createTextStream(result, getUnderlyingError) {
        return this.streamHandler.createTextStream(result, getUnderlyingError);
    }
    /**
     * Create standardized stream result - delegated to StreamHandler
     */
    createStreamResult(stream, additionalProps = {}) {
        return this.streamHandler.createStreamResult(stream, additionalProps);
    }
    /**
     * Create stream analytics - delegated to StreamHandler
     */
    async createStreamAnalytics(result, startTime, options) {
        return this.streamHandler.createStreamAnalytics(result, startTime, options);
    }
    /**
     * Handle common error patterns - delegated to Utilities
     */
    handleCommonErrors(error) {
        return this.utilities.handleCommonErrors(error);
    }
    /**
     * Set up tool executor - delegated to ToolsManager
     * @param sdk - The NeuroLinkSDK instance for tool execution
     * @param functionTag - Function name for logging
     */
    setupToolExecutor(sdk, functionTag) {
        this.toolsManager.setupToolExecutor(sdk, functionTag);
    }
    // ===================
    // TEMPLATE METHODS - COMMON FUNCTIONALITY
    // ===================
    /**
     * Normalize text generation options - delegated to Utilities
     */
    normalizeTextOptions(optionsOrPrompt) {
        return this.utilities.normalizeTextOptions(optionsOrPrompt);
    }
    /**
     * Normalize stream options - delegated to Utilities
     */
    normalizeStreamOptions(optionsOrPrompt) {
        return this.utilities.normalizeStreamOptions(optionsOrPrompt);
    }
    async enhanceResult(result, options, startTime) {
        const responseTime = Date.now() - startTime;
        // CRITICAL FIX: Store imageOutput separately to ensure it's preserved
        const imageOutput = result.imageOutput;
        let enhancedResult = { ...result };
        if (options.enableAnalytics) {
            try {
                const analytics = await this.createAnalytics(result, responseTime, options);
                // Preserve ALL fields including imageOutput when adding analytics
                enhancedResult = { ...enhancedResult, analytics, imageOutput };
            }
            catch (error) {
                logger.warn(`Analytics creation failed for ${this.providerName}:`, error);
            }
        }
        if (options.enableEvaluation) {
            try {
                const evaluation = await this.createEvaluation(result, options);
                // Preserve ALL fields including imageOutput when adding evaluation
                enhancedResult = { ...enhancedResult, evaluation, imageOutput };
            }
            catch (error) {
                logger.warn(`Evaluation creation failed for ${this.providerName}:`, error);
            }
        }
        // CRITICAL FIX: Always restore imageOutput if it existed in the original result
        if (imageOutput) {
            enhancedResult.imageOutput = imageOutput;
        }
        return enhancedResult;
    }
    /**
     * 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
     * ```
     */
    // eslint-disable-next-line max-lines-per-function
    async handleVideoGeneration(options, startTime) {
        // Dynamic imports to avoid loading video dependencies unless needed.
        // Pull VideoError + VIDEO_ERROR_CODES from VideoProcessor (which already
        // re-exports both) so non-vertex routes don't carry a direct dependency
        // on the Vertex adapter's module.
        const { VideoProcessor, VideoError, VIDEO_ERROR_CODES } = await import("../utils/videoProcessor.js");
        const { validateVideoGenerationInput, validateImageForVideo, validateDirectorModeInput, } = await import("../utils/parameterValidation.js");
        const { ErrorFactory } = await import("../utils/errorHandling.js");
        // Build GenerateOptions for validation
        const generateOptions = {
            input: options.input || { text: options.prompt || "" },
            output: options.output,
            provider: options.provider,
            model: options.model,
        };
        // ===== DIRECTOR MODE =====
        // Route to Director pipeline when segments are provided
        if (generateOptions.input?.segments &&
            Array.isArray(generateOptions.input.segments) &&
            generateOptions.input.segments.length > 0) {
            // Type narrowing: segments is guaranteed to exist here
            const segments = generateOptions.input.segments;
            const directorValidation = validateDirectorModeInput(generateOptions);
            if (!directorValidation.isValid) {
                throw ErrorFactory.invalidParameters("director-mode", new Error(directorValidation.errors
                    .map((e) => e.message)
                    .join("; ")), { errors: directorValidation.errors });
            }
            if (directorValidation.warnings.length > 0) {
                for (const warning of directorValidation.warnings) {
                    logger.warn(`Director Mode warning: ${warning}`);
                }
            }
            const { executeDirectorPipeline, DIRECTOR_PIPELINE_TIMEOUT_MS } = await import("../adapters/video/directorPipeline.js");
            // Use caller's timeout if provided, otherwise use default Director timeout
            const directorTimeout = options.timeout ?? DIRECTOR_PIPELINE_TIMEOUT_MS;
            const videoResult = await this.executeWithTimeout(() => executeDirectorPipeline(segments, generateOptions.output?.video ?? {}, generateOptions.output?.director ?? {}, options.region), { timeout: directorTimeout, operationType: "generate" });
            // Build content summary with metadata
            const joinedPrompts = generateOptions.input.segments
                .map((s) => s.prompt)
                .join(" → ");
            const segmentCount = videoResult.metadata?.segmentCount ??
                generateOptions.input.segments.length;
            const transitionCount = videoResult.metadata?.transitionCount ?? Math.max(0, segmentCount - 1);
            const totalDuration = videoResult.metadata?.duration ?? 0;
            const contentSummary = `${joinedPrompts} — duration: ${totalDuration}s, segments: ${segmentCount}, transitions: ${transitionCount}`;
            const baseResult = {
                content: contentSummary,
                provider: "vertex",
                model: options.model || "veo-3.1-generate-001",
                usage: { input: 0, output: 0, total: 0 },
                video: videoResult,
            };
            return await this.enhanceResult(baseResult, options, startTime);
        }
        // ===== STANDARD SINGLE-CLIP VIDEO GENERATION =====
        // Validate video generation input
        const validation = validateVideoGenerationInput(generateOptions);
        if (!validation.isValid) {
            throw ErrorFactory.invalidParameters("video-generation", new Error(validation.errors.map((e) => e.message).join("; ")), { errors: validation.errors });
        }
        // Log warnings if any
        if (validation.warnings.length > 0) {
            for (const warning of validation.warnings) {
                logger.warn(`Video generation warning: ${warning}`);
            }
        }
        // Extract image from input
        const imageInput = options.input?.images?.[0];
        if (!imageInput) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.INVALID_INPUT,
                message: "Video generation requires an input image. Provide via input.images array.",
                retriable: false,
                context: { field: "input.images" },
            });
        }
        // Timeout for image IO operations (15 seconds)
        const IMAGE_IO_TIMEOUT_MS = 15000;
        // Load image buffer if path/URL
        let imageBuffer;
        if (typeof imageInput === "string") {
            if (imageInput.startsWith("http://") ||
                imageInput.startsWith("https://")) {
                // URL - fetch the image with timeout
                logger.debug("Fetching image from URL for video generation", {
                    url: imageInput.substring(0, 100),
                });
                let response;
                try {
                    response = await this.executeWithTimeout(() => fetch(imageInput), {
                        timeout: IMAGE_IO_TIMEOUT_MS,
                        operationType: "generate", // Part of video generation flow
                    });
                }
                catch (error) {
                    throw new VideoError({
                        code: VIDEO_ERROR_CODES.INVALID_INPUT,
                        message: `Failed to fetch image from URL: ${error instanceof Error ? error.message : "Request timed out"}`,
                        retriable: true,
                        context: { url: imageInput, timeout: IMAGE_IO_TIMEOUT_MS },
                        originalError: error instanceof Error ? error : undefined,
                    });
                }
                if (!response.ok) {
                    throw new VideoError({
                        code: VIDEO_ERROR_CODES.INVALID_INPUT,
                        message: `Failed to fetch image from URL: ${response.status} ${response.statusText}`,
                        retriable: response.status >= 500,
                        context: { url: imageInput, status: response.status },
                    });
                }
                imageBuffer = Buffer.from(await response.arrayBuffer());
            }
            else {
                // File path - read from disk with timeout
                logger.debug("Reading image from path for video generation", {
                    path: imageInput,
                });
                const fs = await import("node:fs/promises");
                try {
                    imageBuffer = await this.executeWithTimeout(() => fs.readFile(imageInput), { timeout: IMAGE_IO_TIMEOUT_MS, operationType: "generate" });
                }
                catch (error) {
                    throw new VideoError({
                        code: VIDEO_ERROR_CODES.INVALID_INPUT,
                        message: `Failed to read image file: ${error instanceof Error ? error.message : String(error)}`,
                        retriable: false,
                        context: { path: imageInput, timeout: IMAGE_IO_TIMEOUT_MS },
                        originalError: error instanceof Error ? error : undefined,
                    });
                }
            }
        }
        else if (Buffer.isBuffer(imageInput)) {
            imageBuffer = imageInput;
        }
        else if (typeof imageInput === "object" && "data" in imageInput) {
            // ImageWithAltText type
            const imgData = imageInput.data;
            if (typeof imgData === "string") {
                imageBuffer = Buffer.from(imgData, "base64");
            }
            else if (Buffer.isBuffer(imgData)) {
                imageBuffer = imgData;
            }
            else {
                throw new VideoError({
                    code: VIDEO_ERROR_CODES.INVALID_INPUT,
                    message: "ImageWithAltText.data must be a base64 string or Buffer.",
                    retriable: false,
                    context: { field: "input.images[0].data", type: typeof imgData },
                });
            }
        }
        else {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.INVALID_INPUT,
                message: "Invalid image input type. Provide Buffer, path string, URL, or ImageWithAltText.",
                retriable: false,
                context: { field: "input.images[0]", type: typeof imageInput },
            });
        }
        // Validate image format and size (for Buffer inputs)
        const imageValidation = validateImageForVideo(imageBuffer);
        if (imageValidation) {
            throw ErrorFactory.invalidParameters("video-generation", new Error(imageValidation.message), {
                field: "input.images[0]",
                validation: imageValidation,
            });
        }
        // Get prompt text
        const prompt = options.prompt || options.input?.text || "";
        // Honor output.video.provider — when omitted, fall back to the
        // catalog-derived default (currently "vertex") for backward
        // compatibility with the original implementation.
        const requestedProvider = options.output?.video?.provider ?? defaultProviderFor("video");
        if (!VideoProcessor.supports(requestedProvider)) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.PROVIDER_NOT_SUPPORTED,
                message: `Video provider "${requestedProvider}" is not registered. Available: ${VideoProcessor.listProviders().join(", ")}`,
                retriable: false,
                context: {
                    provider: requestedProvider,
                    available: VideoProcessor.listProviders(),
                },
            });
        }
        // Resolve the model name without hardcoding a Vertex default for
        // non-Vertex routes. Precedence: caller-supplied output.video.model,
        // then options.model (LLM-level field that the caller may have repurposed
        // for video), then the Vertex Veo default but only when we're actually
        // calling Vertex. Otherwise leave it null at this stage and let the
        // handler's metadata fill it in below.
        const requestedVideoModel = options.output?.video?.model;
        const resolvedRequestModel = requestedVideoModel ??
            options.model ??
            (requestedProvider === "vertex" ? "veo-3.1-generate-001" : undefined);
        logger.info("Starting video generation", {
            provider: requestedProvider,
            ...(resolvedRequestModel ? { model: resolvedRequestModel } : {}),
            promptLength: prompt.length,
            imageSize: imageBuffer.length,
            resolution: options.output?.video?.resolution || "720p",
            duration: options.output?.video?.length || 6,
        });
        // Dispatch through the central VideoProcessor — picks up vertex,
        // kling, runway, replicate (or any custom handler) registered via
        // ProviderRegistry / VideoProcessor.registerHandler(). Wrap in the
        // shared timeout helper so standard video gen honors the caller's
        // timeout the same way director mode does (see above ~Line 2062).
        const videoTimeout = options.timeout ?? 600_000; // 10 min default
        // Thread the caller's cancellation signal into the handler chain —
        // output.video.abortSignal (video-scoped) wins over the request-level
        // options.abortSignal, matching the general per-field precedence.
        const videoAbortSignal = options.output?.video?.abortSignal ?? options.abortSignal;
        const videoResult = await this.executeWithTimeout(() => VideoProcessor.generate(requestedProvider, {
            ...(options.output?.video ?? {}),
            image: imageBuffer,
            prompt,
            region: options.region,
            abortSignal: videoAbortSignal,
        }), { timeout: videoTimeout, operationType: "generate" });
        // Prefer the handler's own model id (more accurate — it knows the exact
        // checkpoint that ran). Fall back to the request-time value, and finally
        // to the Vertex default only when we're on the Vertex route.
        const responseModel = videoResult.metadata?.model ??
            resolvedRequestModel ??
            (requestedProvider === "vertex" ? "veo-3.1-generate-001" : "unknown");
        logger.info("Video generation complete", {
            provider: requestedProvider,
            model: responseModel,
            videoSize: videoResult.data.length,
            duration: videoResult.metadata?.duration,
            processingTime: videoResult.metadata?.processingTime,
        });
        // Build result
        const baseResult = {
            content: prompt, // Echo the prompt as content
            provider: requestedProvider,
            model: responseModel,
            usage: { input: 0, output: 0, total: 0 },
            video: videoResult,
        };
        return await this.enhanceResult(baseResult, options, startTime);
    }
    /**
     * Create analytics - delegated to TelemetryHandler
     */
    async createAnalytics(result, responseTime, options) {
        return this.telemetryHandler.createAnalytics(result, responseTime, options.context);
    }
    /**
     * Create evaluation - delegated to TelemetryHandler
     */
    async createEvaluation(result, options) {
        return this.telemetryHandler.createEvaluation(result, options);
    }
    /**
     * Validate text generation options - delegated to Utilities
     */
    validateOptions(options) {
        this.utilities.validateOptions(options);
    }
    /**
     * Get provider information - delegated to Utilities
     */
    getProviderInfo() {
        return this.utilities.getProviderInfo();
    }
    /**
     * Get timeout value in milliseconds - delegated to Utilities
     */
    getTimeout(options) {
        return this.utilities.getTimeout(options);
    }
    /**
     * Check if tool executions should be stored and handle storage
     */
    async handleToolExecutionStorage(toolCalls, toolResults, options, currentTime) {
        return this.telemetryHandler.handleToolExecutionStorage(toolCalls, toolResults, options, currentTime);
    }
    /**
     * 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, maxChunkSize = 900000, overlap = 100) {
        if (prompt.length <= maxChunkSize) {
            return [prompt];
        }
        const chunks = [];
        let start = 0;
        while (start < prompt.length) {
            const end = Math.min(start + maxChunkSize, prompt.length);
            chunks.push(prompt.slice(start, end));
            // Break if we've reached the end
            if (end >= prompt.length) {
                break;
            }
            // Move start forward, accounting for overlap
            const nextStart = end - overlap;
            // Ensure we make progress (avoid infinite loops)
            if (nextStart <= start) {
                start = end;
            }
            else {
                start = Math.max(nextStart, 0);
            }
        }
        return chunks;
    }
}