""" LLM Provider Factory and Configuration Management. [AC-ASA-14, AC-ASA-15, AC-ASA-16, AC-ASA-17, AC-ASA-18] LLM provider management. Design pattern: Factory pattern for pluggable LLM providers. """ import json import logging from dataclasses import dataclass from pathlib import Path from typing import Any from app.services.llm.base import LLMClient, LLMConfig from app.services.llm.openai_client import OpenAIClient logger = logging.getLogger(__name__) LLM_CONFIG_FILE = Path("config/llm_config.json") @dataclass class LLMProviderInfo: """Information about an LLM provider.""" name: str display_name: str description: str config_schema: dict[str, Any] LLM_PROVIDERS: dict[str, LLMProviderInfo] = { "openai": LLMProviderInfo( name="openai", display_name="OpenAI", description="OpenAI GPT 系列模型 (GPT-4, GPT-3.5 等)", config_schema={ "type": "object", "properties": { "api_key": { "type": "string", "title": "API Key", "description": "API Key", "required": True, }, "base_url": { "type": "string", "title": "API Base URL", "description": "API Base URL", "default": "https://api.openai.com/v1", }, "model": { "type": "string", "title": "模型名称", "description": "模型名称", "default": "gpt-4o-mini", }, "max_tokens": { "type": "integer", "title": "最大输出 Token 数", "description": "最大输出 Token 数", "default": 2048, }, "temperature": { "type": "number", "title": "温度参数", "description": "温度参数 (0-2)", "default": 0.7, "minimum": 0, "maximum": 2, }, "timeout_seconds": { "type": "integer", "title": "请求超时(秒)", "description": "LLM 请求超时时间(秒)", "default": 60, "minimum": 5, "maximum": 180, }, "max_retries": { "type": "integer", "title": "最大重试次数", "description": "请求失败后的最大重试次数", "default": 3, "minimum": 0, "maximum": 10, }, }, "required": ["api_key"], }, ), "ollama": LLMProviderInfo( name="ollama", display_name="Ollama", description="Ollama 本地模型 (Llama, Qwen 等)", config_schema={ "type": "object", "properties": { "base_url": { "type": "string", "title": "Ollama API 地址", "description": "Ollama API 地址", "default": "http://localhost:11434/v1", }, "model": { "type": "string", "title": "模型名称", "description": "模型名称", "default": "llama3.2", }, "max_tokens": { "type": "integer", "title": "最大输出 Token 数", "description": "最大输出 Token 数", "default": 2048, }, "temperature": { "type": "number", "title": "温度参数", "description": "温度参数 (0-2)", "default": 0.7, "minimum": 0, "maximum": 2, }, }, "required": [], }, ), "deepseek": LLMProviderInfo( name="deepseek", display_name="DeepSeek", description="DeepSeek 大模型 (deepseek-chat, deepseek-coder)", config_schema={ "type": "object", "properties": { "api_key": { "type": "string", "title": "API Key", "description": "DeepSeek API Key", "required": True, }, "base_url": { "type": "string", "title": "API Base URL", "description": "API Base URL", "default": "https://api.deepseek.com/v1", }, "model": { "type": "string", "title": "模型名称", "description": "模型名称 (deepseek-chat, deepseek-coder)", "default": "deepseek-chat", }, "max_tokens": { "type": "integer", "title": "最大输出 Token 数", "description": "最大输出 Token 数", "default": 2048, }, "temperature": { "type": "number", "title": "温度参数", "description": "温度参数 (0-2)", "default": 0.7, "minimum": 0, "maximum": 2, }, }, "required": ["api_key"], }, ), "azure": LLMProviderInfo( name="azure", display_name="Azure OpenAI", description="Azure OpenAI 服务", config_schema={ "type": "object", "properties": { "api_key": { "type": "string", "title": "API Key", "description": "API Key", "required": True, }, "base_url": { "type": "string", "title": "Azure Endpoint", "description": "Azure Endpoint", "required": True, }, "model": { "type": "string", "title": "部署名称", "description": "部署名称", "required": True, }, "api_version": { "type": "string", "title": "API 版本", "description": "API 版本", "default": "2024-02-15-preview", }, "max_tokens": { "type": "integer", "title": "最大输出 Token 数", "description": "最大输出 Token 数", "default": 2048, }, "temperature": { "type": "number", "title": "温度参数", "description": "温度参数 (0-2)", "default": 0.7, "minimum": 0, "maximum": 2, }, }, "required": ["api_key", "base_url", "model"], }, ), } class LLMProviderFactory: """ Factory for creating LLM clients. [AC-ASA-14, AC-ASA-15] Dynamic provider creation. """ @classmethod def get_providers(cls) -> list[LLMProviderInfo]: """Get all registered LLM providers.""" return list(LLM_PROVIDERS.values()) @classmethod def get_provider_info(cls, name: str) -> LLMProviderInfo | None: """Get provider info by name.""" return LLM_PROVIDERS.get(name) @classmethod def create_client( cls, provider: str, config: dict[str, Any], ) -> LLMClient: """ Create an LLM client for the specified provider. [AC-ASA-15] Factory method for client creation. Args: provider: Provider name (openai, ollama, azure) config: Provider configuration Returns: LLMClient instance Raises: ValueError: If provider is not supported """ if provider not in LLM_PROVIDERS: raise ValueError(f"Unsupported LLM provider: {provider}") if provider in ("openai", "ollama", "azure", "deepseek"): return OpenAIClient( api_key=config.get("api_key"), base_url=config.get("base_url"), model=config.get("model"), default_config=LLMConfig( model=config.get("model", "gpt-4o-mini"), max_tokens=config.get("max_tokens", 2048), temperature=config.get("temperature", 0.7), timeout_seconds=config.get("timeout_seconds", 60), max_retries=config.get("max_retries", 3), ), ) raise ValueError(f"Unsupported LLM provider: {provider}") class LLMConfigManager: """ Manager for LLM configuration. [AC-ASA-16, AC-ASA-17, AC-ASA-18] Configuration management with hot-reload and persistence. """ def __init__(self): from app.core.config import get_settings settings = get_settings() self._current_provider: str = settings.llm_provider self._current_config: dict[str, Any] = { "api_key": settings.llm_api_key, "base_url": settings.llm_base_url, "model": settings.llm_model, "max_tokens": settings.llm_max_tokens, "temperature": settings.llm_temperature, "timeout_seconds": settings.llm_timeout_seconds, "max_retries": settings.llm_max_retries, } self._client: LLMClient | None = None self._load_from_file() def _load_from_file(self) -> None: """Load configuration from file if exists.""" try: if LLM_CONFIG_FILE.exists(): with open(LLM_CONFIG_FILE, encoding='utf-8') as f: saved = json.load(f) self._current_provider = saved.get("provider", self._current_provider) saved_config = saved.get("config", {}) if saved_config: self._current_config.update(saved_config) logger.info(f"[AC-ASA-16] Loaded LLM config from file: provider={self._current_provider}") except Exception as e: logger.warning(f"[AC-ASA-16] Failed to load LLM config from file: {e}") def _save_to_file(self) -> None: """Save configuration to file.""" try: LLM_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LLM_CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump({ "provider": self._current_provider, "config": self._current_config, }, f, indent=2, ensure_ascii=False) logger.info(f"[AC-ASA-16] Saved LLM config to file: provider={self._current_provider}") except Exception as e: logger.error(f"[AC-ASA-16] Failed to save LLM config to file: {e}") def get_current_config(self) -> dict[str, Any]: """Get current LLM configuration.""" return { "provider": self._current_provider, "config": self._current_config.copy(), } async def update_config( self, provider: str, config: dict[str, Any], ) -> bool: """ Update LLM configuration. [AC-ASA-16] Hot-reload configuration with persistence. Args: provider: Provider name config: New configuration Returns: True if update successful """ if provider not in LLM_PROVIDERS: raise ValueError(f"Unsupported LLM provider: {provider}") provider_info = LLM_PROVIDERS[provider] validated_config = self._validate_config(provider_info, config) if self._client: await self._client.close() self._client = None self._current_provider = provider self._current_config = validated_config self._save_to_file() logger.info(f"[AC-ASA-16] LLM config updated: provider={provider}") return True def _validate_config( self, provider_info: LLMProviderInfo, config: dict[str, Any], ) -> dict[str, Any]: """Validate configuration against provider schema.""" schema_props = provider_info.config_schema.get("properties", {}) required_fields = provider_info.config_schema.get("required", []) validated = {} for key, prop_schema in schema_props.items(): if key in config: validated[key] = config[key] elif "default" in prop_schema: validated[key] = prop_schema["default"] elif key in required_fields: raise ValueError(f"Missing required config: {key}") return validated def get_client(self) -> LLMClient: """Get or create LLM client with current config.""" if self._client is None: self._client = LLMProviderFactory.create_client( self._current_provider, self._current_config, ) return self._client async def test_connection( self, test_prompt: str = "你好,请简单介绍一下自己。", provider: str | None = None, config: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Test LLM connection. [AC-ASA-17, AC-ASA-18] Connection testing. Args: test_prompt: Test prompt to send provider: Optional provider to test (uses current if not specified) config: Optional config to test (uses current if not specified) Returns: Test result with success status, response, and metrics """ import time test_provider = provider or self._current_provider test_config = config if config else self._current_config logger.info(f"[AC-ASA-17] Test connection: provider={test_provider}, model={test_config.get('model')}") if test_provider not in LLM_PROVIDERS: return { "success": False, "error": f"Unsupported provider: {test_provider}", } try: client = LLMProviderFactory.create_client(test_provider, test_config) start_time = time.time() response = await client.generate( messages=[{"role": "user", "content": test_prompt}], ) latency_ms = (time.time() - start_time) * 1000 await client.close() return { "success": True, "response": response.content, "latency_ms": round(latency_ms, 2), "prompt_tokens": response.usage.get("prompt_tokens", 0), "completion_tokens": response.usage.get("completion_tokens", 0), "total_tokens": response.usage.get("total_tokens", 0), "model": response.model, "message": f"连接成功,模型: {response.model}", } except Exception as e: logger.error(f"[AC-ASA-18] LLM test failed: {e}") return { "success": False, "error": str(e), "message": f"连接失败: {str(e)}", } async def close(self) -> None: """Close the current client.""" if self._client: await self._client.close() self._client = None _llm_config_manager: LLMConfigManager | None = None def get_llm_config_manager() -> LLMConfigManager: """Get or create LLM config manager instance.""" global _llm_config_manager if _llm_config_manager is None: _llm_config_manager = LLMConfigManager() return _llm_config_manager