@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,296 lines • 165 kB
JavaScript
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, resolveTTSMode } 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 { resolveTTSHandlerBudgetMs, TTS_ERROR_CODES, TTSProcessor, } from "../utils/ttsProcessor.js";
import { prepareTextForSpeech, resolveSpeechSanitizeOptions, } from "../utils/speechText.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(#1953): 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.NL_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,
mode: "response",
}
: 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() : {};
// Registered and MCP tools arrive from ToolsManager already emitting
// tool:start / tool:end; per-call tools get the same wrapper here so the
// event bus sees every execution exactly once.
let tools = shouldUseTools
? {
...baseTools,
...this.toolsManager.wrapExternalToolsWithEvents((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 = this.toolsManager.wrapExternalToolsWithEvents((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) {
this.applyToolReplayDefault(options);
return this.messageBuilder.buildMessages(options);
}
/**
* Fold the instance's `conversationMemory.replayToolSteps` into the request
* when the caller set none, so every path into the message builder — text
* or multimodal, generate or stream — reads one resolved value. The
* builder itself defaults to `"marker"` when neither is set.
*
* Then degrade `"full"` to `"marker"` for a request that declares no tools:
* Anthropic rejects a prompt carrying tool_use / tool_result blocks without
* a `tools` field ("Requests which include tool_use or tool_result blocks
* must define tools"), so a tools-off call — `disableTools`, or a filter
* that removed every tool — in a session that already ran one would fail
* on every later turn. Runs after the provider has merged `options.tools`,
* which is where every native path calls the builder from.
*/
applyToolReplayDefault(options) {
if (options.replayToolSteps === undefined) {
const configured = this.neurolink?.conversationMemory?.config?.replayToolSteps;
if (configured !== undefined) {
options.replayToolSteps = configured;
}
}
if (options.replayToolSteps !== "full") {
return;
}
const declaredTools = options.tools;
const declaresNoTools = options.disableTools === true ||
(declaredTools !== undefined &&
declaredTools !== null &&
typeof declaredTools === "object" &&
Object.keys(declaredTools).length === 0);
if (declaresNoTools) {
logger.debug(`[${this.providerName}] replayToolSteps "full" degraded to "marker": the request declares no tools`);
options.replayToolSteps = "marker";
}
}
/**
* 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) {
this.applyToolReplayDefault(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
const otelSpan = tracers.provider.startSpan("provider.generate", {
kind: SpanKind.CLIENT,
attributes: {
[ATTR.NL_MODEL]: this.modelName || options.model || "unknown",
[ATTR.NL_PROVIDER]: this.providerName || "unknown",
},
});
const otelSpanState = { ended: false };
return await 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();
}
}
}
/**
* Mode 1 — synthesize the input text itself, with no LLM call.
*
* This is the documented default for `generate({ tts: { enabled: true } })`
* and what every "speak this text" caller relies on, so a request that
* looks like it wanted the model's reply (tools, a system prompt, message
* history, a schema, conversation memory) is warned about, never rejected:
* those options have no effect here, and the fix is `tts.mode: "response"`.
*/
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);
}
this.warnIfDirectTTSLooksLikeResponseRequest(options);
const ttsStartTime = Date.now();
const ttsProvider = ttsOptions.provider ?? options.provider ?? this.providerName;
const ttsTimeout = this.resolveTTSSynthesisTimeout(options, ttsOptions, ttsProvider);
const speechText = this.prepareTTSInput(textToSynthesize, ttsOptions);
try {
baseResult.audio = await this.runTTSSynthesis(speechText, ttsProvider, ttsOptions, ttsTimeout, options.abortSignal);
baseResult.ttsMetadata = {
attempted: true,
success: true,
mode: "direct",
latency: Date.now() - ttsStartTime,
};
}
catch (ttsError) {
const latency = Date.now() - ttsStartTime;
const error = this.getTTSErrorDetails(ttsError);
baseResult.ttsMetadata = {
attempted: true,
success: false,
mode: "direct",
error,
latency,
};
logger.error(`TTS synthesis failed in Mode 1 (direct input synthesis):`, ttsError);
}
return this.enhanceResult(baseResult, options, startTime);
}
/**
* One warning when a direct-synthesis request carries options that only a
* model call would honour and the caller never chose a mode. Silence is
* the wrong answer here — the caller gets `usage` of zero and `content`
* echoing their prompt, and would otherwise have to guess why.
*
* Only options the CALLER supplied count. `conversationMemoryConfig` is
* attached by NeuroLink to every request on a memory-enabled instance, and
* on such an instance `conversationMessages` is the stored history
* NeuroLink injected itself — neither is something the caller set, and a
* voice bot with memory used to see this warning on every turn after the
* first for options it never passed.
*/
warnIfDirectTTSLooksLikeResponseRequest(options) {
const tts = options.tts;
if (!tts || tts.mode !== undefined || tts.useAiResponse !== undefined) {
return;
}
const memoryAttached = options.conversationMemoryConfig !== undefined;
const llmShaped = [];
if (options.tools && Object.keys(options.tools).length > 0) {
llmShaped.push("tools");
}
if (options.systemPrompt) {
llmShaped.push("systemPrompt");
}
if (!memoryAttached &&
options.conversationMessages &&
options.conversationMessages.length > 0) {
llmShaped.push("conversationMessages");
}
if (options.conversationHistory && options.conversationHistory.length > 0) {
llmShaped.push("conversationHistory");
}
if (options.schema) {
llmShaped.push("schema");
}
if (llmShaped.length === 0) {
return;
}
logger.warn(`[${this.providerName}] tts.enabled without tts.mode synthesizes the INPUT text and makes no model call, so ${llmShaped.join(", ")} will be ignored. Set tts.mode: "response" to speak the model's reply, or tts.mode: "direct" to silence this warning.`);
}
/**
* Outer bound on one synthesis call: the provider's request timeout, raised
* to whatever the handler itself may legitimately spend — `timeoutMs × (1 +
* retries)` plus retry pauses, with the handler defaults (30 s, one retry)
* standing in for a knob the caller left unset. Without the raise, a
* handler's own retry loop could be cut off from outside one attempt in:
* with the 60 s provider default, a first attempt that hung to its 30 s
* abort left no room for the documented default retry.
*/
resolveTTSSynthesisTimeout(options, ttsOptions, ttsProvider) {
return Math.max(this.getTimeout(options), resolveTTSHandlerBudgetMs(ttsOptions, ttsProvider));
}
/**
* Apply `tts.sanitize` (off by default — existing callers see byte-identical
* input). Always runs when configured, regardless of the raw text's
* length against the provider's cap: sanitizing can shrink text a great
* deal (the default `codeBlocks: "phrase"` replaces a whole fenced block
* with one short phrase, `urls: "hostname"` shortens every link), so a
* reply that is over the cap raw can still fit after sanitizing — a coding
* answer with a large code block is exactly the reply this option is for.
* A reply still over the cap afterwards is rejected downstream by
* `TTSProcessor.synthesize` as `TTS_TEXT_TOO_LONG`, same as today.
*/
prepareTTSInput(text, ttsOptions) {
const sanitizeOptions = resolveSpeechSanitizeOptions(ttsOptions.sanitize);
if (!sanitizeOptions) {
return text;
}
return prepareTextForSpeech(text, sanitizeOptions);
}
/**
* One synthesis call under the outer budget. The handler receives a signal
* that fires when that budget lapses or the caller's own `abortSignal`
* does, so a handler with a retry loop (ElevenLabs) stops rather than
* pausing and issuing a further billable request after the caller has
* already been told the synthesis failed.
*/
async runTTSSynthesis(speechText, ttsProvider, ttsOptions, ttsTimeout, abortSignal) {
const cancel = new AbortController();
const signals = [cancel.signal];
if (abortSignal) {
signals.push(abortSignal);
}
if (ttsOptions.signal) {
signals.push(ttsOptions.signal);
}
const signal = AbortSignal.any(signals);
try {
return await withTimeoutFn(() => TTSProcessor.synthesize(speechText, ttsProvider, {
...ttsOptions,
signal,
}), ttsTimeout, `TTS synthesis timed out after ${ttsTimeout}ms for provider "${ttsProvider}"`);
}
finally {
cancel.abort();
}
}
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, toolExecutionSummaries) {
// 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);
await this.persistGenerateToolSteps(options, toolExecutionSummaries);
const synthesized = await this.synthesizeAIResponseIfNeeded(result, options);
return this.enhanceResult(synthesized, options, startTime);
}
/**
* Persist a generate() turn's tool steps into conversation memory.
*
* The stream loops store each step's calls and results as they run;
* generate() never did, so the same session held tool rows for streamed
* turns and none for generated ones, and a later turn could not replay
* what generate() had called. Every native generate path closes out
* through finalizeNativeGenerate, so this is the one place to do it.
*
* Gated on a real session id: the storage helper otherwise invents a
* `session-<nanoid>` key, and on the in-memory backend that creates a
* throwaway session per tool-using call — evicting real ones once
* `maxSessions` is reached. A turn with no session has no history to
* replay into, so there is nothing to persist.
*/
async persistGenerateToolSteps(options, summaries) {
if (!summaries || summaries.length === 0) {
return;
}
const sessionId = options.context?.sessionId ??
options.sessionId;
if (typeof sessionId !== "string" || sessionId.length === 0) {
return;
}
// One storage call per loop step, in step order. The stream loops store
// as each step finishes, so a step's calls and results sit together;
// storing a whole generate() turn as one batch replayed a sequential
// lookup-then-send as a single parallel step, with the second call's
// arguments appearing before the output they were built from.
//
// Summaries arrive in execution order, so a new batch starts whenever
// the step changes rather than grouping by stepIndex value: a provider
// can run the native loop more than once against the same summaries
// array (a response_format rejection retry, a schema-validation retry),
// and each run numbers its own steps from 0 — grouping by value would
// merge run 1's step 0 with run 2's step 0 into one parallel batch.
const batches = [];
for (const summary of summaries) {
const last = batches[batches.length - 1];
if (last && last[0].stepIndex === summary.stepIndex) {
last.push(summary);
}
else {
batches.push([summary]);
}
}
for (const stepSummaries of batches) {
const toolCalls = stepSummaries.map((s) => ({
toolCallId: s.toolCallId,
toolName: s.toolName,
args: s.input !== null &&
typeof s.input === "object" &&
!Array.isArray(s.input)
? s.input
: {},
...(s.stepIndex !== undefined ? { stepIndex: s.stepIndex } : {}),
timestamp: s.startTime,
}));
const toolResults = stepSummaries.map((s) => ({
toolCallId: s.toolCallId,
toolName: s.toolName,
output: s.output,
...(s.error ? { error: s.error } : {}),
...(s.stepIndex !== undefined ? { stepIndex: s.stepIndex } : {}),
timestamp: s.endTime,
}));
try {
await this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date());
}
catch (error) {
logger.warn(`[${this.providerName}] Failed to store tool executions`, {
provider: this.providerName,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* 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 || resolveTTSMode(options.tts) !== "response") {
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,
mode: options.tts?.mode,
useAiResponse: options.tts?.useAiResponse,
},
reason: !aiResponse
? "AI response is empty or undefined"
: "Provider is missing",
});
return {
...enhancedResult,
ttsMetadata: {
attempted: false,
success: false,
mode: "response",
},
};
}
const ttsStartTime = Date.now();
const ttsTimeout = this.resolveTTSSynthesisTimeout(options, ttsOptions, ttsProvider);
const speechText = this.prepareTTSInput(aiResponse, ttsOptions);
try {
const ttsResult = await this.runTTSSynthesis(speechText, ttsProvider, ttsOptions, ttsTimeout, options.abortSignal);
return {
...enhancedResult,
audio: ttsResult,
ttsMetadata: {
attempted: true,
success: true,
mode: "response",
latency: Date.now() - ttsStartTime,
},
};
}
catch (ttsError) {
const latency = Date.now() - ttsStartTime;
const error = this.getTTSErrorDetails(ttsError);
logger.error(`TTS synthesis failed in Mode 2 (AI response synthesis):`, ttsError);
return {
...enhancedResult,
ttsMetadata: {
attempted: true,
success: false,
mode: "response",
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(#1953): 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));
}
/**
* Emit the start/end pair for a tool call a loop rejected before execute
* (unknown tool, schema rejection). See ToolsManager.emitRejectedToolCall.
*/
emitRejectedToolCall(toolName, error, toolCallId) {
this.toolsManager.emitRejectedToolCall(toolName, error, toolCallId);
}
/**
* 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;
}
}