@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
607 lines (606 loc) • 29.7 kB
JavaScript
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { NoOutputGeneratedError, Output, stepCountIs, streamText, } from "ai";
import { AIProviderName } from "../constants/enums.js";
import { BaseProvider } from "../core/baseProvider.js";
import { DEFAULT_MAX_STEPS } from "../core/constants.js";
import { streamAnalyticsCollector } from "../core/streamAnalytics.js";
import { createProxyFetch } from "../proxy/proxyFetch.js";
import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
import { isAbortError } from "../utils/errorHandling.js";
import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
import { logger } from "../utils/logger.js";
import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
import { getProviderModel } from "../utils/providerConfig.js";
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
import { resolveToolChoice } from "../utils/toolChoice.js";
// Constants
const MODELS_DISCOVERY_TIMEOUT_MS = 5000; // 5 seconds for model discovery
// Configuration helpers
const getOpenRouterConfig = () => {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable is required. " +
"Get your API key at https://openrouter.ai/keys");
}
return {
apiKey,
referer: process.env.OPENROUTER_REFERER,
appName: process.env.OPENROUTER_APP_NAME,
};
};
/**
* Returns the default model name for OpenRouter.
*
* OpenRouter uses a 'provider/model' format for model names.
* For example:
* - 'anthropic/claude-sonnet-4.5'
* - 'openai/gpt-4o'
* - 'google/gemini-2.5-flash'
* - 'meta-llama/llama-3-70b-instruct'
*
* The previous default `anthropic/claude-3-5-sonnet` was retired by OpenRouter
* in late 2025 and now returns "No endpoints found for model" for every
* caller. Default bumped to the current Anthropic mainline (Claude Sonnet
* 4.5) so callers without an `OPENROUTER_MODEL` env var don't hit a dead
* model. Must stay aligned with the registry default in
* `src/lib/factories/providerRegistry.ts` and `PROVIDER_DEFAULTS` in
* `src/lib/utils/modelChoices.ts`.
*
* You can override the default by setting the OPENROUTER_MODEL environment variable.
*/
const getDefaultOpenRouterModel = () => {
return getProviderModel("OPENROUTER_MODEL", "anthropic/claude-sonnet-4.5");
};
/**
* OpenRouter Provider - BaseProvider Implementation
* Provides access to 300+ models from 60+ providers via OpenRouter unified gateway
*/
export class OpenRouterProvider extends BaseProvider {
model;
openRouterClient;
config;
// Cache for available models to avoid repeated API calls
static modelsCache = [];
static modelsCacheTime = 0;
static MODELS_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
// Cache for model capabilities (which models support tools)
static toolCapableModels = new Set();
static capabilitiesCached = false;
constructor(modelName, sdk, _region, credentials) {
super(modelName, AIProviderName.OPENROUTER, sdk);
// Build config: prefer credentials over env vars to avoid throwing when env vars are absent
if (credentials?.apiKey) {
this.config = {
apiKey: credentials.apiKey,
referer: process.env.OPENROUTER_REFERER,
appName: process.env.OPENROUTER_APP_NAME,
};
}
else {
this.config = getOpenRouterConfig(); // throws if OPENROUTER_API_KEY missing
}
const config = this.config;
// Build headers for attribution on openrouter.ai/activity dashboard
const headers = {};
if (config.referer) {
headers["HTTP-Referer"] = config.referer;
}
if (config.appName) {
headers["X-Title"] = config.appName;
}
// Create OpenRouter client with optional attribution headers
this.openRouterClient = createOpenRouter({
apiKey: config.apiKey,
...(credentials?.baseURL ? { baseURL: credentials.baseURL } : {}),
...(Object.keys(headers).length > 0 && { headers }),
});
// Initialize model with OpenRouter client
// OpenRouterChatLanguageModel implements LanguageModelV3 which is part of the LanguageModel union
this.model = this.openRouterClient(this.modelName || getDefaultOpenRouterModel());
logger.debug("OpenRouter Provider initialized", {
modelName: this.modelName,
provider: this.providerName,
});
}
getProviderName() {
return AIProviderName.OPENROUTER;
}
getDefaultModel() {
return getDefaultOpenRouterModel();
}
/**
* Returns the Vercel AI SDK model instance for OpenRouter
*/
getAISDKModel() {
return this.model;
}
formatProviderError(error) {
if (error instanceof TimeoutError) {
return new NetworkError(`Request timed out: ${error.message}`, "openrouter");
}
// Check for timeout by error name and message as fallback
const errorRecord = error;
if (errorRecord?.name === "TimeoutError" ||
(typeof errorRecord?.message === "string" &&
errorRecord.message.includes("Timeout"))) {
return new NetworkError(`Request timed out: ${errorRecord?.message || "Unknown timeout"}`, "openrouter");
}
if (typeof errorRecord?.message === "string") {
if (errorRecord.message.includes("ECONNREFUSED") ||
errorRecord.message.includes("Failed to fetch")) {
return new NetworkError("OpenRouter API not available. Please check your network connection and try again.", "openrouter");
}
if (errorRecord.message.includes("API_KEY_INVALID") ||
errorRecord.message.includes("Invalid API key") ||
errorRecord.message.includes("invalid_api_key") ||
errorRecord.message.includes("Unauthorized")) {
return new AuthenticationError("Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY environment variable. " +
"Get your key at https://openrouter.ai/keys", "openrouter");
}
if (errorRecord.message.includes("rate limit")) {
return new RateLimitError("OpenRouter rate limit exceeded. Please try again later or upgrade your account at https://openrouter.ai/credits", "openrouter");
}
if (errorRecord.message.includes("model") &&
errorRecord.message.includes("not found")) {
return new InvalidModelError(`Model '${this.modelName}' not available on OpenRouter. ` +
"Browse available models at https://openrouter.ai/models", "openrouter");
}
if (errorRecord.message.includes("insufficient_credits")) {
return new ProviderError("Insufficient OpenRouter credits. Add credits at https://openrouter.ai/credits", "openrouter");
}
// "No endpoints found" — model temporarily unavailable or unsupported parameters
// This is distinct from tool errors: it can happen on any request when the
// model has no available providers on OpenRouter (e.g., free-tier model down).
if (errorRecord.message.includes("No endpoints found")) {
return new InvalidModelError(`No endpoints found for model '${this.modelName}' on OpenRouter. ` +
"The model may be temporarily unavailable or does not support the requested parameters. " +
"Try a different model or check availability at https://openrouter.ai/models", "openrouter");
}
// Tool/function calling errors
if (errorRecord.message.includes("tool use") ||
errorRecord.message.includes("tool_use") ||
errorRecord.message.includes("function_call") ||
errorRecord.message.includes("tools are not supported")) {
return new ProviderError(`Model '${this.modelName}' does not support tool calling. ` +
"Use a tool-capable model like:\n" +
" • google/gemini-2.0-flash-exp:free (free)\n" +
" • meta-llama/llama-3.3-70b-instruct:free (free)\n" +
" • anthropic/claude-3.7-sonnet (paid)\n" +
" • openai/gpt-4o (paid)\n" +
"Or use --disableTools flag. " +
"See all tool-capable models at https://openrouter.ai/models?supported_parameters=tools", "openrouter");
}
}
return new ProviderError(`OpenRouter error: ${errorRecord?.message || "Unknown error"}`, "openrouter");
}
/**
* OpenRouter supports tools for compatible models
* Checks cached model capabilities or uses known patterns as fallback
*/
supportsTools() {
const modelName = this.modelName || getDefaultOpenRouterModel();
// If we have cached capabilities, use them
if (OpenRouterProvider.capabilitiesCached) {
const supported = OpenRouterProvider.toolCapableModels.has(modelName);
logger.debug("OpenRouter: Tool support check (cached)", {
model: modelName,
supportsTools: supported,
});
return supported;
}
// Fallback: Known tool-capable model patterns (conservative list)
const knownToolCapablePatterns = [
"anthropic/claude",
"openai/gpt-4",
"openai/gpt-3.5",
"openai/o1",
"openai/o3",
"openai/o4",
"google/gemini",
"google/gemma-3",
"mistralai/mistral-large",
"mistralai/mistral-small",
"mistralai/devstral",
"meta-llama/llama-3.3",
"meta-llama/llama-3.2",
"qwen/qwen3",
"nvidia/nemotron",
];
const isKnownCapable = knownToolCapablePatterns.some((pattern) => modelName.toLowerCase().includes(pattern.toLowerCase()));
if (isKnownCapable) {
logger.debug("OpenRouter: Tool support enabled (pattern match)", {
model: modelName,
});
return true;
}
// For unknown models, warn and disable tools (safe default)
logger.warn("OpenRouter: Unknown model tool capability, disabling tools", {
model: modelName,
suggestion: "Use a known tool-capable model like anthropic/claude-3.7-sonnet, openai/gpt-4o, or google/gemini-2.0-flash-exp:free",
});
return false;
}
/**
* Provider-specific streaming implementation
* Note: This is only used when tools are disabled
*/
async executeStream(options, analysisSchema) {
this.validateStreamOptions(options);
const startTime = Date.now();
let chunkCount = 0; // Track chunk count for debugging
// Reviewer follow-up: capture upstream provider errors via onError so
// the post-stream NoOutput detect can propagate the *real* cause
// (e.g. content_filter, provider crash) into the sentinel's
// providerError / modelResponseRaw instead of the AI SDK's generic
// "No output generated" message.
let capturedProviderError;
const timeout = this.getTimeout(options);
const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
try {
// Build message array from options with multimodal support
// Using protected helper from BaseProvider to eliminate code duplication
const messages = await this.buildMessagesForStream(options);
const model = await this.getAISDKModelWithMiddleware(options);
// Get all available tools (direct + MCP + external) for streaming
// BaseProvider.stream() pre-merges base tools + external tools into options.tools
const shouldUseTools = !options.disableTools && this.supportsTools();
const tools = shouldUseTools
? options.tools || (await this.getAllTools())
: {};
logger.debug(`OpenRouter: Tools for streaming`, {
shouldUseTools,
toolCount: Object.keys(tools).length,
toolNames: Object.keys(tools),
});
// Build complete stream options with proper typing
// Note: maxRetries set to 0 for OpenRouter free tier to prevent SDK's quick retries
// from consuming rate limits. Our test suite handles retries with appropriate delays.
let streamOptions = {
model: model,
messages: messages,
temperature: options.temperature,
maxRetries: 0, // Disable SDK retries - let caller handle rate limit retries with delays
// AI SDK v6 renamed `maxTokens` to `maxOutputTokens` — using the old
// name here is a silent no-op, so OpenRouter sees no cap and applies
// the model's full output max (typically 64K+ tokens) to its pre-bill
// affordability check. That trips "This request requires more credits"
// even on cheap models when the account balance is low.
...(options.maxTokens && { maxOutputTokens: options.maxTokens }),
...(shouldUseTools &&
Object.keys(tools).length > 0 && {
tools,
toolChoice: resolveToolChoice(options, tools, shouldUseTools),
stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS),
}),
abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
experimental_repairToolCall: this.getToolCallRepairFn(options),
onError: (event) => {
const error = event.error;
const errorMessage = error instanceof Error ? error.message : String(error);
// Reviewer follow-up: propagate the captured error to the
// post-stream NoOutput sentinel so telemetry sees the real
// provider cause instead of "No output generated".
capturedProviderError = error;
logger.error(`OpenRouter: Stream error`, {
provider: this.providerName,
modelName: this.modelName,
error: errorMessage,
chunkCount,
});
},
onFinish: (event) => {
logger.debug(`OpenRouter: Stream finished`, {
finishReason: event.finishReason,
totalChunks: chunkCount,
});
},
onChunk: () => {
chunkCount++;
},
onStepFinish: ({ toolCalls, toolResults }) => {
emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults);
logger.info("Tool execution completed", {
toolCallCount: toolCalls?.length || 0,
toolResultCount: toolResults?.length || 0,
toolNames: toolCalls?.map((tc) => tc.toolName),
});
this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
logger.warn("OpenRouterProvider: Failed to store tool executions", {
provider: this.providerName,
error: error instanceof Error ? error.message : String(error),
});
});
},
};
// Add analysisSchema support if provided
if (analysisSchema) {
try {
streamOptions = {
...streamOptions,
experimental_output: Output.object({
schema: analysisSchema,
}),
};
}
catch (error) {
logger.warn("Schema application failed, continuing without schema", {
error: String(error),
});
}
}
const result = await streamText(streamOptions);
// Guard against NoOutputGeneratedError becoming an unhandled rejection.
Promise.resolve(result.text)
.catch((err) => {
logger.debug("Stream text promise rejected (expected for empty streams)", {
error: err instanceof Error ? err.message : String(err),
});
})
.finally(() => timeoutController?.cleanup());
// Transform stream to content object stream using fullStream (handles both text and tool calls)
const transformedStream = (async function* () {
// Reviewer follow-up: gate the post-stream NoOutput detect on
// *content yielded*, not raw chunk count. AI SDK fullStream emits
// control events ({ type: "start" }, "step-start", etc.) before
// any text-delta — those incremented `chunkCount` and made the
// post-stream check dead even when zero text was produced.
let contentYielded = 0;
try {
// Try fullStream first (handles both text and tool calls), fallback to textStream
const streamToUse = result.fullStream || result.textStream;
for await (const chunk of streamToUse) {
// Handle different chunk types from fullStream
if (chunk && typeof chunk === "object") {
// Check for error chunks first (critical error handling)
if ("type" in chunk && chunk.type === "error") {
const errorChunk = chunk;
logger.error(`OpenRouter: Error chunk received:`, {
errorType: errorChunk.type,
errorDetails: errorChunk.error,
});
throw new Error(`OpenRouter streaming error: ${errorChunk.error?.message ||
"Unknown error"}`);
}
if ("textDelta" in chunk) {
// Text delta from fullStream
const textDelta = chunk.textDelta;
if (textDelta) {
contentYielded++;
yield { content: textDelta };
}
}
else if ("type" in chunk &&
chunk.type === "tool-call" &&
"toolCallId" in chunk) {
// Tool call event - log for debugging
const toolCallId = String(chunk.toolCallId);
const toolName = "toolName" in chunk ? String(chunk.toolName) : "unknown";
logger.debug("OpenRouter: Tool call", {
toolCallId,
toolName,
});
}
}
else if (typeof chunk === "string") {
// Direct string chunk from textStream fallback
contentYielded++;
yield { content: chunk };
}
}
}
catch (streamError) {
if (NoOutputGeneratedError.isInstance(streamError)) {
logger.warn("OpenRouter: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
const sentinel = await buildNoOutputSentinel(streamError, result, capturedProviderError);
stampNoOutputSpan(sentinel);
yield sentinel;
return;
}
throw streamError;
}
// Curator P3-6 (round-2 fix): production trigger comes through
// result.finishReason rejection, not textStream throws.
if (contentYielded === 0) {
const detected = await detectPostStreamNoOutput(result, capturedProviderError);
if (detected) {
logger.warn("OpenRouter: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
stampNoOutputSpan(detected.sentinel);
yield detected.sentinel;
}
}
})();
// Create analytics promise that resolves after stream completion
const analyticsPromise = streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, result, Date.now() - startTime, {
requestId: `openrouter-stream-${Date.now()}`,
streamingMode: true,
});
return {
stream: transformedStream,
provider: this.providerName,
model: this.modelName,
analytics: analyticsPromise,
metadata: {
startTime,
streamId: `openrouter-${Date.now()}`,
},
};
}
catch (error) {
timeoutController?.cleanup();
throw this.handleProviderError(error);
}
}
/**
* Get available models from OpenRouter API
* Dynamically fetches from /api/v1/models endpoint with caching and fallback
*/
async getAvailableModels() {
const functionTag = "OpenRouterProvider.getAvailableModels";
const now = Date.now();
// Check if cached models are still valid
if (OpenRouterProvider.modelsCache.length > 0 &&
now - OpenRouterProvider.modelsCacheTime <
OpenRouterProvider.MODELS_CACHE_DURATION) {
logger.debug(`[${functionTag}] Using cached models`, {
cacheAge: Math.round((now - OpenRouterProvider.modelsCacheTime) / 1000),
modelCount: OpenRouterProvider.modelsCache.length,
});
return OpenRouterProvider.modelsCache;
}
// Try to fetch models dynamically
try {
const dynamicModels = await this.fetchModelsFromAPI();
if (dynamicModels.length > 0) {
// Cache successful result
OpenRouterProvider.modelsCache = dynamicModels;
OpenRouterProvider.modelsCacheTime = now;
logger.debug(`[${functionTag}] Successfully fetched models from API`, {
modelCount: dynamicModels.length,
});
return dynamicModels;
}
}
catch (error) {
logger.warn(`[${functionTag}] Failed to fetch models from API, using fallback`, {
error: error instanceof Error ? error.message : String(error),
});
}
// Fallback to hardcoded list if API fetch fails. Aligned with
// `getDefaultOpenRouterModel()` — `anthropic/claude-3-5-sonnet` was
// retired by OpenRouter late 2025 and would return a dead model here.
const fallbackModels = [
// Anthropic Claude models
"anthropic/claude-3.7-sonnet",
"anthropic/claude-3-5-haiku",
"anthropic/claude-3-opus",
// OpenAI models
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-4-turbo",
// Google models
"google/gemini-2.0-flash",
"google/gemini-1.5-pro",
// Meta Llama models
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-8b-instruct",
// Mistral models
"mistralai/mistral-large",
"mistralai/mixtral-8x7b-instruct",
];
logger.debug(`[${functionTag}] Using fallback model list`, {
modelCount: fallbackModels.length,
});
return fallbackModels;
}
/**
* Fetch available models from OpenRouter API /api/v1/models endpoint
* @private
*/
async fetchModelsFromAPI() {
const functionTag = "OpenRouterProvider.fetchModelsFromAPI";
const config = this.config;
const modelsUrl = "https://openrouter.ai/api/v1/models";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS);
try {
logger.debug(`[${functionTag}] Fetching models from ${modelsUrl}`);
const proxyFetch = createProxyFetch();
const response = await proxyFetch(modelsUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Parse OpenRouter models response with type guard
if (!this.isValidModelsResponse(data)) {
throw new Error("Invalid response format: expected data.data array");
}
const models = data.data
.map((model) => model.id)
.filter((id) => typeof id === "string" && id.length > 0)
.sort();
logger.debug(`[${functionTag}] Successfully parsed models`, {
totalModels: models.length,
sampleModels: models.slice(0, 5),
});
return models;
}
catch (error) {
clearTimeout(timeoutId);
if (isAbortError(error)) {
throw new Error(`Request timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000} seconds`, { cause: error });
}
throw error;
}
}
/**
* Type guard to validate the models API response structure
* @private
*/
isValidModelsResponse(data) {
return (data !== null &&
typeof data === "object" &&
"data" in data &&
Array.isArray(data.data));
}
/**
* Fetch and cache model capabilities from OpenRouter API
* Call this to enable accurate tool support detection
*/
async cacheModelCapabilities() {
const functionTag = "OpenRouterProvider.cacheModelCapabilities";
if (OpenRouterProvider.capabilitiesCached) {
return; // Already cached
}
try {
const config = this.config;
const modelsUrl = "https://openrouter.ai/api/v1/models";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS);
const proxyFetch = createProxyFetch();
const response = await proxyFetch(modelsUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!this.isValidModelsResponse(data)) {
throw new Error("Invalid response format");
}
// Extract tool-capable models
const toolCapable = new Set();
for (const model of data.data) {
if (model.id && model.supported_parameters?.includes("tools")) {
toolCapable.add(model.id);
}
}
OpenRouterProvider.toolCapableModels = toolCapable;
OpenRouterProvider.capabilitiesCached = true;
logger.debug(`[${functionTag}] Cached model capabilities`, {
totalModels: data.data.length,
toolCapableCount: toolCapable.size,
});
}
catch (error) {
logger.warn(`[${functionTag}] Failed to cache capabilities, using fallback patterns`, {
error: error instanceof Error ? error.message : String(error),
});
// Don't set capabilitiesCached - let it use fallback patterns
}
}
}