UNPKG

@juspay/neurolink

Version:

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

403 lines (402 loc) • 18.7 kB
import { AIProviderName } from "../../constants/enums.js"; import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js"; import { createProxyFetch } from "../../proxy/proxyFetch.js"; import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js"; import { isAbortError } from "../../utils/errorHandling.js"; import { logger } from "../../utils/logger.js"; import { redactUrlCredentials } from "../../utils/logSanitize.js"; import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js"; import { stripTrailingSlash } from "../openaiChatCompletionsClient.js"; import { getDefaultOpenRouterModel } from "./utils.js"; // OpenRouter's OpenAI-compatible gateway. `${baseURL}/chat/completions` and // `${baseURL}/models` both resolve correctly off this root. const OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"; const MODELS_DISCOVERY_TIMEOUT_MS = 5000; // 5 seconds for model discovery const getOpenRouterApiKey = () => { 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; }; /** * OpenRouter Provider — direct HTTP, no AI SDK. * * OpenAI-compatible unified gateway to 300+ models from 60+ providers. All * request/stream/tool-loop orchestration lives in * `OpenAIChatCompletionsProvider`; this class declares configuration plus the * OpenRouter-specific behaviour: * * 1. Attribution headers — optional `HTTP-Referer` / `X-Title` (from * `OPENROUTER_REFERER` / `OPENROUTER_APP_NAME`) are merged into every * request via `getAuthHeaders` so usage shows up on the openrouter.ai * activity dashboard. * 2. Per-model tool gating — OpenRouter proxies many models with varying * tool support, so `supportsTools()` consults a cached capability set * (populated by `cacheModelCapabilities()`) and falls back to a * conservative known-capable pattern list. * 3. Dynamic model discovery — `getAvailableModels()` fetches the live * `/models` list (10-minute cache) with a hardcoded fallback. * * @see https://openrouter.ai/docs */ export class OpenRouterProvider extends OpenAIChatCompletionsProvider { referer; appName; // 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) { // Trim the override before applying precedence. A blank/whitespace // `credentials.apiKey` must NOT bypass `getOpenRouterApiKey()` — that // would build a client with an unusable bearer token and fail at request // time with a confusing 401 instead of at construction time. const overrideApiKey = credentials?.apiKey?.trim(); const apiKey = overrideApiKey && overrideApiKey.length > 0 ? overrideApiKey : getOpenRouterApiKey(); // Treat blank/whitespace overrides as unset so an empty // `credentials.baseURL` or `OPENROUTER_BASE_URL=` cannot silently override // the default with "" (mirrors the apiKey precedence above). const baseURL = credentials?.baseURL?.trim() || process.env.OPENROUTER_BASE_URL?.trim() || OPENROUTER_DEFAULT_BASE_URL; super(AIProviderName.OPENROUTER, modelName, sdk, { baseURL, apiKey }); // Trim attribution values so whitespace-only env vars are not sent as // empty HTTP-Referer / X-Title headers. this.referer = process.env.OPENROUTER_REFERER?.trim() || undefined; this.appName = process.env.OPENROUTER_APP_NAME?.trim() || undefined; logger.debug("OpenRouter Provider initialized", { modelName: this.modelName, providerName: this.providerName, baseURL: redactUrlCredentials(this.config.baseURL), }); } // =========================================================================== // Abstract hooks (required) // =========================================================================== getProviderName() { return AIProviderName.OPENROUTER; } getDefaultModel() { return getDefaultOpenRouterModel(); } formatProviderError(error) { const rules = [ // Duck-typed timeout detection (name === "TimeoutError" OR message // contains "Timeout", case-sensitive — matches the original's // `.includes("Timeout")`) distinct from the `instanceof TimeoutError` // check classifyProviderError already performs first. { match: (ctx) => ctx.errorName === "TimeoutError" || /Timeout/.test(ctx.message), errorClass: NetworkError, message: (ctx) => `Request timed out: ${ctx.message}`, }, { match: (ctx) => /ECONNREFUSED|Failed to fetch/.test(ctx.message), errorClass: NetworkError, message: "OpenRouter API not available. Please check your network connection and try again.", }, { match: (ctx) => /API_KEY_INVALID|Invalid API key|invalid_api_key|Unauthorized/.test(ctx.message), errorClass: AuthenticationError, message: "Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY environment variable. " + "Get your key at https://openrouter.ai/keys", }, { match: (ctx) => /rate limit/.test(ctx.message), errorClass: RateLimitError, message: "OpenRouter rate limit exceeded. Please try again later or upgrade your account at https://openrouter.ai/credits", }, { match: (ctx) => /model/.test(ctx.message) && /not found/.test(ctx.message), errorClass: InvalidModelError, message: () => `Model '${this.modelName}' not available on OpenRouter. ` + "Browse available models at https://openrouter.ai/models", }, { match: (ctx) => /insufficient_credits/.test(ctx.message), errorClass: ProviderError, message: "Insufficient OpenRouter credits. Add credits at https://openrouter.ai/credits", }, // "No endpoints found" — model temporarily unavailable or unsupported // parameters. Distinct from tool errors: it can happen on any request // when the model has no available providers on OpenRouter. { match: (ctx) => /No endpoints found/.test(ctx.message), errorClass: InvalidModelError, message: () => `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", }, // Tool/function calling errors { match: (ctx) => /tool use|tool_use|function_call|tools are not supported/.test(ctx.message), errorClass: ProviderError, message: () => `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", }, ...DEFAULT_ERROR_RULES, ]; return classifyProviderError(error, rules, this.providerName, this.modelName); } // =========================================================================== // Optional hooks — provider-specific quirks // =========================================================================== getFallbackModelName() { return getDefaultOpenRouterModel(); } /** * Attribution headers are merged into every request alongside the bearer * token so OpenRouter can attribute usage on its activity dashboard. */ getAuthHeaders() { const headers = { ...super.getAuthHeaders() }; if (this.referer) { headers["HTTP-Referer"] = this.referer; } if (this.appName) { headers["X-Title"] = this.appName; } return headers; } /** * OpenRouter proxies models with varying tool support. Use cached * capabilities when available (populated by `cacheModelCapabilities()`), * otherwise fall back to a conservative known-capable pattern list and * disable tools for unknown models. */ 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", "meta-llama/llama-3.1", "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; } // =========================================================================== // Model discovery (OpenRouter /models endpoint) // =========================================================================== /** * Models/capabilities endpoint, derived from the configured `baseURL` so a * custom OpenRouter-compatible gateway is honoured for discovery too. */ getModelsUrl() { return `${stripTrailingSlash(this.config.baseURL)}/models`; } /** * Get available models from the OpenRouter `/models` endpoint, with a * 10-minute cache and a hardcoded fallback when the fetch fails. */ 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) { 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-sonnet-4.5` is the // current default and avoids the retired model. 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-2.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 the OpenRouter `/models` endpoint. * @private */ async fetchModelsFromAPI() { const functionTag = "OpenRouterProvider.fetchModelsFromAPI"; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS); try { const modelsUrl = this.getModelsUrl(); logger.debug(`[${functionTag}] Fetching models from ${modelsUrl}`); const proxyFetch = createProxyFetch(); const response = await proxyFetch(modelsUrl, { method: "GET", headers: { ...this.getAuthHeaders(), "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 the OpenRouter `/models` endpoint. * Call this to enable accurate per-model tool support detection. */ async cacheModelCapabilities() { const functionTag = "OpenRouterProvider.cacheModelCapabilities"; if (OpenRouterProvider.capabilitiesCached) { return; // Already cached } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), MODELS_DISCOVERY_TIMEOUT_MS); const proxyFetch = createProxyFetch(); const response = await proxyFetch(this.getModelsUrl(), { method: "GET", headers: { ...this.getAuthHeaders(), "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 } } }