@cyanheads/pubmed-mcp-server 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +174 -0
- package/dist/config/index.d.ts +91 -0
- package/dist/config/index.js +324 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +190 -0
- package/dist/mcp-server/resources/echoResource/echoResourceLogic.d.ts +79 -0
- package/dist/mcp-server/resources/echoResource/echoResourceLogic.js +82 -0
- package/dist/mcp-server/resources/echoResource/index.d.ts +13 -0
- package/dist/mcp-server/resources/echoResource/index.js +13 -0
- package/dist/mcp-server/resources/echoResource/registration.d.ts +30 -0
- package/dist/mcp-server/resources/echoResource/registration.js +168 -0
- package/dist/mcp-server/server.d.ts +28 -0
- package/dist/mcp-server/server.js +159 -0
- package/dist/mcp-server/tools/fetchPubMedContent/index.d.ts +6 -0
- package/dist/mcp-server/tools/fetchPubMedContent/index.js +6 -0
- package/dist/mcp-server/tools/fetchPubMedContent/logic.d.ts +63 -0
- package/dist/mcp-server/tools/fetchPubMedContent/logic.js +518 -0
- package/dist/mcp-server/tools/fetchPubMedContent/registration.d.ts +10 -0
- package/dist/mcp-server/tools/fetchPubMedContent/registration.js +37 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/index.d.ts +6 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/index.js +6 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.d.ts +9 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/citationFormatter.js +288 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/elinkHandler.d.ts +9 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/elinkHandler.js +208 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.d.ts +15 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/index.js +82 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/types.d.ts +27 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic/types.js +5 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic.d.ts +6 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/logic.js +6 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.d.ts +36 -0
- package/dist/mcp-server/tools/getPubMedArticleConnections/registration.js +76 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/index.d.ts +6 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/index.js +6 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/index.d.ts +7 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/index.js +9 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/inputSchema.d.ts +111 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/inputSchema.js +149 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/outputTypes.d.ts +106 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/outputTypes.js +7 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/planOrchestrator.d.ts +10 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic/planOrchestrator.js +251 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.d.ts +12 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/logic.js +55 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.d.ts +10 -0
- package/dist/mcp-server/tools/pubmedResearchAgent/registration.js +36 -0
- package/dist/mcp-server/tools/searchPubMedArticles/index.d.ts +6 -0
- package/dist/mcp-server/tools/searchPubMedArticles/index.js +6 -0
- package/dist/mcp-server/tools/searchPubMedArticles/logic.d.ts +61 -0
- package/dist/mcp-server/tools/searchPubMedArticles/logic.js +229 -0
- package/dist/mcp-server/tools/searchPubMedArticles/registration.d.ts +10 -0
- package/dist/mcp-server/tools/searchPubMedArticles/registration.js +38 -0
- package/dist/mcp-server/transports/authentication/authMiddleware.d.ts +29 -0
- package/dist/mcp-server/transports/authentication/authMiddleware.js +174 -0
- package/dist/mcp-server/transports/httpTransport.d.ts +23 -0
- package/dist/mcp-server/transports/httpTransport.js +463 -0
- package/dist/mcp-server/transports/stdioTransport.d.ts +42 -0
- package/dist/mcp-server/transports/stdioTransport.js +63 -0
- package/dist/services/NCBI/ncbiConstants.d.ts +22 -0
- package/dist/services/NCBI/ncbiConstants.js +5 -0
- package/dist/services/NCBI/ncbiCoreApiClient.d.ts +24 -0
- package/dist/services/NCBI/ncbiCoreApiClient.js +118 -0
- package/dist/services/NCBI/ncbiRequestQueueManager.d.ts +36 -0
- package/dist/services/NCBI/ncbiRequestQueueManager.js +96 -0
- package/dist/services/NCBI/ncbiResponseHandler.d.ts +22 -0
- package/dist/services/NCBI/ncbiResponseHandler.js +192 -0
- package/dist/services/NCBI/ncbiService.d.ts +24 -0
- package/dist/services/NCBI/ncbiService.js +57 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.js +7 -0
- package/dist/services/llm-providers/index.d.ts +7 -0
- package/dist/services/llm-providers/index.js +7 -0
- package/dist/services/llm-providers/llmFactory.d.ts +69 -0
- package/dist/services/llm-providers/llmFactory.js +132 -0
- package/dist/services/llm-providers/openRouter/index.d.ts +6 -0
- package/dist/services/llm-providers/openRouter/index.js +7 -0
- package/dist/services/llm-providers/openRouter/openRouterProvider.d.ts +99 -0
- package/dist/services/llm-providers/openRouter/openRouterProvider.js +329 -0
- package/dist/types-global/errors.d.ts +119 -0
- package/dist/types-global/errors.js +119 -0
- package/dist/types-global/pubmedXml.d.ts +350 -0
- package/dist/types-global/pubmedXml.js +7 -0
- package/dist/utils/index.d.ts +10 -0
- package/dist/utils/index.js +18 -0
- package/dist/utils/internal/errorHandler.d.ts +176 -0
- package/dist/utils/internal/errorHandler.js +335 -0
- package/dist/utils/internal/index.d.ts +9 -0
- package/dist/utils/internal/index.js +9 -0
- package/dist/utils/internal/logger.d.ts +141 -0
- package/dist/utils/internal/logger.js +426 -0
- package/dist/utils/internal/requestContext.d.ts +83 -0
- package/dist/utils/internal/requestContext.js +72 -0
- package/dist/utils/metrics/index.d.ts +7 -0
- package/dist/utils/metrics/index.js +7 -0
- package/dist/utils/metrics/tokenCounter.d.ts +35 -0
- package/dist/utils/metrics/tokenCounter.js +110 -0
- package/dist/utils/parsing/dateParser.d.ts +73 -0
- package/dist/utils/parsing/dateParser.js +107 -0
- package/dist/utils/parsing/index.d.ts +8 -0
- package/dist/utils/parsing/index.js +8 -0
- package/dist/utils/parsing/jsonParser.d.ts +82 -0
- package/dist/utils/parsing/jsonParser.js +126 -0
- package/dist/utils/parsing/ncbi-parsing/eSummaryResultParser.d.ts +32 -0
- package/dist/utils/parsing/ncbi-parsing/eSummaryResultParser.js +295 -0
- package/dist/utils/parsing/ncbi-parsing/index.d.ts +8 -0
- package/dist/utils/parsing/ncbi-parsing/index.js +8 -0
- package/dist/utils/parsing/ncbi-parsing/pubmedArticleStructureParser.d.ts +70 -0
- package/dist/utils/parsing/ncbi-parsing/pubmedArticleStructureParser.js +229 -0
- package/dist/utils/parsing/ncbi-parsing/xmlGenericHelpers.d.ts +31 -0
- package/dist/utils/parsing/ncbi-parsing/xmlGenericHelpers.js +76 -0
- package/dist/utils/security/idGenerator.d.ts +134 -0
- package/dist/utils/security/idGenerator.js +198 -0
- package/dist/utils/security/index.d.ts +9 -0
- package/dist/utils/security/index.js +9 -0
- package/dist/utils/security/rateLimiter.d.ts +108 -0
- package/dist/utils/security/rateLimiter.js +179 -0
- package/dist/utils/security/sanitization.d.ts +173 -0
- package/dist/utils/security/sanitization.js +439 -0
- package/package.json +97 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Factory for creating LLM client instances.
|
|
3
|
+
* Provides a centralized way to instantiate clients for different LLM providers
|
|
4
|
+
* like OpenRouter and Google Gemini, handling API key configuration and
|
|
5
|
+
* basic client setup.
|
|
6
|
+
* @module src/services/llm-providers/llmFactory
|
|
7
|
+
*/
|
|
8
|
+
import { GoogleGenAI } from "@google/genai"; // Updated import path
|
|
9
|
+
import OpenAI from "openai";
|
|
10
|
+
import { config } from "../../config/index.js";
|
|
11
|
+
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
|
|
12
|
+
import { logger } from "../../utils/index.js";
|
|
13
|
+
/**
|
|
14
|
+
* LLM Factory class to create and configure LLM clients.
|
|
15
|
+
*/
|
|
16
|
+
class LlmFactory {
|
|
17
|
+
/**
|
|
18
|
+
* Creates and returns an LLM client instance for the specified provider.
|
|
19
|
+
*
|
|
20
|
+
* @param provider - The LLM provider to create a client for.
|
|
21
|
+
* @param context - The request context for logging.
|
|
22
|
+
* @param options - Optional provider-specific configuration options.
|
|
23
|
+
* @returns A Promise resolving to an instance of OpenAI (for OpenRouter)
|
|
24
|
+
* or GoogleGenAI (for Gemini).
|
|
25
|
+
* @throws {McpError} If the provider is unsupported or API key/config is missing.
|
|
26
|
+
*/
|
|
27
|
+
async getLlmClient(provider, context, options) {
|
|
28
|
+
// Return type changed for Gemini
|
|
29
|
+
const operation = `LlmFactory.getLlmClient.${provider}`;
|
|
30
|
+
logger.info(`[${operation}] Requesting LLM client`, {
|
|
31
|
+
...context,
|
|
32
|
+
provider,
|
|
33
|
+
});
|
|
34
|
+
switch (provider) {
|
|
35
|
+
case "openrouter":
|
|
36
|
+
return this.createOpenRouterClient(context, options);
|
|
37
|
+
case "gemini":
|
|
38
|
+
return this.createGeminiClient(context, options);
|
|
39
|
+
default:
|
|
40
|
+
logger.error(`[${operation}] Unsupported LLM provider requested: ${provider}`, context);
|
|
41
|
+
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Unsupported LLM provider: ${provider}`, { operation, provider });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Creates an OpenAI client configured for OpenRouter.
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
createOpenRouterClient(context, options) {
|
|
49
|
+
const operation = "LlmFactory.createOpenRouterClient";
|
|
50
|
+
const apiKey = options?.apiKey || config.openrouterApiKey;
|
|
51
|
+
const baseURL = options?.baseURL || "https://openrouter.ai/api/v1";
|
|
52
|
+
const siteUrl = options?.siteUrl || config.openrouterAppUrl;
|
|
53
|
+
const siteName = options?.siteName || config.openrouterAppName;
|
|
54
|
+
if (!apiKey) {
|
|
55
|
+
logger.error(`[${operation}] OPENROUTER_API_KEY is not set.`, context);
|
|
56
|
+
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OpenRouter API key is not configured.", { operation });
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const client = new OpenAI({
|
|
60
|
+
baseURL,
|
|
61
|
+
apiKey,
|
|
62
|
+
defaultHeaders: {
|
|
63
|
+
"HTTP-Referer": siteUrl,
|
|
64
|
+
"X-Title": siteName,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
logger.info(`[${operation}] OpenRouter client created successfully.`, context);
|
|
68
|
+
return client;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
logger.error(`[${operation}] Failed to create OpenRouter client`, {
|
|
72
|
+
...context,
|
|
73
|
+
error: error.message,
|
|
74
|
+
});
|
|
75
|
+
throw new McpError(BaseErrorCode.INITIALIZATION_FAILED, `Failed to initialize OpenRouter client: ${error.message}`, { operation, cause: error });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a GoogleGenAI client for Gemini, supporting standard API key or Vertex AI.
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
createGeminiClient(context, options) {
|
|
83
|
+
const operation = "LlmFactory.createGeminiClient";
|
|
84
|
+
if (options?.useVertexAi) {
|
|
85
|
+
if (!options.project || !options.location) {
|
|
86
|
+
logger.error(`[${operation}] Vertex AI project and location are required when useVertexAi is true.`, context);
|
|
87
|
+
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "Vertex AI project and location must be configured if useVertexAi is true.", { operation });
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// For Vertex AI, apiKey in GoogleGenAI constructor is optional if ADC are set up.
|
|
91
|
+
// The SDK handles ADC automatically if apiKey is not provided.
|
|
92
|
+
const clientConfig = {
|
|
93
|
+
project: options.project,
|
|
94
|
+
location: options.location,
|
|
95
|
+
vertexai: true,
|
|
96
|
+
};
|
|
97
|
+
if (options.apiKey) {
|
|
98
|
+
// Allow API key to be passed for Vertex if specific auth needed
|
|
99
|
+
clientConfig.apiKey = options.apiKey;
|
|
100
|
+
}
|
|
101
|
+
const genAI = new GoogleGenAI(clientConfig);
|
|
102
|
+
logger.info(`[${operation}] GoogleGenAI client for Vertex AI created successfully.`, context);
|
|
103
|
+
return genAI;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
logger.error(`[${operation}] Failed to create Gemini client for Vertex AI`, { ...context, error: error.message });
|
|
107
|
+
throw new McpError(BaseErrorCode.INITIALIZATION_FAILED, `Failed to initialize Gemini client for Vertex AI: ${error.message}`, { operation, cause: error });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Standard Gemini API key authentication
|
|
112
|
+
const apiKey = options?.apiKey || config.geminiApiKey;
|
|
113
|
+
if (!apiKey) {
|
|
114
|
+
logger.error(`[${operation}] GEMINI_API_KEY is not set for standard API usage.`, context);
|
|
115
|
+
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "Gemini API key is not configured for standard API usage.", { operation });
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const genAI = new GoogleGenAI({ apiKey });
|
|
119
|
+
logger.info(`[${operation}] GoogleGenAI client (standard API key) created successfully.`, context);
|
|
120
|
+
return genAI;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
logger.error(`[${operation}] Failed to create Gemini client (standard API key)`, { ...context, error: error.message });
|
|
124
|
+
throw new McpError(BaseErrorCode.INITIALIZATION_FAILED, `Failed to initialize Gemini client (standard API key): ${error.message}`, { operation, cause: error });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Singleton instance of the LlmFactory.
|
|
131
|
+
*/
|
|
132
|
+
export const llmFactory = new LlmFactory();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Barrel file for the OpenRouter provider service.
|
|
3
|
+
* Exports the OpenRouterProvider class and any related types.
|
|
4
|
+
* @module services/llm-providers/openRouter/index
|
|
5
|
+
*/
|
|
6
|
+
export * from "./openRouterProvider";
|
|
7
|
+
// Add other exports from this module if any in the future
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { OpenRouterClientOptions } from "../llmFactory.js";
|
|
2
|
+
import { ChatCompletion, ChatCompletionChunk, ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming } from "openai/resources/chat/completions";
|
|
3
|
+
import { Stream } from "openai/streaming";
|
|
4
|
+
import { OperationContext, RequestContext } from "../../../utils/internal/requestContext.js";
|
|
5
|
+
/**
|
|
6
|
+
* Defines the parameters for an OpenRouter chat completion request.
|
|
7
|
+
* This type extends standard OpenAI chat completion parameters and includes
|
|
8
|
+
* OpenRouter-specific fields.
|
|
9
|
+
*
|
|
10
|
+
* @property top_k - OpenRouter specific: Sample from the k most likely next tokens.
|
|
11
|
+
* @property min_p - OpenRouter specific: Minimum probability for a token to be considered.
|
|
12
|
+
* @property transforms - OpenRouter specific: Apply transformations to the request or response.
|
|
13
|
+
* @property models - OpenRouter specific: A list of models to use, often for fallback or routing.
|
|
14
|
+
* @property route - OpenRouter specific: Specifies routing strategy, e.g., 'fallback'.
|
|
15
|
+
* @property provider - OpenRouter specific: Provider-specific parameters or routing preferences.
|
|
16
|
+
* @property stream - If true, the response will be a stream of `ChatCompletionChunk` objects.
|
|
17
|
+
* If false or undefined, a single `ChatCompletion` object is returned.
|
|
18
|
+
*/
|
|
19
|
+
export type OpenRouterChatParams = (ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming) & {
|
|
20
|
+
top_k?: number;
|
|
21
|
+
min_p?: number;
|
|
22
|
+
transforms?: string[];
|
|
23
|
+
models?: string[];
|
|
24
|
+
route?: "fallback";
|
|
25
|
+
provider?: Record<string, any>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Service class for interacting with the OpenRouter API.
|
|
29
|
+
* Uses the OpenAI SDK for chat completions, configured for OpenRouter.
|
|
30
|
+
* Handles API key management, default headers, model-specific parameter adjustments,
|
|
31
|
+
* and provides methods for chat completions and listing models.
|
|
32
|
+
*/
|
|
33
|
+
declare class OpenRouterProvider {
|
|
34
|
+
/**
|
|
35
|
+
* The OpenAI SDK client instance configured for OpenRouter.
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
private client?;
|
|
39
|
+
/**
|
|
40
|
+
* Current status of the OpenRouter service.
|
|
41
|
+
* - `unconfigured`: API key is missing.
|
|
42
|
+
* - `initializing`: Constructor is running.
|
|
43
|
+
* - `ready`: Client initialized successfully and service is usable.
|
|
44
|
+
* - `error`: An error occurred during initialization.
|
|
45
|
+
*/
|
|
46
|
+
status: "unconfigured" | "initializing" | "ready" | "error";
|
|
47
|
+
/**
|
|
48
|
+
* Stores any error that occurred during client initialization.
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
private initializationError;
|
|
52
|
+
/**
|
|
53
|
+
* Constructs an `OpenRouterProvider` instance.
|
|
54
|
+
* Initializes the OpenAI client for OpenRouter if an API key is provided.
|
|
55
|
+
* Sets default headers required by OpenRouter.
|
|
56
|
+
* @param apiKey - The OpenRouter API key. If undefined, the service remains 'unconfigured'.
|
|
57
|
+
* @param parentOpContext - Optional parent operation context for linked logging.
|
|
58
|
+
*/
|
|
59
|
+
constructor(options?: OpenRouterClientOptions, parentOpContext?: OperationContext);
|
|
60
|
+
/**
|
|
61
|
+
* Checks if the service is ready to make API calls.
|
|
62
|
+
* @param operation - The name of the operation attempting to use the service.
|
|
63
|
+
* @param context - The request context for logging.
|
|
64
|
+
* @throws {McpError} If the service is not ready.
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
private checkReady;
|
|
68
|
+
/**
|
|
69
|
+
* Creates a chat completion using the OpenRouter API.
|
|
70
|
+
* Can return either a single response or a stream of chunks.
|
|
71
|
+
* Applies rate limiting and handles model-specific parameter adjustments.
|
|
72
|
+
*
|
|
73
|
+
* @param params - Parameters for the chat completion request.
|
|
74
|
+
* @param context - Request context for logging, error handling, and rate limiting.
|
|
75
|
+
* @returns A promise resolving with either a `ChatCompletion` or a `Stream<ChatCompletionChunk>`.
|
|
76
|
+
* @throws {McpError} If service not ready, rate limit exceeded, or API call fails.
|
|
77
|
+
*/
|
|
78
|
+
chatCompletion(params: OpenRouterChatParams, context: RequestContext): Promise<ChatCompletion | Stream<ChatCompletionChunk>>;
|
|
79
|
+
/**
|
|
80
|
+
* Lists available models from the OpenRouter API.
|
|
81
|
+
* Makes a direct `fetch` call to the `/models` endpoint.
|
|
82
|
+
*
|
|
83
|
+
* @param context - Request context for logging and error handling.
|
|
84
|
+
* @returns A promise resolving with the JSON response from the OpenRouter API.
|
|
85
|
+
* @throws {McpError} If the service is not ready, or if the API call fails.
|
|
86
|
+
*/
|
|
87
|
+
listModels(context: RequestContext): Promise<any>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Singleton instance of the `OpenRouterProvider`.
|
|
91
|
+
* Initialized with the OpenRouter API key from application configuration.
|
|
92
|
+
*/
|
|
93
|
+
declare const openRouterProviderInstance: OpenRouterProvider;
|
|
94
|
+
export { openRouterProviderInstance as openRouterProvider };
|
|
95
|
+
/**
|
|
96
|
+
* Exporting the type of the OpenRouterProvider class for use in dependency injection
|
|
97
|
+
* or for type hinting elsewhere in the application.
|
|
98
|
+
*/
|
|
99
|
+
export type { OpenRouterProvider };
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { llmFactory } from "../llmFactory.js"; // Import factory
|
|
2
|
+
import { config } from "../../../config/index.js";
|
|
3
|
+
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
4
|
+
import { ErrorHandler } from "../../../utils/internal/errorHandler.js";
|
|
5
|
+
import { logger } from "../../../utils/internal/logger.js";
|
|
6
|
+
import { requestContextService, } from "../../../utils/internal/requestContext.js";
|
|
7
|
+
import { rateLimiter } from "../../../utils/security/rateLimiter.js";
|
|
8
|
+
import { sanitization } from "../../../utils/security/sanitization.js";
|
|
9
|
+
/**
|
|
10
|
+
* Service class for interacting with the OpenRouter API.
|
|
11
|
+
* Uses the OpenAI SDK for chat completions, configured for OpenRouter.
|
|
12
|
+
* Handles API key management, default headers, model-specific parameter adjustments,
|
|
13
|
+
* and provides methods for chat completions and listing models.
|
|
14
|
+
*/
|
|
15
|
+
class OpenRouterProvider {
|
|
16
|
+
/**
|
|
17
|
+
* Constructs an `OpenRouterProvider` instance.
|
|
18
|
+
* Initializes the OpenAI client for OpenRouter if an API key is provided.
|
|
19
|
+
* Sets default headers required by OpenRouter.
|
|
20
|
+
* @param apiKey - The OpenRouter API key. If undefined, the service remains 'unconfigured'.
|
|
21
|
+
* @param parentOpContext - Optional parent operation context for linked logging.
|
|
22
|
+
*/
|
|
23
|
+
constructor(options, parentOpContext) {
|
|
24
|
+
/**
|
|
25
|
+
* Stores any error that occurred during client initialization.
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
this.initializationError = null;
|
|
29
|
+
const operationName = parentOpContext?.operation
|
|
30
|
+
? `${parentOpContext.operation}.OpenRouterProvider.constructor`
|
|
31
|
+
: "OpenRouterProvider.constructor";
|
|
32
|
+
const opContext = requestContextService.createRequestContext({
|
|
33
|
+
operation: operationName,
|
|
34
|
+
parentRequestId: parentOpContext?.requestId,
|
|
35
|
+
});
|
|
36
|
+
this.status = "initializing";
|
|
37
|
+
// The factory will use config.openrouterApiKey if options.apiKey is not provided.
|
|
38
|
+
// If neither is available, the factory will throw a CONFIGURATION_ERROR.
|
|
39
|
+
// The 'unconfigured' status here might become less relevant if factory handles all key checks.
|
|
40
|
+
// However, we can keep it for cases where the service is instantiated without attempting client creation immediately.
|
|
41
|
+
if (!options?.apiKey && !config.openrouterApiKey) {
|
|
42
|
+
this.status = "unconfigured";
|
|
43
|
+
logger.warning("OpenRouter API key not provided in options or global config. Service is unconfigured.", { ...opContext, service: "OpenRouterProvider" });
|
|
44
|
+
// Early return if no key is available at all, factory would fail anyway.
|
|
45
|
+
// Or, let the factory attempt and catch the error. For now, let's try to initialize.
|
|
46
|
+
}
|
|
47
|
+
llmFactory
|
|
48
|
+
.getLlmClient("openrouter", opContext, options)
|
|
49
|
+
.then((client) => {
|
|
50
|
+
this.client = client; // Factory returns OpenAI for 'openrouter'
|
|
51
|
+
this.status = "ready";
|
|
52
|
+
logger.info("OpenRouter Service Initialized and Ready via LlmFactory", {
|
|
53
|
+
...opContext,
|
|
54
|
+
service: "OpenRouterProvider",
|
|
55
|
+
});
|
|
56
|
+
})
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
this.status = "error";
|
|
59
|
+
this.initializationError =
|
|
60
|
+
error instanceof Error
|
|
61
|
+
? error
|
|
62
|
+
: new McpError(BaseErrorCode.INITIALIZATION_FAILED, String(error));
|
|
63
|
+
logger.error("Failed to initialize OpenRouter client via LlmFactory", {
|
|
64
|
+
...opContext,
|
|
65
|
+
service: "OpenRouterProvider",
|
|
66
|
+
error: this.initializationError.message,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Checks if the service is ready to make API calls.
|
|
72
|
+
* @param operation - The name of the operation attempting to use the service.
|
|
73
|
+
* @param context - The request context for logging.
|
|
74
|
+
* @throws {McpError} If the service is not ready.
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
checkReady(operation, context) {
|
|
78
|
+
if (this.status !== "ready") {
|
|
79
|
+
let errorCode = BaseErrorCode.SERVICE_UNAVAILABLE;
|
|
80
|
+
let message = `OpenRouter service is not available (status: ${this.status}).`;
|
|
81
|
+
if (this.status === "unconfigured") {
|
|
82
|
+
errorCode = BaseErrorCode.CONFIGURATION_ERROR;
|
|
83
|
+
message = "OpenRouter service is not configured (missing API key).";
|
|
84
|
+
}
|
|
85
|
+
else if (this.status === "error") {
|
|
86
|
+
errorCode = BaseErrorCode.INITIALIZATION_FAILED;
|
|
87
|
+
message = `OpenRouter service failed to initialize: ${this.initializationError?.message || "Unknown error"}`;
|
|
88
|
+
}
|
|
89
|
+
logger.error(`[${operation}] Attempted to use OpenRouter service when not ready.`, { ...context, status: this.status });
|
|
90
|
+
throw new McpError(errorCode, message, {
|
|
91
|
+
operation,
|
|
92
|
+
status: this.status,
|
|
93
|
+
cause: this.initializationError,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (!this.client) {
|
|
97
|
+
// This should ideally not happen if status is 'ready', but as a safeguard:
|
|
98
|
+
logger.error(`[${operation}] Service status is ready, but client is missing.`, { ...context });
|
|
99
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Internal inconsistency: OpenRouter client is missing despite ready status.", { operation });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Creates a chat completion using the OpenRouter API.
|
|
104
|
+
* Can return either a single response or a stream of chunks.
|
|
105
|
+
* Applies rate limiting and handles model-specific parameter adjustments.
|
|
106
|
+
*
|
|
107
|
+
* @param params - Parameters for the chat completion request.
|
|
108
|
+
* @param context - Request context for logging, error handling, and rate limiting.
|
|
109
|
+
* @returns A promise resolving with either a `ChatCompletion` or a `Stream<ChatCompletionChunk>`.
|
|
110
|
+
* @throws {McpError} If service not ready, rate limit exceeded, or API call fails.
|
|
111
|
+
*/
|
|
112
|
+
async chatCompletion(params, context) {
|
|
113
|
+
const operation = "OpenRouterProvider.chatCompletion";
|
|
114
|
+
this.checkReady(operation, context);
|
|
115
|
+
const isStreaming = params.stream === true;
|
|
116
|
+
const effectiveModelId = params.model || config.llmDefaultModel;
|
|
117
|
+
const standardParams = {
|
|
118
|
+
model: effectiveModelId,
|
|
119
|
+
messages: params.messages,
|
|
120
|
+
...(params.temperature !== undefined ||
|
|
121
|
+
config.llmDefaultTemperature !== undefined
|
|
122
|
+
? { temperature: params.temperature ?? config.llmDefaultTemperature }
|
|
123
|
+
: {}),
|
|
124
|
+
...(params.top_p !== undefined || config.llmDefaultTopP !== undefined
|
|
125
|
+
? { top_p: params.top_p ?? config.llmDefaultTopP }
|
|
126
|
+
: {}),
|
|
127
|
+
...(params.presence_penalty !== undefined
|
|
128
|
+
? { presence_penalty: params.presence_penalty }
|
|
129
|
+
: {}),
|
|
130
|
+
...(params.stream !== undefined && { stream: params.stream }),
|
|
131
|
+
...(params.tools !== undefined && { tools: params.tools }),
|
|
132
|
+
...(params.tool_choice !== undefined && {
|
|
133
|
+
tool_choice: params.tool_choice,
|
|
134
|
+
}),
|
|
135
|
+
...(params.response_format !== undefined && {
|
|
136
|
+
response_format: params.response_format,
|
|
137
|
+
}),
|
|
138
|
+
...(params.stop !== undefined && { stop: params.stop }),
|
|
139
|
+
...(params.seed !== undefined && { seed: params.seed }),
|
|
140
|
+
...(params.frequency_penalty !== undefined
|
|
141
|
+
? { frequency_penalty: params.frequency_penalty }
|
|
142
|
+
: {}),
|
|
143
|
+
...(params.logit_bias !== undefined && { logit_bias: params.logit_bias }),
|
|
144
|
+
};
|
|
145
|
+
const extraBody = {};
|
|
146
|
+
const standardKeys = new Set(Object.keys(standardParams));
|
|
147
|
+
standardKeys.add("messages");
|
|
148
|
+
for (const key in params) {
|
|
149
|
+
if (Object.prototype.hasOwnProperty.call(params, key) &&
|
|
150
|
+
!standardKeys.has(key) &&
|
|
151
|
+
key !== "max_tokens") {
|
|
152
|
+
extraBody[key] = params[key];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (extraBody.top_k === undefined && config.llmDefaultTopK !== undefined) {
|
|
156
|
+
extraBody.top_k = config.llmDefaultTopK;
|
|
157
|
+
}
|
|
158
|
+
if (extraBody.min_p === undefined && config.llmDefaultMinP !== undefined) {
|
|
159
|
+
extraBody.min_p = config.llmDefaultMinP;
|
|
160
|
+
}
|
|
161
|
+
if (extraBody.provider && typeof extraBody.provider === "object") {
|
|
162
|
+
if (!extraBody.provider.sort)
|
|
163
|
+
extraBody.provider.sort = "throughput";
|
|
164
|
+
}
|
|
165
|
+
else if (extraBody.provider === undefined) {
|
|
166
|
+
extraBody.provider = { sort: "throughput" };
|
|
167
|
+
}
|
|
168
|
+
// Conditional logic for max_tokens vs max_completion_tokens
|
|
169
|
+
// Certain underlying models (e.g., newer OpenAI models like the o1 series)
|
|
170
|
+
// may require `max_completion_tokens` instead of `max_tokens`.
|
|
171
|
+
// This client sends `max_completion_tokens` in `extra_body` for these models if a limit is specified.
|
|
172
|
+
// For other models, `max_tokens` is used as a standard parameter.
|
|
173
|
+
const modelsRequiringMaxCompletionTokens = ["openai/o1", "openai/gpt-4.1"];
|
|
174
|
+
const needsMaxCompletionTokens = modelsRequiringMaxCompletionTokens.some((modelPrefix) => effectiveModelId.startsWith(modelPrefix));
|
|
175
|
+
const effectiveMaxTokensValue = params.max_tokens ?? config.llmDefaultMaxTokens;
|
|
176
|
+
if (effectiveMaxTokensValue !== undefined) {
|
|
177
|
+
if (needsMaxCompletionTokens) {
|
|
178
|
+
extraBody.max_completion_tokens = effectiveMaxTokensValue;
|
|
179
|
+
logger.info(`[${operation}] Using 'max_completion_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId} (sent via extra_body).`, context);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// For models not in the list, or if OpenRouter handles the mapping transparently,
|
|
183
|
+
// send max_tokens as a standard parameter.
|
|
184
|
+
standardParams.max_tokens = effectiveMaxTokensValue;
|
|
185
|
+
logger.info(`[${operation}] Using 'max_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId}.`, context);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const allEffectiveParams = { ...standardParams, ...extraBody };
|
|
189
|
+
const sanitizedParams = sanitization.sanitizeForLogging(allEffectiveParams);
|
|
190
|
+
logger.info(`[${operation}] Request received`, {
|
|
191
|
+
...context,
|
|
192
|
+
params: sanitizedParams,
|
|
193
|
+
streaming: isStreaming,
|
|
194
|
+
});
|
|
195
|
+
const rateLimitKey = context.requestId || "openrouter_default_key";
|
|
196
|
+
try {
|
|
197
|
+
rateLimiter.check(rateLimitKey, context);
|
|
198
|
+
logger.debug(`[${operation}] Rate limit check passed`, {
|
|
199
|
+
...context,
|
|
200
|
+
key: rateLimitKey,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
logger.warning(`[${operation}] Rate limit exceeded`, {
|
|
205
|
+
...context,
|
|
206
|
+
key: rateLimitKey,
|
|
207
|
+
error: error instanceof Error ? error.message : String(error),
|
|
208
|
+
});
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
212
|
+
if (!this.client)
|
|
213
|
+
throw new Error("Client missing despite ready status");
|
|
214
|
+
const apiParams = { ...standardParams };
|
|
215
|
+
if (Object.keys(extraBody).length > 0) {
|
|
216
|
+
apiParams.extra_body = extraBody;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
if (isStreaming) {
|
|
220
|
+
const stream = await this.client.chat.completions.create(apiParams);
|
|
221
|
+
logger.info(`[${operation}] Streaming request successful`, {
|
|
222
|
+
...context,
|
|
223
|
+
model: apiParams.model,
|
|
224
|
+
});
|
|
225
|
+
return stream;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const completion = await this.client.chat.completions.create(apiParams);
|
|
229
|
+
logger.info(`[${operation}] Non-streaming request successful`, {
|
|
230
|
+
...context,
|
|
231
|
+
model: apiParams.model,
|
|
232
|
+
});
|
|
233
|
+
return completion;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
logger.error(`[${operation}] API call failed`, {
|
|
238
|
+
...context,
|
|
239
|
+
error: error.message,
|
|
240
|
+
status: error.status,
|
|
241
|
+
});
|
|
242
|
+
const errorDetails = {
|
|
243
|
+
providerStatus: error.status,
|
|
244
|
+
providerMessage: error.message,
|
|
245
|
+
cause: error?.cause,
|
|
246
|
+
};
|
|
247
|
+
if (error.status === 401) {
|
|
248
|
+
throw new McpError(BaseErrorCode.UNAUTHORIZED, `OpenRouter authentication failed: ${error.message}`, errorDetails);
|
|
249
|
+
}
|
|
250
|
+
else if (error.status === 429) {
|
|
251
|
+
throw new McpError(BaseErrorCode.RATE_LIMITED, `OpenRouter rate limit exceeded: ${error.message}`, errorDetails);
|
|
252
|
+
}
|
|
253
|
+
else if (error.status === 402) {
|
|
254
|
+
throw new McpError(BaseErrorCode.FORBIDDEN, `OpenRouter insufficient credits or payment required: ${error.message}`, errorDetails);
|
|
255
|
+
}
|
|
256
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter API error (${error.status || "unknown status"}): ${error.message}`, errorDetails);
|
|
257
|
+
}
|
|
258
|
+
}, {
|
|
259
|
+
operation,
|
|
260
|
+
context,
|
|
261
|
+
input: sanitizedParams,
|
|
262
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Lists available models from the OpenRouter API.
|
|
267
|
+
* Makes a direct `fetch` call to the `/models` endpoint.
|
|
268
|
+
*
|
|
269
|
+
* @param context - Request context for logging and error handling.
|
|
270
|
+
* @returns A promise resolving with the JSON response from the OpenRouter API.
|
|
271
|
+
* @throws {McpError} If the service is not ready, or if the API call fails.
|
|
272
|
+
*/
|
|
273
|
+
async listModels(context) {
|
|
274
|
+
const operation = "OpenRouterProvider.listModels";
|
|
275
|
+
this.checkReady(operation, context);
|
|
276
|
+
logger.info(`[${operation}] Request received`, context);
|
|
277
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
278
|
+
try {
|
|
279
|
+
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
|
280
|
+
method: "GET",
|
|
281
|
+
headers: {
|
|
282
|
+
"Content-Type": "application/json",
|
|
283
|
+
// Authorization header might be needed if OpenRouter changes their /models endpoint access
|
|
284
|
+
// "Authorization": `Bearer ${this.client?.apiKey}`, // apiKey is private on OpenAI client
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
const errorBody = await response.text();
|
|
289
|
+
const errorDetails = {
|
|
290
|
+
providerStatus: response.status,
|
|
291
|
+
providerMessage: errorBody,
|
|
292
|
+
};
|
|
293
|
+
logger.error(`[${operation}] Failed to list models`, {
|
|
294
|
+
...context,
|
|
295
|
+
...errorDetails,
|
|
296
|
+
});
|
|
297
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter list models API request failed with status ${response.status}.`, errorDetails);
|
|
298
|
+
}
|
|
299
|
+
const models = await response.json();
|
|
300
|
+
logger.info(`[${operation}] Successfully listed models`, context);
|
|
301
|
+
return models;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
logger.error(`[${operation}] Error listing models`, {
|
|
305
|
+
...context,
|
|
306
|
+
error: error.message,
|
|
307
|
+
});
|
|
308
|
+
if (error instanceof McpError) {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Network or unexpected error listing OpenRouter models: ${error.message}`, { cause: error });
|
|
312
|
+
}
|
|
313
|
+
}, {
|
|
314
|
+
operation,
|
|
315
|
+
context,
|
|
316
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Singleton instance of the `OpenRouterProvider`.
|
|
322
|
+
* Initialized with the OpenRouter API key from application configuration.
|
|
323
|
+
*/
|
|
324
|
+
// Update instantiation to pass options if needed, or rely on factory's use of global config.
|
|
325
|
+
// For a singleton, it usually relies on global config.
|
|
326
|
+
// If the constructor now takes OpenRouterClientOptions, and we want the singleton
|
|
327
|
+
// to use global config, we'd pass undefined or an empty object for options.
|
|
328
|
+
const openRouterProviderInstance = new OpenRouterProvider(undefined);
|
|
329
|
+
export { openRouterProviderInstance as openRouterProvider };
|