UNPKG

pocketsmith-mcp

Version:

MCP server for managing budgets via PocketSmith API

229 lines (228 loc) 9.49 kB
/** * @fileoverview Provides a service class (`OpenRouterProvider`) for interacting with the * OpenRouter API. This file implements the "handler" pattern internally, where the * OpenRouterProvider class manages state and error handling, while private logic functions * execute the core API interactions and throw structured errors. * @module src/services/llm-providers/openRouterProvider */ import OpenAI from "openai"; import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { ErrorHandler } from "../../utils/internal/errorHandler.js"; import { logger } from "../../utils/internal/logger.js"; import { requestContextService, } from "../../utils/internal/requestContext.js"; import { rateLimiter } from "../../utils/security/rateLimiter.js"; import { sanitization } from "../../utils/security/sanitization.js"; // #region Internal Logic Functions (Throwing Errors) /** * Prepares parameters for the OpenRouter API call, separating standard * and extra parameters and applying defaults. * @internal */ function _prepareApiParameters(params) { const effectiveModelId = params.model || config.llmDefaultModel; const standardParams = { model: effectiveModelId, messages: params.messages, ...(params.temperature !== undefined || config.llmDefaultTemperature !== undefined ? { temperature: params.temperature ?? config.llmDefaultTemperature } : {}), ...(params.top_p !== undefined || config.llmDefaultTopP !== undefined ? { top_p: params.top_p ?? config.llmDefaultTopP } : {}), ...(params.presence_penalty !== undefined ? { presence_penalty: params.presence_penalty } : {}), ...(params.stream !== undefined && { stream: params.stream }), ...(params.tools !== undefined && { tools: params.tools }), ...(params.tool_choice !== undefined && { tool_choice: params.tool_choice, }), ...(params.response_format !== undefined && { response_format: params.response_format, }), ...(params.stop !== undefined && { stop: params.stop }), ...(params.seed !== undefined && { seed: params.seed }), ...(params.frequency_penalty !== undefined ? { frequency_penalty: params.frequency_penalty } : {}), ...(params.logit_bias !== undefined && { logit_bias: params.logit_bias }), }; const extraBody = {}; const standardKeys = new Set(Object.keys(standardParams)); standardKeys.add("messages"); for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key) && !standardKeys.has(key) && key !== "max_tokens") { extraBody[key] = params[key]; } } if (extraBody.top_k === undefined && config.llmDefaultTopK !== undefined) { extraBody.top_k = config.llmDefaultTopK; } if (extraBody.min_p === undefined && config.llmDefaultMinP !== undefined) { extraBody.min_p = config.llmDefaultMinP; } if (extraBody.provider && typeof extraBody.provider === "object" && extraBody.provider !== null) { const provider = extraBody.provider; if (!provider.sort) { provider.sort = "throughput"; } } else if (extraBody.provider === undefined) { extraBody.provider = { sort: "throughput" }; } const modelsRequiringMaxCompletionTokens = ["openai/o1", "openai/gpt-4.1"]; const needsMaxCompletionTokens = modelsRequiringMaxCompletionTokens.some((modelPrefix) => effectiveModelId.startsWith(modelPrefix)); const effectiveMaxTokensValue = params.max_tokens ?? config.llmDefaultMaxTokens; if (effectiveMaxTokensValue !== undefined) { if (needsMaxCompletionTokens) { extraBody.max_completion_tokens = effectiveMaxTokensValue; } else { standardParams.max_tokens = effectiveMaxTokensValue; } } return { standardParams, extraBody }; } async function _openRouterChatCompletionLogic(client, params, context) { const isStreaming = params.stream === true; const { standardParams, extraBody } = _prepareApiParameters(params); const apiParams = { ...standardParams }; if (Object.keys(extraBody).length > 0) { apiParams.extra_body = extraBody; } logger.logInteraction("OpenRouterRequest", { context, request: apiParams, }); try { if (isStreaming) { return await client.chat.completions.create(apiParams); } const response = await client.chat.completions.create(apiParams); logger.logInteraction("OpenRouterResponse", { context, response, streaming: false, }); return response; } catch (e) { const error = e; logger.logInteraction("OpenRouterError", { context, error: { message: error.message, stack: error.stack, status: error.status, cause: error.cause, }, }); const errorDetails = { providerStatus: error.status, providerMessage: error.message, cause: error?.cause, }; if (error.status === 401) { throw new McpError(BaseErrorCode.UNAUTHORIZED, `OpenRouter authentication failed: ${error.message}`, errorDetails); } else if (error.status === 429) { throw new McpError(BaseErrorCode.RATE_LIMITED, `OpenRouter rate limit exceeded: ${error.message}`, errorDetails); } else if (error.status === 402) { throw new McpError(BaseErrorCode.FORBIDDEN, `OpenRouter insufficient credits or payment required: ${error.message}`, errorDetails); } throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter API error (${error.status || "unknown status"}): ${error.message}`, errorDetails); } } class OpenRouterProvider { constructor() { this.initializationError = null; this.status = "unconfigured"; } initialize(options) { const opContext = requestContextService.createRequestContext({ operation: "OpenRouterProvider.initialize", }); this.status = "initializing"; const apiKey = options?.apiKey || config.openrouterApiKey; if (!apiKey) { this.status = "unconfigured"; this.initializationError = new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OpenRouter API key is not configured."); logger.error(this.initializationError.message, opContext); return; } try { this.client = new OpenAI({ baseURL: options?.baseURL || "https://openrouter.ai/api/v1", apiKey, defaultHeaders: { "HTTP-Referer": options?.siteUrl || config.openrouterAppUrl, "X-Title": options?.siteName || config.openrouterAppName, }, }); this.status = "ready"; logger.info("OpenRouter Service Initialized and Ready", opContext); } catch (e) { const error = e; this.status = "error"; this.initializationError = error; logger.error("Failed to initialize OpenRouter client", { ...opContext, error: error.message, }); } } checkReady(operation, context) { if (this.status !== "ready" || !this.client) { const message = `OpenRouter service is not available (status: ${this.status}).`; logger.error(`[${operation}] ${message}`, { ...context, status: this.status, }); throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, message, { cause: this.initializationError, }); } } async chatCompletion(params, context) { const operation = "OpenRouterProvider.chatCompletion"; const sanitizedParams = sanitization.sanitizeForLogging(params); return await ErrorHandler.tryCatch(async () => { this.checkReady(operation, context); const rateLimitKey = context.requestId || "openrouter_default_key"; rateLimiter.check(rateLimitKey, context); return await _openRouterChatCompletionLogic(this.client, params, context); }, { operation, context, input: sanitizedParams }); } async chatCompletionStream(params, context) { const streamParams = { ...params, stream: true }; const response = await this.chatCompletion(streamParams, context); const responseStream = response; async function* loggingStream() { const chunks = []; try { for await (const chunk of responseStream) { chunks.push(chunk); yield chunk; } } finally { logger.logInteraction("OpenRouterResponse", { context, response: chunks, streaming: true, }); } } return loggingStream(); } } const openRouterProviderInstance = new OpenRouterProvider(); export { openRouterProviderInstance as openRouterProvider };