├── src ├── utils │ ├── index.ts │ ├── logger.ts │ ├── simple-event-emitter.ts │ ├── formatting.ts │ ├── json-utils.ts │ └── error.ts ├── history │ ├── memory-storage.ts │ ├── disk-storage.ts │ ├── redis-history-plugin.ts │ ├── unified-history-manager.ts │ └── history-analyzer.ts ├── config.ts ├── security │ ├── index.ts │ ├── types.ts │ ├── access-control-manager.ts │ ├── argument-sanitizer.ts │ ├── rate-limit-manager.ts │ ├── auth-manager.ts │ └── security-manager.ts ├── index.ts ├── plugins │ ├── logging-middleware-plugin.ts │ ├── custom-tool-registry-plugin.ts │ ├── billing-cost-tracker-plugin.ts │ └── external-security-plugin.ts ├── core │ ├── message-preparer.ts │ └── plugin-manager.ts ├── cost-tracker.ts └── types │ └── index.ts ├── tsconfig.json ├── .npmignore ├── .gitignore ├── LICENSE ├── package.json └── examples ├── streaming-security-test.js ├── streaming-demo.js └── streaming-test.js /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // Path: utils/index.ts 2 | /** 3 | * Barrel file exporting utilities from the utils directory. 4 | */ 5 | 6 | export * from './error'; 7 | export * from './formatting'; 8 | export * from './validation'; 9 | export * from './logger'; 10 | // Export jsonUtils as a namespace for clarity (e.g., jsonUtils.parseOrThrow) 11 | export * as jsonUtils from './json-utils'; 12 | export * from './simple-event-emitter'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist", "tests", "examples"] 16 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tests/ 3 | examples/ 4 | docs/ 5 | 6 | .github/ 7 | .vscode/ 8 | .travis.yml 9 | .eslintrc* 10 | .prettierrc* 11 | .editorconfig 12 | tsconfig.json 13 | jest.config.js 14 | *.tsbuildinfo 15 | 16 | coverage/ 17 | .nyc_output/ 18 | .circleci/ 19 | .husky/ 20 | .git/ 21 | .gitignore 22 | .openrouter-chats/ 23 | 24 | *.log 25 | *.tmp 26 | *.temp 27 | .DS_Store 28 | .env 29 | node_modules/ 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | x.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | package-lock.json 7 | yarn.lock 8 | 9 | dist/ 10 | lib/ 11 | build/ 12 | *.tsbuildinfo 13 | 14 | coverage/ 15 | 16 | .idea/ 17 | .vscode/* 18 | !.vscode/settings.json 19 | !.vscode/tasks.json 20 | !.vscode/launch.json 21 | !.vscode/extensions.json 22 | *.sublime-* 23 | *.swp 24 | *.swo 25 | .DS_Store 26 | .env 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | .openrouter-chats/ 33 | 34 | logs/ 35 | *.log 36 | 37 | .npm 38 | 39 | .eslintcache 40 | 41 | .node_repl_history 42 | 43 | x.js -------------------------------------------------------------------------------- /src/history/memory-storage.ts: -------------------------------------------------------------------------------- 1 | // Path: src/history/memory-storage.ts 2 | import type { IHistoryStorage, HistoryEntry } from '../types'; // Import HistoryEntry 3 | 4 | export class MemoryHistoryStorage implements IHistoryStorage { 5 | // Store HistoryEntry arrays 6 | private storage = new Map(); 7 | 8 | async load(key: string): Promise { 9 | const entries = this.storage.get(key); 10 | return entries ? [...entries] : []; // Return a copy 11 | } 12 | 13 | async save(key: string, entries: HistoryEntry[]): Promise { 14 | this.storage.set(key, [...entries]); // Store a copy 15 | } 16 | 17 | async delete(key: string): Promise { 18 | this.storage.delete(key); 19 | } 20 | 21 | async listKeys(): Promise { 22 | return Array.from(this.storage.keys()); 23 | } 24 | 25 | async destroy(): Promise { 26 | this.storage.clear(); 27 | } 28 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // --- API Endpoints and URLs --- 2 | export const API_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"; 3 | export const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1'; 4 | export const API_KEY_INFO_PATH = '/auth/key'; 5 | export const CREDITS_API_PATH = '/credits'; 6 | export const MODELS_API_PATH = '/models'; 7 | 8 | // --- Default Model & Parameters --- 9 | export const DEFAULT_MODEL = "google/gemini-2.5-flash"; 10 | export const DEFAULT_TIMEOUT = 120000; // 120 seconds 11 | export const DEFAULT_TEMPERATURE = 0.7; 12 | export const DEFAULT_MAX_TOOL_CALLS = 10; 13 | 14 | // --- History --- 15 | /** @deprecated Max history entries are now managed by the history adapter/manager. */ 16 | export const MAX_HISTORY_ENTRIES = 20; 17 | export const DEFAULT_CHATS_FOLDER = "./.openrouter-chats"; 18 | 19 | // --- Headers --- 20 | export const DEFAULT_REFERER_URL = "https://github.com/mmeerrkkaa/openrouter-kit"; 21 | export const DEFAULT_X_TITLE = "openrouter-kit"; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mmeerrkkaa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openrouter-kit", 3 | "version": "0.1.80", 4 | "description": "TypeScript/JavaScript client for OpenRouter API with history management and tool calls support", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "prepublishOnly": "npm run build" 13 | }, 14 | "keywords": [ 15 | "openrouter", 16 | "api", 17 | "client", 18 | "llm", 19 | "ии", 20 | "ai" 21 | ], 22 | "license": "MIT", 23 | "dependencies": { 24 | "ajv": "^8.17.1", 25 | "axios": "^1.6.0", 26 | "https-proxy-agent": "^5.0.1", 27 | "jsonwebtoken": "^9.0.0" 28 | }, 29 | "optionalDependencies": { 30 | "ioredis": "^5.6.0" 31 | }, 32 | "devDependencies": { 33 | "@types/ioredis": "^4.28.10", 34 | "@types/jest": "^29.5.11", 35 | "@types/jsonwebtoken": "^9.0.9", 36 | "@types/node": "^18.19.3", 37 | "@typescript-eslint/eslint-plugin": "^6.15.0", 38 | "@typescript-eslint/parser": "^6.15.0", 39 | "eslint": "^8.56.0", 40 | "jest": "^29.7.0", 41 | "prettier": "^3.1.1", 42 | "rimraf": "^5.0.5", 43 | "ts-jest": "^29.1.1", 44 | "typedoc": "^0.25.4", 45 | "typescript": "^5.3.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/security/index.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/index.ts 2 | // Export all types from security/types, including the extended ones 3 | export * from './types'; 4 | 5 | export { SecurityManager } from './security-manager'; 6 | export { AuthManager } from './auth-manager'; 7 | export { AccessControlManager } from './access-control-manager'; 8 | export { RateLimitManager } from './rate-limit-manager'; 9 | export { ArgumentSanitizer } from './argument-sanitizer'; 10 | 11 | // Import the extended config type for the factory function 12 | import { ExtendedSecurityConfig } from './types'; 13 | // Import SecurityManager class *type* for return type annotation 14 | import { SecurityManager as SecurityManagerClass } from './security-manager'; 15 | 16 | 17 | export const createDefaultSecurityManager = ( 18 | // Accept partial extended config 19 | config?: Partial, 20 | secretKeyOrDebug?: string | boolean 21 | ): SecurityManagerClass => { 22 | const { SecurityManager } = require('./security-manager'); 23 | 24 | // Define effectiveConfig with the extended type 25 | const effectiveConfig: ExtendedSecurityConfig = { 26 | // Start with defaults that satisfy the extended type 27 | defaultPolicy: 'deny-all', 28 | debug: false, // debug is required in ExtendedSecurityConfig 29 | requireAuthentication: false, 30 | allowUnauthenticatedAccess: false, 31 | // Apply user config over defaults 32 | ...(config || {}), 33 | // Ensure nested dangerousArguments has defaults and merges correctly 34 | dangerousArguments: { 35 | auditOnlyMode: false, // Default for extended type 36 | ...(config?.dangerousArguments || {}) 37 | }, 38 | // Ensure userAuthentication exists for potential secret assignment 39 | userAuthentication: { 40 | type: 'jwt', // Default type 41 | ...(config?.userAuthentication || {}) 42 | } 43 | }; 44 | 45 | // Determine final debug state 46 | let finalDebug: boolean; 47 | if (typeof secretKeyOrDebug === 'boolean') { 48 | finalDebug = secretKeyOrDebug; 49 | } else { 50 | // effectiveConfig.debug is now guaranteed boolean 51 | finalDebug = effectiveConfig.debug ?? false; // Fallback just in case 52 | } 53 | effectiveConfig.debug = finalDebug; 54 | 55 | 56 | // Handle secret key precedence 57 | if (typeof secretKeyOrDebug === 'string') { 58 | // userAuthentication is guaranteed to exist here 59 | effectiveConfig.userAuthentication!.jwtSecret = secretKeyOrDebug; 60 | } 61 | 62 | // Pass the fully constructed ExtendedSecurityConfig 63 | return new SecurityManager(effectiveConfig); 64 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Path: src/index.ts 2 | 3 | // --- Core Client --- 4 | export { OpenRouterClient } from './client'; 5 | 6 | // --- Configuration & Constants --- 7 | export * as config from './config'; 8 | 9 | // --- Core Types --- 10 | export * from './types'; 11 | 12 | // --- Streaming Types (explicit export for better visibility) --- 13 | export type { StreamChunk, StreamCallbacks, ChatStreamResult } from './types'; 14 | 15 | // --- Error Handling --- 16 | export * from './utils/error'; 17 | 18 | // --- Utilities --- 19 | export * as utils from './utils'; 20 | export { Logger } from './utils/logger'; 21 | export { SimpleEventEmitter } from './utils/simple-event-emitter'; 22 | 23 | // --- Tool Handling --- 24 | export { ToolHandler } from './tool-handler'; 25 | 26 | // --- History Management --- 27 | export { UnifiedHistoryManager } from './history/unified-history-manager'; 28 | export { HistoryAnalyzer } from './history/history-analyzer'; // Ensure export 29 | export type { HistoryQueryOptions, HistoryStats, TimeSeriesDataPoint, TimeSeriesData } from './history/history-analyzer'; // Ensure export 30 | // Export built-in Storage Adapters 31 | export { MemoryHistoryStorage } from './history/memory-storage'; 32 | export { DiskHistoryStorage } from './history/disk-storage'; 33 | // Export History Plugins 34 | export { createRedisHistoryPlugin } from './history/redis-history-plugin'; 35 | 36 | // --- Security Module --- 37 | export { SecurityManager } from './security/security-manager'; 38 | export { createDefaultSecurityManager } from './security/index'; 39 | export { AuthManager } from './security/auth-manager'; 40 | export { AccessControlManager } from './security/access-control-manager'; 41 | export { RateLimitManager } from './security/rate-limit-manager'; 42 | export { ArgumentSanitizer } from './security/argument-sanitizer'; 43 | 44 | export type { 45 | ISecurityManager, 46 | IAuthManager, 47 | IAccessControlManager, 48 | IRateLimitManager, 49 | IArgumentSanitizer, 50 | ExtendedRateLimit, 51 | ExtendedUserAuthInfo, 52 | ExtendedDangerousArgumentsConfig, 53 | ExtendedSecurityConfig, 54 | SecurityContext, 55 | SecurityCheckParams, 56 | TokenValidationResult, 57 | TokenConfig, 58 | RateLimitParams, 59 | RateLimitResult, 60 | ExtendedToolCallEvent 61 | } from './security/types'; 62 | 63 | // --- General Plugins --- 64 | export { createBillingCostTrackerPlugin } from './plugins/billing-cost-tracker-plugin'; 65 | export { createCustomToolRegistryPlugin } from './plugins/custom-tool-registry-plugin'; 66 | export { createExternalSecurityPlugin } from './plugins/external-security-plugin'; 67 | export { createLoggingMiddlewarePlugin } from './plugins/logging-middleware-plugin'; 68 | 69 | // --- Cost Tracking --- 70 | export { CostTracker } from './cost-tracker'; 71 | 72 | // --- Core Components (Optional Export) --- 73 | // export { ApiHandler } from './core/api-handler'; 74 | // export { ChatProcessor } from './core/chat-processor'; 75 | // export { PluginManager } from './core/plugin-manager'; 76 | // export * from './core/message-preparer'; -------------------------------------------------------------------------------- /src/plugins/logging-middleware-plugin.ts: -------------------------------------------------------------------------------- 1 | // Path: src/plugins/logging-middleware-plugin.ts 2 | import type { OpenRouterPlugin, MiddlewareContext } from '../types'; 3 | import type { OpenRouterClient } from '../client'; 4 | import { OpenRouterError } from '../utils/error'; 5 | import { mapError } from '../utils/error'; 6 | 7 | /** 8 | * Example plugin that registers a simple logging middleware. 9 | */ 10 | export function createLoggingMiddlewarePlugin(): OpenRouterPlugin { 11 | return { 12 | async init(client: OpenRouterClient) { 13 | const logger = (client as any)['logger']?.withPrefix('LoggingMiddleware'); 14 | 15 | if (!logger) { 16 | console.warn("[LoggingMiddlewarePlugin] Client logger not found, middleware will not log."); 17 | return; 18 | } 19 | 20 | const loggingMiddleware = async (ctx: MiddlewareContext, next: () => Promise) => { 21 | const startTime = Date.now(); 22 | // Use the public getter for the default model 23 | const defaultModel = client.getDefaultModel(); // Use the getter 24 | const requestOptionsSummary = { 25 | model: ctx.request.options.model || defaultModel, 26 | promptLength: ctx.request.options.prompt?.length, 27 | customMessagesCount: ctx.request.options.customMessages?.length, 28 | user: ctx.request.options.user, 29 | toolsCount: ctx.request.options.tools?.length, 30 | responseFormat: ctx.request.options.responseFormat?.type, 31 | }; 32 | logger.log(`Request starting...`, requestOptionsSummary); 33 | 34 | try { 35 | await next(); 36 | 37 | const duration = Date.now() - startTime; 38 | if (ctx.response && !ctx.response.error) { 39 | const responseSummary = { 40 | model: ctx.response.result?.model, 41 | finishReason: ctx.response.result?.finishReason, 42 | toolCallsCount: ctx.response.result?.toolCallsCount, 43 | usage: ctx.response.result?.usage, 44 | cost: ctx.response.result?.cost, 45 | contentType: typeof ctx.response.result?.content, 46 | id: ctx.response.result?.id, 47 | }; 48 | logger.log(`Request finished successfully in ${duration}ms.`, responseSummary); 49 | logger.debug(`Full response content:`, ctx.response.result?.content); 50 | } else if (ctx.response?.error) { 51 | const error = ctx.response.error as OpenRouterError; 52 | logger.error(`Request failed after ${duration}ms. Error: ${error.message} (Code: ${error.code}, Status: ${error.statusCode || 'N/A'})`); 53 | logger.debug(`Error details:`, error.details || error); 54 | } else { 55 | logger.warn(`Request finished in ${duration}ms but context has no response or error.`); 56 | } 57 | } catch (error) { 58 | const duration = Date.now() - startTime; 59 | const mappedError = mapError(error); 60 | logger.error(`Request middleware chain interrupted after ${duration}ms. Unhandled Error: ${mappedError.message}`, mappedError); 61 | throw mappedError; 62 | } 63 | }; 64 | 65 | client.useMiddleware(loggingMiddleware); 66 | logger.log('Logging middleware registered.'); 67 | } 68 | }; 69 | } -------------------------------------------------------------------------------- /src/history/disk-storage.ts: -------------------------------------------------------------------------------- 1 | // Path: src/history/disk-storage.ts 2 | import * as fs from 'fs/promises'; 3 | import * as path from 'path'; 4 | import type { IHistoryStorage, HistoryEntry, Message } from '../types'; // Import HistoryEntry 5 | 6 | export class DiskHistoryStorage implements IHistoryStorage { 7 | private folder: string; 8 | 9 | constructor(folder: string = './.openrouter-chats') { 10 | this.folder = path.resolve(folder); 11 | } 12 | 13 | private getFilePath(key: string): string { 14 | const safeKey = key.replace(/[^a-zA-Z0-9_.\-]/g, '_'); 15 | return path.join(this.folder, `or_hist_${safeKey}.json`); 16 | } 17 | 18 | async load(key: string): Promise { 19 | const filePath = this.getFilePath(key); 20 | try { 21 | const data = await fs.readFile(filePath, 'utf8'); 22 | const parsedData = JSON.parse(data); 23 | 24 | if (!Array.isArray(parsedData)) { 25 | console.warn(`[DiskHistoryStorage] Data in ${filePath} is not an array. Returning empty.`); 26 | return []; 27 | } 28 | 29 | // Basic check for old vs new format 30 | if (parsedData.length > 0 && parsedData[0].role && parsedData[0].content !== undefined) { 31 | console.warn(`[DiskHistoryStorage] Data in ${filePath} appears to be in the old Message[] format. Converting to HistoryEntry[] without metadata.`); 32 | return parsedData.map((msg: Message) => ({ message: msg, apiCallMetadata: null })); 33 | } else if (parsedData.length === 0 || (parsedData[0].message && parsedData[0].message.role)) { 34 | return parsedData as HistoryEntry[]; 35 | } else { 36 | console.warn(`[DiskHistoryStorage] Data in ${filePath} has an unrecognized format. Returning empty.`); 37 | return []; 38 | } 39 | 40 | } catch (err: any) { 41 | if (err.code === 'ENOENT') { 42 | return []; 43 | } 44 | console.error(`[DiskHistoryStorage] Error loading history for key '${key}' from ${filePath}:`, err); 45 | throw err; 46 | } 47 | } 48 | 49 | async save(key: string, entries: HistoryEntry[]): Promise { 50 | const filePath = this.getFilePath(key); 51 | try { 52 | await fs.mkdir(this.folder, { recursive: true }); 53 | await fs.writeFile(filePath, JSON.stringify(entries, null, 2), 'utf8'); 54 | } catch (err: any) { 55 | console.error(`[DiskHistoryStorage] Error saving history for key '${key}' to ${filePath}:`, err); 56 | throw err; 57 | } 58 | } 59 | 60 | async delete(key: string): Promise { 61 | const filePath = this.getFilePath(key); 62 | try { 63 | await fs.unlink(filePath); 64 | } catch (err: any) { 65 | if (err.code !== 'ENOENT') { 66 | console.error(`[DiskHistoryStorage] Error deleting history file for key '${key}' at ${filePath}:`, err); 67 | throw err; 68 | } 69 | } 70 | } 71 | 72 | async listKeys(): Promise { 73 | try { 74 | const files = await fs.readdir(this.folder); 75 | const keys: string[] = []; 76 | const prefix = 'or_hist_'; 77 | const suffix = '.json'; 78 | 79 | files.forEach(f => { 80 | if (f.startsWith(prefix) && f.endsWith(suffix)) { 81 | const safeKey = f.slice(prefix.length, -suffix.length); 82 | keys.push(safeKey); 83 | } 84 | }); 85 | return keys; 86 | } catch (err: any) { 87 | if (err.code === 'ENOENT') { 88 | return []; 89 | } 90 | console.error(`[DiskHistoryStorage] Error listing history keys in folder ${this.folder}:`, err); 91 | throw err; 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/core/message-preparer.ts: -------------------------------------------------------------------------------- 1 | // Path: src/core/message-preparer.ts 2 | import { Message, HistoryEntry } from '../types'; 3 | import { UnifiedHistoryManager } from '../history/unified-history-manager'; 4 | import { Logger } from '../utils/logger'; 5 | import { ConfigError } from '../utils/error'; 6 | 7 | /** 8 | * Filters an array of message objects for sending to the API. 9 | */ 10 | export function filterHistoryForApi(messages: Message[]): Message[] { 11 | if (!Array.isArray(messages)) { 12 | return []; 13 | } 14 | return messages.map(msg => { 15 | const filteredMsg: Partial = { role: msg.role }; 16 | if (msg.content !== null && msg.content !== undefined) { 17 | filteredMsg.content = msg.content; 18 | } else { 19 | filteredMsg.content = null; 20 | } 21 | if (msg.tool_calls) { 22 | filteredMsg.tool_calls = msg.tool_calls; 23 | } 24 | if (msg.tool_call_id) { 25 | filteredMsg.tool_call_id = msg.tool_call_id; 26 | } 27 | if (msg.name) { 28 | filteredMsg.name = msg.name; 29 | } 30 | return filteredMsg as Message; 31 | }).filter(msg => msg !== null); 32 | } 33 | 34 | /** 35 | * Prepares the list of messages to be sent to the API. 36 | */ 37 | export async function prepareMessagesForApi( 38 | params: { 39 | user?: string; 40 | group?: string | null; 41 | prompt: string; 42 | systemPrompt?: string | null; 43 | customMessages?: Message[] | null; 44 | _loadedHistoryEntries?: HistoryEntry[]; 45 | getHistoryKeyFn: (user: string, group?: string | null) => string; 46 | }, 47 | historyManager: UnifiedHistoryManager, 48 | logger: Logger 49 | ): Promise { 50 | const { user, group, prompt, systemPrompt, customMessages, _loadedHistoryEntries } = params; 51 | 52 | if (customMessages) { 53 | logger.debug(`Using provided customMessages (${customMessages.length} items).`); 54 | let finalMessages = [...customMessages]; 55 | const hasSystem = finalMessages.some(m => m.role === 'system'); 56 | if (systemPrompt && !hasSystem) { 57 | logger.debug('Prepending systemPrompt to customMessages.'); 58 | finalMessages.unshift({ role: 'system', content: systemPrompt }); 59 | } else if (systemPrompt && hasSystem) { 60 | logger.warn('Both `systemPrompt` and a system message in `customMessages` were provided. Using the one from `customMessages`.'); 61 | } 62 | return finalMessages.map(m => ({ ...m, content: m.content ?? null })); 63 | } 64 | 65 | const loadedMessages = _loadedHistoryEntries ? _loadedHistoryEntries.map(entry => entry.message) : []; 66 | 67 | if (!prompt && !systemPrompt) { 68 | if (loadedMessages.length === 0) { 69 | throw new ConfigError("'prompt' must be provided if 'customMessages' is not used and history is empty"); 70 | } else { 71 | logger.warn("Neither 'prompt' nor 'systemPrompt' provided, proceeding with history only."); 72 | } 73 | } 74 | 75 | let messages: Message[] = []; 76 | 77 | if (loadedMessages.length > 0) { 78 | logger.debug(`Using pre-loaded history (${loadedMessages.length} messages). Filtering...`); 79 | const filteredHistory = filterHistoryForApi(loadedMessages); 80 | messages = [...messages, ...filteredHistory]; 81 | logger.debug(`Added ${filteredHistory.length} filtered messages from pre-loaded history.`); 82 | } 83 | 84 | if (systemPrompt && !messages.some(m => m.role === 'system')) { 85 | messages.unshift({ role: 'system', content: systemPrompt }); 86 | } 87 | 88 | if (prompt) { 89 | messages.push({ role: 'user', content: prompt }); 90 | } 91 | 92 | logger.debug(`${messages.length} messages prepared for API request.`); 93 | return messages.map(m => ({ ...m, content: m.content ?? null })); 94 | } 95 | 96 | export {}; -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | // Path: utils/logger.ts 2 | /** 3 | * Simple console logger for the OpenRouter Kit library. 4 | * Allows conditional logging based on debug mode and adding prefixes. 5 | */ 6 | export class Logger { 7 | private debugMode: boolean; 8 | private readonly prefix: string; 9 | 10 | /** 11 | * Creates a logger instance. 12 | * @param options - Configuration object `{ debug?: boolean, prefix?: string }` or a boolean indicating debug state. 13 | * @param prefix - Optional prefix string (only used if `options` is boolean). 14 | */ 15 | constructor(options: { debug?: boolean, prefix?: string } | boolean = false, prefix: string = '') { 16 | let effectivePrefix = ''; 17 | if (typeof options === 'boolean') { 18 | this.debugMode = options; 19 | effectivePrefix = prefix || ''; 20 | } else { 21 | this.debugMode = options.debug ?? false; 22 | effectivePrefix = options.prefix || ''; 23 | } 24 | // Ensure prefix has a trailing space if it exists 25 | this.prefix = effectivePrefix ? `${effectivePrefix} ` : ''; 26 | } 27 | 28 | /** 29 | * Enables or disables debug logging. 30 | * @param debug - `true` to enable, `false` to disable. 31 | */ 32 | setDebug(debug: boolean): void { 33 | if (this.debugMode !== debug) { 34 | // Use console.log directly to ensure this message always shows 35 | console.log(`${this.prefix}Logger debug mode changed to: ${debug ? 'ENABLED' : 'DISABLED'}.`); 36 | this.debugMode = debug; 37 | } 38 | } 39 | 40 | /** Checks if debug mode is currently enabled. */ 41 | isDebugEnabled(): boolean { 42 | return this.debugMode; 43 | } 44 | 45 | /** Logs a debug message (only if debug mode is enabled). */ 46 | debug(message: string | any, ...args: any[]): void { 47 | if (this.debugMode) { 48 | // Handle non-string first argument correctly 49 | if (typeof message === 'string') { 50 | console.debug(`${this.prefix}${message}`, ...args); 51 | } else { 52 | console.debug(this.prefix, message, ...args); 53 | } 54 | } 55 | } 56 | 57 | /** Logs an informational message (only if debug mode is enabled). */ 58 | log(message: string | any, ...args: any[]): void { 59 | if (this.debugMode) { 60 | if (typeof message === 'string') { 61 | console.log(`${this.prefix}${message}`, ...args); 62 | } else { 63 | console.log(this.prefix, message, ...args); 64 | } 65 | } 66 | } 67 | 68 | /** Logs a warning message (only if debug mode is enabled). */ 69 | warn(message: string | any, ...args: any[]): void { 70 | if (this.debugMode) { 71 | if (typeof message === 'string') { 72 | console.warn(`${this.prefix}${message}`, ...args); 73 | } else { 74 | console.warn(this.prefix, message, ...args); 75 | } 76 | } 77 | } 78 | 79 | /** Logs an error message (only if debug mode is enabled). */ 80 | error(message: string | any, ...args: any[]): void { 81 | // Errors might be important even if debug is off? 82 | // Let's keep errors always visible unless explicitly silenced elsewhere. 83 | // if (this.debugMode) { 84 | if (typeof message === 'string') { 85 | console.error(`${this.prefix}${message}`, ...args); 86 | } else { 87 | console.error(this.prefix, message, ...args); 88 | } 89 | // } 90 | } 91 | 92 | /** 93 | * Creates a new logger instance inheriting the current debug state 94 | * but with an added prefix segment. 95 | * @param newPrefix - The additional prefix string. 96 | * @returns A new Logger instance. 97 | */ 98 | withPrefix(newPrefix: string): Logger { 99 | // Combine prefixes carefully, ensuring single space separation 100 | const combinedPrefix = `${this.prefix.trim()} ${newPrefix || ''}`.trim(); 101 | return new Logger({ debug: this.debugMode, prefix: combinedPrefix }); 102 | } 103 | } -------------------------------------------------------------------------------- /src/core/plugin-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/core/plugin-manager.ts 2 | import { OpenRouterPlugin, MiddlewareFunction, MiddlewareContext } from '../types'; 3 | import { OpenRouterClient } from '../client'; 4 | import { Logger } from '../utils/logger'; 5 | import { mapError, ConfigError, OpenRouterError, ErrorCode } from '../utils/error'; 6 | 7 | export class PluginManager { // Ensure export 8 | private plugins: OpenRouterPlugin[] = []; 9 | private middlewares: MiddlewareFunction[] = []; 10 | private logger: Logger; 11 | 12 | constructor(logger: Logger) { 13 | this.logger = logger.withPrefix('PluginManager'); 14 | this.logger.log('PluginManager initialized.'); 15 | } 16 | 17 | public async registerPlugin(plugin: OpenRouterPlugin, client: OpenRouterClient): Promise { 18 | if (!plugin || typeof plugin.init !== 'function') { 19 | throw new ConfigError('Invalid plugin: missing init() method or plugin is not an object'); 20 | } 21 | try { 22 | await plugin.init(client); 23 | this.plugins.push(plugin); 24 | this.logger.log(`Plugin registered: ${plugin.constructor?.name || 'anonymous plugin'}`); 25 | } catch (error) { 26 | const mappedError = mapError(error); 27 | this.logger.error(`Error initializing plugin ${plugin.constructor?.name || 'anonymous plugin'}: ${mappedError.message}`, mappedError.details); 28 | throw mappedError; 29 | } 30 | } 31 | 32 | public registerMiddleware(fn: MiddlewareFunction): void { 33 | if (typeof fn !== 'function') { 34 | throw new ConfigError('Middleware must be a function'); 35 | } 36 | this.middlewares.push(fn); 37 | this.logger.log(`Middleware registered: ${fn.name || 'anonymous'}`); 38 | } 39 | 40 | public async runMiddlewares(ctx: MiddlewareContext, coreFn: () => Promise): Promise { 41 | const stack = this.middlewares.slice(); 42 | 43 | const dispatch = async (i: number): Promise => { 44 | if (i < stack.length) { 45 | const middleware = stack[i]; 46 | const middlewareName = middleware.name || `middleware[${i}]`; 47 | this.logger.debug(`Executing middleware: ${middlewareName}`); 48 | try { 49 | await middleware(ctx, () => dispatch(i + 1)); 50 | this.logger.debug(`Finished middleware: ${middlewareName}`); 51 | } catch (mwError) { 52 | this.logger.error(`Error in middleware ${middlewareName}:`, mwError); 53 | ctx.response = { ...(ctx.response || {}), error: mapError(mwError) }; 54 | throw mapError(mwError); 55 | } 56 | } else { 57 | this.logger.debug('Executing core chat function...'); 58 | await coreFn(); 59 | this.logger.debug('Finished core chat function.'); 60 | } 61 | }; 62 | 63 | this.logger.debug(`Starting middleware chain execution (${stack.length} middlewares)...`); 64 | await dispatch(0); 65 | this.logger.debug('Middleware chain execution finished.'); 66 | } 67 | 68 | public async destroyPlugins(): Promise { 69 | this.logger.log(`Destroying ${this.plugins.length} registered plugins...`); 70 | for (const plugin of this.plugins) { 71 | if (plugin.destroy && typeof plugin.destroy === 'function') { 72 | try { 73 | await plugin.destroy(); 74 | this.logger.debug(`Plugin destroyed: ${plugin.constructor?.name || 'anonymous plugin'}`); 75 | } catch (error) { 76 | this.logger.error(`Error destroying plugin ${plugin.constructor?.name || 'anonymous plugin'}:`, error); 77 | } 78 | } 79 | } 80 | this.plugins = []; 81 | this.middlewares = []; 82 | this.logger.log('Plugins destroyed and middlewares cleared.'); 83 | } 84 | } 85 | 86 | 87 | export {}; -------------------------------------------------------------------------------- /src/utils/simple-event-emitter.ts: -------------------------------------------------------------------------------- 1 | // Path: utils/simple-event-emitter.ts 2 | /** 3 | * A basic, dependency-free event emitter implementation for internal library use. 4 | */ 5 | 6 | // Type definition for event handler functions 7 | type EventHandler = (payload?: any) => void; 8 | 9 | export class SimpleEventEmitter { 10 | // Store listeners in an object where keys are event names and values are arrays of handlers 11 | private listeners: { [event: string]: EventHandler[] } = {}; 12 | 13 | /** 14 | * Subscribes a handler function to an event. 15 | * @param event - The name of the event to subscribe to. 16 | * @param handler - The function to call when the event is emitted. 17 | */ 18 | on(event: string, handler: EventHandler): void { 19 | // Ensure the handler is a function 20 | if (typeof handler !== 'function') { 21 | console.error(`[SimpleEventEmitter] Handler for event "${event}" is not a function.`); 22 | return; 23 | } 24 | // Initialize the listener array if it doesn't exist 25 | if (!this.listeners[event]) { 26 | this.listeners[event] = []; 27 | } 28 | // Add the handler to the array 29 | this.listeners[event].push(handler); 30 | } 31 | 32 | /** 33 | * Unsubscribes a specific handler function from an event. 34 | * @param event - The name of the event to unsubscribe from. 35 | * @param handler - The handler function to remove. 36 | */ 37 | off(event: string, handler: EventHandler): void { 38 | // Check if there are any listeners for this event 39 | if (this.listeners[event]) { 40 | // Filter out the specific handler 41 | this.listeners[event] = this.listeners[event].filter(h => h !== handler); 42 | // Optional: Clean up the event entry if no listeners remain 43 | if (this.listeners[event].length === 0) { 44 | delete this.listeners[event]; 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Emits an event, calling all subscribed handlers with the provided payload. 51 | * Handlers are called synchronously in the order they were added. 52 | * Includes basic error handling for individual handlers. 53 | * @param event - The name of the event to emit. 54 | * @param payload - Optional data to pass to the event handlers. 55 | */ 56 | emit(event: string, payload?: any): void { 57 | // Check if there are any listeners for this event 58 | if (this.listeners[event]) { 59 | // Create a copy of the listeners array before iterating. 60 | // This prevents issues if a handler modifies the listeners array (e.g., calls off() on itself). 61 | const handlersToExecute = [...this.listeners[event]]; 62 | 63 | // Execute each handler 64 | handlersToExecute.forEach(handler => { 65 | try { 66 | // Call the handler with the payload 67 | handler(payload); 68 | } catch (error) { 69 | // Log errors from handlers but don't let them stop other handlers 70 | console.error(`[SimpleEventEmitter] Error in handler for event "${event}":`, error); 71 | // Optionally re-throw or emit an 'error' event? For internal use, console.error is often sufficient. 72 | // this.emit('error', { sourceEvent: event, handlerError: error }); 73 | } 74 | }); 75 | } 76 | } 77 | 78 | /** 79 | * Removes all event handlers for a specific event, or all handlers for all events. 80 | * @param event - Optional: The name of the event to clear listeners for. If omitted, all listeners for all events are removed. 81 | */ 82 | removeAllListeners(event?: string): void { 83 | if (event) { 84 | // Remove listeners only for the specified event 85 | delete this.listeners[event]; 86 | } else { 87 | // Remove all listeners for all events 88 | this.listeners = {}; 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/plugins/custom-tool-registry-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { OpenRouterPlugin, Tool, OpenRouterRequestOptions, ChatCompletionResult } from '../types'; 2 | import type { OpenRouterClient } from '../client'; 3 | // Use relative path for ToolHandler import 4 | import { ToolHandler } from '../tool-handler'; 5 | 6 | /** 7 | * Example plugin that modifies the tool registry used for chat calls. 8 | * It could fetch tools dynamically, add metadata, enforce policies by filtering, etc. 9 | * This example simply overrides/sets the tools for every chat call. 10 | * 11 | * @param tools - The array of Tool objects to be used, or a function returning them. 12 | */ 13 | export function createCustomToolRegistryPlugin( 14 | // Allow providing tools directly or via an async function for dynamic loading 15 | toolProvider: Tool[] | (() => Promise | Tool[]) 16 | ): OpenRouterPlugin { 17 | return { 18 | async init(client: OpenRouterClient) { 19 | const logger = client['logger']?.withPrefix('CustomToolRegistryPlugin'); 20 | 21 | // Store the original chat method 22 | const originalChat = client.chat.bind(client); 23 | 24 | // Get the tools once or set up the provider function 25 | let resolvedTools: Tool[] | null = null; 26 | let isDynamic = false; 27 | if (typeof toolProvider === 'function') { 28 | isDynamic = true; 29 | logger?.log?.('Using dynamic tool provider function.'); 30 | } else if (Array.isArray(toolProvider)) { 31 | // Validate and format tools immediately if provided as an array 32 | try { 33 | resolvedTools = toolProvider.map(ToolHandler.formatToolForAPI); 34 | logger?.log?.(`Initialized with ${resolvedTools.length} static tools.`); 35 | } catch (error) { 36 | logger?.error?.(`Failed to validate/format provided static tools: ${(error as Error).message}`); 37 | resolvedTools = []; // Set to empty on error? Or throw? 38 | } 39 | } else { 40 | logger?.error?.('Invalid toolProvider: must be an array or function.'); 41 | return; // Don't modify chat if provider is invalid 42 | } 43 | 44 | // Override the client.chat method 45 | client.chat = async (options: OpenRouterRequestOptions): Promise => { 46 | logger?.debug?.('Intercepting chat call...'); 47 | 48 | let currentTools: Tool[] = []; 49 | 50 | // Resolve tools dynamically if necessary 51 | if (isDynamic) { 52 | try { 53 | const provided = await (toolProvider as () => Promise | Tool[])(); 54 | if (Array.isArray(provided)) { 55 | // Validate and format dynamically provided tools 56 | currentTools = provided.map(ToolHandler.formatToolForAPI); 57 | logger?.debug?.(`Dynamically resolved ${currentTools.length} tools.`); 58 | } else { 59 | logger?.warn?.('Dynamic tool provider did not return an array.'); 60 | } 61 | } catch (error) { 62 | logger?.error?.(`Error resolving tools from dynamic provider: ${(error as Error).message}`); 63 | // Decide behavior on error: use empty tools, original tools, or throw? 64 | currentTools = options.tools || []; // Fallback to original tools in options 65 | } 66 | } else { 67 | // Use the statically resolved tools 68 | currentTools = resolvedTools || []; 69 | logger?.debug?.(`Using ${currentTools.length} statically configured tools.`); 70 | } 71 | 72 | // --- Tool Merging/Overriding Logic --- 73 | // Decide how to handle tools passed in options vs tools from the plugin. 74 | // Option 1: Plugin overrides completely (current implementation) 75 | options.tools = currentTools; 76 | 77 | // Option 2: Merge (careful about duplicates) 78 | /* 79 | const combinedTools = [...(options.tools || [])]; 80 | currentTools.forEach(pluginTool => { 81 | if (!combinedTools.some(optTool => optTool.function.name === pluginTool.function.name)) { 82 | combinedTools.push(pluginTool); 83 | } else { 84 | logger?.debug?.(`Tool '${pluginTool.function.name}' from plugin skipped (already present in options).`); 85 | } 86 | }); 87 | options.tools = combinedTools; 88 | logger?.debug?.(`Merged tools. Total: ${options.tools.length}`); 89 | */ 90 | 91 | // Option 3: Plugin adds only if options.tools is empty 92 | /* 93 | if (!options.tools || options.tools.length === 0) { 94 | options.tools = currentTools; 95 | logger?.debug?.(`Applied plugin tools as none were provided in options.`); 96 | } else { 97 | logger?.debug?.(`Skipping plugin tools as tools were provided in options.`); 98 | } 99 | */ 100 | 101 | // Call the original chat method with potentially modified options 102 | return originalChat(options); 103 | }; 104 | 105 | // Ensure the overridden method has the same 'this' context if accessed differently (less common) 106 | // client.chat = client.chat.bind(client); // Re-bind might be necessary in some edge cases 107 | 108 | logger?.log?.('Custom tool registry plugin initialized and chat method overridden.'); 109 | } 110 | }; 111 | } -------------------------------------------------------------------------------- /src/security/types.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/types.ts 2 | import { 3 | Tool as BaseTool, 4 | UserAuthInfo as BaseUserAuthInfo, 5 | SecurityConfig as BaseSecurityConfig, 6 | ToolCallEvent as BaseToolCallEvent, 7 | RateLimit as BaseRateLimit, 8 | DangerousArgumentsConfig as BaseDangerousArgumentsConfig, 9 | UserAuthConfig as BaseUserAuthConfig, 10 | ToolAccessConfig as BaseToolAccessConfig, 11 | RoleConfig as BaseRoleConfig, 12 | } from '../types'; 13 | import type { Logger } from '../utils/logger'; 14 | import type { SimpleEventEmitter } from '../utils/simple-event-emitter'; 15 | 16 | // --- Extended/Specific Types for Security Module --- 17 | 18 | // Rename RateLimit to avoid conflict with base type 19 | export interface ExtendedRateLimit extends BaseRateLimit { 20 | interval?: string | number; 21 | } 22 | // Use the extended type within the security module where interval is needed 23 | // Base RateLimit is still available via import from '../types' if needed elsewhere 24 | 25 | // Rename UserAuthInfo to avoid conflict with base type 26 | export type ExtendedUserAuthInfo = BaseUserAuthInfo & { 27 | username?: string; 28 | roles?: string[]; 29 | permissions?: string[]; 30 | metadata?: Record; 31 | }; 32 | 33 | // Rename DangerousArgumentsConfig to avoid conflict with base type 34 | export type ExtendedDangerousArgumentsConfig = BaseDangerousArgumentsConfig & { 35 | extendablePatterns?: Array; 36 | auditOnlyMode?: boolean; 37 | specificKeyRules?: Record; 38 | }; 39 | 40 | // Re-export base types that are used directly by security interfaces but not extended 41 | export type UserAuthConfig = BaseUserAuthConfig; 42 | export type ToolAccessConfig = BaseToolAccessConfig; 43 | export type RoleConfig = BaseRoleConfig; 44 | 45 | // Rename SecurityConfig to avoid conflict with base type 46 | export type ExtendedSecurityConfig = BaseSecurityConfig & { 47 | debug: boolean; // Make debug required 48 | allowUnauthenticatedAccess?: boolean; 49 | dangerousArguments?: ExtendedDangerousArgumentsConfig; // Use extended type 50 | toolConfig?: Record; 52 | }>; 53 | }; 54 | 55 | export interface SecurityContext { 56 | config: ExtendedSecurityConfig; // Use extended SecurityConfig 57 | debug: boolean; 58 | userId?: string; 59 | toolName?: string; 60 | } 61 | 62 | // --- Interfaces for Security Components --- 63 | 64 | export interface ISecurityManager { 65 | getConfig(): ExtendedSecurityConfig; // Use extended SecurityConfig 66 | updateConfig(config: Partial): void; // Use extended SecurityConfig 67 | authenticateUser(accessToken?: string): Promise; // Use extended UserAuthInfo 68 | createAccessToken(userInfo: Omit, expiresIn?: string | number): string; // Use extended UserAuthInfo 69 | checkToolAccessAndArgs(tool: BaseTool, userInfo: ExtendedUserAuthInfo | null, args?: any): Promise; // Use BaseTool, extended UserAuthInfo 70 | logToolCall(event: ExtendedToolCallEvent): void; 71 | isDebugEnabled(): boolean; 72 | setDebugMode(debug: boolean): void; 73 | on(event: string, handler: (event: any) => void): ISecurityManager; 74 | off(event: string, handler: (event: any) => void): ISecurityManager; 75 | clearTokenCache(): void; 76 | clearRateLimitCounters(userId?: string): void; 77 | destroy?(): void; 78 | } 79 | 80 | export interface SecurityCheckParams { 81 | tool: BaseTool; 82 | userInfo: ExtendedUserAuthInfo | null; // Use extended UserAuthInfo 83 | args?: any; 84 | context: SecurityContext; 85 | securityManager?: ISecurityManager; 86 | } 87 | 88 | export interface TokenValidationResult { 89 | isValid: boolean; 90 | userInfo?: ExtendedUserAuthInfo; // Use extended UserAuthInfo 91 | error?: Error; 92 | } 93 | 94 | export interface TokenConfig { 95 | payload: Omit; // Use extended UserAuthInfo 96 | expiresIn?: string | number; 97 | } 98 | 99 | export interface RateLimitParams { 100 | userId: string; 101 | toolName: string; 102 | rateLimit: ExtendedRateLimit; // Use extended RateLimit 103 | source?: string; 104 | } 105 | 106 | export interface RateLimitResult { 107 | allowed: boolean; 108 | currentCount: number; 109 | limit: number; 110 | resetTime: Date; 111 | timeLeft?: number; 112 | } 113 | 114 | // --- Interfaces for Sub-Managers --- 115 | 116 | export interface IAuthManager { 117 | authenticateUser(accessToken?: string): Promise; // Use extended UserAuthInfo 118 | createAccessToken(config: TokenConfig): string; 119 | validateToken(token: string): Promise; 120 | clearTokenCache(): void; 121 | setDebugMode(debug: boolean): void; 122 | updateSecret?(newSecret: string | undefined): void; 123 | destroy?(): void; 124 | } 125 | 126 | export interface IAccessControlManager { 127 | checkAccess(params: SecurityCheckParams): Promise; 128 | setDebugMode(debug: boolean): void; 129 | destroy?(): void; 130 | } 131 | 132 | export interface IRateLimitManager { 133 | checkRateLimit(params: RateLimitParams): RateLimitResult; 134 | clearLimits(userId?: string): void; 135 | setDebugMode(debug: boolean): void; 136 | destroy?(): void; 137 | } 138 | 139 | export interface IArgumentSanitizer { 140 | validateArguments(tool: BaseTool, args: any, context: SecurityContext): Promise; 141 | setDebugMode(debug: boolean): void; 142 | destroy?(): void; 143 | } 144 | 145 | // --- Event Payloads --- 146 | 147 | export interface ExtendedToolCallEvent extends BaseToolCallEvent { 148 | duration?: number; 149 | } -------------------------------------------------------------------------------- /src/history/redis-history-plugin.ts: -------------------------------------------------------------------------------- 1 | // Path: src/history/redis-history-plugin.ts 2 | import type { OpenRouterPlugin, IHistoryStorage, HistoryEntry, Message } from '../types'; // Import HistoryEntry 3 | import type { OpenRouterClient } from '../client'; 4 | import Redis, { RedisOptions } from 'ioredis'; 5 | import { UnifiedHistoryManager } from './unified-history-manager'; 6 | 7 | class RedisHistoryStorage implements IHistoryStorage { 8 | private redis: Redis; 9 | private prefix: string; 10 | 11 | constructor(redisInstance: Redis, prefix: string = 'chat_history:') { 12 | if (!redisInstance) { 13 | throw new Error("[RedisHistoryStorage] Redis instance is required."); 14 | } 15 | this.redis = redisInstance; 16 | this.prefix = prefix; 17 | } 18 | 19 | private key(rawKey: string): string { 20 | const safeKey = rawKey.replace(/[^a-zA-Z0-9_.\-:]/g, '_'); 21 | return `${this.prefix}${safeKey}`; 22 | } 23 | 24 | async load(key: string): Promise { 25 | const redisKey = this.key(key); 26 | try { 27 | const data = await this.redis.get(redisKey); 28 | if (!data) return []; 29 | try { 30 | const parsedData = JSON.parse(data); 31 | if (!Array.isArray(parsedData)) { 32 | console.error(`[RedisHistoryStorage] Data for key ${redisKey} is not an array.`); 33 | return []; 34 | } 35 | 36 | // Basic check for old vs new format 37 | if (parsedData.length > 0 && parsedData[0].role && parsedData[0].content !== undefined) { 38 | console.warn(`[RedisHistoryStorage] Data for key ${redisKey} appears to be in the old Message[] format. Converting to HistoryEntry[] without metadata.`); 39 | return parsedData.map((msg: Message) => ({ message: msg, apiCallMetadata: null })); 40 | } else if (parsedData.length === 0 || (parsedData[0].message && parsedData[0].message.role)) { 41 | return parsedData as HistoryEntry[]; 42 | } else { 43 | console.warn(`[RedisHistoryStorage] Data for key ${redisKey} has an unrecognized format. Returning empty.`); 44 | return []; 45 | } 46 | 47 | } catch (parseError) { 48 | console.error(`[RedisHistoryStorage] Failed to parse JSON data for key ${redisKey}:`, parseError); 49 | return []; 50 | } 51 | } catch (redisError) { 52 | console.error(`[RedisHistoryStorage] Redis error loading key ${redisKey}:`, redisError); 53 | throw redisError; 54 | } 55 | } 56 | 57 | async save(key: string, entries: HistoryEntry[]): Promise { 58 | const redisKey = this.key(key); 59 | try { 60 | const data = JSON.stringify(entries); 61 | await this.redis.set(redisKey, data); 62 | } catch (redisError) { 63 | console.error(`[RedisHistoryStorage] Redis error saving key ${redisKey}:`, redisError); 64 | throw redisError; 65 | } 66 | } 67 | 68 | async delete(key: string): Promise { 69 | const redisKey = this.key(key); 70 | try { 71 | await this.redis.del(redisKey); 72 | } catch (redisError) { 73 | console.error(`[RedisHistoryStorage] Redis error deleting key ${redisKey}:`, redisError); 74 | throw redisError; 75 | } 76 | } 77 | 78 | async listKeys(): Promise { 79 | const pattern = `${this.prefix}*`; 80 | try { 81 | const keys = await this.redis.keys(pattern); 82 | return keys.map((k: string) => k.slice(this.prefix.length)); 83 | } catch (redisError) { 84 | console.error(`[RedisHistoryStorage] Redis error listing keys with pattern ${pattern}:`, redisError); 85 | throw redisError; 86 | } 87 | } 88 | 89 | async destroy(): Promise { 90 | if (this.redis.status === 'ready' || this.redis.status === 'connecting') { 91 | await this.redis.quit(); 92 | } 93 | } 94 | } 95 | 96 | export function createRedisHistoryPlugin( 97 | redisConfig: RedisOptions | string, 98 | prefix: string = 'chat_history:', 99 | historyManagerOptions: ConstructorParameters[1] = {} 100 | ): OpenRouterPlugin { 101 | return { 102 | async init(client: OpenRouterClient) { 103 | let redisInstance: Redis | null = null; 104 | try { 105 | redisInstance = new Redis(redisConfig as any); 106 | 107 | redisInstance.on('error', (err) => { 108 | (client as any)['logger']?.error?.('[RedisHistoryPlugin] Redis connection error:', err); 109 | }); 110 | 111 | await redisInstance.ping(); 112 | (client as any)['logger']?.log?.('[RedisHistoryPlugin] Connected to Redis successfully.'); 113 | 114 | const adapter = new RedisHistoryStorage(redisInstance, prefix); 115 | 116 | const oldManager = client.getHistoryManager(); 117 | if (oldManager && typeof oldManager.destroy === 'function') { 118 | await oldManager.destroy(); 119 | } 120 | 121 | // Pass logger correctly 122 | const newManager = new UnifiedHistoryManager(adapter, historyManagerOptions, (client as any)['logger']?.withPrefix('UnifiedHistoryManager')); 123 | (client as any)['unifiedHistoryManager'] = newManager; 124 | 125 | (client as any)['logger']?.log?.('Redis history plugin initialized and replaced existing history manager.'); 126 | 127 | } catch (error) { 128 | (client as any)['logger']?.error?.('[RedisHistoryPlugin] Failed to initialize Redis connection or plugin:', error); 129 | if (redisInstance && redisInstance.status !== 'end') { 130 | await redisInstance.quit(); 131 | } 132 | throw new Error(`RedisHistoryPlugin initialization failed: ${(error as Error).message}`); 133 | } 134 | } 135 | }; 136 | } -------------------------------------------------------------------------------- /examples/streaming-security-test.js: -------------------------------------------------------------------------------- 1 | const { OpenRouterClient } = require('../dist'); 2 | 3 | async function testSecurity() { 4 | console.log('='.repeat(60)); 5 | console.log('Testing Streaming Security & Cost Tracking'); 6 | console.log('='.repeat(60)); 7 | 8 | // ========================================== 9 | // Test 1: Cost Tracking Enabled 10 | // ========================================== 11 | console.log('\n💰 Test 1: Cost Tracking in Streaming Mode\n'); 12 | 13 | const clientWithCost = new OpenRouterClient({ 14 | apiKey: process.env.OPENROUTER_API_KEY || 'token', 15 | model: 'x-ai/grok-4-fast', 16 | enableCostTracking: true, 17 | debug: false 18 | }); 19 | 20 | try { 21 | const result = await clientWithCost.chatStream({ 22 | prompt: 'Say hello in 5 words', 23 | streamCallbacks: { 24 | onContent: (content) => process.stdout.write(content) 25 | } 26 | }); 27 | 28 | console.log('\n\n✓ Streaming result:'); 29 | console.log(' Cost:', result.cost !== null ? `$${result.cost?.toFixed(6)}` : 'Not calculated'); 30 | console.log(' Duration:', `${result.durationMs}ms`); 31 | console.log(' Usage:', result.usage); 32 | } catch (error) { 33 | console.error('❌ Error:', error.message); 34 | } 35 | 36 | await clientWithCost.destroy(); 37 | 38 | // ========================================== 39 | // Test 2: Authentication Required 40 | // ========================================== 41 | console.log('\n\n' + '='.repeat(60)); 42 | console.log('🔒 Test 2: Authentication Required (No Token)\n'); 43 | 44 | const clientWithAuth = new OpenRouterClient({ 45 | apiKey: process.env.OPENROUTER_API_KEY || 'token', 46 | model: 'x-ai/grok-4-fast', 47 | security: { 48 | requireAuthentication: true, 49 | userAuthentication: { 50 | type: 'jwt', 51 | jwtSecret: 'test-secret-key-123' 52 | } 53 | }, 54 | debug: false 55 | }); 56 | 57 | try { 58 | await clientWithAuth.chatStream({ 59 | prompt: 'Test without token' 60 | }); 61 | console.log('❌ FAILED: Should have thrown authentication error'); 62 | } catch (error) { 63 | if (error.message.includes('Authentication') || error.message.includes('required')) { 64 | console.log('✓ PASSED: Authentication correctly required'); 65 | console.log(' Error:', error.message); 66 | } else { 67 | console.log('❌ FAILED: Wrong error type:', error.message); 68 | } 69 | } 70 | 71 | // ========================================== 72 | // Test 3: Valid Token Accepted 73 | // ========================================== 74 | console.log('\n\n' + '='.repeat(60)); 75 | console.log('✅ Test 3: Valid Token Accepted\n'); 76 | 77 | const validToken = clientWithAuth.createAccessToken({ 78 | userId: 'test-user-123', 79 | role: 'admin' 80 | }); 81 | 82 | try { 83 | const result = await clientWithAuth.chatStream({ 84 | prompt: 'Say hi', 85 | accessToken: validToken, 86 | streamCallbacks: { 87 | onContent: (content) => process.stdout.write(content) 88 | } 89 | }); 90 | 91 | console.log('\n\n✓ PASSED: Valid token accepted'); 92 | console.log(' Duration:', `${result.durationMs}ms`); 93 | } catch (error) { 94 | console.error('❌ FAILED:', error.message); 95 | } 96 | 97 | await clientWithAuth.destroy(); 98 | 99 | // ========================================== 100 | // Test 4: Error Event Emission 101 | // ========================================== 102 | console.log('\n\n' + '='.repeat(60)); 103 | console.log('📡 Test 4: Error Event Emission\n'); 104 | 105 | const clientWithEvents = new OpenRouterClient({ 106 | apiKey: 'invalid-key-for-testing', 107 | model: 'x-ai/grok-4-fast', 108 | debug: false 109 | }); 110 | 111 | let errorEventReceived = false; 112 | clientWithEvents.on('error', (error) => { 113 | errorEventReceived = true; 114 | console.log('✓ Error event received:', error.message); 115 | }); 116 | 117 | try { 118 | await clientWithEvents.chatStream({ 119 | prompt: 'This should fail' 120 | }); 121 | console.log('❌ FAILED: Should have thrown error'); 122 | } catch (error) { 123 | if (errorEventReceived) { 124 | console.log('✓ PASSED: Error event was emitted'); 125 | } else { 126 | console.log('⚠️ WARNING: Error thrown but event not emitted'); 127 | } 128 | } 129 | 130 | await clientWithEvents.destroy(); 131 | 132 | // ========================================== 133 | // Test 5: Stream Cleanup on Abort 134 | // ========================================== 135 | console.log('\n\n' + '='.repeat(60)); 136 | console.log('🛑 Test 5: Stream Cleanup on Abort\n'); 137 | 138 | const clientForAbort = new OpenRouterClient({ 139 | apiKey: process.env.OPENROUTER_API_KEY || 'token', 140 | model: 'x-ai/grok-4-fast', 141 | debug: false 142 | }); 143 | 144 | const abortController = new AbortController(); 145 | 146 | setTimeout(() => { 147 | console.log('Aborting stream...'); 148 | abortController.abort(); 149 | }, 500); 150 | 151 | try { 152 | await clientForAbort.chatStream({ 153 | prompt: 'Write a long story', 154 | streamCallbacks: { 155 | onContent: (content) => process.stdout.write(content) 156 | } 157 | }, abortController.signal); 158 | console.log('\n❌ FAILED: Should have been aborted'); 159 | } catch (error) { 160 | if (error.message?.includes('cancel') || error.message?.includes('abort')) { 161 | console.log('\n✓ PASSED: Stream properly aborted and cleaned up'); 162 | } else { 163 | console.log('\n⚠️ Unexpected error:', error.message); 164 | } 165 | } 166 | 167 | await clientForAbort.destroy(); 168 | 169 | console.log('\n\n' + '='.repeat(60)); 170 | console.log('✨ All Security & Tracking Tests Complete!'); 171 | console.log('='.repeat(60)); 172 | } 173 | 174 | testSecurity().catch(console.error); 175 | -------------------------------------------------------------------------------- /src/plugins/billing-cost-tracker-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { OpenRouterPlugin, ModelPricingInfo, UsageInfo } from '../types'; 2 | import type { OpenRouterClient } from '../client'; 3 | // Use relative path for CostTracker import 4 | import { CostTracker } from '../cost-tracker'; 5 | // Import AxiosInstance if needed for constructor call 6 | import { AxiosInstance } from 'axios'; 7 | 8 | /** 9 | * Example plugin that replaces the default CostTracker with an extended version. 10 | * This extended version could, for example, push usage/cost data to an external billing service. 11 | */ 12 | export function createBillingCostTrackerPlugin( 13 | // Add any options needed for the billing logic, e.g., billing API endpoint 14 | billingOptions: { apiEndpoint?: string; apiKey?: string } = {} 15 | ): OpenRouterPlugin { 16 | return { 17 | async init(client: OpenRouterClient) { 18 | const logger = client['logger']?.withPrefix('BillingCostTrackerPlugin'); // Get client logger 19 | 20 | // Get the existing CostTracker instance from the client 21 | const existingCostTracker = client.getCostTracker(); 22 | 23 | // If cost tracking wasn't enabled initially, this plugin might not make sense 24 | if (!existingCostTracker) { 25 | logger?.warn?.('Cannot initialize BillingCostTracker: Cost tracking is not enabled on the client.'); 26 | return; 27 | } 28 | 29 | // Define the extended CostTracker class 30 | class BillingCostTracker extends CostTracker { 31 | private billingApiEndpoint?: string; 32 | private billingApiKey?: string; 33 | 34 | constructor( 35 | axiosInstance: AxiosInstance, // Receive Axios instance 36 | parentLogger: typeof logger, // Receive logger 37 | // Pass necessary config from existing tracker and plugin options 38 | config: ConstructorParameters[2] & { billingApiEndpoint?: string, billingApiKey?: string } 39 | ) { 40 | super(axiosInstance, parentLogger, config); // Call parent constructor 41 | this.billingApiEndpoint = config.billingApiEndpoint; 42 | this.billingApiKey = config.billingApiKey; 43 | this['logger'].log('BillingCostTracker extension initialized.'); // Use internal logger 44 | } 45 | 46 | // Override calculateCost to add billing logic 47 | override calculateCost(modelId: string, usage: UsageInfo | null | undefined): number | null { 48 | // Calculate cost using the parent's logic 49 | const cost = super.calculateCost(modelId, usage); 50 | 51 | // If cost calculation was successful, report it 52 | if (cost !== null && usage) { 53 | this['logger'].debug(`Calculated cost: $${cost.toFixed(8)}. Reporting to billing system...`); 54 | // --- Billing API Call Placeholder --- 55 | // Use this.billingApiEndpoint, this.billingApiKey, etc. 56 | // Make an async call to your billing service here. 57 | // Example (pseudo-code): 58 | /* 59 | this.reportToBilling({ 60 | timestamp: Date.now(), 61 | model: modelId, 62 | promptTokens: usage.prompt_tokens || 0, 63 | completionTokens: usage.completion_tokens || 0, 64 | totalTokens: usage.total_tokens || 0, 65 | calculatedCost: cost, 66 | // Add user identifier if available from context (needs passing down) 67 | // userId: context?.userInfo?.userId 68 | }).catch(err => { 69 | this['logger'].error('Failed to report usage to billing system:', err); 70 | }); 71 | */ 72 | this['logger'].log(`[Placeholder] Reported usage for ${modelId} (Cost: ${cost.toFixed(8)}) to billing system.`); 73 | } 74 | 75 | return cost; // Return the calculated cost 76 | } 77 | 78 | // Example placeholder for the reporting function 79 | /* 80 | private async reportToBilling(data: any): Promise { 81 | if (!this.billingApiEndpoint) { 82 | this['logger'].warn('Billing API endpoint not configured. Skipping report.'); 83 | return; 84 | } 85 | try { 86 | // Use the shared axiosInstance or create a new one for billing 87 | await this['axiosInstance'].post(this.billingApiEndpoint, data, { 88 | headers: { 'Authorization': `Bearer ${this.billingApiKey || ''}` } 89 | }); 90 | this['logger'].debug('Successfully reported usage to billing system.'); 91 | } catch (error) { 92 | // Handle billing API errors 93 | throw new Error(`Billing API request failed: ${mapError(error).message}`); 94 | } 95 | } 96 | */ 97 | } // End of BillingCostTracker class 98 | 99 | // Create an instance of the new BillingCostTracker 100 | // We need to access potentially private fields of the existing tracker to re-initialize 101 | // This is a limitation of this pattern and might require making fields protected or providing getters. 102 | // Accessing private fields directly is generally discouraged. 103 | let initialPrices = {}; 104 | let trackerApiBaseUrl = ''; 105 | let trackerAxiosInstance: AxiosInstance | undefined = undefined; 106 | let trackerLogger: typeof logger | undefined = undefined; 107 | 108 | try { 109 | // Attempt to access necessary config from the existing tracker 110 | // This relies on implementation details and might break. 111 | initialPrices = existingCostTracker.getAllModelPrices(); 112 | trackerApiBaseUrl = existingCostTracker['apiBaseUrl']; // Access potentially private field 113 | trackerAxiosInstance = existingCostTracker['axiosInstance']; // Access potentially private field 114 | trackerLogger = existingCostTracker['logger']; // Access potentially private field 115 | 116 | if (!trackerAxiosInstance || !trackerLogger || !trackerApiBaseUrl) { 117 | throw new Error("Could not retrieve necessary configuration from existing CostTracker instance."); 118 | } 119 | 120 | } catch (e) { 121 | logger?.error(`Failed to get necessary configuration from existing CostTracker for BillingCostTracker plugin: ${(e as Error).message}`); 122 | return; // Abort initialization 123 | } 124 | 125 | 126 | const billingTracker = new BillingCostTracker( 127 | trackerAxiosInstance, // Pass the Axios instance 128 | trackerLogger, // Pass the logger 129 | { 130 | // Inherit cost tracking settings 131 | enableCostTracking: true, // Must be true for cost tracker to work 132 | priceRefreshIntervalMs: existingCostTracker['refreshIntervalMs'], // Access potentially private field 133 | initialModelPrices: initialPrices, 134 | apiBaseUrl: trackerApiBaseUrl, 135 | // Add billing specific options 136 | billingApiEndpoint: billingOptions.apiEndpoint, 137 | billingApiKey: billingOptions.apiKey, 138 | } 139 | ); 140 | 141 | // Replace the client's cost tracker with the new billing-aware instance 142 | client.setCostTracker(billingTracker); 143 | logger?.log?.('Billing CostTracker plugin initialized and replaced default tracker.'); 144 | } 145 | }; 146 | } -------------------------------------------------------------------------------- /src/security/access-control-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/access-control-manager.ts 2 | import { 3 | IAccessControlManager, 4 | SecurityCheckParams, 5 | SecurityContext, 6 | ExtendedUserAuthInfo as UserAuthInfo, // Use renamed type locally 7 | ExtendedSecurityConfig as SecurityConfig // Use renamed type locally 8 | } from './types'; 9 | import { Tool } from '../types'; 10 | import { Logger } from '../utils/logger'; 11 | import { AccessDeniedError } from '../utils/error'; 12 | import type { SimpleEventEmitter } from '../utils/simple-event-emitter'; 13 | 14 | export class AccessControlManager implements IAccessControlManager { 15 | private eventEmitter: SimpleEventEmitter; 16 | private logger: Logger; 17 | private debugMode: boolean = false; 18 | 19 | constructor(eventEmitter: SimpleEventEmitter, logger: Logger) { 20 | this.eventEmitter = eventEmitter; 21 | this.logger = logger; 22 | } 23 | 24 | setDebugMode(debug: boolean): void { 25 | this.debugMode = debug; 26 | if (typeof (this.logger as any).setDebug === 'function') { 27 | (this.logger as any).setDebug(debug); 28 | } 29 | } 30 | 31 | async checkAccess(params: SecurityCheckParams): Promise { 32 | const { tool, userInfo, context } = params; 33 | const toolName = context.toolName || tool.function?.name || tool.name || 'unknown_tool'; 34 | const userId = userInfo?.userId || 'anonymous'; 35 | 36 | this.logger.debug(`Checking access to tool '${toolName}' for user '${userId}'...`); 37 | 38 | // Ensure context uses the extended SecurityConfig type 39 | const securityContext: SecurityContext = { 40 | config: context?.config || { defaultPolicy: 'deny-all', debug: this.debugMode }, 41 | debug: context?.debug ?? this.debugMode, 42 | userId: context?.userId ?? userInfo?.userId, 43 | toolName: context?.toolName ?? toolName, 44 | }; 45 | 46 | const denialReason = await this.getAccessDenialReason(tool, userInfo, securityContext); 47 | 48 | if (denialReason) { 49 | this.logger.warn(`Access DENIED for tool '${toolName}' (User: ${userId}). Reason: ${denialReason}`); 50 | this.eventEmitter.emit('access:denied', { userId: userInfo?.userId, toolName: toolName, reason: denialReason }); 51 | throw new AccessDeniedError(`Access to tool '${toolName}' denied: ${denialReason}`); 52 | } else { 53 | this.logger.debug(`Access GRANTED for tool '${toolName}' (User: ${userId}).`); 54 | this.eventEmitter.emit('access:granted', { userId: userInfo?.userId, toolName: toolName }); 55 | return true; 56 | } 57 | } 58 | 59 | // Accept extended UserAuthInfo and SecurityConfig types 60 | private async getAccessDenialReason( 61 | tool: Tool, 62 | userInfo: UserAuthInfo | null, // Now ExtendedUserAuthInfo 63 | context: SecurityContext // Context now contains ExtendedSecurityConfig 64 | ): Promise { 65 | const toolName = context.toolName!; 66 | const config = context.config; // This is now ExtendedSecurityConfig 67 | const toolSecurity = tool.security; 68 | 69 | // --- 1. Check Tool-Specific Requirements --- 70 | if (toolSecurity?.requiredRole) { 71 | if (!userInfo) return `Authentication required (tool requires role).`; 72 | const requiredRoles = Array.isArray(toolSecurity.requiredRole) ? toolSecurity.requiredRole : [toolSecurity.requiredRole]; 73 | const userRoles = [userInfo.role, ...(userInfo.roles || [])].filter(Boolean) as string[]; 74 | const hasRequiredRole = requiredRoles.some((reqRole: string) => userRoles.includes(reqRole)); 75 | if (!hasRequiredRole) { 76 | return `Tool requires one of the following roles: ${requiredRoles.join(', ')}. User has: ${userRoles.join(', ') || 'none'}.`; 77 | } 78 | } 79 | if (toolSecurity?.requiredScopes) { 80 | if (!userInfo) return `Authentication required (tool requires permissions/scopes).`; 81 | const requiredScopes = Array.isArray(toolSecurity.requiredScopes) ? toolSecurity.requiredScopes : [toolSecurity.requiredScopes]; 82 | const userScopes = [...(userInfo.scopes || []), ...(userInfo.permissions || [])].filter(Boolean) as string[]; 83 | const hasAllRequiredScopes = requiredScopes.every((reqScope: string) => userScopes.includes(reqScope)); 84 | if (!hasAllRequiredScopes) { 85 | const missingScopes = requiredScopes.filter((rs: string) => !userScopes.includes(rs)); 86 | return `Tool requires permissions/scopes: ${requiredScopes.join(', ')}. User is missing: ${missingScopes.join(', ')}.`; 87 | } 88 | } 89 | 90 | // --- 2. Check Configuration-Based Rules --- 91 | const toolAccessConfig = config.toolAccess?.[toolName]; 92 | const globalToolAccessConfig = config.toolAccess?.['*']; 93 | const userRole = userInfo?.role; 94 | const roleSpecificConfig = userRole ? config.roles?.roles?.[userRole] : undefined; 95 | 96 | const isAllowedBy = (accessConfig: typeof toolAccessConfig | undefined): boolean => { 97 | if (!accessConfig) return false; 98 | if (accessConfig.allow === false) return false; 99 | if (accessConfig.allow === true) return true; 100 | 101 | if (userInfo?.role && accessConfig.roles) { 102 | const allowedRoles = Array.isArray(accessConfig.roles) ? accessConfig.roles : [accessConfig.roles]; 103 | if (allowedRoles.includes(userInfo.role)) return true; 104 | } 105 | if (userInfo?.apiKey && accessConfig.allowedApiKeys) { 106 | if (accessConfig.allowedApiKeys.includes(userInfo.apiKey) || accessConfig.allowedApiKeys.includes('*')) return true; 107 | } 108 | if (userInfo?.scopes && accessConfig.scopes) { 109 | const requiredScopes = Array.isArray(accessConfig.scopes) ? accessConfig.scopes : [accessConfig.scopes]; 110 | const userScopes = [...(userInfo.scopes || []), ...(userInfo.permissions || [])].filter(Boolean) as string[]; 111 | if (requiredScopes.every((reqScope: string) => userScopes.includes(reqScope))) return true; 112 | } 113 | 114 | return false; 115 | }; 116 | 117 | const allowedByToolSpecific = isAllowedBy(toolAccessConfig); 118 | const allowedByGlobal = isAllowedBy(globalToolAccessConfig) && toolAccessConfig?.allow !== false; 119 | 120 | let allowedByRole = false; 121 | if (roleSpecificConfig) { 122 | const allowedTools = roleSpecificConfig.allowedTools; 123 | if (allowedTools === '*' || (Array.isArray(allowedTools) && allowedTools.includes(toolName))) { 124 | allowedByRole = true; 125 | } 126 | } 127 | 128 | // --- 3. Apply Default Policy --- 129 | if (config.defaultPolicy === 'deny-all') { 130 | if (!allowedByToolSpecific && !allowedByGlobal && !allowedByRole) { 131 | let reason = `Access denied by 'deny-all' policy. No matching allow rule found`; 132 | if (userInfo) { 133 | reason += ` for user '${userInfo.userId}' (Role: ${userInfo.role || 'none'})`; 134 | } 135 | if (toolAccessConfig) reason += ` (Tool-specific config exists).`; 136 | if (globalToolAccessConfig) reason += ` (Global tool config exists).`; 137 | if (roleSpecificConfig) reason += ` (Role config for '${userInfo?.role}' exists).`; 138 | return reason + '.'; 139 | } 140 | } else if (config.defaultPolicy === 'allow-all') { 141 | if (toolAccessConfig?.allow === false) { 142 | return `Access explicitly denied by toolAccess configuration for '${toolName}'.`; 143 | } 144 | } 145 | 146 | return null; // Access allowed 147 | } 148 | 149 | destroy(): void { 150 | this.logger.log("AccessControlManager destroyed."); 151 | } 152 | } -------------------------------------------------------------------------------- /examples/streaming-demo.js: -------------------------------------------------------------------------------- 1 | const { OpenRouterClient } = require('../dist'); 2 | 3 | async function main() { 4 | const client = new OpenRouterClient({ 5 | apiKey: process.env.OPENROUTER_API_KEY || 'token', 6 | model: 'x-ai/grok-4-fast', 7 | debug: false 8 | }); 9 | 10 | console.log('='.repeat(60)); 11 | console.log('OpenRouter Streaming Examples'); 12 | console.log('='.repeat(60)); 13 | 14 | // ========================================== 15 | // Example 1: Basic Streaming 16 | // ========================================== 17 | console.log('\n📝 Example 1: Basic Streaming\n'); 18 | 19 | try { 20 | const result = await client.chatStream({ 21 | prompt: 'Write a haiku about coding', 22 | streamCallbacks: { 23 | onContent: (content) => { 24 | process.stdout.write(content); 25 | }, 26 | onComplete: (fullContent, usage) => { 27 | console.log('\n\n✓ Complete!'); 28 | console.log('Tokens:', usage?.total_tokens); 29 | } 30 | } 31 | }); 32 | } catch (error) { 33 | console.error('❌ Error:', error.message); 34 | } 35 | 36 | // ========================================== 37 | // Example 2: Streaming with History 38 | // ========================================== 39 | console.log('\n\n' + '='.repeat(60)); 40 | console.log('📚 Example 2: Streaming with Conversation History\n'); 41 | 42 | try { 43 | // First message 44 | await client.chatStream({ 45 | prompt: 'My favorite color is blue.', 46 | user: 'demo-user', 47 | streamCallbacks: { 48 | onContent: (content) => process.stdout.write(content) 49 | } 50 | }); 51 | 52 | console.log('\n'); 53 | 54 | // Second message - uses history 55 | await client.chatStream({ 56 | prompt: 'What did I just tell you?', 57 | user: 'demo-user', 58 | streamCallbacks: { 59 | onContent: (content) => process.stdout.write(content), 60 | onComplete: () => console.log('\n\n✓ History working!') 61 | } 62 | }); 63 | } catch (error) { 64 | console.error('❌ Error:', error.message); 65 | } 66 | 67 | // ========================================== 68 | // Example 3: Streaming with AUTO Tool Execution 69 | // ========================================== 70 | console.log('\n\n' + '='.repeat(60)); 71 | console.log('🔧 Example 3: Streaming with AUTO Tool Execution\n'); 72 | 73 | const tools = [ 74 | { 75 | type: 'function', 76 | function: { 77 | name: 'get_weather', 78 | description: 'Get current weather for a location', 79 | parameters: { 80 | type: 'object', 81 | properties: { 82 | location: { type: 'string', description: 'City name' }, 83 | unit: { type: 'string', enum: ['celsius', 'fahrenheit'] } 84 | }, 85 | required: ['location'] 86 | } 87 | }, 88 | execute: async (args) => { 89 | return { 90 | location: args.location, 91 | temperature: 22, 92 | unit: args.unit || 'celsius', 93 | condition: 'Sunny' 94 | }; 95 | } 96 | }, 97 | { 98 | type: 'function', 99 | function: { 100 | name: 'calculate', 101 | description: 'Perform math calculation', 102 | parameters: { 103 | type: 'object', 104 | properties: { 105 | operation: { type: 'string', enum: ['add', 'subtract', 'multiply', 'divide'] }, 106 | a: { type: 'number' }, 107 | b: { type: 'number' } 108 | }, 109 | required: ['operation', 'a', 'b'] 110 | } 111 | }, 112 | execute: async (args) => { 113 | const ops = { 114 | add: args.a + args.b, 115 | subtract: args.a - args.b, 116 | multiply: args.a * args.b, 117 | divide: args.a / args.b 118 | }; 119 | return ops[args.operation]; 120 | } 121 | } 122 | ]; 123 | 124 | try { 125 | const result = await client.chatStream({ 126 | prompt: 'What is the weather in Paris and what is 25 * 4?', 127 | tools: tools, // 🔥 Tools execute automatically! 128 | streamCallbacks: { 129 | onContent: (content) => process.stdout.write(content), 130 | onToolCallExecuting: (name, args) => { 131 | console.log(`\n\n🔧 Executing: ${name}(${JSON.stringify(args)})`); 132 | }, 133 | onToolCallResult: (name, result) => { 134 | console.log(`✅ Result: ${JSON.stringify(result)}`); 135 | console.log('\nContinuing stream...\n'); 136 | } 137 | } 138 | }); 139 | 140 | console.log('\n\n✓ Complete with tool execution!'); 141 | console.log('💡 Tip: Tools execute automatically when provided'); 142 | } catch (error) { 143 | console.error('❌ Error:', error.message); 144 | } 145 | 146 | // ========================================== 147 | // Example 4: Abort Streaming 148 | // ========================================== 149 | console.log('\n\n' + '='.repeat(60)); 150 | console.log('🛑 Example 4: Abort Streaming After 1 Second\n'); 151 | 152 | const abortController = new AbortController(); 153 | 154 | setTimeout(() => { 155 | console.log('\n\n⏸️ Aborting stream...'); 156 | abortController.abort(); 157 | }, 1000); 158 | 159 | try { 160 | await client.chatStream({ 161 | prompt: 'Write a very long story about programming', 162 | streamCallbacks: { 163 | onContent: (content) => process.stdout.write(content) 164 | } 165 | }, abortController.signal); 166 | } catch (error) { 167 | if (error.message?.includes('cancel') || error.details?.axiosErrorCode === 'ERR_CANCELED') { 168 | console.log('\n✓ Stream aborted successfully!'); 169 | } else { 170 | console.error('❌ Error:', error.message); 171 | } 172 | } 173 | 174 | // ========================================== 175 | // Example 5: Detailed Chunk Inspection 176 | // ========================================== 177 | console.log('\n\n' + '='.repeat(60)); 178 | console.log('🔍 Example 5: Inspect Stream Chunks\n'); 179 | 180 | let chunkCount = 0; 181 | try { 182 | await client.chatStream({ 183 | prompt: 'Count from 1 to 5', 184 | streamCallbacks: { 185 | onChunk: (chunk) => { 186 | chunkCount++; 187 | if (chunk.choices[0]?.delta?.content) { 188 | process.stdout.write(chunk.choices[0].delta.content); 189 | } 190 | }, 191 | onComplete: (fullContent, usage) => { 192 | console.log(`\n\n✓ Received ${chunkCount} chunks`); 193 | console.log('Full content:', fullContent); 194 | console.log('Usage:', usage); 195 | } 196 | } 197 | }); 198 | } catch (error) { 199 | console.error('❌ Error:', error.message); 200 | } 201 | 202 | // ========================================== 203 | // Cleanup 204 | // ========================================== 205 | console.log('\n\n' + '='.repeat(60)); 206 | console.log('✨ All examples complete!'); 207 | console.log('='.repeat(60)); 208 | 209 | await client.destroy(); 210 | } 211 | 212 | main().catch(console.error); 213 | -------------------------------------------------------------------------------- /src/history/unified-history-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/history/unified-history-manager.ts 2 | import type { IHistoryStorage, HistoryEntry, Message } from '../types'; 3 | import { Logger } from '../utils/logger'; 4 | 5 | // Cache now stores HistoryEntry arrays 6 | interface CachedHistoryData { 7 | entries: HistoryEntry[]; 8 | lastAccess: number; 9 | created: number; 10 | } 11 | 12 | interface UnifiedHistoryManagerOptions { 13 | ttlMs?: number; 14 | cleanupIntervalMs?: number; 15 | } 16 | 17 | export class UnifiedHistoryManager { 18 | private storage: IHistoryStorage; 19 | private cache = new Map(); // Cache stores CachedHistoryData 20 | private ttl: number | null; 21 | private cleanupInterval: number | null; 22 | private cleanupTimer: NodeJS.Timeout | null = null; 23 | private destroyed = false; 24 | private logger: Logger; 25 | 26 | constructor( 27 | storageAdapter: IHistoryStorage, 28 | options: UnifiedHistoryManagerOptions = {}, 29 | logger?: Logger 30 | ) { 31 | if (!storageAdapter) { 32 | throw new Error("UnifiedHistoryManager requires a valid storage adapter."); 33 | } 34 | this.logger = logger || new Logger({ prefix: '[UnifiedHistoryManager]' }); 35 | this.storage = storageAdapter; 36 | 37 | this.ttl = (options.ttlMs !== undefined && options.ttlMs > 0) ? options.ttlMs : null; 38 | this.cleanupInterval = (options.cleanupIntervalMs !== undefined && options.cleanupIntervalMs > 0) ? options.cleanupIntervalMs : null; 39 | 40 | if (this.cleanupInterval && this.ttl) { 41 | this.startCacheCleanup(); 42 | } else { 43 | this.logger.log("Cache TTL or cleanup interval not configured, cache cleanup disabled."); 44 | } 45 | } 46 | 47 | private startCacheCleanup() { 48 | if (this.cleanupTimer) { 49 | clearInterval(this.cleanupTimer); 50 | this.cleanupTimer = null; 51 | } 52 | if (this.cleanupInterval && this.ttl && !this.destroyed) { 53 | this.cleanupTimer = setInterval(() => this.cleanupExpiredCacheEntries(), this.cleanupInterval); 54 | if (this.cleanupTimer?.unref) { 55 | this.cleanupTimer.unref(); 56 | } 57 | this.logger.log(`Cache cleanup timer started (Interval: ${this.cleanupInterval}ms, TTL: ${this.ttl}ms).`); 58 | } 59 | } 60 | 61 | private cleanupExpiredCacheEntries() { 62 | if (this.destroyed || !this.ttl) return; 63 | const now = Date.now(); 64 | let cleanedCount = 0; 65 | for (const [key, cachedData] of this.cache.entries()) { 66 | if (now - cachedData.lastAccess > this.ttl) { 67 | this.cache.delete(key); 68 | cleanedCount++; 69 | } 70 | } 71 | if (cleanedCount > 0) { 72 | this.logger.debug(`Cleaned ${cleanedCount} expired entries from cache.`); 73 | } 74 | } 75 | 76 | /** Retrieves history entries for a key. */ 77 | public async getHistoryEntries(key: string): Promise { // Public method 78 | if (this.destroyed) { 79 | this.logger.warn("[UnifiedHistoryManager] Attempted getHistoryEntries after destroy."); 80 | return []; 81 | } 82 | const now = Date.now(); 83 | const cached = this.cache.get(key); 84 | 85 | if (cached && (!this.ttl || now - cached.lastAccess <= this.ttl)) { 86 | cached.lastAccess = now; 87 | return [...cached.entries]; // Return copy from cache 88 | } 89 | 90 | try { 91 | const entries = await this.storage.load(key); // Load HistoryEntry[] 92 | this.cache.set(key, { entries: [...entries], lastAccess: now, created: now }); 93 | return [...entries]; // Return copy 94 | } catch (error) { 95 | this.logger.error(`Error loading history entries for key '${key}' from storage:`, error); 96 | throw error; 97 | } 98 | } 99 | 100 | /** Retrieves only the messages from the history for a key. */ 101 | public async getHistoryMessages(key: string): Promise { 102 | const entries = await this.getHistoryEntries(key); 103 | return entries.map(entry => entry.message); 104 | } 105 | 106 | /** Adds new history entries to a history key. */ 107 | public async addHistoryEntries(key: string, newEntries: HistoryEntry[]): Promise { // Public method 108 | if (this.destroyed) { 109 | this.logger.warn("[UnifiedHistoryManager] Attempted addHistoryEntries after destroy."); 110 | return; 111 | } 112 | if (!Array.isArray(newEntries) || newEntries.length === 0) { 113 | return; 114 | } 115 | 116 | const now = Date.now(); 117 | let cachedData = this.cache.get(key); 118 | 119 | // Load existing entries if not cached or expired 120 | let currentEntries: HistoryEntry[] = []; 121 | if (!cachedData || (this.ttl && now - cachedData.lastAccess > this.ttl)) { 122 | try { 123 | currentEntries = await this.storage.load(key); 124 | // Update cache with fresh data 125 | cachedData = { entries: [...currentEntries], lastAccess: now, created: cachedData?.created || now }; 126 | this.cache.set(key, cachedData); 127 | } catch (error) { 128 | this.logger.error(`Error loading history entries for key '${key}' before adding new entries:`, error); 129 | throw error; 130 | } 131 | } else { 132 | currentEntries = cachedData.entries; // Use cached entries 133 | } 134 | 135 | // Append new entries 136 | const updatedEntries = [...currentEntries, ...newEntries]; 137 | 138 | // Update cache 139 | this.cache.set(key, { entries: [...updatedEntries], lastAccess: now, created: cachedData?.created || now }); 140 | 141 | // Save back to storage 142 | try { 143 | await this.storage.save(key, updatedEntries); 144 | } catch (error) { 145 | this.logger.error(`Error saving history entries for key '${key}' to storage:`, error); 146 | // Consider reverting cache on save failure? For now, no. 147 | throw error; 148 | } 149 | } 150 | 151 | /** Clears all history entries for a specific history key. */ 152 | public async clearHistory(key: string): Promise { 153 | if (this.destroyed) { 154 | this.logger.warn("[UnifiedHistoryManager] Attempted clearHistory after destroy."); 155 | return; 156 | } 157 | const now = Date.now(); 158 | this.cache.set(key, { entries: [], lastAccess: now, created: now }); 159 | try { 160 | await this.storage.save(key, []); // Save empty array 161 | } catch (error) { 162 | this.logger.error(`Error clearing history for key '${key}' in storage:`, error); 163 | throw error; 164 | } 165 | } 166 | 167 | /** Deletes a history entry entirely from cache and storage adapter. */ 168 | public async deleteHistory(key: string): Promise { 169 | if (this.destroyed) { 170 | this.logger.warn("[UnifiedHistoryManager] Attempted deleteHistory after destroy."); 171 | return; 172 | } 173 | this.cache.delete(key); 174 | try { 175 | await this.storage.delete(key); 176 | } catch (error) { 177 | this.logger.error(`Error deleting history for key '${key}' from storage:`, error); 178 | throw error; 179 | } 180 | } 181 | 182 | /** Lists all history keys available via the storage adapter. */ 183 | public async getAllHistoryKeys(): Promise { 184 | if (this.destroyed) return []; 185 | try { 186 | return await this.storage.listKeys(); 187 | } catch (error) { 188 | this.logger.error(`Error listing keys from storage:`, error); 189 | throw error; 190 | } 191 | } 192 | 193 | /** Cleans up resources. */ 194 | public async destroy(): Promise { 195 | if (this.destroyed) return; 196 | this.destroyed = true; 197 | 198 | if (this.cleanupTimer) { 199 | clearInterval(this.cleanupTimer); 200 | this.cleanupTimer = null; 201 | } 202 | this.cache.clear(); 203 | 204 | if (this.storage && typeof (this.storage as any).destroy === 'function') { 205 | try { 206 | await (this.storage as any).destroy(); 207 | } catch (error) { 208 | this.logger.error(`Error destroying storage adapter:`, error); 209 | } 210 | } 211 | this.logger.log("[UnifiedHistoryManager] Destroyed."); 212 | } 213 | } -------------------------------------------------------------------------------- /src/utils/formatting.ts: -------------------------------------------------------------------------------- 1 | // Path: utils/formatting.ts 2 | /** 3 | * Formatting utilities for the OpenRouter Kit library. 4 | */ 5 | 6 | // Use relative path for type import 7 | import { Message } from '../types'; 8 | // Use relative path for utility import 9 | import * as jsonUtils from './json-utils'; 10 | 11 | /** 12 | * Formats an array of message objects for sending to the OpenRouter/OpenAI API. 13 | * Ensures only the necessary fields (`role`, `content`, `name`, `tool_call_id`, `tool_calls`) 14 | * are included and `content` is explicitly `null` if missing (as required by API). 15 | * 16 | * @param history - Array of history messages (`Message[]`). 17 | * @returns A new array containing formatted `Message` objects suitable for the API. 18 | * Returns an empty array if the input is not a valid array. 19 | */ 20 | export function formatMessages(history: Message[]): Message[] { 21 | if (!Array.isArray(history)) { 22 | // Log warning or return empty? Return empty for robustness. 23 | console.warn("[formatMessages] Input is not an array. Returning empty array."); 24 | return []; 25 | } 26 | 27 | return history.map((entry, index) => { 28 | // Basic validation for each entry 29 | if (!entry || typeof entry !== 'object' || !entry.role) { 30 | console.warn(`[formatMessages] Invalid message entry at index ${index}. Skipping.`, entry); 31 | // Return null or skip? Let's filter out invalid messages later. 32 | return null; 33 | } 34 | 35 | // Start with required fields (role and content, ensuring content is null if missing) 36 | const formattedMessage: Partial = { 37 | role: entry.role, 38 | content: entry.content ?? null, // API requires null if content is absent/undefined 39 | }; 40 | 41 | // Add optional fields only if they exist and have values 42 | if (entry.name) { 43 | formattedMessage.name = entry.name; 44 | } 45 | if (entry.tool_call_id) { 46 | formattedMessage.tool_call_id = entry.tool_call_id; 47 | } 48 | // Ensure tool_calls is an array if included 49 | if (Array.isArray(entry.tool_calls) && entry.tool_calls.length > 0) { 50 | // Optional: Deeper validation of tool_calls structure can be added here 51 | formattedMessage.tool_calls = entry.tool_calls; 52 | } 53 | 54 | // Cast back to Message, assuming basic structure is met 55 | return formattedMessage as Message; 56 | 57 | }).filter((msg): msg is Message => msg !== null); // Filter out any invalid messages skipped earlier 58 | } 59 | 60 | /** 61 | * Formats a Date object or a timestamp string into an ISO 8601 UTC string. 62 | * Uses the current time if no valid input is provided. 63 | * 64 | * @param timestamp - Optional: A Date object, or a string parsable by `new Date()`. 65 | * @returns Date and time string in ISO 8601 UTC format (e.g., "2023-10-27T10:30:00.123Z"). 66 | */ 67 | export function formatDateTime(timestamp?: string | Date): string { 68 | let date: Date; 69 | 70 | if (timestamp instanceof Date) { 71 | // Use the provided Date object directly 72 | date = timestamp; 73 | } else if (typeof timestamp === 'string') { 74 | // Attempt to parse the string 75 | try { 76 | date = new Date(timestamp); 77 | // Check if parsing resulted in a valid date 78 | if (isNaN(date.getTime())) { 79 | console.warn(`[formatDateTime] Failed to parse input string as a valid date: "${timestamp}". Using current time.`); 80 | date = new Date(); // Fallback to current time 81 | } 82 | } catch (e) { 83 | // Catch potential errors during Date parsing (less common) 84 | console.warn(`[formatDateTime] Error parsing input string as date: "${timestamp}". Using current time.`, e); 85 | date = new Date(); // Fallback to current time 86 | } 87 | } else { 88 | // If no timestamp (or invalid type) is provided, use the current time 89 | date = new Date(); 90 | } 91 | 92 | // Return the date formatted as ISO 8601 UTC string 93 | return date.toISOString(); 94 | } 95 | 96 | /** 97 | * @deprecated Functionality is context-dependent and better handled by `_parseAndValidateJsonResponse` in client.ts. 98 | * Avoid using this function for response parsing. 99 | */ 100 | export function formatResponseByType(responseText: string, type?: string): any { 101 | console.warn("[formatResponseByType] is deprecated and its logic may be unreliable. Use specific JSON parsing/validation based on 'responseFormat' instead."); 102 | // Keeping original logic but emphasizing deprecation 103 | if (typeof responseText !== 'string') return responseText; 104 | const targetType = type?.toLowerCase() || 'string'; 105 | switch (targetType) { 106 | case 'string': return responseText; 107 | case 'boolean': 108 | const lowerTextBool = responseText.trim().toLowerCase(); 109 | if (lowerTextBool === 'true') return true; 110 | if (lowerTextBool === 'false') return false; 111 | try { const parsedBool = JSON.parse(responseText); if (typeof parsedBool === 'boolean') return parsedBool; } catch {} 112 | throw new Error(`Expected boolean, got: ${responseText}`); 113 | case 'number': case 'integer': 114 | const num = Number(responseText.trim()); 115 | if (!isNaN(num)) return num; 116 | try { const parsedNum = JSON.parse(responseText); if (typeof parsedNum === 'number') return parsedNum; } catch {} 117 | throw new Error(`Expected number, got: ${responseText}`); 118 | case 'json': case 'object': case 'array': 119 | try { return jsonUtils.parseOrThrow(responseText, 'response'); } 120 | catch (e: any) { throw new Error(`Expected valid JSON (${targetType}), got: ${responseText}. Error: ${e.message}`); } 121 | default: return responseText; 122 | } 123 | } 124 | 125 | /** 126 | * Ensures all messages in an array have a `timestamp` field. 127 | * Adds the current timestamp (in ISO 8601 UTC format) if missing. 128 | * Modifies the array in place? No, returns a new array. 129 | * 130 | * @param messages - Array of messages (`Message[]`) to process. 131 | * @returns A new array of messages, each guaranteed to have a `timestamp`. 132 | * Returns an empty array if the input is not a valid array. 133 | */ 134 | export function addRenderingInfoToMessages(messages: Message[]): Message[] { 135 | if (!Array.isArray(messages)) { 136 | console.warn("[addRenderingInfoToMessages] Input is not an array. Returning empty array."); 137 | return []; 138 | } 139 | 140 | const now = formatDateTime(); // Get current time once for efficiency 141 | 142 | return messages.map((message) => { 143 | // Return a new object with timestamp added only if it's missing 144 | if (!message.timestamp) { 145 | return { 146 | ...message, // Copy existing properties 147 | timestamp: now, // Add the current timestamp 148 | }; 149 | } 150 | // If timestamp already exists, return the original message object (or a shallow copy if immutability is strictly needed) 151 | return message; // Or return { ...message } for shallow copy 152 | }); 153 | } 154 | 155 | /** 156 | * Formats model response content (of any type) into a string suitable for display or logging. 157 | * Objects and arrays are pretty-printed as JSON strings. 158 | * Handles primitives, null, and undefined appropriately. 159 | * 160 | * @param content - The response content (can be string, object, array, number, boolean, null, undefined). 161 | * @returns A string representation of the content. 162 | */ 163 | export function formatResponseForDisplay(content: any): string { 164 | // Handle primitive types directly 165 | if (typeof content === 'string') { 166 | return content; 167 | } 168 | if (typeof content === 'number' || typeof content === 'boolean') { 169 | return String(content); 170 | } 171 | // Handle null and undefined explicitly 172 | if (content === null) { 173 | return "null"; 174 | } 175 | if (content === undefined) { 176 | return "undefined"; 177 | } 178 | // Handle objects and arrays: pretty-print JSON 179 | if (typeof content === 'object') { 180 | try { 181 | // Use safeStringify with indentation (2 spaces) for readability 182 | // Provide a fallback string in case stringification fails (e.g., circular refs) 183 | return JSON.stringify(content, null, 2); 184 | } catch (e) { 185 | // Fallback if JSON.stringify fails unexpectedly 186 | return '[Error: Could not stringify object]'; 187 | } 188 | } 189 | // Fallback for any other types (e.g., Symbol, BigInt - less common) 190 | try { 191 | return String(content); 192 | } catch { 193 | return '[Error: Could not convert value to string]'; 194 | } 195 | } -------------------------------------------------------------------------------- /examples/streaming-test.js: -------------------------------------------------------------------------------- 1 | const { OpenRouterClient } = require('../dist'); 2 | 3 | /** 4 | * Quick Test for Streaming + Auto Tool Execution 5 | * 6 | * This test verifies: 7 | * - Basic streaming works 8 | * - Tools execute automatically when provided 9 | * - Callbacks are called correctly 10 | */ 11 | 12 | async function runTests() { 13 | const client = new OpenRouterClient({ 14 | apiKey: process.env.OPENROUTER_API_KEY || 'token', 15 | model: 'x-ai/grok-4-fast', 16 | enableCostTracking: true, 17 | debug: false 18 | }); 19 | 20 | console.log('🧪 OpenRouter-Kit Streaming Tests\n'); 21 | console.log('='.repeat(60)); 22 | 23 | // Test 1: Basic Streaming 24 | console.log('\n✅ Test 1: Basic Streaming'); 25 | console.log('-'.repeat(60)); 26 | try { 27 | let contentReceived = false; 28 | let completeReceived = false; 29 | 30 | const result1 = await client.chatStream({ 31 | prompt: 'Say "Hello World" in one line', 32 | streamCallbacks: { 33 | onContent: (content) => { 34 | contentReceived = true; 35 | process.stdout.write(content); 36 | }, 37 | onComplete: () => { 38 | completeReceived = true; 39 | } 40 | } 41 | }); 42 | 43 | console.log('\n'); 44 | if (contentReceived && completeReceived && result1.content) { 45 | console.log('✅ PASSED: Basic streaming works'); 46 | } else { 47 | console.log('❌ FAILED: Missing callbacks or content'); 48 | } 49 | } catch (error) { 50 | console.log('❌ FAILED:', error.message); 51 | } 52 | 53 | // Test 2: Streaming with Auto Tool Execution 54 | console.log('\n\n✅ Test 2: Auto Tool Execution'); 55 | console.log('-'.repeat(60)); 56 | 57 | const testTools = [ 58 | { 59 | type: 'function', 60 | function: { 61 | name: 'get_weather', 62 | description: 'Get weather for a city', 63 | parameters: { 64 | type: 'object', 65 | properties: { 66 | city: { type: 'string', description: 'City name' } 67 | }, 68 | required: ['city'] 69 | } 70 | }, 71 | execute: async (args) => { 72 | console.log(`\n 🔧 Tool executed with args:`, args); 73 | return { 74 | city: args.city, 75 | temperature: 20, 76 | condition: 'Sunny' 77 | }; 78 | } 79 | } 80 | ]; 81 | 82 | try { 83 | let toolExecuting = false; 84 | let toolResult = false; 85 | 86 | const result2 = await client.chatStream({ 87 | prompt: 'What is the weather in London?', 88 | tools: testTools, 89 | streamCallbacks: { 90 | onContent: (content) => { 91 | process.stdout.write(content); 92 | }, 93 | onToolCallExecuting: (toolName, args) => { 94 | toolExecuting = true; 95 | console.log(`\n ⚙️ Callback: onToolCallExecuting('${toolName}')`); 96 | }, 97 | onToolCallResult: (toolName, result) => { 98 | toolResult = true; 99 | console.log(` ✅ Callback: onToolCallResult('${toolName}')`); 100 | console.log(`\n Continuing stream...\n`); 101 | }, 102 | onComplete: () => { 103 | console.log('\n ✓ Stream complete'); 104 | } 105 | } 106 | }); 107 | 108 | console.log('\n'); 109 | if (toolExecuting && toolResult) { 110 | console.log('✅ PASSED: Tools execute automatically'); 111 | } else { 112 | console.log('❌ FAILED: Tool callbacks not called'); 113 | } 114 | } catch (error) { 115 | console.log('❌ FAILED:', error.message); 116 | } 117 | 118 | // Test 3: Multiple Tools in Parallel 119 | console.log('\n\n✅ Test 3: Multiple Tools'); 120 | console.log('-'.repeat(60)); 121 | 122 | const multiTools = [ 123 | { 124 | type: 'function', 125 | function: { 126 | name: 'add', 127 | description: 'Add two numbers', 128 | parameters: { 129 | type: 'object', 130 | properties: { 131 | a: { type: 'number' }, 132 | b: { type: 'number' } 133 | }, 134 | required: ['a', 'b'] 135 | } 136 | }, 137 | execute: async (args) => args.a + args.b 138 | }, 139 | { 140 | type: 'function', 141 | function: { 142 | name: 'multiply', 143 | description: 'Multiply two numbers', 144 | parameters: { 145 | type: 'object', 146 | properties: { 147 | a: { type: 'number' }, 148 | b: { type: 'number' } 149 | }, 150 | required: ['a', 'b'] 151 | } 152 | }, 153 | execute: async (args) => args.a * args.b 154 | } 155 | ]; 156 | 157 | try { 158 | let toolCallCount = 0; 159 | 160 | const result3 = await client.chatStream({ 161 | prompt: 'What is 5 + 3? And what is 4 * 2?', 162 | tools: multiTools, 163 | streamCallbacks: { 164 | onContent: (content) => { 165 | process.stdout.write(content); 166 | }, 167 | onToolCallExecuting: (toolName) => { 168 | toolCallCount++; 169 | console.log(`\n 🔧 Executing: ${toolName} (call #${toolCallCount})`); 170 | }, 171 | onToolCallResult: (toolName, result) => { 172 | console.log(` ✅ Result: ${JSON.stringify(result)}\n`); 173 | } 174 | } 175 | }); 176 | 177 | console.log('\n'); 178 | if (toolCallCount >= 2) { 179 | console.log('✅ PASSED: Multiple tools executed'); 180 | } else { 181 | console.log('⚠️ WARNING: Expected 2+ tool calls, got', toolCallCount); 182 | } 183 | } catch (error) { 184 | console.log('❌ FAILED:', error.message); 185 | } 186 | 187 | // Test 4: Cost Tracking 188 | console.log('\n\n✅ Test 4: Cost Tracking'); 189 | console.log('-'.repeat(60)); 190 | try { 191 | const result4 = await client.chatStream({ 192 | prompt: 'Say hello', 193 | streamCallbacks: { 194 | onContent: (content) => process.stdout.write(content) 195 | } 196 | }); 197 | 198 | console.log('\n'); 199 | if (result4.cost !== undefined && result4.cost !== null) { 200 | console.log(`✅ PASSED: Cost tracked: $${result4.cost.toFixed(6)}`); 201 | } else { 202 | console.log('⚠️ WARNING: Cost tracking not available'); 203 | } 204 | 205 | if (result4.durationMs) { 206 | console.log(` Duration: ${result4.durationMs}ms`); 207 | } 208 | if (result4.usage) { 209 | console.log(` Tokens: ${result4.usage.total_tokens}`); 210 | } 211 | } catch (error) { 212 | console.log('❌ FAILED:', error.message); 213 | } 214 | 215 | // Test 5: Abort Stream 216 | console.log('\n\n✅ Test 5: Stream Abort'); 217 | console.log('-'.repeat(60)); 218 | try { 219 | const abortController = new AbortController(); 220 | 221 | setTimeout(() => { 222 | console.log('\n ⏸️ Aborting after 500ms...'); 223 | abortController.abort(); 224 | }, 500); 225 | 226 | await client.chatStream({ 227 | prompt: 'Write a very long story about space exploration', 228 | streamCallbacks: { 229 | onContent: (content) => process.stdout.write(content) 230 | } 231 | }, abortController.signal); 232 | 233 | console.log('\n❌ FAILED: Stream should have been aborted'); 234 | } catch (error) { 235 | if (error.message?.includes('cancel') || error.details?.axiosErrorCode === 'ERR_CANCELED') { 236 | console.log('\n✅ PASSED: Stream aborted successfully'); 237 | } else { 238 | console.log('\n❌ FAILED:', error.message); 239 | } 240 | } 241 | 242 | console.log('\n' + '='.repeat(60)); 243 | console.log('🏁 Tests Complete!\n'); 244 | 245 | await client.destroy(); 246 | } 247 | 248 | runTests().catch(console.error); 249 | -------------------------------------------------------------------------------- /src/plugins/external-security-plugin.ts: -------------------------------------------------------------------------------- 1 | // Path: src/plugins/external-security-plugin.ts 2 | import type { OpenRouterPlugin, Tool, UserAuthInfo } from '../types'; 3 | // Import the RENAMED extended SecurityConfig type 4 | import type { ExtendedSecurityConfig } from '../security/types'; 5 | import type { OpenRouterClient } from '../client'; 6 | import { SecurityManager } from '../security/security-manager'; 7 | import { AccessDeniedError, mapError, RateLimitError, AuthenticationError, AuthorizationError } from '../utils/error'; 8 | 9 | /** 10 | * Example plugin that replaces the built-in SecurityManager with a custom one. 11 | * This custom manager could integrate with external authentication (OAuth, SAML), 12 | * policy decision points (OPA, custom API), or rate limiting services. 13 | * 14 | * @param externalConfig - Configuration specific to the external security integration. 15 | */ 16 | export function createExternalSecurityPlugin( 17 | externalConfig: { authUrl?: string; policyUrl?: string; apiKey?: string } = {} 18 | ): OpenRouterPlugin { 19 | return { 20 | async init(client: OpenRouterClient) { 21 | const logger = client['logger']?.withPrefix('ExternalSecurityPlugin'); 22 | 23 | const originalSecurityConfig = client.getSecurityManager()?.getConfig() || {}; 24 | 25 | class ExternalSecurityManager extends SecurityManager { 26 | private externalAuthUrl?: string; 27 | private externalPolicyUrl?: string; 28 | private externalApiKey?: string; 29 | 30 | constructor( 31 | // Accept partial extended config 32 | config: Partial, 33 | pluginOptions: typeof externalConfig 34 | ) { 35 | // Merge potentially partial config with original extended config 36 | const effectiveConfig: ExtendedSecurityConfig = { 37 | ...(originalSecurityConfig as ExtendedSecurityConfig), // Cast original if needed 38 | ...config 39 | }; 40 | // Ensure debug is boolean 41 | effectiveConfig.debug = typeof effectiveConfig.debug === 'boolean' ? effectiveConfig.debug : client.isDebugMode(); 42 | 43 | super(effectiveConfig, client.isDebugMode()); // Pass merged config and debug state 44 | 45 | this.externalAuthUrl = pluginOptions.authUrl; 46 | this.externalPolicyUrl = pluginOptions.policyUrl; 47 | this.externalApiKey = pluginOptions.apiKey; 48 | 49 | this['logger'].log('ExternalSecurityManager initialized.'); 50 | } 51 | 52 | // ... (rest of the ExternalSecurityManager methods remain the same) ... 53 | override async authenticateUser(accessToken?: string): Promise { 54 | // ... (implementation as before) ... 55 | this['logger'].debug(`External authenticateUser called. Token present: ${!!accessToken}`); 56 | if (!this.externalAuthUrl) { 57 | this['logger'].warn('External auth URL not configured, falling back to default JWT/internal auth.'); 58 | return await super.authenticateUser(accessToken); 59 | } 60 | if (!accessToken) { 61 | this['logger'].debug('No access token provided for external authentication.'); 62 | if (this.getConfig().requireAuthentication) { 63 | throw new AuthenticationError("Authentication required, but no token provided for external check.", 401); 64 | } 65 | return null; 66 | } 67 | try { 68 | this['logger'].log(`Calling external auth service at ${this.externalAuthUrl}...`); 69 | await new Promise(res => setTimeout(res, 50)); 70 | if (accessToken === 'valid-external-token') { 71 | const userInfo: UserAuthInfo = { userId: 'external-user-123', role: 'editor', scopes: ['read', 'write'] }; 72 | this['logger'].log(`External authentication successful for userId: ${userInfo.userId}`); 73 | this['eventEmitter'].emit('user:authenticated', { userInfo }); 74 | return userInfo; 75 | } else { 76 | this['logger'].warn(`External authentication failed for token: ${accessToken.substring(0,10)}...`); 77 | return null; 78 | } 79 | } catch (error) { 80 | this['logger'].error(`Error during external authentication: ${(error as Error).message}`); 81 | this['eventEmitter'].emit('auth:error', { message: 'External authentication failed', error }); 82 | return null; 83 | } 84 | } 85 | override async checkToolAccessAndArgs( 86 | tool: Tool, 87 | userInfo: UserAuthInfo | null, 88 | args?: any 89 | ): Promise { 90 | // ... (implementation as before) ... 91 | const toolName = tool.function?.name || tool.name || 'unknown_tool'; 92 | this['logger'].debug(`External checkToolAccessAndArgs for tool: ${toolName}, user: ${userInfo?.userId || 'anonymous'}`); 93 | const currentConfig = this.getConfig(); 94 | const context: import('../security/types').SecurityContext = { 95 | config: currentConfig, 96 | debug: this.isDebugEnabled(), 97 | userId: userInfo?.userId, 98 | toolName: toolName 99 | }; 100 | await this['argumentSanitizer'].validateArguments(tool, args, context); 101 | this['logger'].debug(`[External Check] Argument sanitization passed for ${toolName}.`); 102 | if (this.externalPolicyUrl) { 103 | this['logger'].log(`Calling external policy service at ${this.externalPolicyUrl}...`); 104 | try { 105 | await new Promise(res => setTimeout(res, 50)); 106 | const decision = { allowed: toolName !== 'delete_everything' }; 107 | if (!decision.allowed) { 108 | const reason = (decision as any).reason || 'Denied by external policy'; 109 | this['logger'].warn(`Access to '${toolName}' denied by external policy. Reason: ${reason}`); 110 | this['eventEmitter'].emit('access:denied', { userId: userInfo?.userId, toolName, reason }); 111 | throw new AccessDeniedError(`Access to tool ${toolName} denied by external policy: ${reason}`, 403); 112 | } 113 | this['logger'].log(`Access to '${toolName}' granted by external policy.`); 114 | this['eventEmitter'].emit('access:granted', { userId: userInfo?.userId, toolName }); 115 | } catch (error) { 116 | this['logger'].error(`Error during external policy check for ${toolName}: ${(error as Error).message}`); 117 | throw mapError(error); 118 | } 119 | } else { 120 | this['logger'].debug('External policy URL not configured, falling back to internal access control check.'); 121 | await this['accessControlManager'].checkAccess({ tool, userInfo, args, context }); 122 | this['logger'].debug(`Internal access control check passed for ${toolName}.`); 123 | } 124 | if (userInfo) { 125 | const rateLimitConfig = this['_findRateLimit'](tool, userInfo); 126 | if (rateLimitConfig) { 127 | this['logger'].debug(`[External Check] Checking internal rate limit for ${toolName}...`); 128 | const rateLimitResult = this['rateLimitManager'].checkRateLimit({ 129 | userId: userInfo.userId, 130 | toolName: toolName, 131 | rateLimit: rateLimitConfig, 132 | source: rateLimitConfig._source 133 | }); 134 | if (!rateLimitResult.allowed) { 135 | const timeLeft = rateLimitResult.timeLeft ?? 0; 136 | const retryAfter = Math.ceil(timeLeft / 1000); 137 | this['logger'].warn(`[External Check] Rate Limit Exceeded for user ${userInfo.userId}. Retry after ${retryAfter}s.`); 138 | throw new RateLimitError( 139 | `Rate limit exceeded for tool '${toolName}'. Please try again after ${retryAfter} seconds.`, 140 | 429, 141 | { limit: rateLimitResult.limit, period: rateLimitConfig.interval || rateLimitConfig.period, timeLeftMs: timeLeft, retryAfterSeconds: retryAfter } 142 | ); 143 | } 144 | this['logger'].debug(`[External Check] Internal rate limit passed for ${toolName}.`); 145 | } 146 | } 147 | this['logger'].log(`All external/internal security checks passed for tool '${toolName}'.`); 148 | return true; 149 | } 150 | 151 | } // End of ExternalSecurityManager class 152 | 153 | const externalSecManager = new ExternalSecurityManager({}, externalConfig); 154 | 155 | client.setSecurityManager(externalSecManager); 156 | logger?.log?.('External SecurityManager plugin initialized and replaced default manager.'); 157 | } 158 | }; 159 | } -------------------------------------------------------------------------------- /src/cost-tracker.ts: -------------------------------------------------------------------------------- 1 | // Path: src/cost-tracker.ts 2 | import { AxiosInstance } from 'axios'; // Keep AxiosInstance for now 3 | import { Logger } from './utils/logger'; 4 | import { ModelPricingInfo, UsageInfo, OpenRouterConfig } from './types'; 5 | import { mapError, APIError } from './utils/error'; 6 | // import { ApiHandler } from './core/api-handler'; // Import if using ApiHandler 7 | 8 | const DEFAULT_PRICE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours 9 | const MODELS_API_PATH = '/models'; // Path relative to base API URL passed from client 10 | 11 | export class CostTracker { 12 | private priceCache: Map = new Map(); 13 | // TODO: Refactor to use ApiHandler.getModels() instead of direct AxiosInstance? 14 | private axiosInstance: AxiosInstance; // Used for making requests 15 | private logger: Logger; 16 | private refreshIntervalMs: number; 17 | private refreshTimer: NodeJS.Timeout | null = null; 18 | private isFetchingPrices: boolean = false; 19 | private initialPricesProvided: boolean = false; 20 | private apiBaseUrl: string; // Store the base URL for the /models endpoint 21 | 22 | constructor( 23 | // Keep AxiosInstance for now, but consider passing ApiHandler in the future 24 | axiosInstance: AxiosInstance, 25 | logger: Logger, 26 | config: Pick & { apiBaseUrl: string } 27 | ) { 28 | this.axiosInstance = axiosInstance; 29 | this.logger = logger.withPrefix('CostTracker'); 30 | this.refreshIntervalMs = config.priceRefreshIntervalMs ?? DEFAULT_PRICE_REFRESH_INTERVAL_MS; 31 | 32 | if (!config.apiBaseUrl) { 33 | this.logger.error("CostTracker initialized without a valid apiBaseUrl. Price fetching will likely fail."); 34 | this.apiBaseUrl = ''; 35 | } else { 36 | this.apiBaseUrl = config.apiBaseUrl; 37 | } 38 | this.logger.debug(`Using API base URL for /models: ${this.apiBaseUrl}`); 39 | 40 | if (config.initialModelPrices) { 41 | this.logger.log('Initializing with provided model prices.'); 42 | this.updatePriceCache(config.initialModelPrices); 43 | this.initialPricesProvided = true; 44 | } 45 | 46 | if (!this.initialPricesProvided && this.apiBaseUrl) { 47 | this.fetchModelPrices().catch(err => { 48 | this.logger.error('Initial model price fetch failed.'); 49 | }); 50 | } else if (!this.apiBaseUrl) { 51 | this.logger.warn('Skipping initial price fetch due to missing apiBaseUrl.'); 52 | } 53 | 54 | this.startPriceRefreshTimer(); 55 | } 56 | 57 | public async fetchModelPrices(): Promise { 58 | if (this.isFetchingPrices) { 59 | this.logger.debug('Price fetch already in progress, skipping.'); 60 | return; 61 | } 62 | if (!this.apiBaseUrl) { 63 | this.logger.error("Cannot fetch model prices: apiBaseUrl is not configured."); 64 | return; 65 | } 66 | 67 | this.isFetchingPrices = true; 68 | this.logger.log('Fetching model prices from API...'); 69 | 70 | try { 71 | const modelsUrl = `${this.apiBaseUrl}${MODELS_API_PATH}`; 72 | this.logger.debug(`Requesting models from: ${modelsUrl}`); 73 | 74 | // Use the axiosInstance provided (or switch to ApiHandler.getModels() later) 75 | const response = await this.axiosInstance.get(modelsUrl, { 76 | baseURL: '' // Treat modelsUrl as absolute 77 | }); 78 | 79 | if (response.status === 200 && response.data?.data) { 80 | const models = response.data.data as any[]; 81 | const prices: Record = {}; 82 | let count = 0; 83 | models.forEach(model => { 84 | if (model.id && model.pricing) { 85 | const promptCost = parseFloat(model.pricing.prompt); 86 | const completionCost = parseFloat(model.pricing.completion); 87 | const requestCost = parseFloat(model.pricing.request || '0'); 88 | 89 | if (!isNaN(promptCost) && !isNaN(completionCost) && !isNaN(requestCost)) { 90 | prices[model.id] = { 91 | id: model.id, 92 | name: model.name, 93 | promptCostPerMillion: promptCost * 1_000_000, 94 | completionCostPerMillion: completionCost * 1_000_000, 95 | context_length: model.context_length, 96 | }; 97 | count++; 98 | } else { 99 | this.logger.warn(`Could not parse pricing for model ${model.id}:`, model.pricing); 100 | } 101 | } 102 | }); 103 | this.updatePriceCache(prices); 104 | this.logger.log(`Successfully fetched and updated prices for ${count} models.`); 105 | } else { 106 | throw new APIError(`Failed to fetch model prices: Status ${response.status}`, response.status, response.data); 107 | } 108 | } catch (error) { 109 | const mappedError = mapError(error); 110 | this.logger.error(`Error fetching model prices: ${mappedError.message}`, mappedError.details); 111 | } finally { 112 | this.isFetchingPrices = false; 113 | } 114 | } 115 | 116 | private updatePriceCache(prices: Record): void { 117 | const newCache = new Map(); 118 | for (const modelId in prices) { 119 | if (Object.prototype.hasOwnProperty.call(prices, modelId)) { 120 | newCache.set(modelId, prices[modelId]); 121 | } 122 | } 123 | this.priceCache = newCache; 124 | this.logger.debug(`Price cache updated with ${newCache.size} entries.`); 125 | } 126 | 127 | public calculateCost(modelId: string, usage: UsageInfo | null | undefined): number | null { 128 | if (!usage) { 129 | this.logger.debug(`Cannot calculate cost for ${modelId}: usage info missing.`); 130 | return null; 131 | } 132 | if (typeof modelId !== 'string' || !modelId) { 133 | this.logger.warn(`Cannot calculate cost: invalid modelId provided.`); 134 | return null; 135 | } 136 | 137 | const prices = this.priceCache.get(modelId); 138 | if (!prices) { 139 | this.logger.warn(`Cannot calculate cost for ${modelId}: price info not found in cache.`); 140 | // Consider attempting a price fetch here if cache is empty? 141 | // if (this.priceCache.size === 0 && !this.isFetchingPrices) { 142 | // this.fetchModelPrices(); // Fire and forget? 143 | // } 144 | return null; 145 | } 146 | 147 | const promptTokens = usage.prompt_tokens || 0; 148 | const completionTokens = usage.completion_tokens || 0; 149 | 150 | const promptCost = (promptTokens / 1_000_000) * prices.promptCostPerMillion; 151 | const completionCost = (completionTokens / 1_000_000) * prices.completionCostPerMillion; 152 | 153 | const totalCost = promptCost + completionCost; 154 | 155 | this.logger.debug(`Calculated cost for ${modelId}: $${totalCost.toFixed(8)} (Prompt: ${promptTokens} tokens, Completion: ${completionTokens} tokens)`); 156 | return totalCost; 157 | } 158 | 159 | public getModelPrice(modelId: string): ModelPricingInfo | undefined { 160 | return this.priceCache.get(modelId); 161 | } 162 | 163 | public getAllModelPrices(): Record { 164 | return Object.fromEntries(this.priceCache); 165 | } 166 | 167 | private startPriceRefreshTimer(): void { 168 | this.stopPriceRefreshTimer(); 169 | if (this.refreshIntervalMs > 0 && this.apiBaseUrl) { 170 | this.logger.log(`Starting price refresh timer with interval: ${this.refreshIntervalMs} ms`); 171 | this.refreshTimer = setInterval(() => { 172 | this.fetchModelPrices().catch(err => { 173 | this.logger.error('Scheduled model price refresh failed unexpectedly inside setInterval:', err.message); 174 | }); 175 | }, this.refreshIntervalMs); 176 | if (this.refreshTimer?.unref) { 177 | this.refreshTimer.unref(); 178 | } 179 | } else if (this.refreshIntervalMs <= 0) { 180 | this.logger.log('Price refresh timer disabled (interval <= 0).'); 181 | } else { 182 | this.logger.warn('Price refresh timer not started (apiBaseUrl missing).'); 183 | } 184 | } 185 | 186 | public stopPriceRefreshTimer(): void { 187 | if (this.refreshTimer) { 188 | clearInterval(this.refreshTimer); 189 | this.refreshTimer = null; 190 | this.logger.log('Price refresh timer stopped.'); 191 | } 192 | } 193 | 194 | public destroy(): void { 195 | this.stopPriceRefreshTimer(); 196 | this.priceCache.clear(); 197 | this.logger.log('CostTracker destroyed.'); 198 | } 199 | } -------------------------------------------------------------------------------- /src/utils/json-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JSON utilities: parsing, schema validation, serialization. 3 | * Uses Ajv for JSON Schema validation. 4 | */ 5 | 6 | import Ajv, { ErrorObject, SchemaObject, ValidateFunction } from 'ajv'; 7 | // Use relative path for error import 8 | import { ValidationError, ErrorCode } from './error'; 9 | // Use relative path for logger import 10 | import { Logger } from './logger'; 11 | 12 | const defaultLogger = new Logger({ debug: false, prefix: 'JsonUtils' }); 13 | 14 | interface JsonUtilsOptions { 15 | logger?: Logger; 16 | ajvInstance?: Ajv; 17 | } 18 | 19 | let globalAjv: Ajv | null = null; 20 | let globalLogger: Logger = defaultLogger; 21 | const compiledSchemaCache = new Map(); 22 | 23 | function getAjvInstance(options?: JsonUtilsOptions): Ajv { 24 | if (options?.ajvInstance) { 25 | return options.ajvInstance; 26 | } 27 | if (!globalAjv) { 28 | globalAjv = new Ajv({ allErrors: true, strict: false, coerceTypes: false }); 29 | } 30 | return globalAjv; 31 | } 32 | 33 | function getLogger(options?: JsonUtilsOptions): Logger { 34 | return options?.logger || globalLogger; 35 | } 36 | 37 | export function safeParse(jsonString: string, fallback: T, options?: JsonUtilsOptions): T { 38 | const logger = getLogger(options); 39 | if (typeof jsonString !== 'string') { 40 | logger.warn('safeParse called with non-string input, returning fallback.', { inputType: typeof jsonString }); 41 | return fallback; 42 | } 43 | const trimmedString = jsonString.trim(); 44 | if (trimmedString === '') { 45 | logger.debug('safeParse called with empty or whitespace string, returning fallback.'); 46 | return fallback; 47 | } 48 | try { 49 | return JSON.parse(trimmedString); 50 | } catch (error) { 51 | logger.warn(`JSON parsing error: ${(error as Error).message}. Returning fallback. Input (start): "${trimmedString.substring(0, 100)}..."`); 52 | return fallback; 53 | } 54 | } 55 | 56 | export function parseOrThrow(jsonString: string, entityName: string = 'JSON', options?: JsonUtilsOptions): any { 57 | const logger = getLogger(options); 58 | 59 | if (typeof jsonString !== 'string') { 60 | const error = new ValidationError(`Invalid input for ${entityName}: expected a string, but received type ${typeof jsonString}.`, { inputType: typeof jsonString }); 61 | logger.error(`parseOrThrow failed: ${error.message}`); 62 | throw error; 63 | } 64 | 65 | const trimmedString = jsonString.trim(); 66 | 67 | if ((trimmedString === '' || trimmedString === '{}') && entityName.toLowerCase().includes('argument')) { 68 | logger.debug(`Parsed ${entityName}: empty string or '{}' treated as empty object.`); 69 | return {}; 70 | } 71 | 72 | if (trimmedString === '') { 73 | const error = new ValidationError(`Error parsing ${entityName}: received empty or whitespace string.`); 74 | logger.error(error.message); 75 | throw error; 76 | } 77 | 78 | try { 79 | const result = JSON.parse(trimmedString); 80 | logger.debug(`Successfully parsed ${entityName} JSON.`); 81 | return result; 82 | } catch (error) { 83 | const message = error instanceof Error ? error.message : String(error); 84 | // Pass the correct ErrorCode to the constructor 85 | const wrappedError = new ValidationError( 86 | `Error parsing ${entityName} JSON: ${message}`, 87 | { 88 | originalError: error, 89 | inputPreview: trimmedString.length > 200 ? `${trimmedString.substring(0, 200)}...` : trimmedString 90 | } 91 | ); 92 | // wrappedError.code = ErrorCode.JSON_PARSE_ERROR; // Cannot assign to readonly property 93 | logger.error(`parseOrThrow failed: ${wrappedError.message}`); 94 | throw wrappedError; // ValidationError constructor now handles setting the code based on message 95 | } 96 | } 97 | 98 | export function safeStringify(value: any, fallback: string = '{"error":"JSON serialization failed"}', options?: JsonUtilsOptions): string { 99 | const logger = getLogger(options); 100 | try { 101 | if (value === undefined) { 102 | logger.debug('safeStringify called with undefined value, returning "null" string.'); 103 | return 'null'; 104 | } 105 | return JSON.stringify(value); 106 | } catch (error) { 107 | logger.warn(`JSON stringify error: ${(error as Error).message}. Returning fallback string. Value type: ${typeof value}`); 108 | return fallback; 109 | } 110 | } 111 | 112 | export function stringifyOrThrow(value: any, entityName: string = 'value', options?: JsonUtilsOptions): string { 113 | const logger = getLogger(options); 114 | try { 115 | if (value === undefined) { 116 | logger.debug(`stringifyOrThrow: ${entityName} is undefined, returning "null" JSON string.`); 117 | return 'null'; 118 | } 119 | const result = JSON.stringify(value); 120 | logger.debug(`Successfully serialized ${entityName} to JSON string.`); 121 | return result; 122 | } catch (error) { 123 | const message = error instanceof Error ? error.message : String(error); 124 | let valueContext = `Type: ${typeof value}`; 125 | if (typeof value === 'object' && value !== null) { 126 | valueContext += `, Keys: ${Object.keys(value).slice(0, 5).join(', ')}${Object.keys(value).length > 5 ? '...' : ''}`; 127 | } 128 | 129 | const wrappedError = new ValidationError( 130 | `Error serializing ${entityName} to JSON: ${message}`, 131 | { originalError: error, valueContext } 132 | ); 133 | logger.error(`stringifyOrThrow failed: ${wrappedError.message}`); 134 | throw wrappedError; 135 | } 136 | } 137 | 138 | function formatAjvErrors(errors: ErrorObject[] | null | undefined): string { 139 | if (!errors || errors.length === 0) return 'Unknown validation error'; 140 | 141 | return errors.map(err => { 142 | const path = err.instancePath || ''; 143 | let message = err.message || 'Invalid value'; 144 | if (err.keyword === 'required') { 145 | message = `Property '${err.params.missingProperty}' is required`; 146 | } else if (err.keyword === 'type') { 147 | message = `Expected type ${err.params.type} but received ${typeof err.data}`; 148 | } else if (err.keyword === 'enum') { 149 | message = `Value must be one of: ${err.params.allowedValues.join(', ')}`; 150 | } else if (err.keyword === 'additionalProperties') { 151 | message = `Property '${err.params.additionalProperty}' is not allowed`; 152 | } 153 | return `${path}: ${message}`; 154 | }).join('; '); 155 | } 156 | 157 | export function validateJsonSchema( 158 | data: any, 159 | schema: SchemaObject, 160 | entityName: string = 'data', 161 | options?: JsonUtilsOptions 162 | ): boolean { 163 | const ajv = getAjvInstance(options); 164 | const logger = getLogger(options); 165 | 166 | if (!schema || typeof schema !== 'object') { 167 | logger.error(`Invalid schema provided for ${entityName} validation: Schema must be an object.`, { schema }); 168 | throw new ValidationError(`Invalid schema provided for ${entityName}: Schema must be an object.`); 169 | } 170 | 171 | let validate: ValidateFunction; 172 | 173 | try { 174 | if (compiledSchemaCache.has(schema)) { 175 | validate = compiledSchemaCache.get(schema)!; 176 | logger.debug(`Using cached compiled schema for ${entityName}.`); 177 | } else { 178 | validate = ajv.compile(schema); 179 | compiledSchemaCache.set(schema, validate); 180 | logger.debug(`Compiled and cached schema for ${entityName}.`); 181 | } 182 | 183 | const valid = validate(data); 184 | 185 | if (!valid) { 186 | const errorMessage = formatAjvErrors(validate.errors); 187 | logger.warn(`Schema validation failed for ${entityName}: ${errorMessage}`, { data, schemaErrors: validate.errors }); 188 | // Pass correct ErrorCode to constructor 189 | const validationError = new ValidationError( 190 | `${entityName} validation failed: ${errorMessage}`, 191 | { schemaErrors: validate.errors, data } 192 | ); 193 | // validationError.code = ErrorCode.JSON_SCHEMA_ERROR; // Cannot assign to readonly 194 | throw validationError; // Constructor handles code based on message now 195 | } 196 | 197 | logger.debug(`Schema validation passed successfully for ${entityName}.`); 198 | return true; 199 | 200 | } catch (error) { 201 | const message = error instanceof Error ? error.message : String(error); 202 | logger.error(`Critical error during ${entityName} schema validation: ${message}`, { error, schema }); 203 | 204 | if (error instanceof ValidationError) { 205 | throw error; 206 | } else { 207 | throw new ValidationError(`Error during ${entityName} schema validation: ${message}`, { originalError: error }); 208 | } 209 | } 210 | } 211 | 212 | export function isValidJsonString(jsonString: string, options?: JsonUtilsOptions): boolean { 213 | const logger = getLogger(options); 214 | if (typeof jsonString !== 'string' || jsonString.trim() === '') { 215 | return false; 216 | } 217 | try { 218 | JSON.parse(jsonString); 219 | return true; 220 | } catch (e) { 221 | logger.debug(`String is not valid JSON: "${jsonString.substring(0, 100)}..."`); 222 | return false; 223 | } 224 | } 225 | 226 | export function setJsonUtilsLogger(loggerInstance: Logger) { 227 | if (loggerInstance && typeof loggerInstance.debug === 'function') { 228 | globalLogger = loggerInstance; 229 | } else { 230 | console.error("[JsonUtils] Attempted to set an invalid logger instance."); 231 | } 232 | } -------------------------------------------------------------------------------- /src/security/argument-sanitizer.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/argument-sanitizer.ts 2 | import { 3 | IArgumentSanitizer, 4 | SecurityContext, 5 | ExtendedDangerousArgumentsConfig as DangerousArgumentsConfig, // Use renamed type locally 6 | ExtendedSecurityConfig as SecurityConfig // Use renamed type locally 7 | } from './types'; 8 | import { Tool } from '../types'; 9 | import { Logger } from '../utils/logger'; 10 | import { SecurityError, ErrorCode } from '../utils/error'; 11 | import type { SimpleEventEmitter } from '../utils/simple-event-emitter'; 12 | 13 | export class ArgumentSanitizer implements IArgumentSanitizer { 14 | private eventEmitter: SimpleEventEmitter; 15 | private logger: Logger; 16 | private debugMode: boolean = false; 17 | 18 | private globalDefaultDangerousPatterns: RegExp[] = [ 19 | /rm\s+(-r|-f|-rf|--force|--recursive)\s+[\/~.]/i, 20 | /(^|\s)(format|mkfs|new-psdrive|mount|umount|parted|fdisk|diskpart)\s+/i, 21 | /(^|\s)dd\s+if=\/dev\/(zero|random|urandom)/i, 22 | /(^|;|\||&)\s*(bash|sh|zsh|ksh|csh|powershell|cmd)(\s+|$)/i, 23 | /(`|\$\(|\{|\}|;|\||&)/, 24 | /require\s*\(\s*['"`](child_process|vm|worker_threads|node:child_process|node:vm)['"`]\s*\)/i, 25 | /process\.(env|exit|kill|memoryUsage|cpuUsage|binding|dlopen|abort)/i, 26 | /import\s+.*\s+from\s+['"`](node:)?(child_process|vm|worker_threads)['"`]/i, 27 | /\sfs\.(?!read|stat|access|exist|list|watch|constants|promises\.readFile|promises\.stat|promises\.access|promises\.exists|promises\.readdir|promises\.lstat)\w+/i, 28 | /(^|\s)(eval|exec|execScript|setTimeout|setInterval|new Function)\s*\(/i, 29 | /<\s*script[\s>]/i, 30 | /javascript:/i, 31 | /on(error|load|click|submit|focus|blur|mouse|key)\s*=/i, 32 | /(--|#|\/\*|;)\s*(drop|delete|insert|update|alter|truncate|create|grant|revoke)\s+/i, 33 | /'\s*OR\s+'\d+'\s*=\s*'\d+'/i, 34 | /\.\.\//, 35 | /%2e%2e%2f/i, /%2e%2e\//i, 36 | /\.\.%5c/i, /%2e%2e%5c/i, 37 | /^[a-z]:\\/i, 38 | /^\//, 39 | ]; 40 | 41 | constructor(eventEmitter: SimpleEventEmitter, logger: Logger) { 42 | this.eventEmitter = eventEmitter; 43 | this.logger = logger; 44 | } 45 | 46 | setDebugMode(debug: boolean): void { 47 | this.debugMode = debug; 48 | if (typeof (this.logger as any).setDebug === 'function') { 49 | (this.logger as any).setDebug(debug); 50 | } 51 | } 52 | 53 | private compilePatterns(patterns: Array | undefined, sourceName: string): RegExp[] { 54 | if (!patterns || !Array.isArray(patterns)) return []; 55 | 56 | const compiled: RegExp[] = []; 57 | patterns.forEach((patternSource, index) => { 58 | try { 59 | if (patternSource instanceof RegExp) { 60 | compiled.push(patternSource); 61 | } else if (typeof patternSource === 'string' && patternSource.trim() !== '') { 62 | const match = patternSource.match(/^\/(.+)\/([gimyus]*)$/); 63 | if (match) { 64 | compiled.push(new RegExp(match[1], match[2])); 65 | } else { 66 | compiled.push(new RegExp(patternSource)); 67 | } 68 | } else { 69 | this.logger.warn(`Skipping empty or invalid pattern source at index ${index} in ${sourceName}`); 70 | } 71 | } catch (error) { 72 | this.logger.error(`Error compiling RegExp from ${sourceName} pattern: "${patternSource}"`, error); 73 | this.eventEmitter.emit('security:pattern_error', { source: sourceName, pattern: patternSource, error }); 74 | } 75 | }); 76 | return compiled; 77 | } 78 | 79 | // Context now contains ExtendedSecurityConfig 80 | async validateArguments(tool: Tool, args: any, context: SecurityContext): Promise { 81 | const toolName = context.toolName || 'unknown_tool'; 82 | const userId = context.userId || 'anonymous'; 83 | 84 | if (args === null || args === undefined) { 85 | this.logger.debug(`Arguments for tool '${toolName}' are missing, validation skipped.`); 86 | return; 87 | } 88 | if (typeof args !== 'object') { 89 | this.logger.debug(`Arguments for tool '${toolName}' are not an object (type: ${typeof args}), validation skipped.`); 90 | return; 91 | } 92 | 93 | this.logger.debug(`Validating arguments for tool '${toolName}' (User: ${userId})`); 94 | 95 | const config = context.config; // This is ExtendedSecurityConfig 96 | // Use the extended DangerousArgumentsConfig type here 97 | const dangerousArgsConfig: DangerousArgumentsConfig = config.dangerousArguments || {}; 98 | const auditOnlyMode = dangerousArgsConfig.auditOnlyMode ?? context.debug; 99 | 100 | const toolSpecificPatterns = this.getToolDangerousPatterns(tool, context); 101 | const userDefinedPatterns = this.compilePatterns(dangerousArgsConfig.extendablePatterns, 'config.dangerousArguments.extendablePatterns'); 102 | const configuredGlobalPatterns = this.compilePatterns(dangerousArgsConfig.globalPatterns, 'config.dangerousArguments.globalPatterns'); 103 | const effectiveGlobalPatterns = configuredGlobalPatterns.length > 0 ? configuredGlobalPatterns : this.globalDefaultDangerousPatterns; 104 | 105 | const allPatterns = [ 106 | ...effectiveGlobalPatterns, 107 | ...toolSpecificPatterns, 108 | ...userDefinedPatterns 109 | ]; 110 | this.logger.debug(`Applying ${allPatterns.length} total patterns (${effectiveGlobalPatterns.length} global, ${toolSpecificPatterns.length} tool-specific, ${userDefinedPatterns.length} user-defined).`); 111 | 112 | const globalBlockedValues: string[] = Array.isArray(dangerousArgsConfig.blockedValues) 113 | ? dangerousArgsConfig.blockedValues.filter((v: any) => typeof v === 'string' && v.length > 0) 114 | : []; 115 | 116 | const violations: string[] = []; 117 | 118 | const checkValue = (value: any, path: string): void => { 119 | if (value === null || value === undefined) return; 120 | 121 | if (typeof value === 'string' && globalBlockedValues.some(blocked => value.includes(blocked))) { 122 | violations.push(`Blocked value fragment found in argument '${path}': "...${value.substring(0, 50)}..."`); 123 | } 124 | 125 | if (typeof value === 'string') { 126 | for (const pattern of allPatterns) { 127 | if (pattern.test(value)) { 128 | violations.push(`Dangerous pattern /${pattern.source}/${pattern.flags} matched in argument '${path}': "${value.substring(0, 100)}..."`); 129 | } 130 | } 131 | } 132 | 133 | const keyName = path.split('.').pop() || path; 134 | const keySpecificRules = dangerousArgsConfig.specificKeyRules?.[keyName]; 135 | if (keySpecificRules) { 136 | this.logger.debug(`Applying specific key rules for '${keyName}' (logic not implemented).`); 137 | } 138 | 139 | if (typeof value === 'object') { 140 | const depth = path.split('.').length; 141 | if (depth > 10) { 142 | this.logger.warn(`Argument validation recursion depth limit reached at path '${path}'. Skipping deeper checks.`); 143 | return; 144 | } 145 | 146 | if (Array.isArray(value)) { 147 | value.forEach((item, index) => checkValue(item, `${path}[${index}]`)); 148 | } else { 149 | for (const key in value) { 150 | if (Object.prototype.hasOwnProperty.call(value, key)) { 151 | checkValue(value[key], `${path ? path + '.' : ''}${key}`); 152 | } 153 | } 154 | } 155 | } 156 | }; 157 | 158 | checkValue(args, ''); 159 | 160 | if (violations.length > 0) { 161 | const uniqueViolations = [...new Set(violations)]; 162 | this.logger.warn(`DANGEROUS ARGUMENTS DETECTED for tool '${toolName}' (User: ${userId}). Audit Mode: ${auditOnlyMode}. Violations:`, uniqueViolations); 163 | this.eventEmitter.emit('security:dangerous_args', { 164 | toolName, 165 | userId, 166 | violations: uniqueViolations, 167 | args, 168 | auditOnlyMode 169 | }); 170 | 171 | if (!auditOnlyMode) { 172 | throw new SecurityError( 173 | `Dangerous arguments detected for tool '${toolName}'. Input rejected for security reasons.`, 174 | ErrorCode.DANGEROUS_ARGS, 175 | 400, 176 | { violations: uniqueViolations } 177 | ); 178 | } else { 179 | this.logger.log(`Audit Mode: Dangerous arguments detected for '${toolName}', but execution is allowed.`); 180 | } 181 | } else { 182 | this.logger.debug(`Argument validation passed successfully for tool '${toolName}'.`); 183 | } 184 | } 185 | 186 | // Context now contains ExtendedSecurityConfig 187 | private getToolDangerousPatterns(tool: Tool, context: SecurityContext): RegExp[] { 188 | const toolName = context.toolName || 'unknown_tool'; 189 | const config = context.config; // This is ExtendedSecurityConfig 190 | let patterns: Array = []; 191 | 192 | // Use extended dangerousArguments type 193 | const configPatterns = config.dangerousArguments?.toolSpecificPatterns?.[toolName]; 194 | if (configPatterns) { 195 | patterns = patterns.concat(configPatterns); 196 | } 197 | 198 | const legacyConfigPatterns = config.toolConfig?.[toolName]?.dangerousPatterns; 199 | if (legacyConfigPatterns) { 200 | this.logger.warn(`Using deprecated 'toolConfig[${toolName}].dangerousPatterns'. Please use 'dangerousArguments.toolSpecificPatterns'.`); 201 | patterns = patterns.concat(legacyConfigPatterns); 202 | } 203 | 204 | const toolDefPatterns = (tool.security as any)?.dangerousPatterns; 205 | if (toolDefPatterns) { 206 | patterns = patterns.concat(toolDefPatterns); 207 | this.logger.debug(`Found dangerous patterns defined directly on tool '${toolName}'.`); 208 | } 209 | 210 | const compiledPatterns = this.compilePatterns(patterns, `tool '${toolName}' specific patterns`); 211 | if (compiledPatterns.length > 0) { 212 | this.logger.debug(`Loaded ${compiledPatterns.length} specific dangerous patterns for tool '${toolName}'.`); 213 | } 214 | return compiledPatterns; 215 | } 216 | 217 | destroy(): void { 218 | this.logger.log("ArgumentSanitizer destroyed."); 219 | } 220 | } -------------------------------------------------------------------------------- /src/security/rate-limit-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/rate-limit-manager.ts 2 | // Import necessary types from the security types file 3 | import { 4 | IRateLimitManager, 5 | RateLimitParams, 6 | RateLimitResult, 7 | ExtendedRateLimit as RateLimit // Import the extended RateLimit type and alias it locally 8 | } from './types'; 9 | import { Logger } from '../utils/logger'; 10 | import type { SimpleEventEmitter } from '../utils/simple-event-emitter'; 11 | 12 | // Interface for internal storage entry 13 | interface RateLimitEntry { 14 | count: number; // Current request count in the window 15 | resetTime: number; // Timestamp (ms) when the count resets 16 | limit: number; // The request limit for this window 17 | windowMs: number; // The duration of the window in ms 18 | } 19 | 20 | /** 21 | * In-memory rate limiter. 22 | * WARNING: This implementation is NOT suitable for distributed/multi-process environments 23 | * as the state is local to each instance. Use an external store (e.g., Redis) for scaling. 24 | */ 25 | export class RateLimitManager implements IRateLimitManager { 26 | // Use a standard Map for storing rate limit state 27 | private limits: Map = new Map(); 28 | private eventEmitter: SimpleEventEmitter; 29 | private logger: Logger; 30 | private debugMode: boolean = false; 31 | // Timer for cleaning up very old entries (optional, helps manage memory) 32 | private cleanupIntervalId: NodeJS.Timeout | null = null; 33 | private readonly DEFAULT_CLEANUP_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes 34 | 35 | constructor(eventEmitter: SimpleEventEmitter, logger: Logger) { 36 | this.eventEmitter = eventEmitter; 37 | this.logger = logger; 38 | // Start periodic cleanup of potentially stale entries 39 | this.startCleanupTimer(this.DEFAULT_CLEANUP_INTERVAL_MS); 40 | } 41 | 42 | setDebugMode(debug: boolean): void { 43 | this.debugMode = debug; 44 | if (typeof (this.logger as any).setDebug === 'function') { 45 | (this.logger as any).setDebug(debug); 46 | } 47 | } 48 | 49 | private startCleanupTimer(intervalMs: number): void { 50 | this.stopCleanupTimer(); // Clear existing timer first 51 | if (intervalMs > 0 && !this.cleanupIntervalId) { // Start only if interval is positive and not already running 52 | this.cleanupIntervalId = setInterval(() => this.cleanupExpiredLimits(), intervalMs); 53 | // Allow Node.js to exit even if timer is active 54 | if (this.cleanupIntervalId?.unref) { 55 | this.cleanupIntervalId.unref(); 56 | } 57 | this.logger.debug(`Rate limit state cleanup timer started (Interval: ${intervalMs}ms).`); 58 | } 59 | } 60 | 61 | public stopCleanupTimer(): void { 62 | if (this.cleanupIntervalId) { 63 | clearInterval(this.cleanupIntervalId); 64 | this.cleanupIntervalId = null; 65 | this.logger.debug("Rate limit state cleanup timer stopped."); 66 | } 67 | } 68 | 69 | /** 70 | * Parses the RateLimit configuration to get the window size in milliseconds. 71 | * @returns Window size in ms, or 0 if invalid. 72 | */ 73 | private getTimeWindowMs(rateLimit: RateLimit): number { // Use aliased RateLimit (ExtendedRateLimit) 74 | const { interval, period } = rateLimit; 75 | let timeWindowMs = 0; 76 | 77 | if (interval !== undefined) { 78 | if (typeof interval === 'number') { 79 | timeWindowMs = interval > 0 ? interval * 1000 : 0; // Assume number is seconds 80 | } else if (typeof interval === 'string') { 81 | const match = interval.match(/^(\d+)(s|m|h|d)$/); 82 | if (match) { 83 | const [, amount, unit] = match; 84 | const value = parseInt(amount, 10); 85 | switch (unit) { 86 | case 's': timeWindowMs = value * 1000; break; 87 | case 'm': timeWindowMs = value * 60 * 1000; break; 88 | case 'h': timeWindowMs = value * 60 * 60 * 1000; break; 89 | case 'd': timeWindowMs = value * 24 * 60 * 60 * 1000; break; 90 | } 91 | } else if (/^\d+$/.test(interval)) { 92 | // Allow plain numbers as strings, treat as seconds 93 | timeWindowMs = parseInt(interval, 10) * 1000; 94 | } 95 | } 96 | } else if (period) { // Fallback to period if interval not set 97 | switch (period) { 98 | case 'second': timeWindowMs = 1000; break; 99 | case 'minute': timeWindowMs = 60 * 1000; break; 100 | case 'hour': timeWindowMs = 60 * 60 * 1000; break; 101 | case 'day': timeWindowMs = 24 * 60 * 60 * 1000; break; 102 | } 103 | } 104 | 105 | if (timeWindowMs <= 0) { 106 | this.logger.warn(`Invalid Rate Limit interval/period defined: ${JSON.stringify(rateLimit)}. Defaulting window to 60000ms.`); 107 | return 60000; // Default to 1 minute if invalid config 108 | } 109 | return timeWindowMs; 110 | } 111 | 112 | /** 113 | * Checks if a request is allowed based on the defined rate limit. 114 | * Increments the counter if the request is allowed. 115 | * @param params - RateLimitParams containing identifiers and limit config. 116 | * @returns RateLimitResult indicating if allowed and current state. 117 | */ 118 | checkRateLimit(params: RateLimitParams): RateLimitResult { // RateLimitParams uses ExtendedRateLimit 119 | const { userId, toolName, rateLimit, source = 'default' } = params; 120 | 121 | // Basic validation of inputs 122 | if (!userId || !toolName || !rateLimit || typeof rateLimit.limit !== 'number' || rateLimit.limit <= 0) { 123 | this.logger.error("Invalid parameters for checkRateLimit.", params); 124 | // Deny request if parameters are invalid to prevent unexpected allowance 125 | return { allowed: false, currentCount: 0, limit: rateLimit?.limit || 0, resetTime: new Date(0) }; 126 | } 127 | 128 | // Generate a unique key for this specific limit (user + tool + source identifier) 129 | const key = `rate_limit:${userId}:${toolName}:${source}`; 130 | this.logger.debug(`Checking Rate Limit for key '${key}'`); 131 | 132 | const now = Date.now(); 133 | const timeWindowMs = this.getTimeWindowMs(rateLimit); 134 | const limitValue = rateLimit.limit; 135 | 136 | let entry = this.limits.get(key); 137 | 138 | // Check if entry exists and is still within its valid time window 139 | if (!entry || entry.resetTime <= now) { 140 | // Create or reset the entry for a new window 141 | this.logger.debug(`Creating/resetting Rate Limit entry for key '${key}' (Limit: ${limitValue}, Window: ${timeWindowMs}ms)`); 142 | const newResetTime = now + timeWindowMs; 143 | entry = { count: 1, resetTime: newResetTime, limit: limitValue, windowMs: timeWindowMs }; 144 | this.limits.set(key, entry); 145 | 146 | this.eventEmitter.emit('ratelimit:new', { userId, toolName, source, limit: limitValue, resetTime: new Date(newResetTime), windowMs: timeWindowMs }); 147 | 148 | // First request in a new window is always allowed 149 | return { allowed: true, currentCount: 1, limit: limitValue, resetTime: new Date(newResetTime) }; 150 | } 151 | 152 | // Entry exists and is within the current window, increment count 153 | entry.count++; 154 | const allowed = entry.count <= entry.limit; 155 | const resetTimeDate = new Date(entry.resetTime); 156 | const timeLeftMs = Math.max(0, entry.resetTime - now); // Time left in ms 157 | 158 | // Update the entry in the map (count incremented) 159 | this.limits.set(key, entry); 160 | 161 | if (!allowed) { 162 | // Limit exceeded 163 | const timeLeftSec = Math.ceil(timeLeftMs / 1000); 164 | this.logger.warn(`Rate Limit EXCEEDED for key '${key}'. Count: ${entry.count}, Limit: ${entry.limit}. Reset in ${timeLeftSec}s.`); 165 | this.eventEmitter.emit('ratelimit:exceeded', { userId, toolName, source, currentCount: entry.count, limit: entry.limit, resetTime: resetTimeDate, timeLeftMs }); 166 | } else { 167 | // Limit check passed 168 | this.logger.debug(`Rate Limit check PASSED for key '${key}'. Count: ${entry.count}, Limit: ${entry.limit}`); 169 | } 170 | 171 | return { allowed, currentCount: entry.count, limit: entry.limit, resetTime: resetTimeDate, timeLeft: timeLeftMs }; 172 | } 173 | 174 | /** Clears rate limits, optionally for a specific user. */ 175 | clearLimits(userId?: string): void { 176 | if (userId) { 177 | // Clear only for the specified user 178 | const prefix = `rate_limit:${userId}:`; 179 | const keysToDelete: string[] = []; 180 | for (const key of this.limits.keys()) { 181 | if (key.startsWith(prefix)) { 182 | keysToDelete.push(key); 183 | } 184 | } 185 | keysToDelete.forEach(key => this.limits.delete(key)); 186 | this.logger.log(`Cleared Rate Limit state for user '${userId}' (${keysToDelete.length} entries removed).`); 187 | this.eventEmitter.emit('ratelimit:cleared', { userId }); 188 | } else { 189 | // Clear all limits 190 | const count = this.limits.size; 191 | this.limits.clear(); 192 | this.logger.log(`Cleared ALL Rate Limit state (${count} entries removed).`); 193 | this.eventEmitter.emit('ratelimit:cleared', { all: true }); 194 | } 195 | } 196 | 197 | /** Periodically removes very old/stale entries from the map to manage memory. */ 198 | private cleanupExpiredLimits(): void { 199 | const now = Date.now(); 200 | const keysToDelete: string[] = []; 201 | const cleanupThresholdFactor = 3; // Remove entries whose reset time is > 3 windows in the past 202 | 203 | this.logger.debug("Running periodic cleanup of stale Rate Limit entries..."); 204 | for (const [key, entry] of this.limits.entries()) { 205 | // Check if the entry's reset time is significantly in the past 206 | if (entry.resetTime + (entry.windowMs * cleanupThresholdFactor) < now) { 207 | keysToDelete.push(key); 208 | } 209 | } 210 | 211 | if (keysToDelete.length > 0) { 212 | keysToDelete.forEach(key => this.limits.delete(key)); 213 | this.logger.log(`Cleaned up ${keysToDelete.length} stale Rate Limit entries.`); 214 | this.eventEmitter.emit('ratelimit:stale_removed', { count: keysToDelete.length }); 215 | } else { 216 | this.logger.debug("No stale Rate Limit entries found during cleanup."); 217 | } 218 | } 219 | 220 | // Destroys the manager, clearing timers and state. 221 | destroy(): void { 222 | this.stopCleanupTimer(); // Stop the cleanup timer 223 | this.limits.clear(); // Clear the rate limit state 224 | this.logger.log("RateLimitManager destroyed."); 225 | } 226 | } -------------------------------------------------------------------------------- /src/history/history-analyzer.ts: -------------------------------------------------------------------------------- 1 | // Path: src/history/history-analyzer.ts 2 | import { UnifiedHistoryManager } from './unified-history-manager'; 3 | import { HistoryEntry, UsageInfo, Role } from '../types'; 4 | import { Logger } from '../utils/logger'; 5 | import { mapError } from '../utils/error'; 6 | 7 | // --- Типы для опций и результатов анализа --- 8 | 9 | /** Опции для фильтрации истории перед анализом */ 10 | export interface HistoryQueryOptions { 11 | startDate?: Date | number; // Начальная дата (или timestamp) 12 | endDate?: Date | number; // Конечная дата (или timestamp) 13 | models?: string[]; // Фильтр по моделям 14 | roles?: Role[]; // Фильтр по ролям сообщений (менее полезно для статистики API) 15 | minCost?: number; // Фильтр по минимальной стоимости вызова API 16 | finishReasons?: string[]; // Фильтр по причинам завершения 17 | } 18 | 19 | /** Агрегированная статистика по истории */ 20 | export interface HistoryStats { 21 | totalApiCalls: number; 22 | totalCost: number; 23 | totalUsage: UsageInfo; 24 | usageByModel: Record; 25 | costByModel: Record; 26 | finishReasonCounts: Record; 27 | firstEntryTimestamp: number | null; 28 | lastEntryTimestamp: number | null; 29 | entriesAnalyzed: number; 30 | entriesTotal: number; // Общее количество записей до фильтрации 31 | } 32 | 33 | /** Данные для временных рядов */ 34 | export interface TimeSeriesDataPoint { 35 | timestamp: number; // Начало интервала 36 | value: number; // Значение за интервал (e.g., cost, tokens) 37 | } 38 | 39 | export type TimeSeriesData = TimeSeriesDataPoint[]; 40 | 41 | export class HistoryAnalyzer { 42 | private historyManager: UnifiedHistoryManager; 43 | private logger: Logger; 44 | 45 | constructor(historyManager: UnifiedHistoryManager, logger: Logger) { 46 | this.historyManager = historyManager; 47 | this.logger = logger.withPrefix('HistoryAnalyzer'); 48 | this.logger.log('HistoryAnalyzer initialized.'); 49 | } 50 | 51 | /** 52 | * Фильтрует записи истории на основе предоставленных опций. 53 | */ 54 | private filterEntries(entries: HistoryEntry[], options?: HistoryQueryOptions): HistoryEntry[] { 55 | if (!options) return entries; 56 | 57 | const { startDate, endDate, models, roles, minCost, finishReasons } = options; 58 | const startTs = startDate instanceof Date ? startDate.getTime() : (typeof startDate === 'number' ? startDate : null); 59 | const endTs = endDate instanceof Date ? endDate.getTime() : (typeof endDate === 'number' ? endDate : null); 60 | 61 | return entries.filter(entry => { 62 | const meta = entry.apiCallMetadata; 63 | const msgTimestamp = entry.message.timestamp ? new Date(entry.message.timestamp).getTime() : null; 64 | const apiTimestamp = meta?.timestamp; // Timestamp ответа API 65 | 66 | // Используем timestamp ответа API для фильтрации по дате, если он есть, иначе timestamp сообщения 67 | const entryTimestamp = apiTimestamp ?? msgTimestamp; 68 | 69 | if (startTs && (!entryTimestamp || entryTimestamp < startTs)) return false; 70 | if (endTs && (!entryTimestamp || entryTimestamp > endTs)) return false; 71 | if (models && (!meta || !models.includes(meta.modelUsed))) return false; 72 | if (roles && !roles.includes(entry.message.role)) return false; // Фильтр по роли сообщения 73 | // Фильтры ниже применяются только к записям с метаданными API 74 | if (meta) { 75 | if (minCost !== undefined && (meta.cost === null || meta.cost < minCost)) return false; 76 | if (finishReasons && (!meta.finishReason || !finishReasons.includes(meta.finishReason))) return false; 77 | } else { 78 | // Если фильтр требует метаданные, а их нет, исключаем запись 79 | if (minCost !== undefined || finishReasons) return false; 80 | } 81 | 82 | return true; 83 | }); 84 | } 85 | 86 | /** 87 | * Рассчитывает агрегированную статистику для указанного ключа истории. 88 | */ 89 | public async getStats(key: string, options?: HistoryQueryOptions): Promise { 90 | this.logger.debug(`Calculating stats for history key '${key}'...`, { options }); 91 | let allEntries: HistoryEntry[] = []; 92 | try { 93 | allEntries = await this.historyManager.getHistoryEntries(key); 94 | } catch (error) { 95 | this.logger.error(`Failed to load history entries for key '${key}'`, mapError(error)); 96 | throw mapError(error); // Перебрасываем ошибку 97 | } 98 | 99 | const filteredEntries = this.filterEntries(allEntries, options); 100 | const entriesAnalyzed = filteredEntries.length; 101 | this.logger.debug(`Analyzing ${entriesAnalyzed} entries out of ${allEntries.length} total.`); 102 | 103 | const stats: HistoryStats = { 104 | totalApiCalls: 0, 105 | totalCost: 0, 106 | totalUsage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, 107 | usageByModel: {}, 108 | costByModel: {}, 109 | finishReasonCounts: {}, 110 | firstEntryTimestamp: null, 111 | lastEntryTimestamp: null, 112 | entriesAnalyzed: entriesAnalyzed, 113 | entriesTotal: allEntries.length, 114 | }; 115 | 116 | let firstTs: number | null = null; 117 | let lastTs: number | null = null; 118 | 119 | for (const entry of filteredEntries) { 120 | const meta = entry.apiCallMetadata; 121 | const entryTimestamp = meta?.timestamp ?? (entry.message.timestamp ? new Date(entry.message.timestamp).getTime() : null); 122 | 123 | if (entryTimestamp) { 124 | if (firstTs === null || entryTimestamp < firstTs) { 125 | firstTs = entryTimestamp; 126 | } 127 | if (lastTs === null || entryTimestamp > lastTs) { 128 | lastTs = entryTimestamp; 129 | } 130 | } 131 | 132 | // Статистика собирается только по записям с метаданными API 133 | if (meta) { 134 | stats.totalApiCalls += 1; 135 | stats.totalCost += meta.cost ?? 0; 136 | 137 | if (meta.usage) { 138 | stats.totalUsage.prompt_tokens += meta.usage.prompt_tokens ?? 0; 139 | stats.totalUsage.completion_tokens += meta.usage.completion_tokens ?? 0; 140 | stats.totalUsage.total_tokens += meta.usage.total_tokens ?? 0; 141 | 142 | // Статистика по моделям 143 | if (!stats.usageByModel[meta.modelUsed]) { 144 | stats.usageByModel[meta.modelUsed] = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; 145 | } 146 | if (!stats.costByModel[meta.modelUsed]) { 147 | stats.costByModel[meta.modelUsed] = 0; 148 | } 149 | 150 | stats.usageByModel[meta.modelUsed].prompt_tokens += meta.usage.prompt_tokens ?? 0; 151 | stats.usageByModel[meta.modelUsed].completion_tokens += meta.usage.completion_tokens ?? 0; 152 | stats.usageByModel[meta.modelUsed].total_tokens += meta.usage.total_tokens ?? 0; 153 | stats.costByModel[meta.modelUsed] += meta.cost ?? 0; 154 | } 155 | 156 | // Статистика по finish_reason 157 | const reason = meta.finishReason ?? 'unknown'; 158 | stats.finishReasonCounts[reason] = (stats.finishReasonCounts[reason] || 0) + 1; 159 | } 160 | } 161 | 162 | stats.firstEntryTimestamp = firstTs; 163 | stats.lastEntryTimestamp = lastTs; 164 | 165 | // Округление общей стоимости 166 | stats.totalCost = parseFloat(stats.totalCost.toFixed(8)); 167 | for(const model in stats.costByModel) { 168 | stats.costByModel[model] = parseFloat(stats.costByModel[model].toFixed(8)); 169 | } 170 | 171 | 172 | this.logger.log(`Stats calculated for key '${key}'. Analyzed ${entriesAnalyzed} entries.`); 173 | this.logger.debug("Calculated Stats:", stats); 174 | return stats; 175 | } 176 | 177 | /** 178 | * Группирует стоимость по временным интервалам (например, по дням). 179 | * @param key - Ключ истории. 180 | * @param interval - Интервал группировки ('day', 'hour', 'minute'). По умолчанию 'day'. 181 | * @param options - Опции фильтрации. 182 | */ 183 | public async getCostOverTime( 184 | key: string, 185 | interval: 'day' | 'hour' | 'minute' = 'day', 186 | options?: HistoryQueryOptions 187 | ): Promise { 188 | this.logger.debug(`Calculating cost over time for key '${key}', interval: ${interval}...`, { options }); 189 | let allEntries: HistoryEntry[] = []; 190 | try { 191 | allEntries = await this.historyManager.getHistoryEntries(key); 192 | } catch (error) { 193 | this.logger.error(`Failed to load history entries for key '${key}'`, mapError(error)); 194 | throw mapError(error); 195 | } 196 | 197 | const filteredEntries = this.filterEntries(allEntries, options); 198 | const costByInterval: Record = {}; 199 | 200 | for (const entry of filteredEntries) { 201 | const meta = entry.apiCallMetadata; 202 | if (meta && meta.cost !== null && meta.timestamp) { 203 | const timestamp = meta.timestamp; 204 | let intervalStartTs: number; 205 | 206 | const date = new Date(timestamp); 207 | if (interval === 'day') { 208 | date.setUTCHours(0, 0, 0, 0); 209 | intervalStartTs = date.getTime(); 210 | } else if (interval === 'hour') { 211 | date.setUTCMinutes(0, 0, 0); 212 | intervalStartTs = date.getTime(); 213 | } else { // minute 214 | date.setUTCSeconds(0, 0); 215 | intervalStartTs = date.getTime(); 216 | } 217 | 218 | costByInterval[intervalStartTs] = (costByInterval[intervalStartTs] || 0) + meta.cost; 219 | } 220 | } 221 | 222 | const timeSeries: TimeSeriesData = Object.entries(costByInterval) 223 | .map(([ts, value]) => ({ 224 | timestamp: parseInt(ts, 10), 225 | value: parseFloat(value.toFixed(8)) // Округление 226 | })) 227 | .sort((a, b) => a.timestamp - b.timestamp); // Сортировка по времени 228 | 229 | this.logger.log(`Cost over time calculated for key '${key}'. Found ${timeSeries.length} data points.`); 230 | return timeSeries; 231 | } 232 | 233 | /** 234 | * Возвращает суммарное использование токенов, сгруппированное по моделям. 235 | */ 236 | public async getTokenUsageByModel(key: string, options?: HistoryQueryOptions): Promise> { 237 | this.logger.debug(`Calculating token usage by model for key '${key}'...`, { options }); 238 | let allEntries: HistoryEntry[] = []; 239 | try { 240 | allEntries = await this.historyManager.getHistoryEntries(key); 241 | } catch (error) { 242 | this.logger.error(`Failed to load history entries for key '${key}'`, mapError(error)); 243 | throw mapError(error); 244 | } 245 | 246 | const filteredEntries = this.filterEntries(allEntries, options); 247 | const usageByModel: Record = {}; 248 | 249 | for (const entry of filteredEntries) { 250 | const meta = entry.apiCallMetadata; 251 | if (meta?.usage) { 252 | const model = meta.modelUsed; 253 | if (!usageByModel[model]) { 254 | usageByModel[model] = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; 255 | } 256 | usageByModel[model].prompt_tokens += meta.usage.prompt_tokens ?? 0; 257 | usageByModel[model].completion_tokens += meta.usage.completion_tokens ?? 0; 258 | usageByModel[model].total_tokens += meta.usage.total_tokens ?? 0; 259 | } 260 | } 261 | this.logger.log(`Token usage by model calculated for key '${key}'. Found usage for ${Object.keys(usageByModel).length} models.`); 262 | return usageByModel; 263 | } 264 | 265 | // TODO: Добавить метод exportHistory 266 | } -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error classes used in the OpenRouter Kit library. 3 | * All custom errors inherit from the base class `OpenRouterError`. 4 | * Includes a `mapError` function to standardize various error types. 5 | */ 6 | 7 | /** 8 | * Standardized error codes for classifying issues. 9 | */ 10 | export enum ErrorCode { 11 | /** Error response from the OpenRouter API (HTTP status >= 400). */ 12 | API_ERROR = 'api_error', 13 | /** Data validation failed (config, arguments, response schema). */ 14 | VALIDATION_ERROR = 'validation_error', 15 | /** Network issue connecting to the API (e.g., DNS, connection refused). */ 16 | NETWORK_ERROR = 'network_error', 17 | /** Authentication failed (e.g., invalid API key, missing auth header). Status 401. */ 18 | AUTHENTICATION_ERROR = 'authentication_error', 19 | /** Authorization failed (e.g., invalid/expired JWT, bad signature). Status 401/403. */ 20 | AUTHORIZATION_ERROR = 'authorization_error', 21 | /** Access denied (authenticated but lacks permissions). Status 403. */ 22 | ACCESS_DENIED_ERROR = 'access_denied', 23 | /** Error during the execution of a tool's `execute` function. */ 24 | TOOL_ERROR = 'tool_error', 25 | /** API rate limit exceeded. Status 429. */ 26 | RATE_LIMIT_ERROR = 'rate_limit_error', 27 | /** Request timed out waiting for API response. Status 408 or network timeout. */ 28 | TIMEOUT_ERROR = 'timeout_error', 29 | /** Invalid library or component configuration. */ 30 | CONFIG_ERROR = 'config_error', 31 | /** General security-related error (e.g., failed check, policy violation). */ 32 | SECURITY_ERROR = 'security_error', 33 | /** Potentially dangerous arguments detected by sanitizer. Status 400. */ 34 | DANGEROUS_ARGS = 'dangerous_args', 35 | /** Error parsing a JSON string. */ 36 | JSON_PARSE_ERROR = 'json_parse_error', 37 | /** Data failed validation against a JSON Schema. */ 38 | JSON_SCHEMA_ERROR = 'json_schema_error', 39 | /** JWT signing error. */ 40 | JWT_SIGN_ERROR = 'jwt_sign_error', 41 | /** Error during JWT validation (expired, invalid signature etc.) */ 42 | JWT_VALIDATION_ERROR = 'jwt_validation_error', 43 | /** An internal error within the library code. */ 44 | INTERNAL_ERROR = 'internal_error', 45 | /** Unknown or unclassified error. */ 46 | UNKNOWN_ERROR = 'unknown_error', 47 | } 48 | 49 | /** 50 | * Base error class for all library-specific errors. 51 | */ 52 | export class OpenRouterError extends Error { 53 | /** Standardized error code for programmatic handling. */ 54 | public readonly code: ErrorCode; 55 | /** Optional: HTTP status code associated with the error. */ 56 | public readonly statusCode?: number; 57 | /** Optional: Additional details, context, or the original error. */ 58 | public readonly details?: any; 59 | 60 | constructor(message: string, code: ErrorCode, statusCode?: number, details?: any) { 61 | super(message); 62 | this.name = this.constructor.name; // Set name to the specific subclass name (e.g., APIError) 63 | this.code = code; 64 | this.statusCode = statusCode; 65 | this.details = details; 66 | 67 | // Ensure instanceof works correctly 68 | Object.setPrototypeOf(this, new.target.prototype); 69 | 70 | // Capture stack trace if available 71 | if (typeof Error.captureStackTrace === 'function') { 72 | Error.captureStackTrace(this, this.constructor); 73 | } else { 74 | this.stack = (new Error(message)).stack; 75 | } 76 | } 77 | } 78 | 79 | // --- Specific Error Subclasses --- 80 | 81 | /** Error returned by the OpenRouter API (status >= 400). */ 82 | export class APIError extends OpenRouterError { 83 | constructor(message: string, statusCode?: number, details?: any) { 84 | super(message, ErrorCode.API_ERROR, statusCode, details); 85 | } 86 | } 87 | 88 | /** Data validation error (config, arguments, schema). */ 89 | export class ValidationError extends OpenRouterError { 90 | constructor(message: string, details?: any) { 91 | // Default to validation error code, potentially overridden by specific types below 92 | let specificCode = ErrorCode.VALIDATION_ERROR; 93 | const lowerMessage = message.toLowerCase(); 94 | if (lowerMessage.includes('json') && (lowerMessage.includes('parse') || lowerMessage.includes('format'))) { 95 | specificCode = ErrorCode.JSON_PARSE_ERROR; 96 | } else if (lowerMessage.includes('json') && lowerMessage.includes('schema')) { 97 | specificCode = ErrorCode.JSON_SCHEMA_ERROR; 98 | } 99 | // Validation errors are typically client errors (400) 100 | super(message, specificCode, 400, details); 101 | } 102 | } 103 | 104 | /** Network error during API request (connection, DNS). */ 105 | export class NetworkError extends OpenRouterError { 106 | constructor(message: string, details?: any) { 107 | // No specific HTTP status code for network errors before response 108 | super(message, ErrorCode.NETWORK_ERROR, undefined, details); 109 | } 110 | } 111 | 112 | /** Authentication error (invalid API key, missing token). Typically 401. */ 113 | export class AuthenticationError extends OpenRouterError { 114 | constructor(message: string, statusCode: number = 401, details?: any) { 115 | super(message, ErrorCode.AUTHENTICATION_ERROR, statusCode, details); 116 | } 117 | } 118 | 119 | /** Authorization error (invalid JWT, expired token, wrong signature). Typically 401/403. */ 120 | export class AuthorizationError extends OpenRouterError { 121 | constructor(message: string, statusCode: number = 401, details?: any) { 122 | let code = ErrorCode.AUTHORIZATION_ERROR; 123 | // Distinguish JWT validation errors if possible from message 124 | if (message.toLowerCase().includes('jwt') || message.toLowerCase().includes('token expired') || message.toLowerCase().includes('invalid signature')) { 125 | code = ErrorCode.JWT_VALIDATION_ERROR; 126 | } 127 | super(message, code, statusCode, details); 128 | } 129 | } 130 | 131 | /** Access denied (authenticated but lacks permission). Typically 403. */ 132 | export class AccessDeniedError extends OpenRouterError { 133 | constructor(message: string, statusCode: number = 403, details?: any) { 134 | super(message, ErrorCode.ACCESS_DENIED_ERROR, statusCode, details); 135 | } 136 | } 137 | 138 | /** Error during tool function execution. Typically 500 (internal server error context). */ 139 | export class ToolError extends OpenRouterError { 140 | constructor(message: string, details?: any) { 141 | super(message, ErrorCode.TOOL_ERROR, 500, details); 142 | } 143 | } 144 | 145 | /** Rate limit exceeded. Typically 429. */ 146 | export class RateLimitError extends OpenRouterError { 147 | constructor(message: string, statusCode: number = 429, details?: any) { 148 | super(message, ErrorCode.RATE_LIMIT_ERROR, statusCode, details); 149 | } 150 | } 151 | 152 | /** Request timed out. Typically 408. */ 153 | export class TimeoutError extends OpenRouterError { 154 | constructor(message: string, statusCode: number = 408, details?: any) { 155 | super(message, ErrorCode.TIMEOUT_ERROR, statusCode, details); 156 | } 157 | } 158 | 159 | /** Invalid library configuration. Typically indicates a setup issue. */ 160 | export class ConfigError extends OpenRouterError { 161 | constructor(message: string, details?: any) { 162 | // Configuration errors are internal/setup related, often not tied to a specific HTTP status 163 | super(message, ErrorCode.CONFIG_ERROR, 500, details); 164 | } 165 | } 166 | 167 | /** General security error (policy violation, dangerous args). Typically 400/403. */ 168 | export class SecurityError extends OpenRouterError { 169 | // Allow passing a specific ErrorCode like DANGEROUS_ARGS 170 | constructor(message: string, code: string = ErrorCode.SECURITY_ERROR, statusCode: number = 400, details?: any) { 171 | // Ensure the provided code is a valid ErrorCode enum member 172 | const finalCode = Object.values(ErrorCode).includes(code as ErrorCode) 173 | ? code as ErrorCode 174 | : ErrorCode.SECURITY_ERROR; 175 | super(message, finalCode, statusCode, details); 176 | } 177 | } 178 | 179 | 180 | /** 181 | * Maps various error types (Axios, standard Error, etc.) to a standardized OpenRouterError subclass. 182 | * 183 | * @param error - The original error object (can be of any type). 184 | * @returns An instance of OpenRouterError or one of its subclasses. 185 | */ 186 | export function mapError(error: any): OpenRouterError { 187 | // If it's already one of our errors, return it directly 188 | if (error instanceof OpenRouterError) { 189 | return error; 190 | } 191 | 192 | // Handle Axios errors specifically 193 | if (error?.isAxiosError === true) { 194 | const axiosError = error as import('axios').AxiosError; 195 | const statusCode = axiosError.response?.status; 196 | const responseData = axiosError.response?.data; 197 | const errorDetails = { 198 | ...(typeof responseData === 'object' ? responseData : { responseData }), // Ensure details are object 199 | requestUrl: axiosError.config?.url, 200 | requestMethod: axiosError.config?.method?.toUpperCase(), 201 | axiosErrorCode: axiosError.code, // e.g., 'ECONNABORTED' 202 | }; 203 | 204 | // Try to extract a meaningful message from response data or Axios error 205 | let message = 'API request failed'; 206 | if (typeof responseData === 'object' && responseData !== null) { 207 | message = (responseData as any).error?.message || (responseData as any).message || axiosError.message; 208 | } else if (typeof responseData === 'string') { 209 | message = responseData; // Use string response directly if available 210 | } else { 211 | message = axiosError.message; // Fallback to Axios message 212 | } 213 | 214 | 215 | if (statusCode) { 216 | // Map HTTP status codes to specific error types 217 | switch (statusCode) { 218 | case 400: 219 | // Could be validation or other bad request 220 | if (message.toLowerCase().includes('validation')) { 221 | return new ValidationError(message, errorDetails); 222 | } 223 | return new APIError(message, statusCode, errorDetails); 224 | case 401: 225 | // Could be Authentication or Authorization depending on context 226 | // Let's default to Authentication, AuthManager might create AuthorizationError specifically 227 | return new AuthenticationError(message || 'Authentication required', statusCode, errorDetails); 228 | case 403: 229 | return new AccessDeniedError(message || 'Access denied', statusCode, errorDetails); 230 | case 408: 231 | return new TimeoutError(message || 'Request timed out', statusCode, errorDetails); 232 | case 429: 233 | return new RateLimitError(message || 'Rate limit exceeded', statusCode, errorDetails); 234 | default: 235 | // General API error for other 4xx/5xx 236 | return new APIError(message || `API Error (Status ${statusCode})`, statusCode, errorDetails); 237 | } 238 | } else if (axiosError.code === 'ECONNABORTED' || axiosError.message.toLowerCase().includes('timeout')) { 239 | // Handle Axios timeout errors 240 | return new TimeoutError(message || 'Request timed out', 408, errorDetails); 241 | } else { 242 | // Handle other network-level errors (DNS, connection refused, etc.) 243 | return new NetworkError(message || 'Network error during API request', errorDetails); 244 | } 245 | } 246 | 247 | // Handle standard JavaScript errors 248 | if (error instanceof Error) { 249 | // Check name for specific standard error types if needed 250 | // if (error.name === 'TypeError') { ... } 251 | 252 | // Map based on known library error names if used without proper class hierarchy before 253 | if (error.name === 'ValidationError') return new ValidationError(error.message, { originalError: error }); 254 | if (error.name === 'ConfigError') return new ConfigError(error.message, { originalError: error }); 255 | if (error.name === 'SecurityError') return new SecurityError(error.message, ErrorCode.SECURITY_ERROR, 400, { originalError: error }); 256 | if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { 257 | return new AuthorizationError(error.message, 401, { originalError: error }); 258 | } 259 | 260 | // Fallback for generic Errors 261 | return new OpenRouterError( 262 | error.message || 'An unexpected error occurred', 263 | ErrorCode.UNKNOWN_ERROR, // Default to unknown 264 | undefined, // No status code available 265 | { originalErrorName: error.name, stack: error.stack } // Include original details 266 | ); 267 | } 268 | 269 | // Handle non-Error types (strings, objects, etc.) 270 | let errorMessage = 'An unknown error object was thrown'; 271 | let details: any = { originalValue: error }; 272 | try { 273 | // Try to stringify non-errors for logging 274 | errorMessage = `Unknown error: ${JSON.stringify(error)}`; 275 | } catch { /* Ignore stringify errors */ } 276 | 277 | return new OpenRouterError( 278 | errorMessage, 279 | ErrorCode.UNKNOWN_ERROR, 280 | undefined, 281 | details 282 | ); 283 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Path: src/types/index.ts 2 | import type { AxiosRequestConfig } from 'axios'; 3 | import type { ISecurityManager } from '../security/types'; 4 | import type { Logger } from '../utils/logger'; 5 | // Import ErrorCode for use in ToolCallDetail 6 | import { ErrorCode } from '../utils/error'; 7 | 8 | // --- Plugin and Middleware Types --- 9 | 10 | export interface OpenRouterPlugin { 11 | init(client: import('../client').OpenRouterClient): Promise | void; 12 | destroy?: () => Promise | void; 13 | } 14 | 15 | export interface MiddlewareContext { 16 | request: { 17 | options: OpenRouterRequestOptions; 18 | }; 19 | response?: { 20 | result?: ChatCompletionResult; 21 | rawResponse?: any; 22 | error?: any; 23 | }; 24 | metadata?: Record; 25 | } 26 | 27 | export type MiddlewareFunction = ( 28 | ctx: MiddlewareContext, 29 | next: () => Promise 30 | ) => Promise; 31 | 32 | // --- History Storage Related Types --- 33 | 34 | export interface ApiCallMetadata { 35 | callId: string; 36 | modelUsed: string; 37 | usage: UsageInfo | null; 38 | cost: number | null; 39 | timestamp: number; 40 | finishReason: string | null; 41 | requestMessagesCount?: number; 42 | } 43 | 44 | export interface HistoryEntry { 45 | message: Message; 46 | apiCallMetadata?: ApiCallMetadata | null; 47 | } 48 | 49 | // --- History Storage Adapter Interface --- 50 | 51 | export interface IHistoryStorage { 52 | load(key: string): Promise; 53 | save(key: string, entries: HistoryEntry[]): Promise; 54 | delete(key: string): Promise; 55 | listKeys(): Promise; 56 | destroy?: () => Promise | void; 57 | } 58 | 59 | // --- Core Message and Tool Types --- 60 | 61 | export type Role = 'user' | 'assistant' | 'system' | 'tool'; 62 | 63 | export interface Message { 64 | role: Role; 65 | content: string | null; 66 | timestamp?: string; 67 | name?: string; 68 | tool_call_id?: string; 69 | tool_calls?: ToolCall[]; 70 | reasoning?: string | null; 71 | annotations?: UrlCitationAnnotation[]; 72 | } 73 | 74 | export interface ToolCall { 75 | id: string; 76 | type: 'function'; 77 | function: { 78 | name: string; 79 | arguments: string; // Arguments as a JSON string from the LLM 80 | }; 81 | } 82 | 83 | // --- Tool Definition --- 84 | 85 | export interface ToolContext { 86 | userInfo?: UserAuthInfo; 87 | securityManager?: ISecurityManager; 88 | logger?: Logger; 89 | includeToolResultInReport?: boolean; 90 | } 91 | 92 | export interface Tool { 93 | type: 'function'; 94 | function: { 95 | name: string; 96 | description?: string; 97 | parameters?: Record; // JSON Schema 98 | }; 99 | execute: (args: any, context?: ToolContext) => Promise | any; 100 | security?: ToolSecurity; 101 | name?: string; // Optional alternative name (used if function.name is missing?) 102 | } 103 | 104 | // --- Tool Call Reporting Types --- 105 | 106 | export type ToolCallStatus = 'success' | 'error_parsing' | 'error_validation' | 'error_security' | 'error_execution' | 'error_unknown' | 'error_not_found'; 107 | 108 | export interface ToolCallDetail { 109 | toolCallId: string; 110 | toolName: string; 111 | requestArgsString: string; // Raw arguments string from LLM 112 | parsedArgs?: any | null; // Arguments after JSON parsing 113 | status: ToolCallStatus; 114 | result?: any | null; // Result from execute() - included based on option 115 | error?: { 116 | type: string; // ErrorCode enum value 117 | message: string; 118 | details?: any; 119 | } | null; 120 | resultString: string; // The final string content sent back to the LLM (JSON result or JSON error) 121 | durationMs?: number; 122 | } 123 | 124 | // Represents the outcome of processing a single tool call internally 125 | export interface ToolCallOutcome { 126 | message: Message; // The 'tool' role message to send back to LLM 127 | details: ToolCallDetail; // The detailed report for this call 128 | } 129 | 130 | 131 | // --- Security Related Types (Base definitions) --- 132 | 133 | export interface RateLimit { 134 | limit: number; 135 | period: 'second' | 'minute' | 'hour' | 'day'; 136 | } 137 | 138 | export interface DangerousArgumentsConfig { 139 | globalPatterns?: Array; 140 | toolSpecificPatterns?: Record>; 141 | blockedValues?: string[]; 142 | } 143 | 144 | export interface ToolSecurity { 145 | requiredRole?: string | string[]; 146 | requiredScopes?: string | string[]; 147 | rateLimit?: RateLimit; // Base RateLimit type here 148 | } 149 | 150 | export interface UserAuthConfig { 151 | type?: 'jwt' | 'api-key' | 'custom'; 152 | jwtSecret?: string; 153 | customAuthenticator?: (token: string) => Promise | UserAuthInfo | null; 154 | } 155 | 156 | export interface UserAuthInfo { 157 | userId: string; 158 | role?: string; 159 | scopes?: string[]; 160 | expiresAt?: number; 161 | apiKey?: string; 162 | [key: string]: any; 163 | } 164 | 165 | export interface ToolAccessConfig { 166 | allow?: boolean; 167 | roles?: string | string[]; 168 | scopes?: string | string[]; 169 | rateLimit?: RateLimit; // Base RateLimit type here 170 | allowedApiKeys?: string[]; 171 | } 172 | 173 | export interface RoleConfig { 174 | allowedTools?: string | string[] | '*'; 175 | rateLimits?: Record; // Base RateLimit type here 176 | } 177 | 178 | export interface RolesConfig { 179 | roles?: Record; 180 | } 181 | 182 | export interface SecurityConfig { 183 | defaultPolicy?: 'allow-all' | 'deny-all'; 184 | userAuthentication?: UserAuthConfig; 185 | toolAccess?: Record; 186 | roles?: RolesConfig; 187 | requireAuthentication?: boolean; 188 | // Note: Extended SecurityConfig is defined in security/types.ts 189 | } 190 | 191 | // --- Event Types --- 192 | 193 | export interface ToolCallEvent { 194 | toolName: string; 195 | userId: string; // Can be 'anonymous' 196 | args: any; 197 | result: any; 198 | success: boolean; 199 | error?: Error; 200 | timestamp: number; 201 | } 202 | 203 | // --- History Types (Legacy - For reference) --- 204 | 205 | /** @deprecated Use IHistoryStorage interface instead */ 206 | export type HistoryStorageType = 'memory' | 'disk'; 207 | 208 | // --- API Interaction Types --- 209 | 210 | export interface ResponseFormat { 211 | type: 'json_object' | 'json_schema'; 212 | json_schema?: { 213 | name: string; 214 | strict?: boolean; 215 | schema: Record; 216 | description?: string; 217 | }; 218 | } 219 | 220 | export interface ProviderRoutingConfig { 221 | order?: string[]; 222 | allow_fallbacks?: boolean; 223 | require_parameters?: boolean; 224 | data_collection?: 'allow' | 'deny'; 225 | ignore?: string[]; 226 | quantizations?: string[]; 227 | sort?: 'price' | 'throughput' | 'latency'; 228 | } 229 | 230 | export interface PluginConfig { 231 | id: string; 232 | max_results?: number; 233 | search_prompt?: string; 234 | [key: string]: any; 235 | } 236 | 237 | export interface ReasoningConfig { 238 | effort?: 'low' | 'medium' | 'high'; 239 | max_tokens?: number; 240 | exclude?: boolean; 241 | } 242 | 243 | export interface ModelPricingInfo { 244 | id: string; 245 | name?: string; 246 | promptCostPerMillion: number; 247 | completionCostPerMillion: number; 248 | context_length?: number; 249 | } 250 | 251 | export interface UrlCitationAnnotation { 252 | type: 'url_citation'; 253 | url_citation: { 254 | url: string; 255 | title: string; 256 | content?: string; 257 | start_index: number; 258 | end_index: number; 259 | }; 260 | } 261 | 262 | // --- Main Client Configuration and Request Options --- 263 | 264 | export interface OpenRouterConfig { 265 | // Core 266 | apiKey: string; 267 | apiEndpoint?: string; 268 | apiBaseUrl?: string; 269 | model?: string; 270 | debug?: boolean; 271 | 272 | // Network & Headers 273 | proxy?: string | { 274 | host: string; 275 | port: number | string; 276 | user?: string; 277 | pass?: string; 278 | } | null; 279 | referer?: string; 280 | title?: string; 281 | axiosConfig?: AxiosRequestConfig; 282 | 283 | // History Management 284 | historyAdapter?: IHistoryStorage; 285 | historyTtl?: number; 286 | historyCleanupInterval?: number; 287 | /** @deprecated Use historyAdapter */ 288 | historyStorage?: HistoryStorageType; 289 | /** @deprecated Configure path in DiskHistoryStorage adapter */ 290 | chatsFolder?: string; 291 | /** @deprecated Limit handling depends on history adapter/manager */ 292 | maxHistoryEntries?: number; 293 | /** @deprecated Auto-saving depends on history adapter */ 294 | historyAutoSave?: boolean; 295 | 296 | // Model Behavior & Routing 297 | defaultProviderRouting?: ProviderRoutingConfig; 298 | modelFallbacks?: string[]; 299 | responseFormat?: ResponseFormat | null; 300 | maxToolCalls?: number; 301 | strictJsonParsing?: boolean; 302 | 303 | // Security 304 | security?: SecurityConfig; // Base SecurityConfig type here 305 | 306 | // Cost Tracking 307 | enableCostTracking?: boolean; 308 | priceRefreshIntervalMs?: number; 309 | initialModelPrices?: Record; 310 | 311 | // Deprecated/Unused? 312 | enableReasoning?: boolean; 313 | webSearch?: boolean; 314 | } 315 | 316 | export interface OpenRouterRequestOptions { 317 | // Input Content 318 | prompt?: string; 319 | customMessages?: Message[] | null; 320 | 321 | // Context & History 322 | user?: string; 323 | group?: string | null; 324 | systemPrompt?: string | null; 325 | accessToken?: string | null; 326 | 327 | // Model & Generation Parameters 328 | model?: string; 329 | temperature?: number; 330 | maxTokens?: number | null; 331 | topP?: number | null; 332 | presencePenalty?: number | null; 333 | frequencyPenalty?: number | null; 334 | stop?: string | string[] | null; 335 | seed?: number | null; 336 | logitBias?: Record | null; 337 | 338 | // Tool / Function Calling 339 | tools?: Tool[] | null; 340 | toolChoice?: "none" | "auto" | { type: "function", function: { name: string } } | null; 341 | parallelToolCalls?: boolean; 342 | maxToolCalls?: number; 343 | includeToolResultInReport?: boolean; 344 | 345 | // Response Formatting 346 | responseFormat?: ResponseFormat | null; 347 | strictJsonParsing?: boolean; 348 | 349 | // Routing and Transforms 350 | route?: string; 351 | transforms?: string[]; 352 | provider?: ProviderRoutingConfig; 353 | models?: string[]; 354 | plugins?: PluginConfig[]; 355 | reasoning?: ReasoningConfig; 356 | 357 | // Streaming 358 | stream?: boolean; 359 | streamCallbacks?: StreamCallbacks; 360 | } 361 | 362 | // --- API Response Structures --- 363 | 364 | export interface UsageInfo { 365 | prompt_tokens: number; 366 | completion_tokens: number; 367 | total_tokens: number; 368 | [key: string]: any; 369 | } 370 | 371 | export interface OpenRouterResponse { 372 | id: string; 373 | object: string; 374 | created: number; 375 | model: string; 376 | choices: Array<{ 377 | index: number; 378 | message: Message; 379 | finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; 380 | logprobs?: any | null; 381 | }>; 382 | usage?: UsageInfo; 383 | system_fingerprint?: string; 384 | error?: { message?: string; type?: string; code?: string; [key: string]: any } | string; 385 | } 386 | 387 | export interface ChatCompletionResult { 388 | content: any; 389 | usage: UsageInfo | null; 390 | model: string; 391 | toolCallsCount: number; 392 | toolCalls?: ToolCallDetail[]; 393 | finishReason: string | null; 394 | durationMs: number; 395 | id?: string; 396 | cost?: number | null; 397 | reasoning?: string | null; 398 | annotations?: UrlCitationAnnotation[]; 399 | } 400 | 401 | export interface CreditBalance { 402 | total_credits: number; 403 | total_usage: number; 404 | } 405 | 406 | export interface ApiKeyInfo { 407 | data: { 408 | limit: number; 409 | usage: number; 410 | is_free_tier: boolean; 411 | rate_limit: { 412 | requests: number; 413 | interval: string; 414 | }; 415 | }; 416 | } 417 | 418 | // --- Streaming Types --- 419 | 420 | export interface StreamChunk { 421 | id: string; 422 | object: string; 423 | created: number; 424 | model: string; 425 | choices: Array<{ 426 | index: number; 427 | delta: { 428 | role?: Role; 429 | content?: string | null; 430 | tool_calls?: Array<{ 431 | index?: number; 432 | id?: string; 433 | type?: 'function'; 434 | function?: { 435 | name?: string; 436 | arguments?: string; 437 | }; 438 | }>; 439 | reasoning?: string | null; 440 | }; 441 | finish_reason?: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; 442 | logprobs?: any | null; 443 | }>; 444 | usage?: UsageInfo; 445 | } 446 | 447 | export interface StreamCallbacks { 448 | onChunk?: (chunk: StreamChunk) => void; 449 | onContent?: (content: string) => void; 450 | onToolCallExecuting?: (toolName: string, args: any) => void; 451 | onToolCallResult?: (toolName: string, result: any) => void; 452 | onComplete?: (fullContent: string, usage?: UsageInfo, toolCalls?: ToolCall[]) => void; 453 | onError?: (error: Error) => void; 454 | } 455 | 456 | export interface ChatStreamResult { 457 | content: string; 458 | usage?: UsageInfo | null; 459 | model?: string; 460 | finishReason?: string | null; 461 | id?: string; 462 | toolCalls?: ToolCall[]; 463 | reasoning?: string; 464 | annotations?: UrlCitationAnnotation[]; 465 | cost?: number | null; 466 | durationMs?: number; 467 | } -------------------------------------------------------------------------------- /src/security/auth-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/auth-manager.ts 2 | import jwt from 'jsonwebtoken'; 3 | // StringValue is internal, remove import 4 | import type { SignOptions, JwtPayload } from 'jsonwebtoken'; 5 | import { 6 | IAuthManager, 7 | TokenConfig, 8 | TokenValidationResult, 9 | ExtendedUserAuthInfo as UserAuthInfo, // Use renamed type locally 10 | UserAuthConfig 11 | } from './types'; 12 | import { Logger } from '../utils/logger'; 13 | // Import AuthorizationError correctly 14 | import { SecurityError, ConfigError, AuthenticationError, AuthorizationError, mapError, ErrorCode } from '../utils/error'; 15 | import type { SimpleEventEmitter } from '../utils/simple-event-emitter'; 16 | 17 | const INSECURE_SECRET_WARNING = "Critical security risk: Using insecure default JWT secret. Set a strong secret via configuration or JWT_SECRET environment variable!"; 18 | const MISSING_SECRET_ERROR = "JWT secret is missing. Cannot perform JWT operations."; 19 | 20 | export class AuthManager implements IAuthManager { 21 | private tokenCache: Map = new Map(); 22 | private authConfig: UserAuthConfig; 23 | private jwtSecret: string | undefined; 24 | private eventEmitter: SimpleEventEmitter; 25 | private logger: Logger; 26 | private debugMode: boolean = false; 27 | 28 | constructor( 29 | authConfig: UserAuthConfig | undefined, 30 | eventEmitter: SimpleEventEmitter, 31 | logger: Logger 32 | ) { 33 | this.authConfig = authConfig || {}; 34 | this.eventEmitter = eventEmitter; 35 | this.logger = logger; 36 | this.jwtSecret = this.authConfig.jwtSecret; 37 | 38 | if (this.authConfig.type === 'jwt') { 39 | if (!this.jwtSecret) { 40 | this.logger.error(MISSING_SECRET_ERROR + " Auth type is 'jwt'."); 41 | } else if (this.jwtSecret === 'default-secret-replace-in-production') { 42 | this.logger.error(INSECURE_SECRET_WARNING); 43 | } 44 | } else if (this.jwtSecret) { 45 | this.logger.warn(`JWT secret provided, but authentication type is '${this.authConfig.type || 'not set'}'. Secret will be ignored for non-JWT operations.`); 46 | } 47 | } 48 | 49 | setDebugMode(debug: boolean): void { 50 | this.debugMode = debug; 51 | if (typeof (this.logger as any).setDebug === 'function') { 52 | (this.logger as any).setDebug(debug); 53 | } 54 | } 55 | 56 | updateSecret(newSecret: string | undefined): void { 57 | if (this.jwtSecret !== newSecret) { 58 | this.logger.log("JWT secret updated."); 59 | this.jwtSecret = newSecret; 60 | this.clearTokenCache(); 61 | if (this.authConfig.type === 'jwt') { 62 | if (!this.jwtSecret) { 63 | this.logger.error(MISSING_SECRET_ERROR + " Auth type is 'jwt'."); 64 | } else if (this.jwtSecret === 'default-secret-replace-in-production') { 65 | this.logger.error(INSECURE_SECRET_WARNING); 66 | } 67 | } 68 | } 69 | } 70 | 71 | async authenticateUser(accessToken?: string): Promise { 72 | if (!accessToken) { 73 | this.logger.debug('Authentication skipped: access token not provided.'); 74 | return null; 75 | } 76 | 77 | if (this.tokenCache.has(accessToken)) { 78 | const cachedUserInfo = this.tokenCache.get(accessToken)!; 79 | if (!cachedUserInfo.expiresAt || new Date(cachedUserInfo.expiresAt).getTime() > Date.now()) { 80 | this.logger.debug(`User ${cachedUserInfo.userId} authenticated from cache.`); 81 | this.eventEmitter.emit('auth:cache_hit', { userId: cachedUserInfo.userId }); 82 | return cachedUserInfo; 83 | } else { 84 | this.logger.debug(`Removing expired token from cache for user ${cachedUserInfo.userId}.`); 85 | this.tokenCache.delete(accessToken); 86 | this.eventEmitter.emit('auth:cache_expired', { userId: cachedUserInfo.userId }); 87 | } 88 | } else { 89 | this.logger.debug(`Token not found in cache: ${accessToken.substring(0, 10)}...`); 90 | } 91 | 92 | let userInfo: UserAuthInfo | null = null; 93 | let validationError: Error | null = null; 94 | 95 | try { 96 | // Use optional chaining for safer access 97 | switch (this.authConfig?.type) { 98 | case 'jwt': 99 | this.logger.debug(`Attempting JWT validation...`); 100 | const jwtResult = await this.validateToken(accessToken); 101 | if (jwtResult.isValid && jwtResult.userInfo) { 102 | userInfo = jwtResult.userInfo; 103 | } else { 104 | validationError = jwtResult.error || new AuthenticationError("JWT validation failed"); 105 | } 106 | break; 107 | 108 | case 'custom': 109 | this.logger.debug(`Attempting Custom authenticator validation...`); 110 | // Use optional chaining 111 | if (typeof this.authConfig?.customAuthenticator === 'function') { 112 | userInfo = await this.authConfig.customAuthenticator(accessToken); 113 | if (!userInfo) { 114 | validationError = new AuthenticationError("Custom authenticator rejected the token"); 115 | } 116 | } else { 117 | this.logger.error("Custom authentication configured, but 'customAuthenticator' function is missing."); 118 | validationError = new ConfigError("Custom authenticator function not provided in configuration"); 119 | } 120 | break; 121 | 122 | case 'api-key': 123 | this.logger.warn("API Key authentication type not fully implemented yet."); 124 | validationError = new ConfigError("API Key authentication not implemented"); 125 | break; 126 | 127 | default: 128 | // Use optional chaining 129 | this.logger.warn(`Unknown or unspecified authentication type: '${this.authConfig?.type || 'not set'}'. Denying authentication.`); 130 | validationError = new ConfigError(`Unsupported authentication type: ${this.authConfig?.type}`); 131 | } 132 | } catch (error) { 133 | this.logger.error('Unexpected error during authentication:', error); 134 | validationError = mapError(error); 135 | } 136 | 137 | if (userInfo && userInfo.userId) { 138 | // Use optional chaining 139 | this.logger.log(`Authentication successful for user ${userInfo.userId} (Type: ${this.authConfig?.type || 'jwt'}). Caching token.`); 140 | this.tokenCache.set(accessToken, userInfo); 141 | this.eventEmitter.emit('user:authenticated', { userInfo }); 142 | return userInfo; 143 | } else { 144 | const reason = validationError ? validationError.message : (userInfo ? 'User info missing userId' : 'Authenticator returned null'); 145 | this.logger.warn(`Authentication failed. Reason: ${reason}`); 146 | this.eventEmitter.emit('auth:failed', { tokenProvided: !!accessToken, reason }); 147 | return null; 148 | } 149 | } 150 | 151 | createAccessToken(config: TokenConfig): string { 152 | // Use optional chaining 153 | if (this.authConfig?.type !== 'jwt') { 154 | this.logger.error(`Cannot create JWT token: Authentication type is '${this.authConfig?.type || 'not set'}', not 'jwt'.`); 155 | throw new ConfigError("Token creation is only supported when userAuthentication.type is 'jwt'."); 156 | } 157 | if (!this.jwtSecret) { 158 | this.logger.error(MISSING_SECRET_ERROR); 159 | throw new SecurityError(MISSING_SECRET_ERROR, ErrorCode.CONFIG_ERROR); 160 | } 161 | if (this.jwtSecret === 'default-secret-replace-in-production') { 162 | this.logger.error(INSECURE_SECRET_WARNING + " Refusing to create token."); 163 | throw new SecurityError('Cannot create token with insecure default secret.', ErrorCode.CONFIG_ERROR); 164 | } 165 | 166 | const { payload, expiresIn } = config; 167 | this.logger.debug(`Creating JWT token for user ${payload.userId} with expiration: ${expiresIn || 'default (e.g., 24h)'}`); 168 | 169 | if (!payload || !payload.userId) { 170 | this.logger.error('Error creating JWT token: payload missing or missing required field "userId".'); 171 | throw new SecurityError('Payload with userId is required to create a JWT token', ErrorCode.VALIDATION_ERROR); 172 | } 173 | 174 | let token: string; 175 | try { 176 | const jwtPayload: Record = { 177 | userId: payload.userId, 178 | ...(payload.role && { role: payload.role }), 179 | ...(payload.scopes && Array.isArray(payload.scopes) && { scopes: payload.scopes }), 180 | ...(payload.username && { username: payload.username }), 181 | ...(payload.roles && Array.isArray(payload.roles) && { roles: payload.roles }), 182 | ...(payload.permissions && Array.isArray(payload.permissions) && { permissions: payload.permissions }), 183 | ...(payload.metadata && typeof payload.metadata === 'object' && { metadata: payload.metadata }), 184 | ...(payload.apiKey && { apiKey: payload.apiKey }), 185 | iat: Math.floor(Date.now() / 1000), 186 | }; 187 | 188 | const signOptions: SignOptions = {}; 189 | // Use 'as any' to bypass the strict StringValue type from @types/jsonwebtoken 190 | signOptions.expiresIn = expiresIn as any; 191 | 192 | token = jwt.sign(jwtPayload, this.jwtSecret, signOptions); 193 | 194 | } catch (error) { 195 | this.logger.error('Error signing JWT token:', error); 196 | throw new SecurityError(`Error creating JWT token: ${(error as Error).message}`, ErrorCode.JWT_SIGN_ERROR); 197 | } 198 | 199 | let expiresAtMs: number | undefined; 200 | try { 201 | const decoded = jwt.decode(token) as JwtPayload | null; 202 | if (decoded?.exp) { 203 | expiresAtMs = decoded.exp * 1000; 204 | } 205 | } catch (decodeError) { 206 | this.logger.warn("Could not decode the newly signed token to cache expiration time.", decodeError); 207 | } 208 | 209 | // Explicitly include userId along with spread to satisfy UserAuthInfo requirement 210 | const userInfoToCache: UserAuthInfo = { 211 | userId: payload.userId, // Ensure userId is explicitly present 212 | ...payload, 213 | expiresAt: expiresAtMs, 214 | }; 215 | 216 | // This check should now always pass, but kept as a safeguard 217 | if (!userInfoToCache.userId) { 218 | this.logger.error('Critical error: userId field missing in userInfo after token creation and decoding.'); 219 | throw new SecurityError('userId missing after token creation', ErrorCode.INTERNAL_ERROR); 220 | } 221 | 222 | this.tokenCache.set(token, userInfoToCache); 223 | this.logger.debug(`JWT Token for user ${payload.userId} created and cached (Expires: ${expiresAtMs ? new Date(expiresAtMs).toISOString() : 'N/A'}).`); 224 | 225 | this.eventEmitter.emit('token:created', { 226 | userId: payload.userId, 227 | expiresAt: userInfoToCache.expiresAt 228 | }); 229 | 230 | return token; 231 | } 232 | 233 | async validateToken(token: string): Promise { 234 | this.logger.debug(`Validating JWT token: ${token.substring(0, 10)}...`); 235 | 236 | // Use optional chaining 237 | if (this.authConfig?.type !== 'jwt') { 238 | this.logger.error("validateToken called, but auth type is not 'jwt'."); 239 | return { isValid: false, error: new ConfigError("JWT validation attempted for non-JWT auth type.") }; 240 | } 241 | if (!this.jwtSecret) { 242 | this.logger.error(MISSING_SECRET_ERROR); 243 | return { isValid: false, error: new SecurityError(MISSING_SECRET_ERROR, ErrorCode.CONFIG_ERROR) }; 244 | } 245 | 246 | try { 247 | const decoded = jwt.verify(token, this.jwtSecret) as JwtPayload; 248 | 249 | if (!decoded || typeof decoded !== 'object' || !decoded.userId) { 250 | this.logger.warn(`JWT validation failed: missing "userId" in payload or payload is not an object.`); 251 | this.eventEmitter.emit('token:invalid', { token, error: new Error('Missing userId or invalid payload structure') }); 252 | return { isValid: false, error: new AuthenticationError('Invalid token payload structure') }; 253 | } 254 | 255 | const userInfo: UserAuthInfo = { 256 | userId: decoded.userId as string, 257 | role: typeof decoded.role === 'string' ? decoded.role : undefined, 258 | scopes: Array.isArray(decoded.scopes) ? decoded.scopes.filter((s: any) => typeof s === 'string') : undefined, 259 | apiKey: typeof decoded.apiKey === 'string' ? decoded.apiKey : undefined, 260 | username: typeof decoded.username === 'string' ? decoded.username : undefined, 261 | roles: Array.isArray(decoded.roles) ? decoded.roles.filter((r: any) => typeof r === 'string') : undefined, 262 | permissions: Array.isArray(decoded.permissions) ? decoded.permissions.filter((p: any) => typeof p === 'string') : undefined, 263 | metadata: typeof decoded.metadata === 'object' && decoded.metadata !== null ? decoded.metadata as Record : undefined, 264 | expiresAt: decoded.exp ? decoded.exp * 1000 : undefined 265 | }; 266 | 267 | this.logger.debug(`JWT Token successfully validated for user ${userInfo.userId}.`); 268 | return { isValid: true, userInfo }; 269 | 270 | } catch (error) { 271 | const typedError = error as Error; 272 | const errorMessage = typedError.message || String(error); 273 | this.logger.warn(`JWT token validation error: ${errorMessage}`); 274 | this.eventEmitter.emit('token:invalid', { token, error: typedError }); 275 | // Use AuthorizationError with JWT_VALIDATION_ERROR code 276 | return { isValid: false, error: new AuthorizationError(`Token validation failed: ${errorMessage}`, 401, { originalError: typedError, code: ErrorCode.JWT_VALIDATION_ERROR }) }; 277 | } 278 | } 279 | 280 | clearTokenCache(): void { 281 | const count = this.tokenCache.size; 282 | this.tokenCache.clear(); 283 | this.logger.log(`In-memory token cache cleared (${count} entries removed).`); 284 | this.eventEmitter.emit('cache:cleared', { type: 'token' }); 285 | } 286 | 287 | destroy(): void { 288 | this.logger.log("AuthManager destroyed."); 289 | this.clearTokenCache(); 290 | } 291 | } -------------------------------------------------------------------------------- /src/security/security-manager.ts: -------------------------------------------------------------------------------- 1 | // Path: src/security/security-manager.ts 2 | import { 3 | ISecurityManager, 4 | SecurityCheckParams, 5 | SecurityContext, 6 | ExtendedToolCallEvent, 7 | ExtendedSecurityConfig, // Use renamed type 8 | ExtendedUserAuthInfo, // Use renamed type 9 | IAuthManager, 10 | IAccessControlManager, 11 | IRateLimitManager, 12 | IArgumentSanitizer, 13 | ExtendedRateLimit, // Use renamed type 14 | UserAuthConfig 15 | } from './types'; 16 | import { SimpleEventEmitter } from '../utils/simple-event-emitter'; 17 | import { AuthManager } from './auth-manager'; 18 | import { RateLimitManager } from './rate-limit-manager'; 19 | import { AccessControlManager } from './access-control-manager'; 20 | import { ArgumentSanitizer } from './argument-sanitizer'; 21 | import { Logger } from '../utils/logger'; 22 | import { Tool } from '../types'; // Use base Tool type from core 23 | import { 24 | AccessDeniedError, 25 | AuthorizationError, 26 | RateLimitError as ToolRateLimitError, 27 | SecurityError, 28 | ConfigError, 29 | AuthenticationError, 30 | mapError, 31 | ErrorCode, 32 | } from '../utils/error'; 33 | 34 | type SecurityConfig = ExtendedSecurityConfig; // Alias for internal use 35 | type UserAuthInfo = ExtendedUserAuthInfo; // Alias for internal use 36 | type RateLimit = ExtendedRateLimit; // Alias for internal use 37 | 38 | export class SecurityManager implements ISecurityManager { 39 | private config: SecurityConfig; // Use the aliased extended type 40 | private eventEmitter: SimpleEventEmitter; 41 | private authManager: IAuthManager; 42 | private rateLimitManager: IRateLimitManager; 43 | private accessControlManager: IAccessControlManager; 44 | private argumentSanitizer: IArgumentSanitizer; 45 | private debug: boolean; // Instance variable type is boolean 46 | private logger: Logger; 47 | private _destroyed: boolean = false; 48 | 49 | constructor(config?: Partial, secretKeyOrDebug?: string | boolean) { 50 | const defaultConfig: SecurityConfig = { 51 | defaultPolicy: 'deny-all', 52 | debug: false, // Explicitly boolean 53 | requireAuthentication: false, 54 | allowUnauthenticatedAccess: false, 55 | userAuthentication: { type: 'jwt' }, 56 | dangerousArguments: { 57 | auditOnlyMode: false 58 | } 59 | }; 60 | 61 | let mergedConfig = { ...defaultConfig, ...(config || {}) }; 62 | if (config?.userAuthentication) { 63 | mergedConfig.userAuthentication = { ...defaultConfig.userAuthentication, ...config.userAuthentication }; 64 | } 65 | if (config?.dangerousArguments) { 66 | mergedConfig.dangerousArguments = { ...(defaultConfig.dangerousArguments || {}), ...config.dangerousArguments }; 67 | } 68 | if (config?.roles?.roles) { 69 | if (!mergedConfig.roles) mergedConfig.roles = {}; 70 | mergedConfig.roles.roles = { ...(defaultConfig.roles?.roles || {}), ...config.roles.roles }; 71 | } else if (config?.roles) { 72 | mergedConfig.roles = { ...(defaultConfig.roles || {}), ...config.roles }; 73 | } 74 | if (config?.toolAccess) mergedConfig.toolAccess = { ...(defaultConfig.toolAccess || {}), ...config.toolAccess }; 75 | if (config?.toolConfig) mergedConfig.toolConfig = { ...(defaultConfig.toolConfig || {}), ...config.toolConfig }; 76 | 77 | // Ensure debug is boolean after merge 78 | mergedConfig.debug = typeof mergedConfig.debug === 'boolean' ? mergedConfig.debug : defaultConfig.debug; 79 | 80 | this.config = mergedConfig; 81 | 82 | // Determine final debug state correctly 83 | let finalDebugValue: boolean; 84 | if (typeof secretKeyOrDebug === 'boolean') { 85 | finalDebugValue = secretKeyOrDebug; 86 | } else { 87 | // this.config.debug is now guaranteed boolean 88 | finalDebugValue = this.config.debug; 89 | } 90 | this.debug = finalDebugValue; 91 | this.config.debug = finalDebugValue; 92 | 93 | 94 | let initialJwtSecret = this.config.userAuthentication?.jwtSecret; 95 | if (typeof secretKeyOrDebug === 'string') { 96 | initialJwtSecret = secretKeyOrDebug; 97 | if (!this.config.userAuthentication) this.config.userAuthentication = {}; 98 | this.config.userAuthentication.jwtSecret = initialJwtSecret; 99 | } 100 | const finalJwtSecret = initialJwtSecret || 'MISSING_SECRET'; 101 | 102 | 103 | this.logger = new Logger({ debug: this.debug, prefix: 'SecurityManager' }); 104 | this.eventEmitter = new SimpleEventEmitter(); 105 | 106 | this.authManager = new AuthManager(this.config.userAuthentication || {}, this.eventEmitter, this.logger.withPrefix('AuthManager')); 107 | this.rateLimitManager = new RateLimitManager(this.eventEmitter, this.logger.withPrefix('RateLimitManager')); 108 | this.accessControlManager = new AccessControlManager(this.eventEmitter, this.logger.withPrefix('AccessControlManager')); 109 | this.argumentSanitizer = new ArgumentSanitizer(this.eventEmitter, this.logger.withPrefix('ArgumentSanitizer')); 110 | 111 | this.setDebugMode(this.debug); 112 | 113 | this.logger.log(`SecurityManager initialized. Debug: ${this.debug}. Default Policy: ${this.config.defaultPolicy}. Auth Type: ${this.config.userAuthentication?.type || 'N/A'}.`); 114 | if (this.debug) { 115 | const configToLog = { ...this.config }; 116 | if (configToLog.userAuthentication?.jwtSecret) { 117 | configToLog.userAuthentication = { ...configToLog.userAuthentication, jwtSecret: '***REDACTED***' }; 118 | } 119 | this.logger.debug(`Initial configuration:`, configToLog); 120 | } 121 | } 122 | 123 | // ... (rest of the SecurityManager methods remain the same) ... 124 | getConfig(): SecurityConfig { // Return extended type 125 | return { ...this.config }; 126 | } 127 | 128 | updateConfig(configUpdate: Partial): void { // Accept partial extended type 129 | this.logger.log('Updating SecurityManager configuration...'); 130 | const oldDebug = this.debug; 131 | const oldJwtSecret = this.config.userAuthentication?.jwtSecret; 132 | 133 | const updatedConfig = { ...this.config }; 134 | 135 | if (configUpdate.userAuthentication) { 136 | updatedConfig.userAuthentication = { ...(updatedConfig.userAuthentication || {}), ...configUpdate.userAuthentication }; 137 | } 138 | if (configUpdate.dangerousArguments) { 139 | updatedConfig.dangerousArguments = { ...(updatedConfig.dangerousArguments || {}), ...configUpdate.dangerousArguments }; 140 | } 141 | if (configUpdate.roles) { 142 | updatedConfig.roles = { 143 | ...(updatedConfig.roles || {}), 144 | ...configUpdate.roles, 145 | roles: { 146 | ...(updatedConfig.roles?.roles || {}), 147 | ...(configUpdate.roles.roles || {}), 148 | } 149 | }; 150 | } 151 | if (configUpdate.toolAccess) { 152 | updatedConfig.toolAccess = { ...(updatedConfig.toolAccess || {}), ...configUpdate.toolAccess }; 153 | } 154 | if (configUpdate.toolConfig) { 155 | updatedConfig.toolConfig = { ...(updatedConfig.toolConfig || {}), ...configUpdate.toolConfig }; 156 | } 157 | 158 | if (configUpdate.defaultPolicy !== undefined) updatedConfig.defaultPolicy = configUpdate.defaultPolicy; 159 | if (configUpdate.requireAuthentication !== undefined) updatedConfig.requireAuthentication = configUpdate.requireAuthentication; 160 | if (configUpdate.allowUnauthenticatedAccess !== undefined) updatedConfig.allowUnauthenticatedAccess = configUpdate.allowUnauthenticatedAccess; 161 | // Update debug state carefully 162 | if (configUpdate.debug !== undefined) updatedConfig.debug = configUpdate.debug; 163 | 164 | 165 | this.config = updatedConfig; 166 | 167 | // Use the updated debug value, falling back to current state if not provided 168 | const newDebug = this.config.debug; // It's guaranteed boolean now 169 | if (newDebug !== oldDebug) { 170 | this.setDebugMode(newDebug); 171 | } 172 | 173 | const newJwtSecret = this.config.userAuthentication?.jwtSecret; 174 | if (newJwtSecret !== oldJwtSecret && typeof (this.authManager as AuthManager)?.updateSecret === 'function') { 175 | (this.authManager as AuthManager).updateSecret(newJwtSecret); 176 | } 177 | 178 | this.eventEmitter.emit('config:updated', { config: this.config }); 179 | this.logger.log(`Security configuration updated. Debug: ${this.debug}. Auth Type: ${this.config.userAuthentication?.type || 'N/A'}.`); 180 | if (this.debug) { 181 | const configToLog = { ...this.config }; 182 | if (configToLog.userAuthentication?.jwtSecret) { 183 | configToLog.userAuthentication = { ...configToLog.userAuthentication, jwtSecret: '***REDACTED***' }; 184 | } 185 | this.logger.debug(`New configuration:`, configToLog); 186 | } 187 | } 188 | 189 | async authenticateUser(accessToken?: string): Promise { // Return extended type 190 | return this.authManager.authenticateUser(accessToken); 191 | } 192 | 193 | createAccessToken(userInfo: Omit, expiresIn: string | number = '24h'): string { // Use extended type 194 | try { 195 | if (this.config.userAuthentication?.type !== 'jwt') { 196 | this.logger.error(`Attempt to create JWT token, but authentication type is set to '${this.config.userAuthentication?.type || 'not jwt'}'.`); 197 | throw new ConfigError("Token creation is only supported for JWT authentication (userAuthentication.type='jwt')."); 198 | } 199 | return this.authManager.createAccessToken({ payload: userInfo, expiresIn }); 200 | } catch (error) { 201 | throw mapError(error); 202 | } 203 | } 204 | 205 | async checkToolAccessAndArgs( 206 | tool: Tool, // Use base Tool type from core 207 | userInfo: UserAuthInfo | null, // Use extended UserAuthInfo 208 | args?: any 209 | ): Promise { 210 | const toolName = tool.function?.name || tool.name || 'unknown_tool'; 211 | const userIdForLog = userInfo?.userId || (this.config.allowUnauthenticatedAccess ? 'anonymous' : 'UNAUTHENTICATED'); 212 | this.logger.debug(`Security Check Sequence START for Tool: '${toolName}', User: ${userIdForLog}`); 213 | 214 | const context: SecurityContext = { 215 | config: this.config, // Pass the extended config 216 | debug: this.debug, 217 | userId: userInfo?.userId, 218 | toolName: toolName 219 | }; 220 | const checkParams: SecurityCheckParams = { tool, userInfo, args, context, securityManager: this }; 221 | 222 | try { 223 | // --- Check 1: Authentication Requirement --- 224 | if (!userInfo && this.config.requireAuthentication && !this.config.allowUnauthenticatedAccess) { 225 | this.logger.warn(`Access DENIED for tool '${toolName}': Authentication required (requireAuthentication=true), user is not authenticated, and allowUnauthenticatedAccess=false.`); 226 | throw new AuthenticationError(`Authentication is required to access tool '${toolName}'.`); 227 | } 228 | if (!userInfo && this.config.allowUnauthenticatedAccess) { 229 | const toolSecurity = tool.security; 230 | if (toolSecurity?.requiredRole || toolSecurity?.requiredScopes) { 231 | this.logger.warn(`Access DENIED for tool '${toolName}': Tool requires specific roles/scopes, but user is anonymous.`); 232 | throw new AuthorizationError(`Access to tool '${toolName}' requires specific permissions/roles (anonymous access denied for this tool).`); 233 | } 234 | } 235 | this.logger.debug(`[Check 1 PASSED] Authentication requirement met for '${toolName}'.`); 236 | 237 | 238 | // --- Check 2: Access Control --- 239 | this.logger.debug(`[Check 2 START] Access control check for '${toolName}'...`); 240 | await this.accessControlManager.checkAccess(checkParams); 241 | this.logger.debug(`[Check 2 PASSED] Access control granted for '${toolName}'.`); 242 | 243 | 244 | // --- Check 3: Rate Limiting --- 245 | if (userInfo) { 246 | const rateLimitConfig = this._findRateLimit(tool, userInfo); 247 | if (rateLimitConfig) { 248 | this.logger.debug(`[Check 3 START] Rate limit check for '${toolName}', User: ${userInfo.userId}. Limit source: ${rateLimitConfig._source || 'unknown'}`); 249 | const rateLimitResult = this.rateLimitManager.checkRateLimit({ 250 | userId: userInfo.userId, 251 | toolName: toolName, 252 | rateLimit: rateLimitConfig, 253 | source: rateLimitConfig._source 254 | }); 255 | if (!rateLimitResult.allowed) { 256 | const timeLeft = rateLimitResult.timeLeft ?? 0; 257 | const retryAfter = Math.ceil(timeLeft / 1000); 258 | this.logger.warn(`[Check 3 FAILED] Rate Limit Exceeded for tool '${toolName}', User: ${userInfo.userId}. Retry after ${retryAfter}s.`); 259 | throw new ToolRateLimitError( 260 | `Rate limit exceeded for tool '${toolName}'. Please try again after ${retryAfter} seconds.`, 261 | 429, 262 | { 263 | limit: rateLimitResult.limit, 264 | period: rateLimitConfig.interval || rateLimitConfig.period, 265 | timeLeftMs: timeLeft, 266 | retryAfterSeconds: retryAfter 267 | } 268 | ); 269 | } 270 | this.logger.debug(`[Check 3 PASSED] Rate limit check passed for '${toolName}'.`); 271 | } else { 272 | this.logger.debug(`[Check 3 SKIPPED] No applicable rate limit found for tool '${toolName}' and user role/config.`); 273 | } 274 | } else { 275 | this.logger.debug(`[Check 3 SKIPPED] User is anonymous, rate limit check skipped.`); 276 | } 277 | 278 | 279 | // --- Check 4: Argument Sanitization --- 280 | if (args !== undefined && args !== null) { 281 | this.logger.debug(`[Check 4 START] Argument sanitization for '${toolName}'...`); 282 | await this.argumentSanitizer.validateArguments(tool, args, context); 283 | this.logger.debug(`[Check 4 PASSED] Argument sanitization passed for '${toolName}'.`); 284 | } else { 285 | this.logger.debug(`[Check 4 SKIPPED] No arguments provided for tool '${toolName}'.`); 286 | } 287 | 288 | this.logger.log(`Security Check Sequence SUCCESS for Tool: '${toolName}', User: ${userIdForLog}`); 289 | return true; 290 | 291 | } catch (error) { 292 | const mappedError = mapError(error); 293 | this.logger.error(`Security Check Sequence FAILED for Tool: '${toolName}', User: ${userIdForLog}. Error: ${mappedError.message} (Code: ${mappedError.code})`, mappedError.details || mappedError); 294 | this.eventEmitter.emit('security:error', { 295 | toolName, 296 | userId: userInfo?.userId, 297 | error: mappedError, 298 | args 299 | }); 300 | throw mappedError; 301 | } 302 | } 303 | 304 | private _findRateLimit(tool: Tool, userInfo: UserAuthInfo): (RateLimit & { _source?: string }) | undefined { // Use extended RateLimit 305 | if (!userInfo) return undefined; 306 | 307 | const toolName = tool.function?.name || tool.name; 308 | if (!toolName) { 309 | this.logger.warn("Cannot find rate limit: Tool name is missing."); 310 | return undefined; 311 | } 312 | 313 | const config = this.config; 314 | const toolSecurity = tool.security; 315 | const userRole = userInfo.role; 316 | 317 | let foundRateLimit: RateLimit | undefined; // Use extended RateLimit 318 | let source: string | undefined; 319 | 320 | if (userRole && config.roles?.roles?.[userRole]?.rateLimits?.[toolName]) { 321 | foundRateLimit = config.roles.roles[userRole].rateLimits![toolName]; 322 | source = `Role ('${userRole}') -> Tool ('${toolName}')`; 323 | } 324 | else if (userRole && config.roles?.roles?.[userRole]?.rateLimits?.['*']) { 325 | foundRateLimit = config.roles.roles[userRole].rateLimits!['*']; 326 | source = `Role ('${userRole}') -> Tool ('*')`; 327 | } 328 | else if (config.toolAccess?.[toolName]?.rateLimit) { 329 | foundRateLimit = config.toolAccess[toolName].rateLimit!; 330 | source = `ToolAccess ('${toolName}')`; 331 | } 332 | else if (config.toolAccess?.['*']?.rateLimit) { 333 | foundRateLimit = config.toolAccess['*'].rateLimit!; 334 | source = `ToolAccess ('*')`; 335 | } 336 | else if (toolSecurity?.rateLimit) { 337 | foundRateLimit = toolSecurity.rateLimit; 338 | source = `Tool Metadata ('${toolName}')`; 339 | } 340 | 341 | if (foundRateLimit) { 342 | this.logger.debug(`Applicable RateLimit found for tool '${toolName}' / user '${userInfo.userId}'. Source: ${source}`); 343 | // Ensure the found limit conforms to ExtendedRateLimit if needed, though types should match now 344 | return { ...(foundRateLimit as ExtendedRateLimit), _source: source }; 345 | } 346 | 347 | this.logger.debug(`No specific RateLimit configuration found for tool '${toolName}' / user '${userInfo.userId}'.`); 348 | return undefined; 349 | } 350 | 351 | logToolCall(event: ExtendedToolCallEvent): void { // Use extended event type 352 | this.logger.log(`Tool Executed: ${event.toolName}`, { 353 | userId: event.userId, 354 | success: event.success, 355 | durationMs: event.duration, 356 | argsProvided: event.args !== null && event.args !== undefined, 357 | error: event.error ? `${event.error.name}: ${event.error.message}` : null 358 | }); 359 | 360 | if (this.debug) { 361 | this.logger.debug(`Tool call details for ${event.toolName}:`, { 362 | timestamp: new Date(event.timestamp).toISOString(), 363 | argsKeys: typeof event.args === 'object' && event.args !== null ? Object.keys(event.args) : null, 364 | resultType: typeof event.result, 365 | fullError: event.error, 366 | }); 367 | } 368 | 369 | this.eventEmitter.emit('tool:call', event); 370 | } 371 | 372 | isDebugEnabled(): boolean { 373 | return this.debug; 374 | } 375 | 376 | setDebugMode(debug: boolean): void { 377 | if (this.debug === debug) return; 378 | 379 | this.debug = debug; 380 | this.logger.setDebug(debug); 381 | 382 | this.authManager?.setDebugMode(debug); 383 | this.rateLimitManager?.setDebugMode(debug); 384 | this.accessControlManager?.setDebugMode(debug); 385 | this.argumentSanitizer?.setDebugMode(debug); 386 | 387 | this.logger.log(`SecurityManager debug mode ${debug ? 'ENABLED' : 'DISABLED'}.`); 388 | } 389 | 390 | clearTokenCache(): void { 391 | this.authManager.clearTokenCache(); 392 | } 393 | 394 | clearRateLimitCounters(userId?: string): void { 395 | this.rateLimitManager.clearLimits(userId); 396 | } 397 | 398 | on(event: string, handler: (event: any) => void): ISecurityManager { 399 | this.eventEmitter.on(event, handler); 400 | this.logger.debug(`External handler attached for security event: '${event}'`); 401 | return this; 402 | } 403 | 404 | off(event: string, handler: (event: any) => void): ISecurityManager { 405 | this.eventEmitter.off(event, handler); 406 | this.logger.debug(`External handler detached for security event: '${event}'`); 407 | return this; 408 | } 409 | 410 | destroy(): void { 411 | if (this._destroyed) return; 412 | 413 | this.logger.log("Destroying SecurityManager and its components..."); 414 | this.rateLimitManager?.destroy?.(); 415 | this.argumentSanitizer?.destroy?.(); 416 | this.accessControlManager?.destroy?.(); 417 | this.authManager?.destroy?.(); 418 | this.eventEmitter.removeAllListeners?.(); 419 | 420 | this._destroyed = true; 421 | this.logger.log("SecurityManager destroyed."); 422 | } 423 | } --------------------------------------------------------------------------------