diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db689f..c77f98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project aims to adhere to [Semantic Versioning](https://semver.org/spec _(Future changes will go here)_ +## [0.7.10] - 2025-07-03 + +### Changed + +- **Migrated to External LLM Module**: Replaced integrated LLM implementation (`electron/modules/llm/`) with external `genai-lite` package (^0.1.0) for better maintainability and separation of concerns. This significant refactoring maintains full backward compatibility while simplifying the codebase. +- **Improved API Key Handling**: Enhanced the ApiKeyProvider to support both secure storage and environment variable fallbacks seamlessly. +- **Centralized IPC Constants**: Moved LLM IPC channel constants to `common/types/llm.ts` for better code organization and reusability. +- **Updated Build Configuration**: Modified webpack configurations to include the new `common/` directory for TypeScript compilation. + +### Fixed + +- **Environment Variable Support**: Restored environment variable fallback functionality that was temporarily lost during the initial genai-lite migration. +- **Documentation Error**: Corrected environment variable name from `ATHANOR_GOOGLE_API_KEY` to `ATHANOR_GEMINI_API_KEY` to match the actual provider ID. + +### Documentation + +- Updated `CLAUDE.md` and `PROJECT.md` to reflect the new architecture using `genai-lite`. +- Added documentation for the `common/types/` directory pattern for shared types. +- Added Mistral to the list of supported environment variables in troubleshooting guide. + ## [0.7.9] - 2025-07-03 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 68740c6..d6c35bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,13 +88,14 @@ Athanor is an Electron desktop application for AI-assisted development workflows - Optional direct API integration via secure storage (`electron/modules/secure-api-storage/`) - Primary workflow: copy prompts to external AI, paste responses back - XML command parsing for applying AI-generated changes -- Modular LLM provider system (`electron/modules/llm/`) supporting: +- LLM functionality provided by external `genai-lite` package supporting: - Anthropic Claude API - OpenAI GPT models - Google Gemini models - - Mistral models (API key storage only) -- Type-safe IPC channels for LLM operations -- Extensive model configuration and client adapters + - Mistral models +- Type-safe IPC channels for LLM operations via `common/types/llm.ts` +- Custom ApiKeyProvider supporting both secure storage and environment variables +- Environment variable fallback: `ATHANOR__API_KEY` **File Management:** @@ -153,6 +154,11 @@ Athanor is an Electron desktop application for AI-assisted development workflows 3. Add corresponding methods to `preload.ts` 4. Update type definitions in `src/types/global.d.ts` +**When working with shared types:** + +- Use `common/types/` for types shared between main and renderer processes +- Update webpack configs to include new directories when needed + **Important UI Patterns:** - **Left Panel**: File explorer with context menus and ignore functionality @@ -186,8 +192,6 @@ Athanor is an Electron desktop application for AI-assisted development workflows - **ignore** - .gitignore/.athignore parsing - **js-tiktoken** - Token counting for prompts - **Jest + ts-jest** - Testing framework -- **@anthropic-ai/sdk** - Anthropic Claude API integration -- **openai** - OpenAI API integration -- **@google/genai** - Google Gemini API integration +- **genai-lite** - Unified LLM integration supporting Claude, GPT, Gemini, and Mistral - **node-pty** - Terminal emulation support - **xterm & xterm-addon-fit** - Terminal rendering in UI diff --git a/PROJECT.md b/PROJECT.md index 31d65e3..e2a0cf7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -36,7 +36,7 @@ Athanor is an **Electron-based desktop application** that integrates AI coding a 3. **Direct LLM API Integration (Optional)** - While the core workflow is API-key-free, Athanor includes an optional feature for direct communication with LLM providers (OpenAI, Anthropic, Gemini, Mistral). - API keys are stored securely using Electron's `safeStorage` via the `ApiKeyServiceMain`. - - The `LLMServiceMain`, client adapters, and extensive model configuration (`electron/modules/llm/main/config.ts`) manage these interactions. + - The `LLMService` from the external `genai-lite` package manages these interactions with a custom ApiKeyProvider that supports both secure storage and environment variable fallbacks. 4. **Git Integration** - Athanor deeply integrates with Git repositories via `GitService.ts`. @@ -116,8 +116,10 @@ Athanor follows Electron's recommended **“secure by default”** pattern, sepa - A lightweight service that listens for file changes from `FileService` to identify which files are being actively edited, providing a real-time relevance signal. - **Dependency Resolver/Scanner (`DependencyResolver.ts`, `DependencyScanner.ts`)**: - Utilities used by the Project Graph Service to perform language-aware dependency analysis for JavaScript/TypeScript and Python. - - **LLM & API Key Services (`electron/modules/`)**: - - A dedicated module for handling optional, direct LLM API calls (`LLMServiceMain`) and secure storage of API keys (`ApiKeyServiceMain`). + - **LLM & API Key Services**: + - LLM functionality provided by external `genai-lite` package with custom ApiKeyProvider + - Secure API key storage via `genai-key-storage-lite` package (`ApiKeyServiceMain`) + - Shared IPC channel types in `common/types/llm.ts` 2. **Renderer Process (React)** - **`src/services/fileSystemService.ts`**: @@ -154,7 +156,7 @@ Athanor follows Electron's recommended **“secure by default”** pattern, sepa - **Chokidar**: Watches the local file system for changes in the open folder. - **ignore**: Reads `.athignore` and `.gitignore` to filter out hidden or excluded files in the Explorer. - **js-tiktoken**: Used for accurate token counting in prompts. -- **LLM SDKs**: Optional integration with `@anthropic-ai/sdk`, `@google/genai`, `openai`. +- **genai-lite**: Unified LLM integration supporting Claude, GPT, Gemini, and Mistral models. - **Webpack & electron-forge**: Build, package, and run the Electron application. - **TailwindCSS 3 + Lucide Icons**: Provides a flexible styling system and icon library for a clean UI. - **Material-UI (MUI) 5**: Partially integrated for certain UI elements (used in some components). diff --git a/common/types/llm.ts b/common/types/llm.ts new file mode 100644 index 0000000..8f7028b --- /dev/null +++ b/common/types/llm.ts @@ -0,0 +1,16 @@ +// common/types/llm.ts +/** + * IPC channel names for LLM operations + */ +export const LLM_IPC_CHANNELS = { + GET_PROVIDERS: 'llm:get-providers', + GET_MODELS: 'llm:get-models', + SEND_MESSAGE: 'llm:send-message', + IS_KEY_AVAILABLE: 'llm:is-key-available', +} as const; + +/** + * Type for LLM IPC channel names + */ +export type LLMIPCChannelName = + (typeof LLM_IPC_CHANNELS)[keyof typeof LLM_IPC_CHANNELS]; \ No newline at end of file diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 59c3684..62a8551 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -91,7 +91,10 @@ export ATHANOR_OPENAI_API_KEY="sk-..." export ATHANOR_ANTHROPIC_API_KEY="sk-ant-..." # For Gemini -export ATHANOR_GOOGLE_API_KEY="..." +export ATHANOR_GEMINI_API_KEY="..." + +# For Mistral +export ATHANOR_MISTRAL_API_KEY="..." ``` When Athanor starts, it will detect these environment variables and enable the "Send via API" functionality for the corresponding providers, even if the OS keyring service is not available. diff --git a/docs/design/switch_api_keys_module.md b/docs/design/switch_api_keys_module.md deleted file mode 100644 index 6aacb87..0000000 --- a/docs/design/switch_api_keys_module.md +++ /dev/null @@ -1,294 +0,0 @@ -# GenAI Key Storage Lite - -A secure API key storage module for generative AI-based Electron applications using native OS credential stores. - -This module leverages Electron's `safeStorage` for OS-level encryption (macOS Keychain, Windows Credential Vault), ensuring that API keys are not stored in plaintext and are not directly exposed to the renderer process. - -## Features - -- **Secure by Default**: Encrypts keys using native OS credential stores via `electron.safeStorage`. -- **Strict Process Separation**: Plaintext keys are never sent to the renderer process, preventing accidental exposure. -- **On-Demand Decryption**: Keys are decrypted only when needed for an API call and are never cached in plaintext in memory. -- **Simple Integration**: Provides clear, separated components for your application's `main`, `renderer`, and `preload` processes. -- **Built-in Provider Validation**: Includes key format validators for popular AI providers (OpenAI, Anthropic, Gemini, Mistral). - -## Installation - -```bash -npm install genai-key-storage-lite -# or -yarn add genai-key-storage-lite -``` - -## Available Exports - -This package provides separate exports for each Electron process type to ensure proper bundling: - -- **Main process**: `genai-key-storage-lite` (default export) - - `ApiKeyServiceMain` - Core service for secure key storage - - `registerSecureApiKeyIpc` - IPC handler registration - -- **Renderer process**: `genai-key-storage-lite/renderer` - - `ApiKeyServiceRenderer` - Client-side service for UI - - `IApiKeyManagerBridge` - TypeScript interface for bridge - -- **Preload script**: `genai-key-storage-lite/preload` - - `createApiKeyManagerBridge` - Creates IPC bridge - -- **Common types**: `genai-key-storage-lite/common` - - `ApiProvider` - Type union of supported providers - - `ApiKeyStorageError` - Error class - - All shared types and interfaces - -> **Important**: Always import common types from `/common` to avoid webpack bundling issues with main process code. - -## How to Use - -Integrating the module into your Electron application involves three steps. - -### 1. Main Process Setup (`main.ts`) - -In your main Electron process file, initialize `ApiKeyServiceMain` and register the IPC handlers it needs to communicate with the renderer process. - -```typescript -// your-electron-app/src/main.ts -import { app, BrowserWindow } from "electron"; -import { - ApiKeyServiceMain, - registerSecureApiKeyIpc, -} from "genai-key-storage-lite"; - -// ... other imports - -app.whenReady().then(() => { - // 1. Initialize the main service with the app's user data path. - // This is where encrypted keys will be stored on disk. - const apiKeyService = new ApiKeyServiceMain(app.getPath("userData")); - - // 2. Register the IPC handlers that the renderer will call. - registerSecureApiKeyIpc(apiKeyService); - - // If you have other main-process services that need to use API keys, - // you can pass the apiKeyService instance to them. - // const myLLMService = new LLMServiceMain(apiKeyService); - - createWindow(); - // ... rest of your app startup logic -}); -``` - -### 2. Preload Script Setup (`preload.ts`) - -The preload script acts as a secure bridge between the sandboxed renderer process and the Node.js environment of the main process. - -```typescript -// your-electron-app/src/preload.ts -import { contextBridge } from "electron"; -import { createApiKeyManagerBridge } from "genai-key-storage-lite/preload"; - -contextBridge.exposeInMainWorld("electronBridge", { - // Expose the secure API key manager bridge under a namespace - secureApiKeyManager: createApiKeyManagerBridge(), - // ... you can expose other APIs here -}); -``` - -To make TypeScript aware of the bridged API in your renderer code, create a type definition file (e.g., `src/renderer.d.ts`) and include it in your `tsconfig.json`: - -```typescript -// your-electron-app/src/renderer.d.ts -import type { IApiKeyManagerBridge } from "genai-key-storage-lite/renderer"; - -declare global { - interface Window { - electronBridge: { - secureApiKeyManager: IApiKeyManagerBridge; - }; - } -} -``` - -### 3. Renderer Process Setup & Usage (e.g., in a React Component) - -Finally, you can use the `ApiKeyServiceRenderer` in your UI. It must be instantiated with the bridge object you exposed in the preload script. - -```typescript -// In a React component or service -import { ApiKeyServiceRenderer } from "genai-key-storage-lite/renderer"; -import { ApiKeyStorageError } from "genai-key-storage-lite/common"; -import type { ApiProvider } from "genai-key-storage-lite/common"; -import React, { useState, useEffect } from "react"; - -// Instantiate the service by passing the bridged object from the window. -// It's best to do this once and share the instance (e.g., via React Context). -const apiKeyService = new ApiKeyServiceRenderer( - window.electronBridge.secureApiKeyManager -); - -const MySettingsComponent = () => { - // Store a key - const handleStoreKey = async (providerId: ApiProvider, key: string) => { - // Client-side validation for instant feedback - if (!apiKeyService.validateApiKeyFormat(providerId, key)) { - alert("Invalid API key format!"); - return; - } - - try { - await apiKeyService.storeKey(providerId, key); - alert(`${providerId} key stored successfully.`); - } catch (error) { - if (error instanceof ApiKeyStorageError) { - alert(`Failed to store key: ${error.message} (${error.code})`); - } else { - alert(`Failed to store key: ${error.message}`); - } - } - }; - - // Get display information for a key (does not return the key itself) - const checkKeyStatus = async (providerId: ApiProvider) => { - try { - const displayInfo = await apiKeyService.getApiKeyDisplayInfo(providerId); - if (displayInfo.isStored) { - console.log( - `${providerId} key is stored. Last four chars:`, - displayInfo.lastFourChars || "N/A" - ); - } else { - console.log(`${providerId} key is not stored.`); - } - } catch (error) { - console.error("Failed to get key display info:", error.message); - } - }; - - // Get all supported provider IDs for UI dropdowns etc. - const availableProviders = apiKeyService.getAvailableProviders(); -}; -``` - -## Advanced Usage - -### Using Keys in the Main Process (`withDecryptedKey`) - -For scenarios where another main process module in your application needs to use an API key directly (e.g., to interact with a provider's SDK), `ApiKeyServiceMain` provides a secure method `withDecryptedKey`. - -This method decrypts the key on-demand and provides it to a callback function, ensuring the plaintext key's scope is strictly limited. - -```typescript -// Example usage within another main process service: -// Assume 'apiKeyServiceMain' is the instance of ApiKeyServiceMain from step 1. - -async function performLLMOperation( - providerId: ApiProvider, - prompt: string -): Promise { - return apiKeyServiceMain.withDecryptedKey(providerId, async (apiKey) => { - // Here, 'apiKey' is the plaintext API key for the specified provider. - // Use it with the provider's SDK directly. - // const anthropicClient = new Anthropic({ apiKey }); - // const response = await anthropicClient.messages.create({ /* ... */ }); - // return response.content[0].text; - - // Placeholder implementation: - console.log( - `Processing "${prompt}" with ${providerId} key ending in ${apiKey.slice( - -4 - )}` - ); - return `Processed: ${prompt}`; - }); -} - -// Call your function -try { - const result = await performLLMOperation("anthropic", "Hello, world!"); - console.log("LLM response:", result); -} catch (error) { - console.error("LLM operation failed:", error.message); -} -``` - -**Key features of `withDecryptedKey`:** - -- **Callback Pattern**: Takes a `providerId` and an asynchronous callback function that receives the decrypted API key. -- **On-Demand Decryption**: The API key is decrypted only when needed. -- **Transient Access**: The plaintext key is **never** cached by `ApiKeyServiceMain`. Its scope is limited to the callback's execution. -- **Main Process Only**: This method is for use **only within Electron's main process**. The key is never sent to the renderer. - -
-Module Architecture - -The module is divided into three main parts, following Electron's process model: - -1. **`src/common/`**: Contains code shared between the main and renderer processes. - - - `types.ts`: Defines core types like `ApiProvider`, IPC channel names, and payload structures. - - `errors.ts`: Defines `ApiKeyStorageError` for consistent error handling. - - `providers/`: Contains the `IApiProviderValidator` interface and implementations for specific services (e.g., `OpenAIProvider.ts`). The `ProviderService` manages these validators. - -2. **`src/main/`**: Contains the core logic that runs in Electron's main process. - - - `ApiKeyServiceMain.ts`: The heart of the secure storage system. It handles encryption/decryption using `electron.safeStorage`, persistence of encrypted keys to disk, and format validation. - - `ipc.ts`: Exports a function `registerSecureApiKeyIpc` that sets up all the IPC handlers to connect the main service with the renderer. - -3. **`src/renderer/`**: Contains the client-side service used by UI components. - - - `ApiKeyServiceRenderer.ts`: Provides a clean, typed API for the UI to interact with the secure storage system via the preload bridge. It does **not** handle plaintext keys directly. - -4. **`src/preload/`**: Contains the bridge logic. - - `index.ts`: Exports a function `createApiKeyManagerBridge` that creates the object to be exposed to the renderer process via `contextBridge`. - -
- -## Contributing a New API Provider - -This package includes validators for several common AI providers. If you wish to add support for a new provider, you'll need to contribute to the package itself. Here's how: - -1. **Define Provider Type**: Add the new provider ID (e.g., `'mynewai'`) to the `ApiProvider` union type in `src/common/types.ts`. - -2. **Create Provider Validator**: Create a new file, e.g., `src/common/providers/MyNewAIProvider.ts`, that implements the `IApiProviderValidator` interface. - - ```typescript - import { IApiProviderValidator } from "./ProviderInterface"; - import { ApiProvider } from "../types"; - - export class MyNewAIProvider implements IApiProviderValidator { - readonly providerId: ApiProvider = "mynewai"; - - // Example: API keys for 'mynewai' must start with 'mna_' - private readonly validationPattern = /^mna_[a-zA-Z0-9]{16}$/; - - validateApiKey(apiKey: string): boolean { - return this.validationPattern.test(apiKey); - } - } - ``` - -3. **Register Provider**: - - - Export your new provider class in `src/common/providers/index.ts`. - - In `src/common/providers/ProviderService.ts`, import your new provider and register it within the `registerBuiltInProviders` method. - - ```typescript - // In ProviderService.ts - import { MyNewAIProvider } from "./MyNewAIProvider"; // Add import - - // ... inside registerBuiltInProviders method ... - this.registerProvider(new MyNewAIProvider()); // Add this line - ``` - -After making these changes, please submit a pull request to the project repository. - -## Security Considerations - -- Plaintext API keys are only held in the memory of the main process **transiently** when they are decrypted on-demand for immediate use. They are **not cached** in plaintext. -- The renderer process **never** receives plaintext API keys from storage. -- `safeStorage` relies on OS-level encryption (e.g., Keychain on macOS, Credential Vault on Windows). The security of the stored keys is tied to the security of the user's OS account. -- Encrypted keys are stored on disk in the application's user data directory. Ensure this location is properly secured by OS file permissions. - -## License - -The code is released under the [MIT license](LICENSE). - diff --git a/electron/handlers/llmIpc.ts b/electron/handlers/llmIpc.ts index 1504c20..f477d2b 100644 --- a/electron/handlers/llmIpc.ts +++ b/electron/handlers/llmIpc.ts @@ -2,19 +2,17 @@ // Registers handlers for getting providers/models and sending messages to LLMs. import { ipcMain } from 'electron'; -import type { LLMServiceMain } from '../modules/llm/main/LLMServiceMain'; -import type { - LLMChatRequest, - ApiProviderId -} from '../modules/llm/common/types'; -import { LLM_IPC_CHANNELS } from '../modules/llm/common/types'; +import type { LLMService, LLMChatRequest, ApiProviderId } from 'genai-lite'; +import type { ApiKeyServiceMain } from 'genai-key-storage-lite'; +import { LLM_IPC_CHANNELS } from '../../common/types/llm'; /** * Registers IPC handlers for LLM operations * * @param llmService - The main process LLM service instance + * @param apiKeyService - The API key service for checking key availability */ -export function registerLlmIpc(llmService: LLMServiceMain): void { +export function registerLlmIpc(llmService: LLMService, apiKeyService: ApiKeyServiceMain): void { console.log('Registering LLM IPC handlers'); // Handler for getting supported providers @@ -52,7 +50,21 @@ export function registerLlmIpc(llmService: LLMServiceMain): void { LLM_IPC_CHANNELS.IS_KEY_AVAILABLE, async (event, providerId: ApiProviderId): Promise => { try { - return await llmService.isKeyAvailable(providerId); + // First check if key exists in storage + const hasStoredKey = await apiKeyService.withDecryptedKey( + providerId as any, + async () => true + ).catch(() => false); + + if (hasStoredKey) { + return true; + } + + // Fall back to checking environment variables + const envVarName = `ATHANOR_${providerId.toUpperCase()}_API_KEY`; + const hasEnvKey = !!process.env[envVarName]; + + return hasEnvKey; } catch (error) { console.error('Error in IS_KEY_AVAILABLE handler:', error); // Return false on error to prevent UI from assuming a key exists diff --git a/electron/ipcHandlers.ts b/electron/ipcHandlers.ts index 7cdc8d5..0749e3c 100644 --- a/electron/ipcHandlers.ts +++ b/electron/ipcHandlers.ts @@ -15,7 +15,7 @@ import { FileService } from './services/FileService'; import { SettingsService } from './services/SettingsService'; import { GitService } from './services/GitService'; import { ApiKeyServiceMain } from 'genai-key-storage-lite'; -import { LLMServiceMain } from './modules/llm/main/LLMServiceMain'; +import type { LLMService } from 'genai-lite'; import { RelevanceEngineService } from './services/RelevanceEngineService'; import { ProjectGraphService } from './services/ProjectGraphService'; import { UserActivityService } from './services/UserActivityService'; @@ -25,7 +25,7 @@ export function setupIpcHandlers( fileService: FileService, settingsService: SettingsService, apiKeyService: ApiKeyServiceMain, - llmService: LLMServiceMain, + llmService: LLMService, relevanceEngine: RelevanceEngineService, projectGraphService: ProjectGraphService, userActivityService: UserActivityService, @@ -36,7 +36,7 @@ export function setupIpcHandlers( setupFileOperationHandlers(fileService); setupFileWatchHandlers(fileService); setupSettingsHandlers(settingsService); - registerLlmIpc(llmService); + registerLlmIpc(llmService, apiKeyService); setupContextHandlers(relevanceEngine, settingsService); setupShellHandlers(shellService); setupGitHandlers(gitService, fileService); diff --git a/electron/main.ts b/electron/main.ts index 54510ef..2a4cf10 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -14,7 +14,7 @@ import { ApiKeyServiceMain, registerSecureApiKeyIpc, } from 'genai-key-storage-lite'; -import { LLMServiceMain } from './modules/llm/main/LLMServiceMain'; +import { LLMService, type ApiKeyProvider } from 'genai-lite'; import { RelevanceEngineService } from './services/RelevanceEngineService'; import { GitService } from './services/GitService'; import { UserActivityService } from './services/UserActivityService'; @@ -71,7 +71,7 @@ export const relevanceEngine = new RelevanceEngineService( ); export const shellService = new ShellService(); export let apiKeyService: ApiKeyServiceMain; -export let llmService: LLMServiceMain; +export let llmService: LLMService; let analysisPromise: Promise | null = null; function runProjectAnalysisWorker(): Promise { @@ -320,8 +320,19 @@ app.whenReady().then(async () => { // Initialize secure API key service apiKeyService = new ApiKeyServiceMain(app.getPath('userData')); - // Initialize LLM service with API key service - llmService = new LLMServiceMain(apiKeyService); + // Initialize LLM service with a custom key provider + const electronKeyProvider: ApiKeyProvider = async (providerId) => { + try { + // Use withDecryptedKey to securely access the key only when needed. + return await apiKeyService.withDecryptedKey(providerId as any, async (key) => key); + } catch { + // If key is not found or decryption fails, check environment variables + const envVarName = `ATHANOR_${providerId.toUpperCase()}_API_KEY`; + const envKey = process.env[envVarName]; + return envKey || null; + } + }; + llmService = new LLMService(electronKeyProvider); // Register IPC handlers from the external package registerSecureApiKeyIpc(apiKeyService); diff --git a/electron/modules/llm/README.md b/electron/modules/llm/README.md deleted file mode 100644 index 261e416..0000000 --- a/electron/modules/llm/README.md +++ /dev/null @@ -1,277 +0,0 @@ -# LLM Integration Module - -This module is responsible for integrating Large Language Model (LLM) capabilities into the Athanor application. It handles communication with various LLM providers, manages request configurations and settings, and provides a standardized interface for both main and renderer processes. - -## Overview - -The LLM module allows Athanor to: -- Fetch lists of supported LLM providers and their models. -- Send chat-based requests to different LLM providers. -- Apply provider-specific and model-specific settings (e.g., temperature, max tokens). -- Handle API responses and errors in a consistent manner. -- Securely utilize API keys managed by the `secure-api-storage` module. - -## Architecture - -The module is structured across Electron's main and renderer processes, with shared components: - -1. **`common/`**: - * `types.ts`: Defines core TypeScript types used across the module, including: - * `ApiProviderId`: Identifiers for LLM providers (e.g., 'openai', 'anthropic'). - * `LLMMessageRole`, `LLMMessage`: Structures for conversation messages. - * `LLMSettings`: Configurable parameters for LLM requests. - * `LLMChatRequest`, `LLMResponse`, `LLMFailureResponse`: Standardized request and response formats. - * `ProviderInfo`, `ModelInfo`: Structures for provider and model metadata, including optional `unsupportedParameters` field. - * `LLM_IPC_CHANNELS`: Constants for IPC communication channel names. - -2. **`main/`**: Contains the core backend logic running in Electron's main process. - * `LLMServiceMain.ts`: The central service in the main process. It orchestrates LLM operations by: - * Managing instances of LLM client adapters. - * Integrating with `ApiKeyServiceMain` (from the `secure-api-storage` module) to securely access API keys. - * Validating requests, applying default and user-defined settings. - * Filtering out unsupported parameters based on model and provider configuration. - * Routing requests to the appropriate client adapter. - * `clients/`: Directory for provider-specific client adapters. - * `types.ts`: Defines the `ILLMClientAdapter` interface that all provider adapters must implement, and `AdapterErrorCode` for standardized error codes. It also defines `InternalLLMChatRequest` which ensures settings are always present. - * `OpenAIClientAdapter.ts`, `AnthropicClientAdapter.ts`, etc.: Implementations of `ILLMClientAdapter` for specific LLM providers (e.g., OpenAI, Anthropic). They handle API-specific request formatting, response parsing, and error mapping. - * `MockClientAdapter.ts`: A mock adapter for testing and development without making real API calls. - * `adapterErrorUtils.ts`: A utility function `getCommonMappedErrorDetails` to map common HTTP and network errors to standardized `AdapterErrorCode` and `LLMError` fields. - * `config.ts`: Contains crucial configuration for the LLM module: - * `ADAPTER_CONSTRUCTORS`: A mapping from `ApiProviderId` to client adapter constructor classes, enabling dynamic registration of adapters in `LLMServiceMain`. - * `ADAPTER_CONFIGS`: Optional configurations for each adapter (e.g., custom base URLs). - * `DEFAULT_LLM_SETTINGS`: Global default settings for LLM requests. - * `PROVIDER_DEFAULT_SETTINGS`: Overrides for default settings on a per-provider basis. - * `MODEL_DEFAULT_SETTINGS`: Overrides for default settings on a per-model basis (highest precedence). - * `SUPPORTED_PROVIDERS`: An array of `ProviderInfo` objects detailing supported LLM providers. - * `SUPPORTED_MODELS`: An array of `ModelInfo` objects detailing supported models, their capabilities, pricing (example), default configurations, and optional `unsupportedParameters` lists. - * Helper functions like `getProviderById`, `getModelById`, `getDefaultSettingsForModel`, `validateLLMSettings`. - -3. **`renderer/`**: Contains the client-side service used by UI components in Electron's renderer process. - * `LLMServiceRenderer.ts`: Provides a typed API for UI components to interact with the LLM system. It communicates with `LLMServiceMain` via IPC. Key methods include: - * `getProviders()`: Fetches available LLM providers. - * `getModels(providerId)`: Fetches models for a specific provider. - * `sendMessage(request)`: Sends an LLM chat request to the main process. - -## Key Components and Responsibilities - -- **`LLMServiceMain`**: - * Central hub for all LLM operations in the main process. - * Dynamically instantiates and manages client adapters based on `config.ts`. - * Securely retrieves API keys using `ApiKeyServiceMain.withDecryptedKey` before passing them to adapters. - * Applies a hierarchy of settings: global defaults -> provider defaults -> model defaults -> request-specific settings. - * Filters out unsupported parameters based on model and provider configuration before sending to adapters. - * Validates incoming `LLMChatRequest` objects. -- **`LLMServiceRenderer`**: - * Acts as the primary interface for the renderer process (UI) to access LLM functionalities. - * Uses `ipcRenderer.invoke` to send requests to the main process and receive responses. - * Abstracts IPC complexity from UI components. -- **`ILLMClientAdapter` (and its implementations)**: - * Defines the contract for interacting with a specific LLM provider. - * Implementations (e.g., `OpenAIClientAdapter`, `AnthropicClientAdapter`) handle the unique aspects of each provider's API: - * Request formatting (e.g., message structures, system prompt placement). - * Authentication (using the provided API key). - * API calls. - * Response parsing into the standard `LLMResponse` format. - * Error mapping into the standard `LLMFailureResponse` format, often using `adapterErrorUtils.ts` and `ADAPTER_ERROR_CODES`. - * A `MockClientAdapter` is available for testing purposes. -- **`config.ts`**: - * The single source of truth for supported providers, models, their configurations, and default operational parameters. - * Enables easy addition or modification of LLM providers and models. - * Supports dynamic registration of client adapters through `ADAPTER_CONSTRUCTORS` and `ADAPTER_CONFIGS`, making the system extensible. -- **`common/types.ts`**: - * Ensures type safety and consistency for data structures exchanged between processes and within different parts of the module. - * Defines `LLM_IPC_CHANNELS` for reliable IPC communication. -- **`adapterErrorUtils.ts`**: - * Promotes consistent error reporting from different client adapters by providing a common mapping for network and HTTP status code errors. - -## IPC Communication - -- IPC channels are defined in `electron/modules/llm/common/types.ts` under `LLM_IPC_CHANNELS`. -- The `LLMServiceRenderer` uses `ipcRenderer.invoke` with these channel names to send requests to the main process. -- In the main process, corresponding IPC handlers (expected to be defined in `electron/handlers/llmIpc.ts`) receive these requests. These handlers then delegate the work to an instance of `LLMServiceMain`. -- Responses (`LLMResponse` or `LLMFailureResponse`) are returned via the `invoke` promise. - -**Example IPC Channels:** -* `LLM_IPC_CHANNELS.GET_PROVIDERS`: To fetch `ProviderInfo[]`. -* `LLM_IPC_CHANNELS.GET_MODELS`: To fetch `ModelInfo[]` for a given `providerId`. -* `LLM_IPC_CHANNELS.SEND_MESSAGE`: To send an `LLMChatRequest` and receive `LLMResponse | LLMFailureResponse`. - -## Configuration - -The LLM module's behavior is heavily driven by `electron/modules/llm/main/config.ts`: - -- **Settings Hierarchy**: - 1. **Global Defaults**: `DEFAULT_LLM_SETTINGS` (e.g., `temperature: 0.7, maxTokens: 2048`). - 2. **Provider Defaults**: `PROVIDER_DEFAULT_SETTINGS` (e.g., Anthropic might have different default `maxTokens`). - 3. **Model Defaults**: `MODEL_DEFAULT_SETTINGS` (e.g., `gpt-4o-mini` might have a specific `maxTokens` and `temperature` different from other OpenAI models). - 4. **Request Settings**: Settings provided in the `LLMChatRequest.settings` object override all defaults. -- **Providers and Models**: - * `SUPPORTED_PROVIDERS`: Array defining all usable LLM providers (ID, name). - * `SUPPORTED_MODELS`: Array defining specific models for each provider, including their ID, name, context window size, pricing hints, and other notes. -- **Parameter Filtering**: Both `ProviderInfo` and `ModelInfo` can specify an optional `unsupportedParameters` field: - * Type: `(keyof LLMSettings)[]` - an array of LLM setting keys that should not be sent to the API - * Purpose: Prevents API errors when models or providers don't support certain parameters (e.g., `topP` for OpenAI's `o4-mini`) - * Precedence: Provider-level and model-level unsupported parameters are combined (union of both lists) - * Implementation: `LLMServiceMain` filters these parameters from the final settings before passing to client adapters -- **Adapter Configuration**: - * `ADAPTER_CONSTRUCTORS`: Maps provider IDs to their client adapter classes. - * `ADAPTER_CONFIGS`: Allows passing constructor arguments to adapters, like custom base URLs which can be sourced from environment variables (e.g., `process.env.OPENAI_API_BASE_URL`). -- **Validation**: `validateLLMSettings` function helps ensure that settings values are within acceptable ranges. - -## How to Use - -### From the Renderer Process (UI) - -1. Import and instantiate `LLMServiceRenderer`: - ```typescript - // Typically in a React component, hook, or UI service - import { LLMServiceRenderer } from 'electron/modules/llm/renderer/LLMServiceRenderer'; - import type { LLMChatRequest, ApiProviderId } from 'electron/modules/llm/common/types'; - - const llmService = new LLMServiceRenderer(); - ``` - -2. Use its methods: - ```typescript - // Get available providers - async function fetchProviders() { - const providers = await llmService.getProviders(); - console.log('Available LLM Providers:', providers); - // Update UI state with providers - } - - // Get models for a specific provider - async function fetchModels(providerId: ApiProviderId) { - const models = await llmService.getModels(providerId); - console.log(`Models for ${providerId}:`, models); - // Update UI state with models - } - - // Send a chat message - async function askLLM(request: LLMChatRequest) { - const response = await llmService.sendMessage(request); - if (response.object === 'chat.completion') { - console.log('LLM Success Response:', response.choices[0].message.content); - } else { // LLMFailureResponse - console.error('LLM Error Response:', response.error.message, response.error.code); - } - } - - // Example usage: - const chatRequest: LLMChatRequest = { - providerId: 'openai', - modelId: 'gpt-4o-mini', - messages: [{ role: 'user', content: 'Translate "hello" to French.' }], - settings: { temperature: 0.5 } - }; - askLLM(chatRequest); - ``` - -### Main Process Integration (e.g., with ApiKeyServiceMain) - -`LLMServiceMain` is designed to be used primarily via IPC calls from the renderer. Its direct interaction with other main process services, especially `ApiKeyServiceMain`, is crucial for its operation. - -When `LLMServiceMain.sendMessage` is called, it internally uses the `ApiKeyServiceMain` instance (passed during its construction) to securely retrieve the necessary API key: - -```typescript -// Simplified snippet from LLMServiceMain.ts -// this.apiKeyService is an instance of ApiKeyServiceMain - -// ... inside sendMessage method ... -const result = await this.apiKeyService.withDecryptedKey( - request.providerId, - async (decryptedApiKey: string) => { - // decryptedApiKey is the plaintext API key for request.providerId - // This key is then passed to the appropriate clientAdapter - return await clientAdapter.sendMessage(internalRequest, decryptedApiKey); - } -); -// ... -```` - -This pattern ensures that plaintext API keys are only handled within the main process, decrypted on-demand, used immediately by the client adapter, and not stored or exposed unnecessarily. - -## Adding a New LLM Provider - -To add support for a new LLM provider (e.g., "MyNewAIProvider"): - -1. **Update Common Types (if provider ID is new to the system)**: - - * If "mynewaprovider" is a completely new `ApiProviderId` not yet known by the `secure-api-storage` module, you'll need to add it there first. See `electron/modules/secure-api-storage/README.md` for instructions on adding a new provider (which includes updating its `ApiProvider` type and adding a validator). - * Ensure the `ApiProviderId` type in `electron/modules/llm/common/types.ts` also includes your new provider ID if it's not implicitly shared or if there are llm-specific considerations. (Usually, `ApiProviderId` from `secure-api-storage` is reused). - -2. **Implement Client Adapter**: - - * Create a new adapter class in `electron/modules/llm/main/clients/MyNewAIProviderClientAdapter.ts`. - * This class must implement the `ILLMClientAdapter` interface from `electron/modules/llm/main/clients/types.ts`. - * Implement `sendMessage`, and optionally `validateApiKey` and `getAdapterInfo`. - * Use the provider's SDK or make HTTP requests directly. - * Map provider-specific responses and errors to `LLMResponse` / `LLMFailureResponse`, utilizing `adapterErrorUtils.ts` and `ADAPTER_ERROR_CODES` where appropriate. - -3. **Update `config.ts` (`electron/modules/llm/main/config.ts`)**: - - * **Register Adapter Constructor**: Add your new adapter to `ADAPTER_CONSTRUCTORS`: - ```typescript - import { MyNewAIProviderClientAdapter } from './clients/MyNewAIProviderClientAdapter'; - // ... - export const ADAPTER_CONSTRUCTORS: Partial ILLMClientAdapter>> = { - 'openai': OpenAIClientAdapter, - 'anthropic': AnthropicClientAdapter, - 'mynewaprovider': MyNewAIProviderClientAdapter, // Add this - // ... - }; - ``` - * **Adapter Configuration (Optional)**: If your adapter needs specific constructor arguments (like a base URL), add an entry to `ADAPTER_CONFIGS`: - ```typescript - export const ADAPTER_CONFIGS: Partial> = { - 'openai': { baseURL: process.env.OPENAI_API_BASE_URL || undefined }, - 'mynewaprovider': { baseURL: process.env.MYNEWAI_API_BASE_URL || '[https://api.mynew.ai/v1](https://api.mynew.ai/v1)' }, - // ... - }; - ``` - * **Add Provider Info**: Add an entry to `SUPPORTED_PROVIDERS`: - ```typescript - export const SUPPORTED_PROVIDERS: ProviderInfo[] = [ - // ... existing providers ... - { id: 'mynewaprovider', name: 'MyNewAI Provider' }, - ]; - ``` - * **Add Model Info**: Add one or more entries to `SUPPORTED_MODELS` for the models offered by this provider: - ```typescript - export const SUPPORTED_MODELS: ModelInfo[] = [ - // ... existing models ... - { - id: 'mynewai-model-x', - name: 'MyNewAI Model X', - providerId: 'mynewaprovider', - contextWindow: 16000, - // ... other properties like pricing, notes, supportsSystemMessage - }, - ]; - ``` - * **Provider/Model Default Settings (Optional)**: If this provider or its models have specific default settings that differ from global defaults, add entries to `PROVIDER_DEFAULT_SETTINGS` and/or `MODEL_DEFAULT_SETTINGS`. - * **Unsupported Parameters (Optional)**: If this provider or its models don't support certain LLM parameters, add `unsupportedParameters` arrays to the provider info and/or model info: - ```typescript - // In SUPPORTED_PROVIDERS or SUPPORTED_MODELS: - unsupportedParameters: ['topP', 'frequencyPenalty'], // Example: exclude these parameters - ``` - -4. **Restart and Test**: After these changes, restart the Electron application. The new provider and its models should be available via `LLMServiceRenderer.getProviders()` and `LLMServiceRenderer.getModels()`, and `LLMServiceRenderer.sendMessage()` should route requests to your new adapter. - -## Error Handling - - - The module uses standardized error responses: `LLMFailureResponse` for failed operations. - - `LLMFailureResponse.error` contains: - * `message`: Human-readable error message. - * `code`: A string error code. For adapter-level errors, this often comes from `ADAPTER_ERROR_CODES` (e.g., `INVALID_API_KEY`, `RATE_LIMIT_EXCEEDED`). For other errors, it might be `VALIDATION_ERROR`, `IPC_ERROR`, etc. - * `type`: A category for the error (e.g., `authentication_error`, `invalid_request_error`, `server_error`). - * `providerError` (optional): The original error object from the provider's SDK or HTTP client. - - `getCommonMappedErrorDetails` in `adapterErrorUtils.ts` helps standardize common network/HTTP errors. Client adapters can further refine error details based on provider-specific error responses. - -## Dependencies - - - **`secure-api-storage` module**: This is a critical dependency. The LLM module relies entirely on `secure-api-storage` (specifically `ApiKeyServiceMain`) for the secure storage, retrieval, and management of API keys. LLM client adapters receive plaintext API keys only transiently via the `withDecryptedKey` callback mechanism. - - **Electron IPC**: For communication between renderer and main processes. - - **External LLM SDKs**: Client adapters may depend on official SDKs from LLM providers (e.g., `@openai/openai-node`, `@anthropic-ai/sdk`). - -This comprehensive setup allows Athanor to flexibly integrate with various LLMs while maintaining a clear separation of concerns and security for API key handling. diff --git a/electron/modules/llm/common/types.ts b/electron/modules/llm/common/types.ts deleted file mode 100644 index 9bedf94..0000000 --- a/electron/modules/llm/common/types.ts +++ /dev/null @@ -1,188 +0,0 @@ -// AI Summary: Core type definitions for the LLM interaction module. -// Defines request/response structures, settings, provider/model info, and error handling types. - -import type { ApiProvider } from 'genai-key-storage-lite'; - -/** - * API provider ID type - reuses the secure storage provider types - */ -export type ApiProviderId = ApiProvider; - -/** - * Message roles supported by LLM APIs - */ -export type LLMMessageRole = 'user' | 'assistant' | 'system'; - -/** - * Individual message in a conversation - */ -export interface LLMMessage { - role: LLMMessageRole; - content: string; -} - -/** - * Gemini harm categories for safety settings - * Only includes categories supported by the API for safety setting rules - */ -export type GeminiHarmCategory = - | 'HARM_CATEGORY_UNSPECIFIED' - | 'HARM_CATEGORY_HATE_SPEECH' - | 'HARM_CATEGORY_SEXUALLY_EXPLICIT' - | 'HARM_CATEGORY_DANGEROUS_CONTENT' - | 'HARM_CATEGORY_HARASSMENT' - | 'HARM_CATEGORY_CIVIC_INTEGRITY'; - -/** - * Gemini harm block thresholds for safety settings - */ -export type GeminiHarmBlockThreshold = - | 'HARM_BLOCK_THRESHOLD_UNSPECIFIED' - | 'BLOCK_LOW_AND_ABOVE' - | 'BLOCK_MEDIUM_AND_ABOVE' - | 'BLOCK_ONLY_HIGH' - | 'BLOCK_NONE'; - -/** - * Individual Gemini safety setting - */ -export interface GeminiSafetySetting { - category: GeminiHarmCategory; - threshold: GeminiHarmBlockThreshold; -} - -/** - * Configurable settings for LLM requests - */ -export interface LLMSettings { - /** Controls randomness in the response (0.0 to 2.0, typically 0.0 to 1.0) */ - temperature?: number; - /** Maximum number of tokens to generate in the response */ - maxTokens?: number; - /** Controls diversity via nucleus sampling (0.0 to 1.0) */ - topP?: number; - /** Sequences where the API will stop generating further tokens */ - stopSequences?: string[]; - /** Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency */ - frequencyPenalty?: number; - /** Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far */ - presencePenalty?: number; - /** A unique identifier representing your end-user, which can help monitor and detect abuse */ - user?: string; - /** Whether the LLM supports system message (almost all LLMs do nowadays) */ - supportsSystemMessage?: boolean; - /** Gemini-specific safety settings for content filtering */ - geminiSafetySettings?: GeminiSafetySetting[]; -} - -/** - * Request structure for chat completion - */ -export interface LLMChatRequest { - providerId: ApiProviderId; - modelId: string; - messages: LLMMessage[]; - systemMessage?: string; - settings?: LLMSettings; -} - -/** - * Individual choice in an LLM response - */ -export interface LLMChoice { - message: LLMMessage; - finish_reason: string | null; - index?: number; -} - -/** - * Token usage information from LLM APIs - */ -export interface LLMUsage { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; -} - -/** - * Successful response from LLM API - */ -export interface LLMResponse { - id: string; - provider: ApiProviderId; - model: string; - created: number; - choices: LLMChoice[]; - usage?: LLMUsage; - object: 'chat.completion'; -} - -/** - * Error information from LLM APIs - */ -export interface LLMError { - message: string; - code?: string | number; - type?: string; - param?: string; - providerError?: any; -} - -/** - * Error response from LLM operations - */ -export interface LLMFailureResponse { - provider: ApiProviderId; - model?: string; - error: LLMError; - object: 'error'; -} - -/** - * Information about a supported LLM provider - */ -export interface ProviderInfo { - id: ApiProviderId; - name: string; - unsupportedParameters?: (keyof LLMSettings)[]; -} - -/** - * Information about a supported LLM model - */ -export interface ModelInfo { - id: string; - name: string; - providerId: ApiProviderId; - contextWindow?: number; - inputPrice?: number; - outputPrice?: number; - supportsSystemMessage?: boolean; - description?: string; - maxTokens?: number; - supportsImages?: boolean; - supportsPromptCache: boolean; - thinkingConfig?: { - maxBudget?: number; - outputPrice?: number; - }; - cacheWritesPrice?: number; - cacheReadsPrice?: number; - unsupportedParameters?: (keyof LLMSettings)[]; -} - -/** - * IPC channel names for LLM operations - */ -export const LLM_IPC_CHANNELS = { - GET_PROVIDERS: 'llm:get-providers', - GET_MODELS: 'llm:get-models', - SEND_MESSAGE: 'llm:send-message', - IS_KEY_AVAILABLE: 'llm:is-key-available', -} as const; - -/** - * Type for LLM IPC channel names - */ -export type LLMIPCChannelName = - (typeof LLM_IPC_CHANNELS)[keyof typeof LLM_IPC_CHANNELS]; diff --git a/electron/modules/llm/main/LLMServiceMain.ts b/electron/modules/llm/main/LLMServiceMain.ts deleted file mode 100644 index c7c505b..0000000 --- a/electron/modules/llm/main/LLMServiceMain.ts +++ /dev/null @@ -1,560 +0,0 @@ -// AI Summary: Main process service for LLM operations, integrating with ApiKeyServiceMain for secure key access. -// Orchestrates LLM requests through provider-specific client adapters with proper error handling. - -import type { ApiKeyServiceMain } from 'genai-key-storage-lite'; -import type { - LLMChatRequest, - LLMResponse, - LLMFailureResponse, - ProviderInfo, - ModelInfo, - ApiProviderId, - LLMSettings, -} from '../common/types'; -import type { - ILLMClientAdapter, - InternalLLMChatRequest, -} from './clients/types'; -import { MockClientAdapter } from './clients/MockClientAdapter'; -import { OpenAIClientAdapter } from './clients/OpenAIClientAdapter'; -import { AnthropicClientAdapter } from './clients/AnthropicClientAdapter'; -import { - SUPPORTED_PROVIDERS, - SUPPORTED_MODELS, - DEFAULT_LLM_SETTINGS, - ADAPTER_CONSTRUCTORS, - ADAPTER_CONFIGS, - getProviderById, - getModelById, - getModelsByProvider, - isProviderSupported, - isModelSupported, - getDefaultSettingsForModel, - validateLLMSettings, -} from './config'; - -/** - * Main process service for LLM operations - * - * This service: - * - Manages LLM provider client adapters - * - Integrates with ApiKeyServiceMain for secure API key access - * - Validates requests and applies default settings - * - Routes requests to appropriate provider adapters - * - Handles errors and provides standardized responses - */ -export class LLMServiceMain { - private apiKeyService: ApiKeyServiceMain; - private clientAdapters: Map; - private mockClientAdapter: MockClientAdapter; - - constructor(apiKeyService: ApiKeyServiceMain) { - this.apiKeyService = apiKeyService; - this.clientAdapters = new Map(); - this.mockClientAdapter = new MockClientAdapter(); - - // Dynamically register client adapters based on configuration - let registeredCount = 0; - const successfullyRegisteredProviders: ApiProviderId[] = []; - - for (const provider of SUPPORTED_PROVIDERS) { - const AdapterClass = ADAPTER_CONSTRUCTORS[provider.id]; - if (AdapterClass) { - try { - const adapterConfig = ADAPTER_CONFIGS[provider.id]; - const adapterInstance = new AdapterClass(adapterConfig); - this.registerClientAdapter(provider.id, adapterInstance); - registeredCount++; - successfullyRegisteredProviders.push(provider.id); - } catch (error) { - console.error( - `LLMServiceMain: Failed to instantiate adapter for provider '${provider.id}'. This provider will use the mock adapter. Error:`, - error - ); - } - } else { - console.warn( - `LLMServiceMain: No adapter constructor found for supported provider '${provider.id}'. This provider will use the mock adapter as a fallback.` - ); - } - } - - if (registeredCount > 0) { - console.log( - `LLMServiceMain: Initialized with ${registeredCount} dynamically registered adapter(s) for: ${successfullyRegisteredProviders.join(', ')}.` - ); - } else { - console.log( - `LLMServiceMain: No real adapters were dynamically registered. All providers will use the mock adapter.` - ); - } - } - - /** - * Gets list of supported LLM providers - * - * @returns Promise resolving to array of provider information - */ - async getProviders(): Promise { - console.log('LLMServiceMain.getProviders called'); - return [...SUPPORTED_PROVIDERS]; // Return a copy to prevent external modification - } - - /** - * Gets list of supported models for a specific provider - * - * @param providerId - The provider ID to get models for - * @returns Promise resolving to array of model information - */ - async getModels(providerId: ApiProviderId): Promise { - console.log(`LLMServiceMain.getModels called for provider: ${providerId}`); - - // Validate provider exists - if (!isProviderSupported(providerId)) { - console.warn(`Requested models for unsupported provider: ${providerId}`); - return []; - } - - const models = getModelsByProvider(providerId); - console.log(`Found ${models.length} models for provider: ${providerId}`); - - return [...models]; // Return a copy to prevent external modification - } - - /** - * Sends a chat message to an LLM provider - * - * @param request - The LLM chat request - * @returns Promise resolving to either success or failure response - */ - async sendMessage( - request: LLMChatRequest - ): Promise { - console.log( - `LLMServiceMain.sendMessage called for provider: ${request.providerId}, model: ${request.modelId}` - ); - - try { - // Validate provider - if (!isProviderSupported(request.providerId)) { - console.warn( - `Unsupported provider in sendMessage: ${request.providerId}` - ); - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Unsupported provider: ${request.providerId}. Supported providers: ${SUPPORTED_PROVIDERS.map((p) => p.id).join(', ')}`, - code: 'UNSUPPORTED_PROVIDER', - type: 'validation_error', - }, - object: 'error', - }; - } - - // Validate model - if (!isModelSupported(request.modelId, request.providerId)) { - const availableModels = getModelsByProvider(request.providerId).map( - (m) => m.id - ); - console.warn( - `Unsupported model ${request.modelId} for provider ${request.providerId}. Available: ${availableModels.join(', ')}` - ); - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Unsupported model: ${request.modelId} for provider: ${request.providerId}. Available models: ${availableModels.join(', ')}`, - code: 'UNSUPPORTED_MODEL', - type: 'validation_error', - }, - object: 'error', - }; - } - - // Get model info for additional validation or settings - const modelInfo = getModelById(request.modelId, request.providerId); - if (!modelInfo) { - // This shouldn't happen if validation above passed, but defensive programming - console.error( - `Model info not found for validated model: ${request.modelId}` - ); - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Internal error: Model configuration not found for ${request.modelId}`, - code: 'MODEL_CONFIG_ERROR', - type: 'internal_error', - }, - object: 'error', - }; - } - - // Validate basic request structure - const structureValidationResult = this.validateRequestStructure(request); - if (structureValidationResult) { - return structureValidationResult; - } - - // Validate settings if provided - if (request.settings) { - const settingsValidationErrors = validateLLMSettings(request.settings); - if (settingsValidationErrors.length > 0) { - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Invalid settings: ${settingsValidationErrors.join(', ')}`, - code: 'INVALID_SETTINGS', - type: 'validation_error', - }, - object: 'error', - }; - } - } - - // Apply model-specific defaults and merge with user settings - const finalSettings = this.mergeSettingsForModel( - request.modelId, - request.providerId, - request.settings - ); - - // Filter out unsupported parameters based on model and provider configuration - let filteredSettings = { ...finalSettings }; // Create a mutable copy - - // Get provider info for parameter filtering (modelInfo is already available from earlier validation) - const providerInfo = getProviderById(request.providerId); - - const paramsToExclude = new Set(); - - // Add provider-level exclusions - if (providerInfo?.unsupportedParameters) { - providerInfo.unsupportedParameters.forEach(param => paramsToExclude.add(param)); - } - // Add model-level exclusions (these will be added to any provider-level ones) - if (modelInfo?.unsupportedParameters) { - modelInfo.unsupportedParameters.forEach(param => paramsToExclude.add(param)); - } - - if (paramsToExclude.size > 0) { - console.log(`LLMServiceMain: Potential parameters to exclude for provider '${request.providerId}', model '${request.modelId}':`, Array.from(paramsToExclude)); - } - - paramsToExclude.forEach(param => { - // Check if the parameter key actually exists in filteredSettings before trying to delete - // (it might have been undefined initially and thus not part of finalSettings depending on merge logic) - // Using 'in' operator is robust for checking presence of properties, including those from prototype chain. - // For direct properties of an object, hasOwnProperty is more specific. - // Given finalSettings is Required, all keys should be present, potentially as undefined. - if (param in filteredSettings) { - console.log(`LLMServiceMain: Removing excluded parameter '${String(param)}' for provider '${request.providerId}', model '${request.modelId}'. Value was:`, filteredSettings[param]); - delete (filteredSettings as Partial)[param]; // Cast to allow deletion - } else { - // This case should ideally not happen if finalSettings truly is Required - // and mergeSettingsForModel ensures all keys are present (even if undefined). - console.log(`LLMServiceMain: Parameter '${String(param)}' marked for exclusion was not found in settings for provider '${request.providerId}', model '${request.modelId}'.`); - } - }); - - const internalRequest: InternalLLMChatRequest = { - ...request, - settings: filteredSettings, - }; - - console.log(`Processing LLM request with (potentially filtered) settings:`, { - provider: request.providerId, - model: request.modelId, - settings: filteredSettings, - messageCount: request.messages.length, - }); - - console.log( - `Processing LLM request: ${request.messages.length} messages, model: ${request.modelId}` - ); - - // Get client adapter - use mock for now, real adapters will be added later - const clientAdapter = this.getClientAdapter(request.providerId); - - // Use ApiKeyServiceMain to securely access the API key and make the request - try { - // First, try to get the key from secure storage. - console.log( - `Attempting to use key from secure storage for ${request.providerId}...` - ); - const result = await this.apiKeyService.withDecryptedKey( - request.providerId, - (apiKey: string) => { - console.log( - `Making LLM request with ${clientAdapter.constructor.name} for provider: ${request.providerId}` - ); - return clientAdapter.sendMessage(internalRequest, apiKey); - } - ); - - console.log( - `LLM request completed successfully for model: ${request.modelId}` - ); - return result; - - } catch (storageError) { - console.warn( - `Secure storage failed for ${request.providerId}: ${storageError instanceof Error ? storageError.message : String(storageError)}. Attempting ENV fallback.` - ); - - // If secure storage fails, try the environment variable. - const envVarName = `ATHANOR_${request.providerId.toUpperCase()}_API_KEY`; - const apiKeyFromEnv = process.env[envVarName]; - - if (apiKeyFromEnv) { - console.log( - `Found key for ${request.providerId} in environment variable.` - ); - // The sendMessage call to the adapter can also throw, which will be caught by the outer catch block. - return await clientAdapter.sendMessage(internalRequest, apiKeyFromEnv); - } - - // If we are here, both methods failed. Re-throw the original error to be handled by the outer catch. - console.error( - `API key for ${request.providerId} not found in secure storage or environment variables.` - ); - throw storageError; - } - } catch (error) { - console.error('Error in LLMServiceMain.sendMessage:', error); - - return { - provider: request.providerId, - model: request.modelId, - error: { - message: - error instanceof Error - ? `API key error for ${request.providerId}: ${error.message}. Check secure storage or the ATHANOR_${request.providerId.toUpperCase()}_API_KEY environment variable.` - : 'Unknown error occurred during API key retrieval or message sending.', - code: 'API_KEY_ERROR', - type: 'authentication_error', - providerError: error, - }, - object: 'error', - }; - } - } - - /** - * Validates basic LLM request structure - * - * @param request - The request to validate - * @returns LLMFailureResponse if validation fails, null if valid - */ - private validateRequestStructure( - request: LLMChatRequest - ): LLMFailureResponse | null { - // Basic request structure validation - if ( - !request.messages || - !Array.isArray(request.messages) || - request.messages.length === 0 - ) { - return { - provider: request.providerId, - model: request.modelId, - error: { - message: 'Request must contain at least one message', - code: 'INVALID_REQUEST', - type: 'validation_error', - }, - object: 'error', - }; - } - - // Validate message structure - for (let i = 0; i < request.messages.length; i++) { - const message = request.messages[i]; - if (!message.role || !message.content) { - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Message at index ${i} must have both 'role' and 'content' properties`, - code: 'INVALID_MESSAGE', - type: 'validation_error', - }, - object: 'error', - }; - } - - if (!['user', 'assistant', 'system'].includes(message.role)) { - return { - provider: request.providerId, - model: request.modelId, - error: { - message: `Invalid message role '${message.role}' at index ${i}. Must be 'user', 'assistant', or 'system'`, - code: 'INVALID_MESSAGE_ROLE', - type: 'validation_error', - }, - object: 'error', - }; - } - } - - return null; // Request is valid - } - - /** - * Merges request settings with model-specific and global defaults - * - * @param modelId - The model ID to get defaults for - * @param providerId - The provider ID to get defaults for - * @param requestSettings - Settings from the request - * @returns Complete settings object with all required fields - */ - private mergeSettingsForModel( - modelId: string, - providerId: ApiProviderId, - requestSettings?: Partial - ): Required { - // Get model-specific defaults - const modelDefaults = getDefaultSettingsForModel(modelId, providerId); - - // Merge with user-provided settings (user settings take precedence) - const mergedSettings: Required = { - temperature: requestSettings?.temperature ?? modelDefaults.temperature, - maxTokens: requestSettings?.maxTokens ?? modelDefaults.maxTokens, - topP: requestSettings?.topP ?? modelDefaults.topP, - stopSequences: - requestSettings?.stopSequences ?? modelDefaults.stopSequences, - frequencyPenalty: - requestSettings?.frequencyPenalty ?? modelDefaults.frequencyPenalty, - presencePenalty: - requestSettings?.presencePenalty ?? modelDefaults.presencePenalty, - user: requestSettings?.user ?? modelDefaults.user, - supportsSystemMessage: - requestSettings?.supportsSystemMessage ?? - modelDefaults.supportsSystemMessage, - geminiSafetySettings: - requestSettings?.geminiSafetySettings ?? - modelDefaults.geminiSafetySettings, - }; - - // Log the final settings for debugging - console.log(`Merged settings for ${providerId}/${modelId}:`, { - temperature: mergedSettings.temperature, - maxTokens: mergedSettings.maxTokens, - topP: mergedSettings.topP, - hasStopSequences: mergedSettings.stopSequences.length > 0, - frequencyPenalty: mergedSettings.frequencyPenalty, - presencePenalty: mergedSettings.presencePenalty, - hasUser: !!mergedSettings.user, - geminiSafetySettingsCount: mergedSettings.geminiSafetySettings.length, - }); - - return mergedSettings; - } - - /** - * Gets the appropriate client adapter for a provider - * - * @param providerId - The provider ID - * @returns The client adapter to use - */ - private getClientAdapter(providerId: ApiProviderId): ILLMClientAdapter { - // Check for registered real adapters first - const registeredAdapter = this.clientAdapters.get(providerId); - if (registeredAdapter) { - console.log(`Using registered adapter for provider: ${providerId}`); - return registeredAdapter; - } - - // Fall back to mock adapter for unsupported providers - console.log(`No real adapter found for ${providerId}, using mock adapter`); - return this.mockClientAdapter; - } - - /** - * Registers a client adapter for a specific provider - * - * @param providerId - The provider ID - * @param adapter - The client adapter implementation - */ - registerClientAdapter( - providerId: ApiProviderId, - adapter: ILLMClientAdapter - ): void { - this.clientAdapters.set(providerId, adapter); - console.log(`Registered client adapter for provider: ${providerId}`); - } - - /** - * Checks if an API key is available from any source (secure storage or ENV). - * @param providerId The provider ID to check for - * @returns Promise resolving to true if a key is available, false otherwise. - */ - async isKeyAvailable(providerId: ApiProviderId): Promise { - if (!providerId) { - return false; - } - - // First, check if the key is in the secure OS storage. - const isStored = await this.apiKeyService.isKeyStored(providerId); - if (isStored) { - return true; // Found it in secure storage - } - - // If not found, check for an environment variable as a fallback. - const envVarName = `ATHANOR_${providerId.toUpperCase()}_API_KEY`; - const apiKeyFromEnv = process.env[envVarName]; - - // Return true only if the environment variable is set to a non-empty string. - return !!apiKeyFromEnv; - } - - /** - * Gets information about registered adapters - * - * @returns Map of provider IDs to adapter info - */ - getRegisteredAdapters(): Map { - const adapterInfo = new Map(); - - for (const [providerId, adapter] of this.clientAdapters.entries()) { - adapterInfo.set(providerId, { - providerId, - hasAdapter: true, - adapterInfo: adapter.getAdapterInfo?.() || { name: 'Unknown Adapter' }, - }); - } - - return adapterInfo; - } - - /** - * Gets a summary of available providers and their adapter status - * - * @returns Summary of provider availability - */ - getProviderSummary(): { - totalProviders: number; - providersWithAdapters: number; - availableProviders: string[]; - unavailableProviders: string[]; - } { - const availableProviders: string[] = []; - const unavailableProviders: string[] = []; - - for (const provider of SUPPORTED_PROVIDERS) { - if (this.clientAdapters.has(provider.id)) { - availableProviders.push(provider.id); - } else { - unavailableProviders.push(provider.id); - } - } - - return { - totalProviders: SUPPORTED_PROVIDERS.length, - providersWithAdapters: availableProviders.length, - availableProviders, - unavailableProviders, - }; - } -} diff --git a/electron/modules/llm/main/clients/AnthropicClientAdapter.ts b/electron/modules/llm/main/clients/AnthropicClientAdapter.ts deleted file mode 100644 index ed7f5f2..0000000 --- a/electron/modules/llm/main/clients/AnthropicClientAdapter.ts +++ /dev/null @@ -1,302 +0,0 @@ -// AI Summary: Anthropic client adapter for making real API calls to Anthropic's messages endpoint. -// Handles Claude-specific request formatting, response parsing, and error mapping to standardized format. - -import Anthropic from '@anthropic-ai/sdk'; -import type { LLMResponse, LLMFailureResponse, LLMMessage } from '../../common/types'; -import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterErrorCode } from './types'; -import { ADAPTER_ERROR_CODES } from './types'; -import { getCommonMappedErrorDetails } from './adapterErrorUtils'; - -/** - * Client adapter for Anthropic API integration - * - * This adapter: - * - Formats requests according to Anthropic's messages API requirements - * - Handles Claude-specific system message positioning and formatting - * - Maps Anthropic responses to standardized LLMResponse format - * - Converts Anthropic errors to standardized LLMFailureResponse format - * - Manages Claude-specific settings and constraints - */ -export class AnthropicClientAdapter implements ILLMClientAdapter { - private baseURL?: string; - - /** - * Creates a new Anthropic client adapter - * - * @param config Optional configuration for the adapter - * @param config.baseURL Custom base URL for Anthropic-compatible APIs - */ - constructor(config?: { baseURL?: string }) { - this.baseURL = config?.baseURL; - } - - /** - * Sends a chat message to Anthropic's API - * - * @param request - The internal LLM request with applied settings - * @param apiKey - The decrypted Anthropic API key - * @returns Promise resolving to success or failure response - */ - async sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise { - try { - // Initialize Anthropic client - const anthropic = new Anthropic({ - apiKey, - ...(this.baseURL && { baseURL: this.baseURL }) - }); - - // Format messages for Anthropic API (Claude has specific requirements) - const { messages, systemMessage } = this.formatMessagesForAnthropic(request); - - // Prepare API call parameters - const messageParams: Anthropic.Messages.MessageCreateParams = { - model: request.modelId, - messages: messages, - max_tokens: request.settings.maxTokens, - temperature: request.settings.temperature, - top_p: request.settings.topP, - ...(systemMessage && { system: systemMessage }), - ...(request.settings.stopSequences.length > 0 && { - stop_sequences: request.settings.stopSequences - }) - }; - - console.log(`Making Anthropic API call for model: ${request.modelId}`); - console.log(`Anthropic API parameters:`, { - model: messageParams.model, - temperature: messageParams.temperature, - max_tokens: messageParams.max_tokens, - top_p: messageParams.top_p, - hasSystem: !!messageParams.system, - messageCount: messages.length, - hasStopSequences: !!messageParams.stop_sequences - }); - - // Make the API call - const completion = await anthropic.messages.create(messageParams); - - console.log(`Anthropic API call successful, response ID: ${completion.id}`); - - // Convert to standardized response format - return this.createSuccessResponse(completion, request); - - } catch (error) { - console.error('Anthropic API error:', error); - return this.createErrorResponse(error, request); - } - } - - /** - * Validates Anthropic API key format - * - * @param apiKey - The API key to validate - * @returns True if the key format appears valid - */ - validateApiKey(apiKey: string): boolean { - // Anthropic API keys typically start with 'sk-ant-' and are longer - return apiKey.startsWith('sk-ant-') && apiKey.length >= 30; - } - - /** - * Gets adapter information - */ - getAdapterInfo() { - return { - providerId: 'anthropic' as const, - name: 'Anthropic Client Adapter', - version: '1.0.0' - }; - } - - /** - * Formats messages for Anthropic API with proper system message handling - * - * @param request - The internal LLM request - * @returns Formatted messages and system message for Anthropic - */ - private formatMessagesForAnthropic(request: InternalLLMChatRequest): { - messages: Anthropic.Messages.MessageParam[]; - systemMessage?: string; - } { - const messages: Anthropic.Messages.MessageParam[] = []; - let systemMessage = request.systemMessage; - - // Process conversation messages - for (const message of request.messages) { - if (message.role === 'system') { - // Anthropic handles system messages separately - // If we already have a system message, append to it - if (systemMessage) { - systemMessage += '\n\n' + message.content; - } else { - systemMessage = message.content; - } - } else if (message.role === 'user') { - messages.push({ - role: 'user', - content: message.content - }); - } else if (message.role === 'assistant') { - messages.push({ - role: 'assistant', - content: message.content - }); - } - } - - // Anthropic requires messages to start with 'user' role - // If the first message is not from user, we need to handle this - if (messages.length > 0 && messages[0].role !== 'user') { - console.warn('Anthropic API requires first message to be from user. Adjusting message order.'); - // Find the first user message and move it to the front, or create a default one - const firstUserIndex = messages.findIndex(msg => msg.role === 'user'); - if (firstUserIndex > 0) { - const firstUserMessage = messages.splice(firstUserIndex, 1)[0]; - messages.unshift(firstUserMessage); - } else if (firstUserIndex === -1) { - // No user message found, create a default one - messages.unshift({ - role: 'user', - content: 'Please respond based on the previous context.' - }); - } - } - - // Ensure alternating user/assistant pattern (Anthropic requirement) - const cleanedMessages = this.ensureAlternatingRoles(messages); - - return { - messages: cleanedMessages, - systemMessage - }; - } - - /** - * Ensures messages alternate between user and assistant roles as required by Anthropic - * - * @param messages - Original messages array - * @returns Cleaned messages with proper alternating pattern - */ - private ensureAlternatingRoles(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { - if (messages.length === 0) return messages; - - const cleanedMessages: Anthropic.Messages.MessageParam[] = []; - let expectedRole: 'user' | 'assistant' = 'user'; - - for (const message of messages) { - if (message.role === expectedRole) { - cleanedMessages.push(message); - expectedRole = expectedRole === 'user' ? 'assistant' : 'user'; - } else if (message.role === 'user' || message.role === 'assistant') { - // If roles don't alternate properly, we might need to combine messages - // or insert a placeholder. For now, we'll skip non-alternating messages - // and log a warning. - console.warn(`Skipping message with unexpected role: expected ${expectedRole}, got ${message.role}`); - } - } - - return cleanedMessages; - } - - /** - * Creates a standardized success response from Anthropic's response - * - * @param completion - Raw Anthropic completion response - * @param request - Original request for context - * @returns Standardized LLM response - */ - private createSuccessResponse( - completion: Anthropic.Messages.Message, - request: InternalLLMChatRequest - ): LLMResponse { - // Anthropic returns content as an array of content blocks - const contentBlock = completion.content[0]; - - if (!contentBlock || contentBlock.type !== 'text') { - throw new Error('Invalid completion structure from Anthropic API'); - } - - // Map Anthropic's stop reason to our standard format - const finishReason = this.mapAnthropicStopReason(completion.stop_reason); - - return { - id: completion.id, - provider: request.providerId, - model: completion.model || request.modelId, - created: Math.floor(Date.now() / 1000), // Anthropic doesn't provide created timestamp - choices: [{ - message: { - role: 'assistant', - content: contentBlock.text - }, - finish_reason: finishReason, - index: 0 - }], - usage: completion.usage ? { - prompt_tokens: completion.usage.input_tokens, - completion_tokens: completion.usage.output_tokens, - total_tokens: completion.usage.input_tokens + completion.usage.output_tokens - } : undefined, - object: 'chat.completion' - }; - } - - /** - * Maps Anthropic stop reasons to standardized format - * - * @param anthropicReason - The stop reason from Anthropic - * @returns Standardized finish reason - */ - private mapAnthropicStopReason(anthropicReason: string | null): string | null { - if (!anthropicReason) return null; - - const reasonMap: Record = { - 'end_turn': 'stop', - 'max_tokens': 'length', - 'stop_sequence': 'stop', - 'content_filter': 'content_filter', - 'tool_use': 'tool_calls' - }; - - return reasonMap[anthropicReason] || 'other'; - } - - /** - * Creates a standardized error response from Anthropic errors - * - * @param error - The error from Anthropic API - * @param request - Original request for context - * @returns Standardized LLM failure response - */ - private createErrorResponse(error: any, request: InternalLLMChatRequest): LLMFailureResponse { - // Use shared error mapping utility for common error patterns - const initialProviderMessage = (error instanceof Anthropic.APIError) ? error.message : undefined; - let { errorCode, errorMessage, errorType, status } = getCommonMappedErrorDetails(error, initialProviderMessage); - - // Apply Anthropic-specific refinements for 400 errors based on message content - if (error instanceof Anthropic.APIError && status === 400) { - if (error.message.toLowerCase().includes('context length') || - error.message.toLowerCase().includes('too long')) { - errorCode = ADAPTER_ERROR_CODES.CONTEXT_LENGTH_EXCEEDED; - } else if (error.message.toLowerCase().includes('content policy') || - error.message.toLowerCase().includes('safety')) { - errorCode = ADAPTER_ERROR_CODES.CONTENT_FILTER; - errorType = 'content_filter_error'; - } - // For other 400 errors, use the default mapping from the utility (PROVIDER_ERROR) - } - - return { - provider: request.providerId, - model: request.modelId, - error: { - message: errorMessage, - code: errorCode, - type: errorType, - ...(status && { status }), - providerError: error - }, - object: 'error' - }; - } -} diff --git a/electron/modules/llm/main/clients/GeminiClientAdapter.ts b/electron/modules/llm/main/clients/GeminiClientAdapter.ts deleted file mode 100644 index 5ff03fa..0000000 --- a/electron/modules/llm/main/clients/GeminiClientAdapter.ts +++ /dev/null @@ -1,280 +0,0 @@ -// AI Summary: Gemini client adapter for making real API calls to Google's Gemini LLM APIs. -// Handles Gemini-specific request formatting, safety settings, response parsing, and error mapping. - -import { GoogleGenAI } from '@google/genai'; -import type { LLMResponse, LLMFailureResponse, GeminiSafetySetting } from '../../common/types'; -import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterErrorCode } from './types'; -import { ADAPTER_ERROR_CODES } from './types'; -import { getCommonMappedErrorDetails } from './adapterErrorUtils'; - -/** - * Client adapter for Google Gemini API integration - * - * This adapter: - * - Formats requests according to Gemini's generative AI API requirements - * - Handles Gemini-specific safety settings and system instructions - * - Maps Gemini responses to standardized LLMResponse format - * - Converts Gemini errors to standardized LLMFailureResponse format - * - Manages Gemini-specific settings and constraints - */ -export class GeminiClientAdapter implements ILLMClientAdapter { - private baseURL?: string; - - /** - * Creates a new Gemini client adapter - * - * @param config Optional configuration for the adapter - * @param config.baseURL Custom base URL (unused for Gemini but kept for consistency) - */ - constructor(config?: { baseURL?: string }) { - this.baseURL = config?.baseURL; - } - - /** - * Sends a chat message to Gemini's API - * - * @param request - The internal LLM request with applied settings - * @param apiKey - The decrypted Gemini API key - * @returns Promise resolving to success or failure response - */ - async sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise { - try { - // Initialize Gemini client - const genAI = new GoogleGenAI({ apiKey }); - - // Format the request for Gemini API - const { contents, generationConfig, safetySettings, systemInstruction } = - this.formatInternalRequestToGemini(request); - - console.log(`Making Gemini API call for model: ${request.modelId}`); - console.log(`Gemini API parameters:`, { - model: request.modelId, - temperature: generationConfig.temperature, - maxOutputTokens: generationConfig.maxOutputTokens, - hasSystemInstruction: !!systemInstruction, - contentsLength: contents.length, - safetySettingsCount: safetySettings?.length || 0 - }); - - // Generate content using the modern API - const result = await genAI.models.generateContent({ - model: request.modelId, - contents: contents, - config: { - ...generationConfig, - safetySettings: safetySettings, - ...(systemInstruction && { systemInstruction: systemInstruction }) - } - }); - - console.log(`Gemini API call successful, processing response`); - - // Convert to standardized response format - return this.createSuccessResponse(result, request); - - } catch (error) { - console.error('Gemini API error:', error); - return this.createErrorResponse(error, request); - } - } - - /** - * Validates Gemini API key format - * - * @param apiKey - The API key to validate - * @returns True if the key format appears valid - */ - validateApiKey(apiKey: string): boolean { - // Gemini API keys typically start with 'AIza' and are around 39 characters long - return typeof apiKey === 'string' && apiKey.startsWith('AIza') && apiKey.length >= 35; - } - - /** - * Gets adapter information - */ - getAdapterInfo() { - return { - providerId: 'gemini' as const, - name: 'Gemini Client Adapter', - version: '1.0.0' - }; - } - - /** - * Formats the internal LLM request for Gemini API - * - * @param request - The internal LLM request - * @returns Formatted request components for Gemini - */ - private formatInternalRequestToGemini(request: InternalLLMChatRequest): { - contents: any[]; - generationConfig: any; - safetySettings?: any[]; - systemInstruction?: string; - } { - const contents: any[] = []; - let systemInstruction = request.systemMessage; - - // Process messages - separate system messages and build conversation contents - for (const message of request.messages) { - if (message.role === 'system') { - // Gemini handles system messages as systemInstruction - if (systemInstruction) { - systemInstruction += '\n\n' + message.content; - } else { - systemInstruction = message.content; - } - } else if (message.role === 'user') { - contents.push({ - role: 'user', - parts: [{ text: message.content }] - }); - } else if (message.role === 'assistant') { - // Map assistant to model for Gemini - contents.push({ - role: 'model', - parts: [{ text: message.content }] - }); - } - } - - // Build generation config - const generationConfig = { - maxOutputTokens: request.settings.maxTokens, - temperature: request.settings.temperature, - ...(request.settings.topP && { topP: request.settings.topP }), - ...(request.settings.stopSequences && request.settings.stopSequences.length > 0 && { stopSequences: request.settings.stopSequences }) - }; - - // Map safety settings from Athanor format to Gemini SDK format - const safetySettings = request.settings.geminiSafetySettings?.map(setting => ({ - category: setting.category, - threshold: setting.threshold - })); - - return { - contents, - generationConfig, - safetySettings, - systemInstruction - }; - } - - /** - * Creates a standardized success response from Gemini's response - * - * @param response - Raw Gemini response - * @param request - Original request for context - * @returns Standardized LLM response - */ - private createSuccessResponse(response: any, request: InternalLLMChatRequest): LLMResponse { - // Extract content from the response object - const candidate = response.candidates?.[0]; - const content = candidate?.content?.parts?.[0]?.text || ''; - - // Extract usage data if available - const usageMetadata = response.usageMetadata || {}; - - const finishReason = this.mapGeminiFinishReason(candidate?.finishReason || null); - - return { - id: this.generateResponseId(), - provider: request.providerId, - model: response.modelUsed || request.modelId, - created: Math.floor(Date.now() / 1000), - choices: [{ - message: { - role: 'assistant', - content: content - }, - finish_reason: finishReason, - index: 0 - }], - usage: usageMetadata ? { - prompt_tokens: usageMetadata.promptTokenCount || 0, - completion_tokens: usageMetadata.candidatesTokenCount || 0, - total_tokens: usageMetadata.totalTokenCount || 0 - } : undefined, - object: 'chat.completion' - }; - } - - /** - * Maps Gemini finish reasons to standardized format - * - * @param geminiReason - The finish reason from Gemini - * @returns Standardized finish reason - */ - private mapGeminiFinishReason(geminiReason: string | null): string | null { - if (!geminiReason) return null; - - const reasonMap: Record = { - 'STOP': 'stop', - 'MAX_TOKENS': 'length', - 'SAFETY': 'content_filter', - 'RECITATION': 'content_filter', - 'PROHIBITED_CONTENT': 'content_filter', - 'SPII': 'content_filter', - 'BLOCKLIST': 'content_filter', - 'LANGUAGE': 'other', - 'OTHER': 'other', - 'MALFORMED_FUNCTION_CALL': 'function_call_error' - }; - - return reasonMap[geminiReason] || 'other'; - } - - /** - * Creates a standardized error response from Gemini errors - * - * @param error - The error from Gemini API - * @param request - Original request for context - * @returns Standardized LLM failure response - */ - private createErrorResponse(error: any, request: InternalLLMChatRequest): LLMFailureResponse { - // Use shared error mapping utility for common error patterns - const initialProviderMessage = error?.message; - let { errorCode, errorMessage, errorType, status } = getCommonMappedErrorDetails(error, initialProviderMessage); - - // Apply Gemini-specific refinements for certain error types - if (error && error.message) { - const message = error.message.toLowerCase(); - - if (message.includes('context length') || message.includes('too long')) { - errorCode = ADAPTER_ERROR_CODES.CONTEXT_LENGTH_EXCEEDED; - errorType = 'invalid_request_error'; - } else if (message.includes('safety') || message.includes('blocked')) { - errorCode = ADAPTER_ERROR_CODES.CONTENT_FILTER; - errorType = 'content_filter_error'; - } else if (message.includes('api key') || message.includes('authentication')) { - errorCode = ADAPTER_ERROR_CODES.INVALID_API_KEY; - errorType = 'authentication_error'; - } else if (message.includes('quota') || message.includes('limit')) { - errorCode = ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED; - errorType = 'rate_limit_error'; - } - } - - return { - provider: request.providerId, - model: request.modelId, - error: { - message: errorMessage, - code: errorCode, - type: errorType, - ...(status && { status }), - providerError: error - }, - object: 'error' - }; - } - - /** - * Generates a unique response ID - * - * @returns A unique response ID string - */ - private generateResponseId(): string { - return `gemini-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; - } -} diff --git a/electron/modules/llm/main/clients/MockClientAdapter.ts b/electron/modules/llm/main/clients/MockClientAdapter.ts deleted file mode 100644 index 4922907..0000000 --- a/electron/modules/llm/main/clients/MockClientAdapter.ts +++ /dev/null @@ -1,306 +0,0 @@ -// AI Summary: Mock client adapter for testing LLM functionality without making real API calls. -// Provides deterministic responses based on request content for development and testing. - -import type { LLMResponse, LLMFailureResponse, ApiProviderId, LLMSettings } from '../../common/types'; -import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterErrorCode } from './types'; -import { ADAPTER_ERROR_CODES } from './types'; - -/** - * Mock client adapter for testing LLM functionality - * - * This adapter simulates various LLM provider responses without making real API calls. - * It's useful for: - * - Testing the LLM service flow - * - Development when API keys are not available - * - Simulating error conditions - * - Performance testing without API costs - */ -export class MockClientAdapter implements ILLMClientAdapter { - private providerId: ApiProviderId; - - constructor(providerId: ApiProviderId = 'openai') { - this.providerId = providerId; - } - - /** - * Sends a mock message response based on request content - * - * @param request - The LLM request - * @param apiKey - The API key (ignored for mock) - * @returns Promise resolving to mock response - */ - async sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise { - // Simulate network delay - await this.simulateDelay(100, 500); - - try { - // Check for special test patterns in the last user message - const lastMessage = request.messages[request.messages.length - 1]; - const content = lastMessage?.content?.toLowerCase() || ''; - - // Simulate various error conditions based on message content - if (content.includes('error_invalid_key')) { - return this.createErrorResponse('Invalid API key provided', ADAPTER_ERROR_CODES.INVALID_API_KEY, 401, request); - } - - if (content.includes('error_rate_limit')) { - return this.createErrorResponse('Rate limit exceeded', ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED, 429, request); - } - - if (content.includes('error_credits')) { - return this.createErrorResponse('Insufficient credits', ADAPTER_ERROR_CODES.INSUFFICIENT_CREDITS, 402, request); - } - - if (content.includes('error_context_length')) { - return this.createErrorResponse('Context length exceeded', ADAPTER_ERROR_CODES.CONTEXT_LENGTH_EXCEEDED, 400, request); - } - - if (content.includes('error_model_not_found')) { - return this.createErrorResponse('Model not found', ADAPTER_ERROR_CODES.MODEL_NOT_FOUND, 404, request); - } - - if (content.includes('error_content_filter')) { - return this.createErrorResponse('Content filtered due to policy violation', ADAPTER_ERROR_CODES.CONTENT_FILTER, 400, request); - } - - if (content.includes('error_network')) { - return this.createErrorResponse('Network connection failed', ADAPTER_ERROR_CODES.NETWORK_ERROR, 0, request); - } - - if (content.includes('error_generic')) { - return this.createErrorResponse('Generic provider error', ADAPTER_ERROR_CODES.PROVIDER_ERROR, 500, request); - } - - // Generate successful mock response - return this.createSuccessResponse(request, content); - - } catch (error) { - return this.createErrorResponse( - `Mock adapter error: ${error instanceof Error ? error.message : 'Unknown error'}`, - ADAPTER_ERROR_CODES.UNKNOWN_ERROR, - 500, - request - ); - } - } - - /** - * Validates API key format (always returns true for mock) - */ - validateApiKey(apiKey: string): boolean { - return apiKey.length > 0; - } - - /** - * Gets adapter information - */ - getAdapterInfo() { - return { - providerId: this.providerId, - name: 'Mock Client Adapter', - version: '1.0.0', - supportedModels: ['mock-model-1', 'mock-model-2'] - }; - } - - /** - * Creates a successful mock response - */ - private createSuccessResponse(request: InternalLLMChatRequest, userContent: string): LLMResponse { - // Generate response content based on user input and settings - let responseContent: string; - - // Check for settings-based test patterns - if (userContent.includes('test_temperature')) { - responseContent = this.generateTemperatureTestResponse(request.settings.temperature); - } else if (userContent.includes('test_settings')) { - responseContent = this.generateSettingsTestResponse(request.settings); - } else if (userContent.includes('hello') || userContent.includes('hi')) { - responseContent = 'Hello! I\'m a mock LLM assistant. How can I help you today?'; - } else if (userContent.includes('weather')) { - responseContent = 'I\'m a mock assistant and don\'t have access to real weather data, but I can pretend it\'s sunny and 72°F!'; - } else if (userContent.includes('code') || userContent.includes('programming')) { - responseContent = 'Here\'s some mock code:\n\n```javascript\nfunction mockFunction() {\n return "This is mock code!";\n}\n```'; - } else if (userContent.includes('long') || userContent.includes('detailed')) { - responseContent = this.generateLongResponse(); - } else { - responseContent = `You said: "${userContent}". This is a mock response from the ${this.providerId} mock adapter.`; - } - - // Apply creativity based on temperature - responseContent = this.applyTemperatureEffects(responseContent, request.settings.temperature); - - // Apply maxTokens constraint (rough simulation) - const originalLength = responseContent.length; - if (request.settings.maxTokens && request.settings.maxTokens < 200) { - const words = responseContent.split(' '); - const maxWords = Math.max(1, Math.floor(request.settings.maxTokens / 4)); - if (words.length > maxWords) { - responseContent = words.slice(0, maxWords).join(' ') + '...'; - } - } - - // Check for stop sequences - if (request.settings.stopSequences.length > 0) { - for (const stopSeq of request.settings.stopSequences) { - const stopIndex = responseContent.indexOf(stopSeq); - if (stopIndex !== -1) { - responseContent = responseContent.substring(0, stopIndex); - break; - } - } - } - - const mockTokenCount = Math.floor(responseContent.length / 4); // Rough token estimation - const promptTokenCount = Math.floor(request.messages.reduce((acc, msg) => acc + msg.content.length, 0) / 4); - - // Determine finish reason - let finishReason = 'stop'; - if (originalLength > responseContent.length && request.settings.maxTokens && mockTokenCount >= request.settings.maxTokens) { - finishReason = 'length'; - } else if (request.settings.stopSequences.some(seq => responseContent.includes(seq))) { - finishReason = 'stop'; - } - - return { - id: `mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - provider: request.providerId, - model: request.modelId, - created: Math.floor(Date.now() / 1000), - choices: [{ - message: { - role: 'assistant', - content: responseContent - }, - finish_reason: finishReason, - index: 0 - }], - usage: { - prompt_tokens: promptTokenCount, - completion_tokens: mockTokenCount, - total_tokens: promptTokenCount + mockTokenCount - }, - object: 'chat.completion' - }; - } - - /** - * Generates a response that demonstrates temperature effects - */ - private generateTemperatureTestResponse(temperature: number): string { - if (temperature < 0.3) { - return 'Low temperature setting detected. This response should be more deterministic and focused.'; - } else if (temperature > 0.8) { - return 'High temperature setting detected! This response should be more creative, varied, and potentially surprising in its word choices and structure.'; - } else { - return 'Moderate temperature setting detected. This response balances consistency with some creative variation.'; - } - } - - /** - * Generates a response that shows current settings - */ - private generateSettingsTestResponse(settings: Required): string { - return `Current mock settings: -- Temperature: ${settings.temperature} -- Max Tokens: ${settings.maxTokens} -- Top P: ${settings.topP} -- Stop Sequences: ${settings.stopSequences.length > 0 ? settings.stopSequences.join(', ') : 'none'} -- Frequency Penalty: ${settings.frequencyPenalty} -- Presence Penalty: ${settings.presencePenalty} -- User: ${settings.user || 'not set'}`; - } - - /** - * Applies mock temperature effects to response content - */ - private applyTemperatureEffects(content: string, temperature: number): string { - // At very low temperatures, make responses more formal - if (temperature < 0.2) { - return content.replace(/!/g, '.').replace(/\?/g, '.'); - } - - // At high temperatures, add some creative variations - if (temperature > 0.8) { - const variations = [ - content + ' 🎯', - content + ' (with creative flair!)', - '✨ ' + content, - content + ' — quite interesting, isn\'t it?' - ]; - return variations[Math.floor(Math.random() * variations.length)]; - } - - return content; - } - - /** - * Creates an error response - */ - private createErrorResponse( - message: string, - code: AdapterErrorCode, - status: number, - request: InternalLLMChatRequest - ): LLMFailureResponse { - return { - provider: request.providerId, - model: request.modelId, - error: { - message, - code, - type: this.getErrorType(code), - ...(status > 0 && { status }) - }, - object: 'error' - }; - } - - /** - * Maps error codes to error types - */ - private getErrorType(code: AdapterErrorCode): string { - switch (code) { - case ADAPTER_ERROR_CODES.INVALID_API_KEY: - return 'authentication_error'; - case ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED: - case ADAPTER_ERROR_CODES.INSUFFICIENT_CREDITS: - return 'rate_limit_error'; - case ADAPTER_ERROR_CODES.MODEL_NOT_FOUND: - case ADAPTER_ERROR_CODES.CONTEXT_LENGTH_EXCEEDED: - return 'invalid_request_error'; - case ADAPTER_ERROR_CODES.CONTENT_FILTER: - return 'content_filter_error'; - case ADAPTER_ERROR_CODES.NETWORK_ERROR: - return 'connection_error'; - default: - return 'server_error'; - } - } - - /** - * Generates a longer mock response for testing - */ - private generateLongResponse(): string { - return `This is a detailed mock response from the ${this.providerId} adapter. - -I can simulate various types of responses based on your input. Here are some features: - -1. **Error Simulation**: Include phrases like "error_rate_limit" to test error handling -2. **Variable Length**: Request "long" responses to test token limits -3. **Code Generation**: Ask about "programming" to get mock code snippets -4. **Conversational**: Simple greetings work too - -The mock adapter is useful for testing the LLM integration without making real API calls. It simulates realistic response times, token usage, and various error conditions that you might encounter with real LLM providers. - -This response demonstrates how the adapter can generate longer content while still respecting the maxTokens parameter if specified in the request settings.`; - } - - /** - * Simulates network delay with random variation - */ - private async simulateDelay(minMs: number, maxMs: number): Promise { - const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; - return new Promise(resolve => setTimeout(resolve, delay)); - } -} diff --git a/electron/modules/llm/main/clients/OpenAIClientAdapter.ts b/electron/modules/llm/main/clients/OpenAIClientAdapter.ts deleted file mode 100644 index 928a866..0000000 --- a/electron/modules/llm/main/clients/OpenAIClientAdapter.ts +++ /dev/null @@ -1,254 +0,0 @@ -// AI Summary: OpenAI client adapter for making real API calls to OpenAI's chat completions endpoint. -// Handles request formatting, response parsing, and error mapping to standardized format. - -import OpenAI from 'openai'; -import type { LLMResponse, LLMFailureResponse } from '../../common/types'; -import type { - ILLMClientAdapter, - InternalLLMChatRequest, - AdapterErrorCode, -} from './types'; -import { ADAPTER_ERROR_CODES } from './types'; -import { getCommonMappedErrorDetails } from './adapterErrorUtils'; - -/** - * Client adapter for OpenAI API integration - * - * This adapter: - * - Formats requests according to OpenAI's chat completions API - * - Handles OpenAI-specific authentication and headers - * - Maps OpenAI responses to standardized LLMResponse format - * - Converts OpenAI errors to standardized LLMFailureResponse format - */ -export class OpenAIClientAdapter implements ILLMClientAdapter { - private baseURL?: string; - - /** - * Creates a new OpenAI client adapter - * - * @param config Optional configuration for the adapter - * @param config.baseURL Custom base URL for OpenAI-compatible APIs - */ - constructor(config?: { baseURL?: string }) { - this.baseURL = config?.baseURL; - } - - /** - * Sends a chat message to OpenAI's API - * - * @param request - The internal LLM request with applied settings - * @param apiKey - The decrypted OpenAI API key - * @returns Promise resolving to success or failure response - */ - async sendMessage( - request: InternalLLMChatRequest, - apiKey: string - ): Promise { - try { - // Initialize OpenAI client - const openai = new OpenAI({ - apiKey, - ...(this.baseURL && { baseURL: this.baseURL }), - }); - - // Format messages for OpenAI API - const messages = this.formatMessages(request); - - // Prepare API call parameters - const completionParams: OpenAI.Chat.Completions.ChatCompletionCreateParams = - { - model: request.modelId, - messages: messages, - temperature: request.settings.temperature, - max_completion_tokens: request.settings.maxTokens, - top_p: request.settings.topP, - ...(request.settings.stopSequences.length > 0 && { - stop: request.settings.stopSequences, - }), - ...(request.settings.frequencyPenalty !== 0 && { - frequency_penalty: request.settings.frequencyPenalty, - }), - ...(request.settings.presencePenalty !== 0 && { - presence_penalty: request.settings.presencePenalty, - }), - ...(request.settings.user && { - user: request.settings.user, - }), - }; - - console.log(`OpenAI API parameters:`, { - model: completionParams.model, - temperature: completionParams.temperature, - max_completion_tokens: completionParams.max_completion_tokens, - top_p: completionParams.top_p, - hasStop: !!completionParams.stop, - frequency_penalty: completionParams.frequency_penalty, - presence_penalty: completionParams.presence_penalty, - hasUser: !!completionParams.user, - }); - - console.log(`Making OpenAI API call for model: ${request.modelId}`); - - // Make the API call - const completion = await openai.chat.completions.create(completionParams); - - console.log(`OpenAI API call successful, response ID: ${completion.id}`); - - // Convert to standardized response format - return this.createSuccessResponse(completion, request); - } catch (error) { - console.error('OpenAI API error:', error); - return this.createErrorResponse(error, request); - } - } - - /** - * Validates OpenAI API key format - * - * @param apiKey - The API key to validate - * @returns True if the key format appears valid - */ - validateApiKey(apiKey: string): boolean { - // OpenAI API keys typically start with 'sk-' and are at least 20 characters - return apiKey.startsWith('sk-') && apiKey.length >= 20; - } - - /** - * Gets adapter information - */ - getAdapterInfo() { - return { - providerId: 'openai' as const, - name: 'OpenAI Client Adapter', - version: '1.0.0', - }; - } - - /** - * Formats messages for OpenAI API - * - * @param request - The internal LLM request - * @returns Formatted messages array for OpenAI - */ - private formatMessages( - request: InternalLLMChatRequest - ): OpenAI.Chat.Completions.ChatCompletionMessageParam[] { - const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = []; - - // Add system message if provided - if (request.systemMessage) { - messages.push({ - role: 'system', - content: request.systemMessage, - }); - } - - // Add conversation messages - for (const message of request.messages) { - if (message.role === 'system') { - // Handle system messages in conversation - messages.push({ - role: 'system', - content: message.content, - }); - } else if (message.role === 'user') { - messages.push({ - role: 'user', - content: message.content, - }); - } else if (message.role === 'assistant') { - messages.push({ - role: 'assistant', - content: message.content, - }); - } - } - - return messages; - } - - /** - * Creates a standardized success response from OpenAI's response - * - * @param completion - Raw OpenAI completion response - * @param request - Original request for context - * @returns Standardized LLM response - */ - private createSuccessResponse( - completion: OpenAI.Chat.Completions.ChatCompletion, - request: InternalLLMChatRequest - ): LLMResponse { - const choice = completion.choices[0]; - - if (!choice || !choice.message) { - throw new Error('Invalid completion structure from OpenAI API'); - } - - return { - id: completion.id, - provider: request.providerId, - model: completion.model || request.modelId, - created: completion.created, - choices: [ - { - message: { - role: choice.message.role as 'assistant', - content: choice.message.content || '', - }, - finish_reason: choice.finish_reason, - index: choice.index, - }, - ], - usage: completion.usage - ? { - prompt_tokens: completion.usage.prompt_tokens, - completion_tokens: completion.usage.completion_tokens, - total_tokens: completion.usage.total_tokens, - } - : undefined, - object: 'chat.completion', - }; - } - - /** - * Creates a standardized error response from OpenAI errors - * - * @param error - The error from OpenAI API - * @param request - Original request for context - * @returns Standardized LLM failure response - */ - private createErrorResponse( - error: any, - request: InternalLLMChatRequest - ): LLMFailureResponse { - // Use shared error mapping utility for common error patterns - const initialProviderMessage = - error instanceof OpenAI.APIError ? error.message : undefined; - let { errorCode, errorMessage, errorType, status } = - getCommonMappedErrorDetails(error, initialProviderMessage); - - // Apply OpenAI-specific refinements for 400 errors based on message content - if (error instanceof OpenAI.APIError && status === 400) { - if (error.message.toLowerCase().includes('context length')) { - errorCode = ADAPTER_ERROR_CODES.CONTEXT_LENGTH_EXCEEDED; - } else if (error.message.toLowerCase().includes('content policy')) { - errorCode = ADAPTER_ERROR_CODES.CONTENT_FILTER; - errorType = 'content_filter_error'; - } - // For other 400 errors, use the default mapping from the utility (PROVIDER_ERROR) - } - - return { - provider: request.providerId, - model: request.modelId, - error: { - message: errorMessage, - code: errorCode, - type: errorType, - ...(status && { status }), - providerError: error, - }, - object: 'error', - }; - } -} diff --git a/electron/modules/llm/main/clients/adapterErrorUtils.ts b/electron/modules/llm/main/clients/adapterErrorUtils.ts deleted file mode 100644 index 3cc76d4..0000000 --- a/electron/modules/llm/main/clients/adapterErrorUtils.ts +++ /dev/null @@ -1,122 +0,0 @@ -// AI Summary: Centralized error mapping utility for LLM client adapters. -// Maps common HTTP status codes and network errors to standardized AdapterErrorCode and errorType. -// Reduces duplication across OpenAI, Anthropic and other provider adapters. - -import { ADAPTER_ERROR_CODES, type AdapterErrorCode } from './types'; - -/** - * Mapped error details returned by the utility function - */ -export interface MappedErrorDetails { - errorCode: AdapterErrorCode; - errorMessage: string; - errorType: string; - status?: number; -} - -/** - * Maps common error patterns to standardized error codes and types - * - * This utility handles: - * - Common HTTP status codes (401, 402, 404, 429, 4xx, 5xx) - * - Network connection errors (ENOTFOUND, ECONNREFUSED, timeouts) - * - Generic JavaScript errors - * - * Individual adapters can further refine the mappings for provider-specific cases, - * particularly for 400 errors where message content determines the specific error type. - * - * @param error - The error object from the provider SDK or network layer - * @param providerMessageOverride - Optional override for the error message (e.g., from provider SDK) - * @returns Mapped error details with standardized codes and types - */ -export function getCommonMappedErrorDetails( - error: any, - providerMessageOverride?: string -): MappedErrorDetails { - let errorCode: AdapterErrorCode = ADAPTER_ERROR_CODES.UNKNOWN_ERROR; - let errorMessage = providerMessageOverride || error?.message || 'Unknown error occurred'; - let errorType = 'server_error'; - let status: number | undefined; - - // Handle API errors with HTTP status codes - if (error && typeof error.status === 'number') { - const httpStatus = error.status; - status = httpStatus; - errorMessage = providerMessageOverride || error.message || `HTTP ${httpStatus} error`; - - // Map common HTTP status codes - // TypeScript knows httpStatus is defined here due to the typeof check above - switch (httpStatus) { - case 400: - // Default mapping for 400 errors - adapters should refine based on message content - errorCode = ADAPTER_ERROR_CODES.PROVIDER_ERROR; - errorType = 'invalid_request_error'; - break; - case 401: - errorCode = ADAPTER_ERROR_CODES.INVALID_API_KEY; - errorType = 'authentication_error'; - break; - case 402: - errorCode = ADAPTER_ERROR_CODES.INSUFFICIENT_CREDITS; - errorType = 'rate_limit_error'; - break; - case 404: - errorCode = ADAPTER_ERROR_CODES.MODEL_NOT_FOUND; - errorType = 'invalid_request_error'; - break; - case 429: - errorCode = ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED; - errorType = 'rate_limit_error'; - break; - case 500: - case 502: - case 503: - case 504: - errorCode = ADAPTER_ERROR_CODES.PROVIDER_ERROR; - errorType = 'server_error'; - break; - default: - if (httpStatus >= 400 && httpStatus < 500) { - errorCode = ADAPTER_ERROR_CODES.PROVIDER_ERROR; - errorType = 'invalid_request_error'; - } else if (httpStatus >= 500) { - errorCode = ADAPTER_ERROR_CODES.PROVIDER_ERROR; - errorType = 'server_error'; - } else { - errorCode = ADAPTER_ERROR_CODES.PROVIDER_ERROR; - errorType = 'server_error'; - } - } - } - // Handle network connection errors - else if (error && ( - error.code === 'ENOTFOUND' || - error.code === 'ECONNREFUSED' || - error.code === 'ETIMEDOUT' || - error.name === 'ConnectTimeoutError' || - (error.type && error.type.includes('timeout')) - )) { - errorCode = ADAPTER_ERROR_CODES.NETWORK_ERROR; - errorType = 'connection_error'; - errorMessage = providerMessageOverride || error.message || 'Network connection failed'; - } - // Handle generic JavaScript errors - else if (error instanceof Error) { - errorMessage = providerMessageOverride || error.message || 'Client error occurred'; - errorCode = ADAPTER_ERROR_CODES.UNKNOWN_ERROR; - errorType = 'client_error'; - } - // Handle unknown error types - else { - errorMessage = providerMessageOverride || 'Unknown error occurred'; - errorCode = ADAPTER_ERROR_CODES.UNKNOWN_ERROR; - errorType = 'server_error'; - } - - return { - errorCode, - errorMessage, - errorType, - status - }; -} diff --git a/electron/modules/llm/main/clients/types.ts b/electron/modules/llm/main/clients/types.ts deleted file mode 100644 index f08d927..0000000 --- a/electron/modules/llm/main/clients/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -// AI Summary: Interface definition for LLM client adapters that handle provider-specific API calls. -// Defines the contract that all LLM provider clients must implement with enhanced type safety. - -import type { LLMChatRequest, LLMResponse, LLMFailureResponse, LLMSettings } from '../../common/types'; - -/** - * Internal request structure used by client adapters with applied defaults - * This ensures all settings have values and adapters don't need to handle undefined values - */ -export interface InternalLLMChatRequest extends Omit { - settings: Required; -} - -/** - * Interface that all LLM client adapters must implement - * - * Client adapters handle the provider-specific logic for: - * - Formatting requests according to provider API requirements - * - Making HTTP calls to provider endpoints - * - Parsing responses into standardized format - * - Handling provider-specific errors - * - Managing provider-specific authentication - */ -export interface ILLMClientAdapter { - /** - * Sends a chat message to the LLM provider - * - * @param request - The LLM request with applied default settings - * @param apiKey - The decrypted API key for the provider - * @returns Promise resolving to either a successful response or failure response - * - * @throws Should not throw - all errors should be caught and returned as LLMFailureResponse - */ - sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise; - - /** - * Optional method to validate API key format before making requests - * - * @param apiKey - The API key to validate - * @returns True if the key format appears valid for this provider - */ - validateApiKey?(apiKey: string): boolean; - - /** - * Optional method to get provider-specific information - * - * @returns Information about this adapter's capabilities or configuration - */ - getAdapterInfo?(): { - providerId: string; - name: string; - version?: string; - }; -} - -/** - * Base error codes that adapters should use for consistency - */ -export const ADAPTER_ERROR_CODES = { - INVALID_API_KEY: 'INVALID_API_KEY', - RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', - INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS', - MODEL_NOT_FOUND: 'MODEL_NOT_FOUND', - CONTEXT_LENGTH_EXCEEDED: 'CONTEXT_LENGTH_EXCEEDED', - CONTENT_FILTER: 'CONTENT_FILTER', - NETWORK_ERROR: 'NETWORK_ERROR', - PROVIDER_ERROR: 'PROVIDER_ERROR', - UNKNOWN_ERROR: 'UNKNOWN_ERROR' -} as const; - -/** - * Helper type for adapter error codes - */ -export type AdapterErrorCode = typeof ADAPTER_ERROR_CODES[keyof typeof ADAPTER_ERROR_CODES]; diff --git a/electron/modules/llm/main/config.ts b/electron/modules/llm/main/config.ts deleted file mode 100644 index e38f333..0000000 --- a/electron/modules/llm/main/config.ts +++ /dev/null @@ -1,588 +0,0 @@ -// AI Summary: Configuration for LLM module including default settings, supported providers, and models. -// Defines operational parameters and available LLM options for the application. - -import type { - LLMSettings, - ProviderInfo, - ModelInfo, - ApiProviderId, - GeminiSafetySetting, - GeminiHarmCategory, - GeminiHarmBlockThreshold, -} from '../common/types'; -import type { ILLMClientAdapter } from './clients/types'; -import { OpenAIClientAdapter } from './clients/OpenAIClientAdapter'; -import { AnthropicClientAdapter } from './clients/AnthropicClientAdapter'; -import { GeminiClientAdapter } from './clients/GeminiClientAdapter'; -// Placeholder for future imports: -// import { MistralClientAdapter } from './clients/MistralClientAdapter'; - -/** - * Mapping from provider IDs to their corresponding adapter constructor classes - * This enables dynamic registration of client adapters in LLMServiceMain - */ -export const ADAPTER_CONSTRUCTORS: Partial< - Record< - ApiProviderId, - new (config?: { baseURL?: string }) => ILLMClientAdapter - > -> = { - openai: OpenAIClientAdapter, - anthropic: AnthropicClientAdapter, - gemini: GeminiClientAdapter, - // 'mistral': MistralClientAdapter, // Uncomment and add when Mistral adapter is ready -}; - -/** - * Optional configuration objects for each adapter - * Allows passing parameters like baseURL during instantiation - */ -export const ADAPTER_CONFIGS: Partial< - Record -> = { - openai: { - baseURL: process.env.OPENAI_API_BASE_URL || undefined, - }, - anthropic: { - baseURL: process.env.ANTHROPIC_API_BASE_URL || undefined, - }, - // 'gemini': { /* ... Gemini specific config ... */ }, - // 'mistral': { /* ... Mistral specific config ... */ }, -}; - -/** - * Default settings applied to all LLM requests unless overridden - */ -export const DEFAULT_LLM_SETTINGS: Required = { - temperature: 0.5, - maxTokens: 4096, - topP: 0.95, - stopSequences: [], - frequencyPenalty: 0.0, - presencePenalty: 0.0, - supportsSystemMessage: true, - user: undefined as any, // Will be filtered out when undefined - geminiSafetySettings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, - ], -}; - -/** - * Per-provider default setting overrides - */ -export const PROVIDER_DEFAULT_SETTINGS: Partial< - Record> -> = { - openai: {}, - anthropic: {}, - gemini: { - geminiSafetySettings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' }, - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, - ], - }, - mistral: {}, -}; - -/** - * Per-model default setting overrides (takes precedence over provider defaults) - */ -export const MODEL_DEFAULT_SETTINGS: Record> = { - // OpenAI model-specific overrides - 'o4-mini': { temperature: 1.0 }, - // Anthropic model-specific overrides - // Gemini model-specific overrides - // Mistral model-specific overrides -}; - -/** - * Supported LLM providers - */ -export const SUPPORTED_PROVIDERS: ProviderInfo[] = [ - { - id: 'openai', - name: 'OpenAI', - unsupportedParameters: ['frequencyPenalty'], - }, - { - id: 'anthropic', - name: 'Anthropic', - }, - { - id: 'gemini', - name: 'Google Gemini', - }, - { - id: 'mistral', - name: 'Mistral AI', - }, -]; - -/** - * Supported LLM models with their configurations - * ModelInfo is similar to Cline model info - * See: https://github.com/cline/cline/blob/main/src/shared/api.ts - */ -export const SUPPORTED_MODELS: ModelInfo[] = [ - // Anthropic Models - { - id: 'claude-sonnet-4-20250514', - name: 'Claude Sonnet 4', - providerId: 'anthropic', - contextWindow: 200000, - inputPrice: 3.0, - outputPrice: 15.0, - description: 'Latest Claude Sonnet model with enhanced capabilities', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: true, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - }, - { - id: 'claude-opus-4-20250514', - name: 'Claude Opus 4', - providerId: 'anthropic', - contextWindow: 200000, - inputPrice: 15.0, - outputPrice: 75.0, - description: 'Most powerful Claude model for highly complex tasks', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: true, - cacheWritesPrice: 18.75, - cacheReadsPrice: 1.5, - }, - { - id: 'claude-3-7-sonnet-20250219', - name: 'Claude 3.7 Sonnet', - providerId: 'anthropic', - contextWindow: 200000, - inputPrice: 3.0, - outputPrice: 15.0, - description: 'Advanced Claude model with improved reasoning', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: true, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - }, - { - id: 'claude-3-5-sonnet-20241022', - name: 'Claude 3.5 Sonnet', - providerId: 'anthropic', - contextWindow: 200000, - inputPrice: 3.0, - outputPrice: 15.0, - description: 'Best balance of intelligence, speed, and cost', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: true, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - }, - { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - providerId: 'anthropic', - contextWindow: 200000, - inputPrice: 0.8, - outputPrice: 4.0, - description: 'Fastest and most cost-effective Claude model', - maxTokens: 8192, - supportsImages: false, - supportsPromptCache: true, - cacheWritesPrice: 1.0, - cacheReadsPrice: 0.08, - }, - - // Google Gemini Models - { - id: 'gemini-2.5-pro', - name: 'Gemini 2.5 Pro', - providerId: 'gemini', - contextWindow: 1048576, - inputPrice: 1.25, - outputPrice: 10, - description: - 'Most advanced Gemini model for complex reasoning and multimodal tasks', - maxTokens: 65536, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.31, - }, - { - id: 'gemini-2.5-flash', - name: 'Gemini 2.5 Flash', - providerId: 'gemini', - contextWindow: 1048576, - inputPrice: 0.3, - outputPrice: 2.5, - description: - 'Fast, efficient model with large context and reasoning capabilities', - maxTokens: 65536, - supportsImages: true, - supportsPromptCache: true, - thinkingConfig: { - maxBudget: 24576, - outputPrice: 2.5, - }, - }, - { - id: 'gemini-2.5-flash-lite-preview-06-17', - name: 'Gemini 2.5 Flash-Lite Preview', - providerId: 'gemini', - contextWindow: 1000000, - inputPrice: 0.1, - outputPrice: 0.4, - description: - 'Smallest and most cost effective model, built for at scale usage', - maxTokens: 64000, - supportsImages: true, - supportsPromptCache: true, - }, - { - id: 'gemini-2.0-flash', - name: 'Gemini 2.0 Flash', - providerId: 'gemini', - contextWindow: 1048576, - inputPrice: 0.1, - outputPrice: 0.4, - description: 'High-performance model with multimodal capabilities', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.025, - cacheWritesPrice: 1.0, - }, - { - id: 'gemini-2.0-flash-lite', - name: 'Gemini 2.0 Flash Lite', - providerId: 'gemini', - contextWindow: 1048576, - inputPrice: 0.075, - outputPrice: 0.3, - description: 'Lightweight version of Gemini 2.0 Flash', - maxTokens: 8192, - supportsImages: true, - supportsPromptCache: false, - }, - - // OpenAI Models - { - id: 'o4-mini', - name: 'o4-mini', - providerId: 'openai', - contextWindow: 200000, - inputPrice: 1.1, - outputPrice: 4.4, - description: 'Advanced reasoning model with high token capacity', - maxTokens: 100000, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.275, - unsupportedParameters: ['topP'], - }, - { - id: 'gpt-4.1', - name: 'GPT-4.1', - providerId: 'openai', - contextWindow: 1047576, - inputPrice: 2, - outputPrice: 8, - description: 'Latest GPT-4 model with enhanced capabilities', - maxTokens: 32768, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.5, - }, - { - id: 'gpt-4.1-mini', - name: 'GPT-4.1 Mini', - providerId: 'openai', - contextWindow: 1047576, - inputPrice: 0.4, - outputPrice: 1.6, - description: 'Smaller version of GPT-4.1 for cost-effective tasks', - maxTokens: 32768, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.1, - }, - { - id: 'gpt-4.1-nano', - name: 'GPT-4.1 Nano', - providerId: 'openai', - contextWindow: 1047576, - inputPrice: 0.1, - outputPrice: 0.4, - description: 'Ultra-efficient version of GPT-4.1', - maxTokens: 32768, - supportsImages: true, - supportsPromptCache: true, - cacheReadsPrice: 0.025, - }, - - // Mistral AI Models - { - id: 'codestral-2501', - name: 'Codestral', - providerId: 'mistral', - contextWindow: 256000, - inputPrice: 0.3, - outputPrice: 0.9, - description: 'Specialized model for code generation and programming tasks', - maxTokens: 256000, - supportsImages: false, - supportsPromptCache: false, - }, - { - id: 'devstral-small-2505', - name: 'Devstral Small', - providerId: 'mistral', - contextWindow: 131072, - inputPrice: 0.1, - outputPrice: 0.3, - description: 'Compact development-focused model', - maxTokens: 128000, - supportsImages: false, - supportsPromptCache: false, - }, -]; - -/** - * Gets provider information by ID - * - * @param providerId - The provider ID to look up - * @returns The provider info or undefined if not found - */ -export function getProviderById(providerId: string): ProviderInfo | undefined { - return SUPPORTED_PROVIDERS.find((provider) => provider.id === providerId); -} - -/** - * Gets model information by ID and provider - * - * @param modelId - The model ID to look up - * @param providerId - The provider ID to filter by - * @returns The model info or undefined if not found - */ -export function getModelById( - modelId: string, - providerId?: string -): ModelInfo | undefined { - return SUPPORTED_MODELS.find( - (model) => - model.id === modelId && (!providerId || model.providerId === providerId) - ); -} - -/** - * Gets all models for a specific provider - * - * @param providerId - The provider ID to filter by - * @returns Array of model info for the provider - */ -export function getModelsByProvider(providerId: string): ModelInfo[] { - return SUPPORTED_MODELS.filter((model) => model.providerId === providerId); -} - -/** - * Validates if a provider is supported - * - * @param providerId - The provider ID to validate - * @returns True if the provider is supported - */ -export function isProviderSupported(providerId: string): boolean { - return SUPPORTED_PROVIDERS.some((provider) => provider.id === providerId); -} - -/** - * Validates if a model is supported for a given provider - * - * @param modelId - The model ID to validate - * @param providerId - The provider ID to validate against - * @returns True if the model is supported for the provider - */ -export function isModelSupported(modelId: string, providerId: string): boolean { - return SUPPORTED_MODELS.some( - (model) => model.id === modelId && model.providerId === providerId - ); -} - -/** - * Gets merged default settings for a specific model and provider - * - * @param modelId - The model ID - * @param providerId - The provider ID - * @returns Merged default settings with model-specific overrides applied - */ -export function getDefaultSettingsForModel( - modelId: string, - providerId: ApiProviderId -): Required { - // Base settings: global defaults, then provider-specific, then model-specific overrides - const baseDefaults = { ...DEFAULT_LLM_SETTINGS }; - const providerDefaults = PROVIDER_DEFAULT_SETTINGS[providerId] || {}; - const modelDefaults = MODEL_DEFAULT_SETTINGS[modelId] || {}; - - // Merge settings in order of precedence - const mergedSettings = { - ...baseDefaults, - ...providerDefaults, - ...modelDefaults, - }; - - // Override maxTokens from ModelInfo if available - const modelInfo = getModelById(modelId, providerId); - if (modelInfo && modelInfo.maxTokens !== undefined) { - mergedSettings.maxTokens = modelInfo.maxTokens; - } - - // Filter out undefined values and ensure required fields - return Object.fromEntries( - Object.entries(mergedSettings).filter(([_, value]) => value !== undefined) - ) as Required; -} - -/** - * Valid Gemini harm categories for validation - * Only includes categories supported by the API for safety setting rules - */ -const VALID_GEMINI_HARM_CATEGORIES: GeminiHarmCategory[] = [ - 'HARM_CATEGORY_HATE_SPEECH', - 'HARM_CATEGORY_SEXUALLY_EXPLICIT', - 'HARM_CATEGORY_DANGEROUS_CONTENT', - 'HARM_CATEGORY_HARASSMENT', - 'HARM_CATEGORY_CIVIC_INTEGRITY', -]; - -/** - * Valid Gemini harm block thresholds for validation - */ -const VALID_GEMINI_HARM_BLOCK_THRESHOLDS: GeminiHarmBlockThreshold[] = [ - 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', - 'BLOCK_LOW_AND_ABOVE', - 'BLOCK_MEDIUM_AND_ABOVE', - 'BLOCK_ONLY_HIGH', - 'BLOCK_NONE', -]; - -/** - * Validates LLM settings values - * - * @param settings - The settings to validate - * @returns Array of validation error messages, empty if valid - */ -export function validateLLMSettings(settings: Partial): string[] { - const errors: string[] = []; - - if (settings.temperature !== undefined) { - if ( - typeof settings.temperature !== 'number' || - settings.temperature < 0 || - settings.temperature > 2 - ) { - errors.push('temperature must be a number between 0 and 2'); - } - } - - if (settings.maxTokens !== undefined) { - if ( - !Number.isInteger(settings.maxTokens) || - settings.maxTokens < 1 || - settings.maxTokens > 100000 - ) { - errors.push('maxTokens must be an integer between 1 and 100000'); - } - } - - if (settings.topP !== undefined) { - if ( - typeof settings.topP !== 'number' || - settings.topP < 0 || - settings.topP > 1 - ) { - errors.push('topP must be a number between 0 and 1'); - } - } - - if (settings.frequencyPenalty !== undefined) { - if ( - typeof settings.frequencyPenalty !== 'number' || - settings.frequencyPenalty < -2 || - settings.frequencyPenalty > 2 - ) { - errors.push('frequencyPenalty must be a number between -2 and 2'); - } - } - - if (settings.presencePenalty !== undefined) { - if ( - typeof settings.presencePenalty !== 'number' || - settings.presencePenalty < -2 || - settings.presencePenalty > 2 - ) { - errors.push('presencePenalty must be a number between -2 and 2'); - } - } - - if (settings.stopSequences !== undefined) { - if (!Array.isArray(settings.stopSequences)) { - errors.push('stopSequences must be an array'); - } else if (settings.stopSequences.length > 4) { - errors.push('stopSequences can contain at most 4 sequences'); - } else if ( - settings.stopSequences.some( - (seq) => typeof seq !== 'string' || seq.length === 0 - ) - ) { - errors.push('stopSequences must contain only non-empty strings'); - } - } - - if (settings.user !== undefined && typeof settings.user !== 'string') { - errors.push('user must be a string'); - } - - if (settings.geminiSafetySettings !== undefined) { - if (!Array.isArray(settings.geminiSafetySettings)) { - errors.push('geminiSafetySettings must be an array'); - } else { - for (let i = 0; i < settings.geminiSafetySettings.length; i++) { - const setting = settings.geminiSafetySettings[i]; - if (!setting || typeof setting !== 'object') { - errors.push( - `geminiSafetySettings[${i}] must be an object with category and threshold` - ); - continue; - } - - if ( - !setting.category || - !VALID_GEMINI_HARM_CATEGORIES.includes(setting.category) - ) { - errors.push( - `geminiSafetySettings[${i}].category must be a valid Gemini harm category` - ); - } - - if ( - !setting.threshold || - !VALID_GEMINI_HARM_BLOCK_THRESHOLDS.includes(setting.threshold) - ) { - errors.push( - `geminiSafetySettings[${i}].threshold must be a valid Gemini harm block threshold` - ); - } - } - } - } - - return errors; -} diff --git a/electron/modules/llm/renderer/LLMServiceRenderer.ts b/electron/modules/llm/renderer/LLMServiceRenderer.ts deleted file mode 100644 index 41ecd20..0000000 --- a/electron/modules/llm/renderer/LLMServiceRenderer.ts +++ /dev/null @@ -1,94 +0,0 @@ -// AI Summary: Renderer process service for LLM operations, providing typed IPC communication with main process. -// Exposes LLM functionality to UI components through standardized async methods. - -import { ipcRenderer } from 'electron'; -import type { - LLMChatRequest, - LLMResponse, - LLMFailureResponse, - ProviderInfo, - ModelInfo, - ApiProviderId -} from '../common/types'; -import { LLM_IPC_CHANNELS } from '../common/types'; - -/** - * Renderer process service for LLM operations - * - * This service provides a typed interface for UI components to: - * - Get available LLM providers and models - * - Send chat messages to LLM providers - * - Handle responses and errors in a standardized way - * - * All operations are performed via IPC communication with the main process. - */ -export class LLMServiceRenderer { - /** - * Gets list of supported LLM providers - * - * @returns Promise resolving to array of provider information - */ - async getProviders(): Promise { - try { - return await ipcRenderer.invoke(LLM_IPC_CHANNELS.GET_PROVIDERS); - } catch (error) { - console.error('Error getting LLM providers:', error); - return []; - } - } - - /** - * Gets list of supported models for a specific provider - * - * @param providerId - The provider ID to get models for - * @returns Promise resolving to array of model information - */ - async getModels(providerId: ApiProviderId): Promise { - try { - return await ipcRenderer.invoke(LLM_IPC_CHANNELS.GET_MODELS, providerId); - } catch (error) { - console.error(`Error getting models for provider ${providerId}:`, error); - return []; - } - } - - /** - * Sends a chat message to an LLM provider - * - * @param request - The LLM chat request - * @returns Promise resolving to either success or failure response - */ - async sendMessage(request: LLMChatRequest): Promise { - try { - return await ipcRenderer.invoke(LLM_IPC_CHANNELS.SEND_MESSAGE, request); - } catch (error) { - console.error('Error sending LLM message:', error); - - // Return standardized error response - return { - provider: request.providerId, - model: request.modelId, - error: { - message: error instanceof Error ? error.message : 'IPC communication error', - code: 'IPC_ERROR', - type: 'communication_error', - providerError: error - }, - object: 'error' - }; - } - } - - /** - * Checks if an API key is available from any source (secure storage or ENV). - * - * @param providerId - The provider ID to check for - * @returns Promise resolving to true if a key is available, false otherwise - */ - async isKeyAvailable(providerId: ApiProviderId): Promise { - return ipcRenderer.invoke(LLM_IPC_CHANNELS.IS_KEY_AVAILABLE, providerId); - } -} - -// Export singleton instance for use in preload -export const llmServiceRenderer = new LLMServiceRenderer(); diff --git a/electron/preload.ts b/electron/preload.ts index 3af1523..131c4de 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -72,8 +72,8 @@ contextBridge.exposeInMainWorld('settingsService', { ipcRenderer.invoke('settings:save-application', settings), }); -// Import LLM service renderer -import { llmServiceRenderer } from './modules/llm/renderer/LLMServiceRenderer'; +// Import LLM IPC channels +import { LLM_IPC_CHANNELS } from '../common/types/llm'; // Expose secure API key management and LLM service contextBridge.exposeInMainWorld('electronBridge', { @@ -82,10 +82,10 @@ contextBridge.exposeInMainWorld('electronBridge', { // application's in-memory state remains synchronized with the file on disk. secureApiKeyManager: createApiKeyManagerBridge(), llmService: { - getProviders: () => llmServiceRenderer.getProviders(), - getModels: (providerId: string) => llmServiceRenderer.getModels(providerId as any), - sendMessage: (request: any) => llmServiceRenderer.sendMessage(request), - isKeyAvailable: (providerId: string) => llmServiceRenderer.isKeyAvailable(providerId as any), + getProviders: () => ipcRenderer.invoke(LLM_IPC_CHANNELS.GET_PROVIDERS), + getModels: (providerId: string) => ipcRenderer.invoke(LLM_IPC_CHANNELS.GET_MODELS, providerId), + sendMessage: (request: any) => ipcRenderer.invoke(LLM_IPC_CHANNELS.SEND_MESSAGE, request), + isKeyAvailable: (providerId: string) => ipcRenderer.invoke(LLM_IPC_CHANNELS.IS_KEY_AVAILABLE, providerId), }, userActivity: () => ipcRenderer.send('user-activity'), context: { diff --git a/package-lock.json b/package-lock.json index 678f850..c0855e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "athanor", - "version": "0.7.8", + "version": "0.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "athanor", - "version": "0.7.8", + "version": "0.7.9", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.52.0", @@ -20,6 +20,7 @@ "diff-match-patch": "^1.0.5", "fix-path": "^4.0.0", "genai-key-storage-lite": "^0.1.4", + "genai-lite": "^0.1.0", "ignore": "^7.0.0", "js-tiktoken": "^1.0.16", "lucide-react": "^0.469.0", @@ -9461,6 +9462,21 @@ "electron": ">=25.0.0" } }, + "node_modules/genai-lite": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/genai-lite/-/genai-lite-0.1.0.tgz", + "integrity": "sha512-o8axryP9nIA+UIJYT7QD4wW1sFEAudFpTjQIKc+xpH63CRA4A7sc8Yuivy/vDkYbpZa4f7D25zIvZCd6VrqWRw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "@google/genai": "^1.0.1", + "openai": "^4.103.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/lacerbi" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", diff --git a/package.json b/package.json index b94f93b..415f2b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "athanor", - "version": "0.7.9", + "version": "0.7.10", "bugs": { "url": "https://github.com/lacerbi/athanor/issues" }, @@ -107,6 +107,7 @@ "diff-match-patch": "^1.0.5", "fix-path": "^4.0.0", "genai-key-storage-lite": "^0.1.4", + "genai-lite": "^0.1.0", "ignore": "^7.0.0", "js-tiktoken": "^1.0.16", "lucide-react": "^0.469.0", diff --git a/src/types/athanorPresets.ts b/src/types/athanorPresets.ts index eca16c0..8b6ba0e 100644 --- a/src/types/athanorPresets.ts +++ b/src/types/athanorPresets.ts @@ -1,5 +1,5 @@ // AI Summary: Defines TypeScript interface for Athanor model presets, combining provider/model references with custom LLM settings. -import type { ApiProviderId, LLMSettings } from '../../electron/modules/llm/common/types'; +import type { ApiProviderId, LLMSettings } from 'genai-lite'; /** * Represents an Athanor-specific model preset with pre-configured LLM settings diff --git a/webpack.main.config.js b/webpack.main.config.js index 96fe4e9..c83f16c 100644 --- a/webpack.main.config.js +++ b/webpack.main.config.js @@ -14,6 +14,7 @@ module.exports = { include: [ path.resolve(__dirname, 'electron'), path.resolve(__dirname, 'electron/handlers'), + path.resolve(__dirname, 'common'), ], use: [{ loader: 'ts-loader' }], }, diff --git a/webpack.preload.config.js b/webpack.preload.config.js index 3a3be5a..42e6832 100644 --- a/webpack.preload.config.js +++ b/webpack.preload.config.js @@ -6,7 +6,10 @@ module.exports = { rules: [ { test: /\.ts$/, - include: [path.resolve(__dirname, 'electron')], + include: [ + path.resolve(__dirname, 'electron'), + path.resolve(__dirname, 'common'), + ], use: [{ loader: 'ts-loader' }], }, ],