UNPKG

@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

342 lines 16.6 kB
import { createOpenAI } from "@ai-sdk/openai"; import { NoOutputGeneratedError, stepCountIs, streamText, } from "ai"; 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 { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js"; import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; import { toAnalyticsStreamResult } from "./providerTypeUtils.js"; // Constants const FALLBACK_OPENAI_COMPATIBLE_MODEL = "gpt-3.5-turbo"; // Configuration helpers const getOpenAICompatibleConfig = () => { const baseURL = process.env.OPENAI_COMPATIBLE_BASE_URL; const apiKey = process.env.OPENAI_COMPATIBLE_API_KEY; if (!baseURL) { throw new Error("OPENAI_COMPATIBLE_BASE_URL environment variable is required. " + "Please set it to your OpenAI-compatible endpoint (e.g., https://api.openrouter.ai/api/v1)"); } if (!apiKey) { throw new Error("OPENAI_COMPATIBLE_API_KEY environment variable is required. " + "Please set it to your API key for the OpenAI-compatible service."); } return { baseURL, apiKey, }; }; /** * Returns the default model name for OpenAI Compatible endpoints. * * Returns undefined if no model is specified via OPENAI_COMPATIBLE_MODEL environment variable, * which triggers auto-discovery from the /v1/models endpoint. */ const getDefaultOpenAICompatibleModel = () => { return process.env.OPENAI_COMPATIBLE_MODEL || undefined; }; // ModelsResponse type now imported from ../types/providerSpecific.js /** * OpenAI Compatible Provider - BaseProvider Implementation * Provides access to one of the OpenAI-compatible endpoint (OpenRouter, vLLM, LiteLLM, etc.) */ export class OpenAICompatibleProvider extends BaseProvider { model; config; discoveredModel; customOpenAI; constructor(modelName, sdk, _region, credentials) { super(modelName, "openai-compatible", sdk); // Build config: prefer credentials over env vars to avoid throwing when env vars are absent if (credentials?.apiKey && credentials?.baseURL) { this.config = { apiKey: credentials.apiKey, baseURL: credentials.baseURL, }; } else { const envConfig = getOpenAICompatibleConfig(); // throws if env vars missing this.config = { apiKey: credentials?.apiKey ?? envConfig.apiKey, baseURL: credentials?.baseURL ?? envConfig.baseURL, }; } // Create OpenAI SDK instance configured for custom endpoint // This allows us to use OpenAI-compatible API by simply changing the baseURL this.customOpenAI = createOpenAI({ baseURL: this.config.baseURL, apiKey: this.config.apiKey, fetch: createProxyFetch(), }); logger.debug("OpenAI Compatible Provider initialized", { modelName: this.modelName, provider: this.providerName, baseURL: this.config.baseURL, }); } getProviderName() { return "openai-compatible"; } getDefaultModel() { // Return empty string when no model is explicitly configured to enable auto-discovery return getDefaultOpenAICompatibleModel() || ""; } /** * Returns the Vercel AI SDK model instance for OpenAI Compatible endpoints * Handles auto-discovery if no model was specified */ async getAISDKModel() { // If model instance doesn't exist yet, create it if (!this.model) { let modelToUse; // Check if a model was explicitly specified via constructor or env var const explicitModel = this.modelName || getDefaultOpenAICompatibleModel(); // Treat empty string as no model specified (trigger auto-discovery) if (explicitModel && explicitModel.trim() !== "") { // Use the explicitly specified model modelToUse = explicitModel; logger.debug(`Using specified model: ${modelToUse}`); } else { // No model specified, auto-discover from endpoint try { const availableModels = await this.getAvailableModels(); if (availableModels.length > 0) { this.discoveredModel = availableModels[0]; modelToUse = this.discoveredModel; logger.info(`🔍 Auto-discovered model: ${modelToUse} from ${availableModels.length} available models`); } else { // Fall back to a common default if no models discovered modelToUse = FALLBACK_OPENAI_COMPATIBLE_MODEL; logger.warn(`No models discovered, using fallback: ${modelToUse}`); } } catch (error) { logger.warn("Model auto-discovery failed, using fallback:", error); modelToUse = FALLBACK_OPENAI_COMPATIBLE_MODEL; } } // Create the model instance this.model = this.customOpenAI(modelToUse); } return this.model; } formatProviderError(error) { if (error instanceof TimeoutError) { return new NetworkError(`Request timed out: ${error.message}`, "openai-compatible"); } // 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"}`, "openai-compatible"); } if (typeof errorRecord?.message === "string") { if (errorRecord.message.includes("ECONNREFUSED") || errorRecord.message.includes("Failed to fetch")) { return new NetworkError(`OpenAI Compatible endpoint not available. Please check your OPENAI_COMPATIBLE_BASE_URL: ${this.config.baseURL}`, "openai-compatible"); } if (errorRecord.message.includes("API_KEY_INVALID") || errorRecord.message.includes("Invalid API key") || errorRecord.message.includes("Unauthorized")) { return new AuthenticationError("Invalid OpenAI Compatible API key. Please check your OPENAI_COMPATIBLE_API_KEY environment variable.", "openai-compatible"); } if (errorRecord.message.includes("rate limit")) { return new RateLimitError("OpenAI Compatible rate limit exceeded. Please try again later.", "openai-compatible"); } if (errorRecord.message.includes("model") && (errorRecord.message.includes("not found") || errorRecord.message.includes("does not exist"))) { return new InvalidModelError(`Model '${this.modelName}' not available on OpenAI Compatible endpoint. ` + "Please check available models or use getAvailableModels() to see supported models.", "openai-compatible"); } } return new ProviderError(`OpenAI Compatible error: ${errorRecord?.message || "Unknown error"}`, "openai-compatible"); } /** * OpenAI Compatible endpoints support tools for compatible models */ supportsTools() { return true; } /** * Provider-specific streaming implementation * Note: This is only used when tools are disabled */ async executeStream(options, _analysisSchema) { this.validateStreamOptions(options); const startTime = Date.now(); const timeout = this.getTimeout(options); const timeoutController = createTimeoutController(timeout, this.providerName, "stream"); try { // Get tools - options.tools is pre-merged by BaseProvider.stream() const shouldUseTools = !options.disableTools && this.supportsTools(); const tools = shouldUseTools ? options.tools || (await this.getAllTools()) : {}; // 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); // This is where network connection happens! // Reviewer follow-up: capture upstream provider errors via onError // so the post-stream NoOutput detect can propagate the real cause // into the sentinel's providerError / modelResponseRaw. let capturedProviderError; const result = streamText({ model, messages: messages, ...(options.maxTokens !== null && options.maxTokens !== undefined ? { maxOutputTokens: options.maxTokens } : {}), ...(options.temperature !== null && options.temperature !== undefined ? { temperature: options.temperature } : {}), 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) => { capturedProviderError = event.error; logger.error("OpenAI-compatible: Stream error", { error: event.error instanceof Error ? event.error.message : String(event.error), }); }, onStepFinish: (event) => { emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), event.toolResults); this.handleToolExecutionStorage([...event.toolCalls], [...event.toolResults], options, new Date()).catch((error) => { logger.warn("[OpenAiCompatibleProvider] Failed to store tool executions", { provider: this.providerName, error: error instanceof Error ? error.message : String(error), }); }); }, }); timeoutController?.cleanup(); // Transform stream to match StreamResult interface const transformedStream = async function* () { let chunkCount = 0; try { for await (const chunk of result.textStream) { chunkCount++; yield { content: chunk }; } } catch (streamError) { // AI SDK v6 *can* throw NoOutputGeneratedError from textStream // iteration in some failure modes (e.g. catastrophic transform // errors); keep this catch as a defensive path. if (NoOutputGeneratedError.isInstance(streamError)) { logger.warn("OpenAI-compatible: 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): the production trigger doesn't // throw from textStream — AI SDK rejects `result.finishReason` // instead. Surface that rejection here so the enriched sentinel // actually fires for real-world no-output streams. if (chunkCount === 0) { const detected = await detectPostStreamNoOutput(result, capturedProviderError); if (detected) { logger.warn("OpenAI-compatible: 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, toAnalyticsStreamResult(result), Date.now() - startTime, { requestId: `openai-compatible-stream-${Date.now()}`, streamingMode: true, }); return { stream: transformedStream(), provider: this.providerName, model: this.modelName, analytics: analyticsPromise, metadata: { startTime, streamId: `openai-compatible-${Date.now()}`, }, }; } catch (error) { timeoutController?.cleanup(); throw this.handleProviderError(error); } } /** * Get available models from OpenAI Compatible endpoint * * Fetches from the /v1/models endpoint to discover available models. * This is useful for auto-discovery when no model is specified. */ async getAvailableModels() { try { const modelsUrl = new URL("/v1/models", this.config.baseURL).toString(); logger.debug(`Fetching available models from: ${modelsUrl}`); const proxyFetch = createProxyFetch(); const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 5000); const response = await proxyFetch(modelsUrl, { headers: { Authorization: `Bearer ${this.config.apiKey}`, "Content-Type": "application/json", }, signal: controller.signal, }); clearTimeout(t); if (!response.ok) { logger.warn(`Models endpoint returned ${response.status}: ${response.statusText}`); return this.getFallbackModels(); } const data = await response.json(); if (!data.data || !Array.isArray(data.data)) { logger.warn("Invalid models response format"); return this.getFallbackModels(); } const models = data.data.map((model) => model.id).filter(Boolean); logger.debug(`Discovered ${models.length} models:`, models); return models.length > 0 ? models : this.getFallbackModels(); } catch (error) { logger.warn(`Failed to fetch models from OpenAI Compatible endpoint:`, error); return this.getFallbackModels(); } } /** * Get the first available model for auto-selection */ async getFirstAvailableModel() { const models = await this.getAvailableModels(); return models[0] || FALLBACK_OPENAI_COMPATIBLE_MODEL; } /** * Fallback models when discovery fails */ getFallbackModels() { return [ "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", FALLBACK_OPENAI_COMPATIBLE_MODEL, "claude-3-5-sonnet", "claude-3-haiku", "gemini-pro", ]; } } //# sourceMappingURL=openaiCompatible.js.map