From 22c528acf12669e184c7f68e8b2cf1c1e9d69622 Mon Sep 17 00:00:00 2001 From: lintsinghua Date: Fri, 28 Nov 2025 16:41:39 +0800 Subject: [PATCH] refactor(llm): consolidate LLM adapters with LiteLLM unified layer - Replace individual adapter implementations (OpenAI, Claude, Gemini, DeepSeek, Qwen, Zhipu, Moonshot, Ollama) with unified LiteLLM adapter - Keep native adapters for providers with special API formats (Baidu, MiniMax, Doubao) - Update LLM factory to route requests through LiteLLM for supported providers - Add test-llm endpoint to validate LLM connections with configurable timeout and token limits - Add get-llm-providers endpoint to retrieve supported providers and their configurations - Update config.py to ignore extra environment variables (VITE_* frontend variables) - Refactor Baidu adapter to use new complete() method signature and improve error handling - Update pyproject.toml dependencies to include litellm package - Update env.example with new configuration options - Simplify adapter initialization and reduce code duplication across multiple provider implementations --- backend/app/api/v1/endpoints/config.py | 106 ++ backend/app/core/config.py | 1 + backend/app/services/llm/adapters/__init__.py | 32 +- .../services/llm/adapters/baidu_adapter.py | 96 +- .../services/llm/adapters/claude_adapter.py | 94 -- .../services/llm/adapters/deepseek_adapter.py | 82 -- .../services/llm/adapters/doubao_adapter.py | 98 +- .../services/llm/adapters/gemini_adapter.py | 114 --- .../services/llm/adapters/litellm_adapter.py | 182 ++++ .../services/llm/adapters/minimax_adapter.py | 104 +- .../services/llm/adapters/moonshot_adapter.py | 80 -- .../services/llm/adapters/ollama_adapter.py | 83 -- .../services/llm/adapters/openai_adapter.py | 93 -- .../app/services/llm/adapters/qwen_adapter.py | 80 -- .../services/llm/adapters/zhipu_adapter.py | 80 -- backend/app/services/llm/factory.py | 81 +- backend/env.example | 3 + backend/pyproject.toml | 1 + .../src/components/system/SystemConfig.tsx | 917 ++++++------------ frontend/src/shared/api/database.ts | 28 + 20 files changed, 807 insertions(+), 1548 deletions(-) delete mode 100644 backend/app/services/llm/adapters/claude_adapter.py delete mode 100644 backend/app/services/llm/adapters/deepseek_adapter.py delete mode 100644 backend/app/services/llm/adapters/gemini_adapter.py create mode 100644 backend/app/services/llm/adapters/litellm_adapter.py delete mode 100644 backend/app/services/llm/adapters/moonshot_adapter.py delete mode 100644 backend/app/services/llm/adapters/ollama_adapter.py delete mode 100644 backend/app/services/llm/adapters/openai_adapter.py delete mode 100644 backend/app/services/llm/adapters/qwen_adapter.py delete mode 100644 backend/app/services/llm/adapters/zhipu_adapter.py diff --git a/backend/app/api/v1/endpoints/config.py b/backend/app/api/v1/endpoints/config.py index a599255..844bca1 100644 --- a/backend/app/api/v1/endpoints/config.py +++ b/backend/app/api/v1/endpoints/config.py @@ -216,3 +216,109 @@ async def delete_my_config( return {"message": "配置已删除"} + +class LLMTestRequest(BaseModel): + """LLM测试请求""" + provider: str + apiKey: str + model: Optional[str] = None + baseUrl: Optional[str] = None + + +class LLMTestResponse(BaseModel): + """LLM测试响应""" + success: bool + message: str + model: Optional[str] = None + response: Optional[str] = None + + +@router.post("/test-llm", response_model=LLMTestResponse) +async def test_llm_connection( + request: LLMTestRequest, + current_user: User = Depends(deps.get_current_user), +) -> Any: + """测试LLM连接是否正常""" + from app.services.llm.factory import LLMFactory + from app.services.llm.types import LLMConfig, LLMProvider, LLMRequest, LLMMessage, DEFAULT_MODELS + + try: + # 解析provider + provider_map = { + 'gemini': LLMProvider.GEMINI, + 'openai': LLMProvider.OPENAI, + 'claude': LLMProvider.CLAUDE, + 'qwen': LLMProvider.QWEN, + 'deepseek': LLMProvider.DEEPSEEK, + 'zhipu': LLMProvider.ZHIPU, + 'moonshot': LLMProvider.MOONSHOT, + 'baidu': LLMProvider.BAIDU, + 'minimax': LLMProvider.MINIMAX, + 'doubao': LLMProvider.DOUBAO, + 'ollama': LLMProvider.OLLAMA, + } + + provider = provider_map.get(request.provider.lower()) + if not provider: + return LLMTestResponse( + success=False, + message=f"不支持的LLM提供商: {request.provider}" + ) + + # 获取默认模型 + model = request.model or DEFAULT_MODELS.get(provider) + + # 创建配置 + config = LLMConfig( + provider=provider, + api_key=request.apiKey, + model=model, + base_url=request.baseUrl, + timeout=30, # 测试使用较短的超时时间 + max_tokens=50, # 测试使用较少的token + ) + + # 创建适配器并测试 + adapter = LLMFactory.create_adapter(config) + + test_request = LLMRequest( + messages=[ + LLMMessage(role="user", content="Say 'Hello' in one word.") + ], + max_tokens=50, + ) + + response = await adapter.complete(test_request) + + return LLMTestResponse( + success=True, + message="LLM连接测试成功", + model=model, + response=response.content[:100] if response.content else None + ) + + except Exception as e: + return LLMTestResponse( + success=False, + message=f"LLM连接测试失败: {str(e)}" + ) + + +@router.get("/llm-providers") +async def get_llm_providers() -> Any: + """获取支持的LLM提供商列表""" + from app.services.llm.factory import LLMFactory + from app.services.llm.types import LLMProvider, DEFAULT_BASE_URLS + + providers = [] + for provider in LLMFactory.get_supported_providers(): + providers.append({ + "id": provider.value, + "name": provider.value.upper(), + "defaultModel": LLMFactory.get_default_model(provider), + "models": LLMFactory.get_available_models(provider), + "defaultBaseUrl": DEFAULT_BASE_URLS.get(provider, ""), + }) + + return {"providers": providers} + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ec3f040..8b7ed66 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -77,6 +77,7 @@ class Settings(BaseSettings): class Config: case_sensitive = True env_file = ".env" + extra = "ignore" # 忽略额外的环境变量(如 VITE_* 前端变量) settings = Settings() diff --git a/backend/app/services/llm/adapters/__init__.py b/backend/app/services/llm/adapters/__init__.py index cddca82..86cda31 100644 --- a/backend/app/services/llm/adapters/__init__.py +++ b/backend/app/services/llm/adapters/__init__.py @@ -1,30 +1,22 @@ """ LLM适配器模块 + +适配器分为两类: +1. LiteLLM 统一适配器 - 支持 OpenAI, Claude, Gemini, DeepSeek, Qwen, Zhipu, Moonshot, Ollama +2. 原生适配器 - 用于 API 格式特殊的提供商: Baidu, MiniMax, Doubao """ -from .openai_adapter import OpenAIAdapter -from .gemini_adapter import GeminiAdapter -from .claude_adapter import ClaudeAdapter -from .deepseek_adapter import DeepSeekAdapter -from .qwen_adapter import QwenAdapter -from .zhipu_adapter import ZhipuAdapter -from .moonshot_adapter import MoonshotAdapter +# LiteLLM 统一适配器 +from .litellm_adapter import LiteLLMAdapter + +# 原生适配器 (用于 API 格式特殊的提供商) from .baidu_adapter import BaiduAdapter from .minimax_adapter import MinimaxAdapter from .doubao_adapter import DoubaoAdapter -from .ollama_adapter import OllamaAdapter __all__ = [ - 'OpenAIAdapter', - 'GeminiAdapter', - 'ClaudeAdapter', - 'DeepSeekAdapter', - 'QwenAdapter', - 'ZhipuAdapter', - 'MoonshotAdapter', - 'BaiduAdapter', - 'MinimaxAdapter', - 'DoubaoAdapter', - 'OllamaAdapter', + "LiteLLMAdapter", + "BaiduAdapter", + "MinimaxAdapter", + "DoubaoAdapter", ] - diff --git a/backend/app/services/llm/adapters/baidu_adapter.py b/backend/app/services/llm/adapters/baidu_adapter.py index 13b6d29..ff447c5 100644 --- a/backend/app/services/llm/adapters/baidu_adapter.py +++ b/backend/app/services/llm/adapters/baidu_adapter.py @@ -3,10 +3,9 @@ """ import httpx -import json from typing import Optional from ..base_adapter import BaseLLMAdapter -from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError +from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError, LLMProvider, LLMUsage class BaiduAdapter(BaseLLMAdapter): @@ -70,8 +69,16 @@ class BaiduAdapter(BaseLLMAdapter): return self._access_token - async def _do_complete(self, request: LLMRequest) -> LLMResponse: + async def complete(self, request: LLMRequest) -> LLMResponse: """执行实际的API调用""" + try: + await self.validate_config() + return await self.retry(lambda: self._send_request(request)) + except Exception as error: + self.handle_error(error, "百度文心一言 API调用失败") + + async def _send_request(self, request: LLMRequest) -> LLMResponse: + """发送请求""" access_token = await self._get_access_token() # 获取模型对应的API端点 @@ -84,55 +91,56 @@ class BaiduAdapter(BaseLLMAdapter): payload = { "messages": messages, - "temperature": request.temperature or self.config.temperature, - "top_p": request.top_p or self.config.top_p, + "temperature": request.temperature if request.temperature is not None else self.config.temperature, + "top_p": request.top_p if request.top_p is not None else self.config.top_p, } if request.max_tokens or self.config.max_tokens: payload["max_output_tokens"] = request.max_tokens or self.config.max_tokens - async with httpx.AsyncClient(timeout=self.config.timeout) as client: - response = await client.post( - url, - json=payload, - headers={"Content-Type": "application/json"} - ) - - if response.status_code != 200: - raise LLMError( - f"百度API错误: {response.text}", - provider="baidu", - status_code=response.status_code - ) - - data = response.json() - - if "error_code" in data: - raise LLMError( - f"百度API错误: {data.get('error_msg', '未知错误')}", - provider="baidu", - status_code=data.get("error_code") - ) - - return LLMResponse( - content=data.get("result", ""), - model=model, - usage=data.get("usage"), - finish_reason=data.get("finish_reason") + response = await self.client.post( + url, + headers=self.build_headers(), + json=payload + ) + + if response.status_code != 200: + error_data = response.json() if response.text else {} + error_msg = error_data.get("error_msg", f"HTTP {response.status_code}") + raise Exception(f"{error_msg}") + + data = response.json() + + if "error_code" in data: + raise Exception(f"百度API错误: {data.get('error_msg', '未知错误')}") + + usage = None + if "usage" in data: + usage = LLMUsage( + prompt_tokens=data["usage"].get("prompt_tokens", 0), + completion_tokens=data["usage"].get("completion_tokens", 0), + total_tokens=data["usage"].get("total_tokens", 0) ) + + return LLMResponse( + content=data.get("result", ""), + model=model, + usage=usage, + finish_reason=data.get("finish_reason") + ) async def validate_config(self) -> bool: """验证配置是否有效""" - try: - await self._get_access_token() - return True - except Exception: - return False - - def get_provider(self) -> str: - return "baidu" - - def get_model(self) -> str: - return self.config.model or "ERNIE-3.5-8K" + if not self.config.api_key: + raise LLMError( + "API Key未配置", + provider=LLMProvider.BAIDU + ) + if ":" not in self.config.api_key: + raise LLMError( + "百度API需要同时提供API Key和Secret Key,格式:api_key:secret_key", + provider=LLMProvider.BAIDU + ) + return True diff --git a/backend/app/services/llm/adapters/claude_adapter.py b/backend/app/services/llm/adapters/claude_adapter.py deleted file mode 100644 index d324be3..0000000 --- a/backend/app/services/llm/adapters/claude_adapter.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Anthropic Claude适配器 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class ClaudeAdapter(BaseLLMAdapter): - """Claude适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.CLAUDE, "https://api.anthropic.com/v1") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "Claude API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # Claude API需要将system消息分离 - system_message = None - messages = [] - - for msg in request.messages: - if msg.role == "system": - system_message = msg.content - else: - messages.append({ - "role": msg.role, - "content": msg.content - }) - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens or 4096, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - } - - if system_message: - request_body["system"] = system_message - - # 构建请求头 - headers = { - "x-api-key": self.config.api_key, - "anthropic-version": "2023-06-01", - } - - url = f"{self.base_url.rstrip('/')}/messages" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - - if not data.get("content") or not data["content"][0]: - raise Exception("API响应格式异常: 缺少content字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("input_tokens", 0), - completion_tokens=data["usage"].get("output_tokens", 0), - total_tokens=data["usage"].get("input_tokens", 0) + data["usage"].get("output_tokens", 0) - ) - - return LLMResponse( - content=data["content"][0].get("text", ""), - model=data.get("model"), - usage=usage, - finish_reason=data.get("stop_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model.startswith("claude-"): - raise Exception(f"无效的Claude模型: {self.config.model}") - return True - - diff --git a/backend/app/services/llm/adapters/deepseek_adapter.py b/backend/app/services/llm/adapters/deepseek_adapter.py deleted file mode 100644 index 5472366..0000000 --- a/backend/app/services/llm/adapters/deepseek_adapter.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -DeepSeek适配器 - 兼容OpenAI格式 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class DeepSeekAdapter(BaseLLMAdapter): - """DeepSeek适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.DEEPSEEK, "https://api.deepseek.com") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "DeepSeek API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # DeepSeek API兼容OpenAI格式 - headers = { - "Authorization": f"Bearer {self.config.api_key}", - } - - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - "frequency_penalty": self.config.frequency_penalty, - "presence_penalty": self.config.presence_penalty, - } - - url = f"{self.base_url.rstrip('/')}/v1/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model: - raise Exception("未指定DeepSeek模型") - return True - - diff --git a/backend/app/services/llm/adapters/doubao_adapter.py b/backend/app/services/llm/adapters/doubao_adapter.py index 8801b48..5b8d5d8 100644 --- a/backend/app/services/llm/adapters/doubao_adapter.py +++ b/backend/app/services/llm/adapters/doubao_adapter.py @@ -2,9 +2,8 @@ 字节跳动豆包适配器 """ -import httpx from ..base_adapter import BaseLLMAdapter -from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError +from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError, LLMProvider, LLMUsage class DoubaoAdapter(BaseLLMAdapter): @@ -17,8 +16,16 @@ class DoubaoAdapter(BaseLLMAdapter): super().__init__(config) self._base_url = config.base_url or "https://ark.cn-beijing.volces.com/api/v3" - async def _do_complete(self, request: LLMRequest) -> LLMResponse: + async def complete(self, request: LLMRequest) -> LLMResponse: """执行实际的API调用""" + try: + await self.validate_config() + return await self.retry(lambda: self._send_request(request)) + except Exception as error: + self.handle_error(error, "豆包 API调用失败") + + async def _send_request(self, request: LLMRequest) -> LLMResponse: + """发送请求""" url = f"{self._base_url}/chat/completions" messages = [{"role": m.role, "content": m.content} for m in request.messages] @@ -26,63 +33,52 @@ class DoubaoAdapter(BaseLLMAdapter): payload = { "model": self.config.model or "doubao-pro-32k", "messages": messages, - "temperature": request.temperature or self.config.temperature, - "top_p": request.top_p or self.config.top_p, + "temperature": request.temperature if request.temperature is not None else self.config.temperature, + "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, + "top_p": request.top_p if request.top_p is not None else self.config.top_p, } - if request.max_tokens or self.config.max_tokens: - payload["max_tokens"] = request.max_tokens or self.config.max_tokens - headers = { - "Content-Type": "application/json", "Authorization": f"Bearer {self.config.api_key}", } - async with httpx.AsyncClient(timeout=self.config.timeout) as client: - response = await client.post(url, json=payload, headers=headers) - - if response.status_code != 200: - raise LLMError( - f"豆包API错误: {response.text}", - provider="doubao", - status_code=response.status_code - ) - - data = response.json() - - if "error" in data: - raise LLMError( - f"豆包API错误: {data['error'].get('message', '未知错误')}", - provider="doubao" - ) - - choices = data.get("choices", []) - if not choices: - raise LLMError("豆包API返回空响应", provider="doubao") - - return LLMResponse( - content=choices[0].get("message", {}).get("content", ""), - model=data.get("model", self.config.model or "doubao-pro-32k"), - usage=data.get("usage"), - finish_reason=choices[0].get("finish_reason") + response = await self.client.post( + url, + headers=self.build_headers(headers), + json=payload + ) + + if response.status_code != 200: + error_data = response.json() if response.text else {} + error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") + raise Exception(f"{error_msg}") + + data = response.json() + choice = data.get("choices", [{}])[0] + + if not choice: + raise Exception("API响应格式异常: 缺少choices字段") + + usage = None + if "usage" in data: + usage = LLMUsage( + prompt_tokens=data["usage"].get("prompt_tokens", 0), + completion_tokens=data["usage"].get("completion_tokens", 0), + total_tokens=data["usage"].get("total_tokens", 0) ) + + return LLMResponse( + content=choice.get("message", {}).get("content", ""), + model=data.get("model"), + usage=usage, + finish_reason=choice.get("finish_reason") + ) async def validate_config(self) -> bool: """验证配置是否有效""" - try: - test_request = LLMRequest( - messages=[{"role": "user", "content": "Hi"}], - max_tokens=10 - ) - await self._do_complete(test_request) - return True - except Exception: - return False - - def get_provider(self) -> str: - return "doubao" - - def get_model(self) -> str: - return self.config.model or "doubao-pro-32k" + await super().validate_config() + if not self.config.model: + raise LLMError("未指定豆包模型", provider=LLMProvider.DOUBAO) + return True diff --git a/backend/app/services/llm/adapters/gemini_adapter.py b/backend/app/services/llm/adapters/gemini_adapter.py deleted file mode 100644 index 0b847ee..0000000 --- a/backend/app/services/llm/adapters/gemini_adapter.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Google Gemini适配器 - 支持官方API和中转站 -""" - -from typing import Dict, Any, List -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class GeminiAdapter(BaseLLMAdapter): - """Gemini适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.GEMINI, "https://generativelanguage.googleapis.com/v1beta") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._generate_content(request)) - except Exception as error: - self.handle_error(error, "Gemini API调用失败") - - async def _generate_content(self, request: LLMRequest) -> LLMResponse: - # 转换消息格式为 Gemini 格式 - contents: List[Dict[str, Any]] = [] - system_content = "" - - for msg in request.messages: - if msg.role == "system": - system_content = msg.content - else: - role = "model" if msg.role == "assistant" else "user" - contents.append({ - "role": role, - "parts": [{"text": msg.content}] - }) - - # 将系统消息合并到第一条用户消息 - if system_content and contents: - contents[0]["parts"][0]["text"] = f"{system_content}\n\n{contents[0]['parts'][0]['text']}" - - # 构建请求体 - request_body = { - "contents": contents, - "generationConfig": { - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "maxOutputTokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, - "topP": request.top_p if request.top_p is not None else self.config.top_p, - } - } - - # API Key 在 URL 参数中 - url = f"{self.base_url}/models/{self.config.model}:generateContent?key={self.config.api_key}" - - response = await self.client.post( - url, - headers=self.build_headers(), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - - # 解析 Gemini 响应格式 - candidates = data.get("candidates", []) - if not candidates: - # 检查是否有错误信息 - if "error" in data: - error_msg = data["error"].get("message", "未知错误") - raise Exception(f"Gemini API错误: {error_msg}") - raise Exception("API响应格式异常: 缺少candidates字段") - - candidate = candidates[0] - if not candidate or "content" not in candidate: - raise Exception("API响应格式异常: 缺少content字段") - - text_parts = candidate.get("content", {}).get("parts", []) - if not text_parts: - raise Exception("API响应格式异常: content.parts为空") - - text = "".join(part.get("text", "") for part in text_parts) - - # 检查响应内容是否为空 - if not text or not text.strip(): - finish_reason = candidate.get("finishReason", "unknown") - raise Exception(f"Gemini返回空响应 - Finish Reason: {finish_reason}") - - usage = None - if "usageMetadata" in data: - usage_data = data["usageMetadata"] - usage = LLMUsage( - prompt_tokens=usage_data.get("promptTokenCount", 0), - completion_tokens=usage_data.get("candidatesTokenCount", 0), - total_tokens=usage_data.get("totalTokenCount", 0) - ) - - return LLMResponse( - content=text, - model=self.config.model, - usage=usage, - finish_reason=candidate.get("finishReason", "stop") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model.startswith("gemini-"): - raise Exception(f"无效的Gemini模型: {self.config.model}") - return True - diff --git a/backend/app/services/llm/adapters/litellm_adapter.py b/backend/app/services/llm/adapters/litellm_adapter.py new file mode 100644 index 0000000..face748 --- /dev/null +++ b/backend/app/services/llm/adapters/litellm_adapter.py @@ -0,0 +1,182 @@ +""" +LiteLLM 统一适配器 +支持通过 LiteLLM 调用多个 LLM 提供商,使用统一的 OpenAI 兼容格式 +""" + +from typing import Dict, Any, Optional +from ..base_adapter import BaseLLMAdapter +from ..types import ( + LLMConfig, + LLMRequest, + LLMResponse, + LLMUsage, + LLMProvider, + LLMError, + DEFAULT_BASE_URLS, +) + + +class LiteLLMAdapter(BaseLLMAdapter): + """ + LiteLLM 统一适配器 + + 支持的提供商: + - OpenAI (openai/gpt-4o-mini) + - Claude (anthropic/claude-3-5-sonnet-20241022) + - Gemini (gemini/gemini-1.5-flash) + - DeepSeek (deepseek/deepseek-chat) + - Qwen (qwen/qwen-turbo) - 通过 OpenAI 兼容模式 + - Zhipu (zhipu/glm-4-flash) - 通过 OpenAI 兼容模式 + - Moonshot (moonshot/moonshot-v1-8k) - 通过 OpenAI 兼容模式 + - Ollama (ollama/llama3) + """ + + # LiteLLM 模型前缀映射 + PROVIDER_PREFIX_MAP = { + LLMProvider.OPENAI: "openai", + LLMProvider.CLAUDE: "anthropic", + LLMProvider.GEMINI: "gemini", + LLMProvider.DEEPSEEK: "deepseek", + LLMProvider.QWEN: "openai", # 使用 OpenAI 兼容模式 + LLMProvider.ZHIPU: "openai", # 使用 OpenAI 兼容模式 + LLMProvider.MOONSHOT: "openai", # 使用 OpenAI 兼容模式 + LLMProvider.OLLAMA: "ollama", + } + + # 需要自定义 base_url 的提供商 + CUSTOM_BASE_URL_PROVIDERS = { + LLMProvider.QWEN, + LLMProvider.ZHIPU, + LLMProvider.MOONSHOT, + LLMProvider.DEEPSEEK, + } + + def __init__(self, config: LLMConfig): + super().__init__(config) + self._litellm_model = self._get_litellm_model() + self._api_base = self._get_api_base() + + def _get_litellm_model(self) -> str: + """获取 LiteLLM 格式的模型名称""" + provider = self.config.provider + model = self.config.model + + # 对于使用 OpenAI 兼容模式的提供商,直接使用模型名 + if provider in self.CUSTOM_BASE_URL_PROVIDERS: + return model + + # 对于原生支持的提供商,添加前缀 + prefix = self.PROVIDER_PREFIX_MAP.get(provider, "openai") + + # 检查模型名是否已经包含前缀 + if "/" in model: + return model + + return f"{prefix}/{model}" + + def _get_api_base(self) -> Optional[str]: + """获取 API 基础 URL""" + # 优先使用用户配置的 base_url + if self.config.base_url: + return self.config.base_url + + # 对于需要自定义 base_url 的提供商,使用默认值 + if self.config.provider in self.CUSTOM_BASE_URL_PROVIDERS: + return DEFAULT_BASE_URLS.get(self.config.provider) + + # Ollama 使用本地地址 + if self.config.provider == LLMProvider.OLLAMA: + return DEFAULT_BASE_URLS.get(LLMProvider.OLLAMA, "http://localhost:11434") + + return None + + async def complete(self, request: LLMRequest) -> LLMResponse: + """使用 LiteLLM 发送请求""" + try: + await self.validate_config() + return await self.retry(lambda: self._send_request(request)) + except Exception as error: + self.handle_error(error, f"LiteLLM ({self.config.provider.value}) API调用失败") + + async def _send_request(self, request: LLMRequest) -> LLMResponse: + """发送请求到 LiteLLM""" + import litellm + + # 构建消息 + messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] + + # 构建请求参数 + kwargs: Dict[str, Any] = { + "model": self._litellm_model, + "messages": messages, + "temperature": request.temperature if request.temperature is not None else self.config.temperature, + "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, + "top_p": request.top_p if request.top_p is not None else self.config.top_p, + } + + # 设置 API Key + if self.config.api_key and self.config.api_key != "ollama": + kwargs["api_key"] = self.config.api_key + + # 设置 API Base URL + if self._api_base: + kwargs["api_base"] = self._api_base + + # 设置超时 + kwargs["timeout"] = self.config.timeout + + # 对于 OpenAI 提供商,添加额外参数 + if self.config.provider == LLMProvider.OPENAI: + kwargs["frequency_penalty"] = self.config.frequency_penalty + kwargs["presence_penalty"] = self.config.presence_penalty + + # 调用 LiteLLM + response = await litellm.acompletion(**kwargs) + + # 解析响应 + choice = response.choices[0] if response.choices else None + if not choice: + raise LLMError("API响应格式异常: 缺少choices字段", self.config.provider) + + usage = None + if hasattr(response, "usage") and response.usage: + usage = LLMUsage( + prompt_tokens=response.usage.prompt_tokens or 0, + completion_tokens=response.usage.completion_tokens or 0, + total_tokens=response.usage.total_tokens or 0, + ) + + return LLMResponse( + content=choice.message.content or "", + model=response.model, + usage=usage, + finish_reason=choice.finish_reason, + ) + + async def validate_config(self) -> bool: + """验证配置""" + # Ollama 不需要 API Key + if self.config.provider == LLMProvider.OLLAMA: + if not self.config.model: + raise LLMError("未指定 Ollama 模型", LLMProvider.OLLAMA) + return True + + # 其他提供商需要 API Key + if not self.config.api_key: + raise LLMError( + f"API Key未配置 ({self.config.provider.value})", + self.config.provider, + ) + + if not self.config.model: + raise LLMError( + f"未指定模型 ({self.config.provider.value})", + self.config.provider, + ) + + return True + + @classmethod + def supports_provider(cls, provider: LLMProvider) -> bool: + """检查是否支持指定的提供商""" + return provider in cls.PROVIDER_PREFIX_MAP diff --git a/backend/app/services/llm/adapters/minimax_adapter.py b/backend/app/services/llm/adapters/minimax_adapter.py index 239d86a..1e1ea34 100644 --- a/backend/app/services/llm/adapters/minimax_adapter.py +++ b/backend/app/services/llm/adapters/minimax_adapter.py @@ -2,9 +2,8 @@ MiniMax适配器 """ -import httpx from ..base_adapter import BaseLLMAdapter -from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError +from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError, LLMProvider, LLMUsage class MinimaxAdapter(BaseLLMAdapter): @@ -14,8 +13,16 @@ class MinimaxAdapter(BaseLLMAdapter): super().__init__(config) self._base_url = config.base_url or "https://api.minimax.chat/v1" - async def _do_complete(self, request: LLMRequest) -> LLMResponse: + async def complete(self, request: LLMRequest) -> LLMResponse: """执行实际的API调用""" + try: + await self.validate_config() + return await self.retry(lambda: self._send_request(request)) + except Exception as error: + self.handle_error(error, "MiniMax API调用失败") + + async def _send_request(self, request: LLMRequest) -> LLMResponse: + """发送请求""" url = f"{self._base_url}/text/chatcompletion_v2" messages = [{"role": m.role, "content": m.content} for m in request.messages] @@ -23,63 +30,58 @@ class MinimaxAdapter(BaseLLMAdapter): payload = { "model": self.config.model or "abab6.5-chat", "messages": messages, - "temperature": request.temperature or self.config.temperature, - "top_p": request.top_p or self.config.top_p, + "temperature": request.temperature if request.temperature is not None else self.config.temperature, + "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, + "top_p": request.top_p if request.top_p is not None else self.config.top_p, } - if request.max_tokens or self.config.max_tokens: - payload["max_tokens"] = request.max_tokens or self.config.max_tokens - headers = { - "Content-Type": "application/json", "Authorization": f"Bearer {self.config.api_key}", } - async with httpx.AsyncClient(timeout=self.config.timeout) as client: - response = await client.post(url, json=payload, headers=headers) - - if response.status_code != 200: - raise LLMError( - f"MiniMax API错误: {response.text}", - provider="minimax", - status_code=response.status_code - ) - - data = response.json() - - if data.get("base_resp", {}).get("status_code") != 0: - raise LLMError( - f"MiniMax API错误: {data.get('base_resp', {}).get('status_msg', '未知错误')}", - provider="minimax" - ) - - choices = data.get("choices", []) - if not choices: - raise LLMError("MiniMax API返回空响应", provider="minimax") - - return LLMResponse( - content=choices[0].get("message", {}).get("content", ""), - model=self.config.model or "abab6.5-chat", - usage=data.get("usage"), - finish_reason=choices[0].get("finish_reason") + response = await self.client.post( + url, + headers=self.build_headers(headers), + json=payload + ) + + if response.status_code != 200: + error_data = response.json() if response.text else {} + error_msg = error_data.get("base_resp", {}).get("status_msg", f"HTTP {response.status_code}") + raise Exception(f"{error_msg}") + + data = response.json() + + # MiniMax 特殊的错误处理 + if data.get("base_resp", {}).get("status_code") != 0: + error_msg = data.get("base_resp", {}).get("status_msg", "未知错误") + raise Exception(f"MiniMax API错误: {error_msg}") + + choice = data.get("choices", [{}])[0] + + if not choice: + raise Exception("API响应格式异常: 缺少choices字段") + + usage = None + if "usage" in data: + usage = LLMUsage( + prompt_tokens=data["usage"].get("prompt_tokens", 0), + completion_tokens=data["usage"].get("completion_tokens", 0), + total_tokens=data["usage"].get("total_tokens", 0) ) + + return LLMResponse( + content=choice.get("message", {}).get("content", ""), + model=data.get("model"), + usage=usage, + finish_reason=choice.get("finish_reason") + ) async def validate_config(self) -> bool: """验证配置是否有效""" - try: - test_request = LLMRequest( - messages=[{"role": "user", "content": "Hi"}], - max_tokens=10 - ) - await self._do_complete(test_request) - return True - except Exception: - return False - - def get_provider(self) -> str: - return "minimax" - - def get_model(self) -> str: - return self.config.model or "abab6.5-chat" + await super().validate_config() + if not self.config.model: + raise LLMError("未指定MiniMax模型", provider=LLMProvider.MINIMAX) + return True diff --git a/backend/app/services/llm/adapters/moonshot_adapter.py b/backend/app/services/llm/adapters/moonshot_adapter.py deleted file mode 100644 index bf02320..0000000 --- a/backend/app/services/llm/adapters/moonshot_adapter.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -月之暗面 Kimi适配器 - 兼容OpenAI格式 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class MoonshotAdapter(BaseLLMAdapter): - """月之暗面Kimi适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.MOONSHOT, "https://api.moonshot.cn/v1") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "Moonshot API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # Moonshot API兼容OpenAI格式 - headers = { - "Authorization": f"Bearer {self.config.api_key}", - } - - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - } - - url = f"{self.base_url.rstrip('/')}/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model: - raise Exception("未指定Moonshot模型") - return True - - diff --git a/backend/app/services/llm/adapters/ollama_adapter.py b/backend/app/services/llm/adapters/ollama_adapter.py deleted file mode 100644 index 708ca19..0000000 --- a/backend/app/services/llm/adapters/ollama_adapter.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Ollama本地大模型适配器 - 兼容OpenAI格式 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class OllamaAdapter(BaseLLMAdapter): - """Ollama本地模型适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.OLLAMA, "http://localhost:11434/v1") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - # Ollama本地运行,跳过API Key验证 - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "Ollama API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # Ollama兼容OpenAI格式 - headers = {} - if self.config.api_key: - headers["Authorization"] = f"Bearer {self.config.api_key}" - - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - } - - # Ollama的max_tokens参数名可能不同 - if request.max_tokens or self.config.max_tokens: - request_body["num_predict"] = request.max_tokens or self.config.max_tokens - - url = f"{self.base_url.rstrip('/')}/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers) if headers else self.build_headers(), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - # Ollama本地运行,不需要API Key - if not self.config.model: - raise Exception("未指定Ollama模型") - return True - - diff --git a/backend/app/services/llm/adapters/openai_adapter.py b/backend/app/services/llm/adapters/openai_adapter.py deleted file mode 100644 index 48fd7ef..0000000 --- a/backend/app/services/llm/adapters/openai_adapter.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -OpenAI适配器 (支持GPT系列和OpenAI兼容API) -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class OpenAIAdapter(BaseLLMAdapter): - """OpenAI适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.OPENAI, "https://api.openai.com/v1") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "OpenAI API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # 构建请求头 - headers = { - "Authorization": f"Bearer {self.config.api_key}", - } - - # 检测是否为推理模型(o1/o3系列) - model_name = self.config.model.lower() - is_reasoning_model = "o1" in model_name or "o3" in model_name - - # 构建请求体 - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - "frequency_penalty": self.config.frequency_penalty, - "presence_penalty": self.config.presence_penalty, - } - - # 推理模型使用max_completion_tokens,其他模型使用max_tokens - max_tokens = request.max_tokens if request.max_tokens is not None else self.config.max_tokens - if is_reasoning_model: - request_body["max_completion_tokens"] = max_tokens - else: - request_body["max_tokens"] = max_tokens - - url = f"{self.base_url.rstrip('/')}/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model: - raise Exception("未指定OpenAI模型") - return True - - diff --git a/backend/app/services/llm/adapters/qwen_adapter.py b/backend/app/services/llm/adapters/qwen_adapter.py deleted file mode 100644 index 667287c..0000000 --- a/backend/app/services/llm/adapters/qwen_adapter.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -阿里云通义千问适配器 - 兼容OpenAI格式 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class QwenAdapter(BaseLLMAdapter): - """通义千问适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.QWEN, "https://dashscope.aliyuncs.com/compatible-mode/v1") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "通义千问 API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # 通义千问兼容OpenAI格式 - headers = { - "Authorization": f"Bearer {self.config.api_key}", - } - - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - } - - url = f"{self.base_url.rstrip('/')}/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model: - raise Exception("未指定通义千问模型") - return True - - diff --git a/backend/app/services/llm/adapters/zhipu_adapter.py b/backend/app/services/llm/adapters/zhipu_adapter.py deleted file mode 100644 index 6069502..0000000 --- a/backend/app/services/llm/adapters/zhipu_adapter.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -智谱AI适配器 (GLM系列) - 兼容OpenAI格式 -""" - -from typing import Dict, Any -from ..base_adapter import BaseLLMAdapter -from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider - - -class ZhipuAdapter(BaseLLMAdapter): - """智谱AI适配器""" - - @property - def base_url(self) -> str: - return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.ZHIPU, "https://open.bigmodel.cn/api/paas/v4") - - async def complete(self, request: LLMRequest) -> LLMResponse: - try: - await self.validate_config() - return await self.retry(lambda: self._send_request(request)) - except Exception as error: - self.handle_error(error, "智谱AI API调用失败") - - async def _send_request(self, request: LLMRequest) -> LLMResponse: - # 智谱AI兼容OpenAI格式 - headers = { - "Authorization": f"Bearer {self.config.api_key}", - } - - messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] - - request_body: Dict[str, Any] = { - "model": self.config.model, - "messages": messages, - "temperature": request.temperature if request.temperature is not None else self.config.temperature, - "max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens, - "top_p": request.top_p if request.top_p is not None else self.config.top_p, - } - - url = f"{self.base_url.rstrip('/')}/chat/completions" - - response = await self.client.post( - url, - headers=self.build_headers(headers), - json=request_body - ) - - if response.status_code != 200: - error_data = response.json() if response.text else {} - error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}") - raise Exception(f"{error_msg}") - - data = response.json() - choice = data.get("choices", [{}])[0] - - if not choice: - raise Exception("API响应格式异常: 缺少choices字段") - - usage = None - if "usage" in data: - usage = LLMUsage( - prompt_tokens=data["usage"].get("prompt_tokens", 0), - completion_tokens=data["usage"].get("completion_tokens", 0), - total_tokens=data["usage"].get("total_tokens", 0) - ) - - return LLMResponse( - content=choice.get("message", {}).get("content", ""), - model=data.get("model"), - usage=usage, - finish_reason=choice.get("finish_reason") - ) - - async def validate_config(self) -> bool: - await super().validate_config() - if not self.config.model: - raise Exception("未指定智谱AI模型") - return True - - diff --git a/backend/app/services/llm/factory.py b/backend/app/services/llm/factory.py index 04af77d..fba107b 100644 --- a/backend/app/services/llm/factory.py +++ b/backend/app/services/llm/factory.py @@ -1,95 +1,105 @@ """ LLM工厂类 - 统一创建和管理LLM适配器 + +使用 LiteLLM 作为主要适配器,支持大多数 LLM 提供商。 +对于 API 格式特殊的提供商(百度、MiniMax、豆包),使用原生适配器。 """ -from typing import Dict, List, Optional +from typing import Dict, List from .types import LLMConfig, LLMProvider, DEFAULT_MODELS from .base_adapter import BaseLLMAdapter from .adapters import ( - OpenAIAdapter, - GeminiAdapter, - ClaudeAdapter, - DeepSeekAdapter, - QwenAdapter, - ZhipuAdapter, - MoonshotAdapter, + LiteLLMAdapter, BaiduAdapter, MinimaxAdapter, DoubaoAdapter, - OllamaAdapter, ) +# 必须使用原生适配器的提供商(API 格式特殊) +NATIVE_ONLY_PROVIDERS = { + LLMProvider.BAIDU, + LLMProvider.MINIMAX, + LLMProvider.DOUBAO, +} + + class LLMFactory: """LLM工厂类""" - + _adapters: Dict[str, BaseLLMAdapter] = {} - + @classmethod def create_adapter(cls, config: LLMConfig) -> BaseLLMAdapter: """创建LLM适配器实例""" cache_key = cls._get_cache_key(config) - + # 从缓存中获取 if cache_key in cls._adapters: return cls._adapters[cache_key] - + # 创建新的适配器实例 adapter = cls._instantiate_adapter(config) - + # 缓存实例 cls._adapters[cache_key] = adapter - + return adapter - + @classmethod def _instantiate_adapter(cls, config: LLMConfig) -> BaseLLMAdapter: """根据提供商类型实例化适配器""" # 如果未指定模型,使用默认模型 if not config.model: config.model = DEFAULT_MODELS.get(config.provider, "gpt-4o-mini") - - adapter_map = { - LLMProvider.OPENAI: OpenAIAdapter, - LLMProvider.GEMINI: GeminiAdapter, - LLMProvider.CLAUDE: ClaudeAdapter, - LLMProvider.DEEPSEEK: DeepSeekAdapter, - LLMProvider.QWEN: QwenAdapter, - LLMProvider.ZHIPU: ZhipuAdapter, - LLMProvider.MOONSHOT: MoonshotAdapter, + + # 对于必须使用原生适配器的提供商 + if config.provider in NATIVE_ONLY_PROVIDERS: + return cls._create_native_adapter(config) + + # 其他提供商使用 LiteLLM + if LiteLLMAdapter.supports_provider(config.provider): + return LiteLLMAdapter(config) + + # 不支持的提供商 + raise ValueError(f"不支持的LLM提供商: {config.provider}") + + @classmethod + def _create_native_adapter(cls, config: LLMConfig) -> BaseLLMAdapter: + """创建原生适配器(仅用于 API 格式特殊的提供商)""" + native_adapter_map = { LLMProvider.BAIDU: BaiduAdapter, LLMProvider.MINIMAX: MinimaxAdapter, LLMProvider.DOUBAO: DoubaoAdapter, - LLMProvider.OLLAMA: OllamaAdapter, } - - adapter_class = adapter_map.get(config.provider) + + adapter_class = native_adapter_map.get(config.provider) if not adapter_class: - raise ValueError(f"不支持的LLM提供商: {config.provider}") - + raise ValueError(f"不支持的原生适配器提供商: {config.provider}") + return adapter_class(config) - + @classmethod def _get_cache_key(cls, config: LLMConfig) -> str: """生成缓存键""" api_key_prefix = config.api_key[:8] if config.api_key else "no-key" return f"{config.provider.value}:{config.model}:{api_key_prefix}" - + @classmethod def clear_cache(cls) -> None: """清除缓存""" cls._adapters.clear() - + @classmethod def get_supported_providers(cls) -> List[LLMProvider]: """获取支持的提供商列表""" return list(LLMProvider) - + @classmethod def get_default_model(cls, provider: LLMProvider) -> str: """获取提供商的默认模型""" return DEFAULT_MODELS.get(provider, "gpt-4o-mini") - + @classmethod def get_available_models(cls, provider: LLMProvider) -> List[str]: """获取提供商的可用模型列表""" @@ -162,4 +172,3 @@ class LLMFactory: ], } return models.get(provider, []) - diff --git a/backend/env.example b/backend/env.example index f0db425..193eb0c 100644 --- a/backend/env.example +++ b/backend/env.example @@ -16,6 +16,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES=11520 # ------------ LLM配置 ------------ # 支持的provider: openai, gemini, claude, qwen, deepseek, zhipu, moonshot, baidu, minimax, doubao, ollama +# +# 使用 LiteLLM 统一适配器: openai, gemini, claude, qwen, deepseek, zhipu, moonshot, ollama +# 使用原生适配器 (API格式特殊): baidu, minimax, doubao LLM_PROVIDER=openai LLM_API_KEY=sk-your-api-key LLM_MODEL= diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1d952bb..c9a19ea 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,4 +18,5 @@ dependencies = [ "email-validator", "greenlet", "bcrypt<5.0.0", + "litellm>=1.0.0", ] diff --git a/frontend/src/components/system/SystemConfig.tsx b/frontend/src/components/system/SystemConfig.tsx index 3c3cbc4..466327a 100644 --- a/frontend/src/components/system/SystemConfig.tsx +++ b/frontend/src/components/system/SystemConfig.tsx @@ -4,792 +4,429 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Badge } from "@/components/ui/badge"; import { - Settings, - Save, - RotateCcw, - Eye, - EyeOff, - CheckCircle2, - AlertCircle, - Info, - Key, - Zap, - Globe, - Database + Settings, Save, RotateCcw, Eye, EyeOff, CheckCircle2, AlertCircle, + Info, Zap, Globe, PlayCircle, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { api } from "@/shared/api/database"; -// LLM 提供商配置 +// LLM 提供商配置 - 简化分类 const LLM_PROVIDERS = [ - { value: 'gemini', label: 'Google Gemini', icon: '🔵', category: 'international' }, - { value: 'openai', label: 'OpenAI GPT', icon: '🟢', category: 'international' }, - { value: 'claude', label: 'Anthropic Claude', icon: '🟣', category: 'international' }, - { value: 'deepseek', label: 'DeepSeek', icon: '🔷', category: 'international' }, - { value: 'qwen', label: '阿里云通义千问', icon: '🟠', category: 'domestic' }, - { value: 'zhipu', label: '智谱AI (GLM)', icon: '🔴', category: 'domestic' }, - { value: 'moonshot', label: 'Moonshot (Kimi)', icon: '🌙', category: 'domestic' }, - { value: 'baidu', label: '百度文心一言', icon: '🔵', category: 'domestic' }, - { value: 'minimax', label: 'MiniMax', icon: '⚡', category: 'domestic' }, - { value: 'doubao', label: '字节豆包', icon: '🎯', category: 'domestic' }, - { value: 'ollama', label: 'Ollama 本地模型', icon: '🖥️', category: 'local' }, + { value: 'openai', label: 'OpenAI GPT', icon: '🟢', category: 'litellm', hint: 'gpt-4o, gpt-4o-mini 等' }, + { value: 'claude', label: 'Anthropic Claude', icon: '🟣', category: 'litellm', hint: 'claude-3.5-sonnet 等' }, + { value: 'gemini', label: 'Google Gemini', icon: '🔵', category: 'litellm', hint: 'gemini-1.5-flash 等' }, + { value: 'deepseek', label: 'DeepSeek', icon: '🔷', category: 'litellm', hint: 'deepseek-chat, deepseek-coder' }, + { value: 'qwen', label: '通义千问', icon: '🟠', category: 'litellm', hint: 'qwen-turbo, qwen-max 等' }, + { value: 'zhipu', label: '智谱AI (GLM)', icon: '🔴', category: 'litellm', hint: 'glm-4-flash, glm-4 等' }, + { value: 'moonshot', label: 'Moonshot (Kimi)', icon: '🌙', category: 'litellm', hint: 'moonshot-v1-8k 等' }, + { value: 'ollama', label: 'Ollama 本地', icon: '🖥️', category: 'litellm', hint: 'llama3, codellama 等' }, + { value: 'baidu', label: '百度文心', icon: '📘', category: 'native', hint: 'ERNIE-3.5-8K (需要 API_KEY:SECRET_KEY)' }, + { value: 'minimax', label: 'MiniMax', icon: '⚡', category: 'native', hint: 'abab6.5-chat 等' }, + { value: 'doubao', label: '字节豆包', icon: '🎯', category: 'native', hint: 'doubao-pro-32k 等' }, ]; -// 默认模型配置 -const DEFAULT_MODELS = { - gemini: 'gemini-1.5-flash', - openai: 'gpt-4o-mini', - claude: 'claude-3-5-sonnet-20241022', - qwen: 'qwen-turbo', - deepseek: 'deepseek-chat', - zhipu: 'glm-4-flash', - moonshot: 'moonshot-v1-8k', - baidu: 'ERNIE-3.5-8K', - minimax: 'abab6.5-chat', - doubao: 'doubao-pro-32k', - ollama: 'llama3', +const DEFAULT_MODELS: Record = { + openai: 'gpt-4o-mini', claude: 'claude-3-5-sonnet-20241022', gemini: 'gemini-2.5-flash', + deepseek: 'deepseek-chat', qwen: 'qwen-turbo', zhipu: 'glm-4-flash', moonshot: 'moonshot-v1-8k', + ollama: 'llama3', baidu: 'ERNIE-3.5-8K', minimax: 'abab6.5-chat', doubao: 'doubao-pro-32k', }; interface SystemConfigData { - // LLM 配置 - llmProvider: string; - llmApiKey: string; - llmModel: string; - llmBaseUrl: string; - llmTimeout: number; - llmTemperature: number; - llmMaxTokens: number; - llmCustomHeaders: string; - - // 平台专用配置 - geminiApiKey: string; - openaiApiKey: string; - claudeApiKey: string; - qwenApiKey: string; - deepseekApiKey: string; - zhipuApiKey: string; - moonshotApiKey: string; - baiduApiKey: string; - minimaxApiKey: string; - doubaoApiKey: string; - ollamaBaseUrl: string; - - // GitHub 配置 - githubToken: string; - - // GitLab 配置 - gitlabToken: string; - - // 分析配置 - maxAnalyzeFiles: number; - llmConcurrency: number; - llmGapMs: number; - outputLanguage: string; + llmProvider: string; llmApiKey: string; llmModel: string; llmBaseUrl: string; + llmTimeout: number; llmTemperature: number; llmMaxTokens: number; + githubToken: string; gitlabToken: string; + maxAnalyzeFiles: number; llmConcurrency: number; llmGapMs: number; outputLanguage: string; } export function SystemConfig() { - // 初始状态为空,等待从后端加载 const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); - const [showApiKeys, setShowApiKeys] = useState>({}); + const [showApiKey, setShowApiKey] = useState(false); const [hasChanges, setHasChanges] = useState(false); - const [configSource, setConfigSource] = useState<'runtime' | 'build'>('build'); + const [testingLLM, setTestingLLM] = useState(false); + const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string } | null>(null); - // 加载配置 - useEffect(() => { - loadConfig(); - }, []); + useEffect(() => { loadConfig(); }, []); const loadConfig = async () => { try { setLoading(true); - // 先从后端获取默认配置 const defaultConfig = await api.getDefaultConfig(); - if (!defaultConfig) { - throw new Error('Failed to get default config from backend'); - } - - // 从后端加载用户配置(已合并默认配置) const backendConfig = await api.getUserConfig(); - if (backendConfig) { - const mergedConfig: SystemConfigData = { - ...defaultConfig.llmConfig, - ...defaultConfig.otherConfig, - ...(backendConfig.llmConfig || {}), - ...(backendConfig.otherConfig || {}), - }; - setConfig(mergedConfig); - setConfigSource('runtime'); - console.log('已从后端加载用户配置(已合并默认配置)'); - } else { - // 使用默认配置 - const mergedConfig: SystemConfigData = { - ...defaultConfig.llmConfig, - ...defaultConfig.otherConfig, - }; - setConfig(mergedConfig); - setConfigSource('build'); - console.log('使用后端默认配置'); - } + + const merged: SystemConfigData = { + llmProvider: backendConfig?.llmConfig?.llmProvider || defaultConfig?.llmConfig?.llmProvider || 'openai', + llmApiKey: backendConfig?.llmConfig?.llmApiKey || '', + llmModel: backendConfig?.llmConfig?.llmModel || '', + llmBaseUrl: backendConfig?.llmConfig?.llmBaseUrl || '', + llmTimeout: backendConfig?.llmConfig?.llmTimeout || defaultConfig?.llmConfig?.llmTimeout || 150000, + llmTemperature: backendConfig?.llmConfig?.llmTemperature ?? defaultConfig?.llmConfig?.llmTemperature ?? 0.1, + llmMaxTokens: backendConfig?.llmConfig?.llmMaxTokens || defaultConfig?.llmConfig?.llmMaxTokens || 4096, + githubToken: backendConfig?.otherConfig?.githubToken || '', + gitlabToken: backendConfig?.otherConfig?.gitlabToken || '', + maxAnalyzeFiles: backendConfig?.otherConfig?.maxAnalyzeFiles || defaultConfig?.otherConfig?.maxAnalyzeFiles || 50, + llmConcurrency: backendConfig?.otherConfig?.llmConcurrency || defaultConfig?.otherConfig?.llmConcurrency || 3, + llmGapMs: backendConfig?.otherConfig?.llmGapMs || defaultConfig?.otherConfig?.llmGapMs || 2000, + outputLanguage: backendConfig?.otherConfig?.outputLanguage || defaultConfig?.otherConfig?.outputLanguage || 'zh-CN', + }; + setConfig(merged); } catch (error) { console.error('Failed to load config:', error); - // 如果后端加载失败,尝试从环境变量加载(后备方案) - const envConfig = loadFromEnv(); - setConfig(envConfig); - setConfigSource('build'); + setConfig({ + llmProvider: 'openai', llmApiKey: '', llmModel: '', llmBaseUrl: '', + llmTimeout: 150000, llmTemperature: 0.1, llmMaxTokens: 4096, + githubToken: '', gitlabToken: '', + maxAnalyzeFiles: 50, llmConcurrency: 3, llmGapMs: 2000, outputLanguage: 'zh-CN', + }); } finally { setLoading(false); } }; - const loadFromEnv = (): SystemConfigData => { - // 从环境变量加载(后备方案,仅在无法从后端获取时使用) - return { - llmProvider: import.meta.env.VITE_LLM_PROVIDER || 'openai', - llmApiKey: import.meta.env.VITE_LLM_API_KEY || '', - llmModel: import.meta.env.VITE_LLM_MODEL || '', - llmBaseUrl: import.meta.env.VITE_LLM_BASE_URL || '', - llmTimeout: Number(import.meta.env.VITE_LLM_TIMEOUT) || 150000, - llmTemperature: Number(import.meta.env.VITE_LLM_TEMPERATURE) || 0.1, - llmMaxTokens: Number(import.meta.env.VITE_LLM_MAX_TOKENS) || 4096, - llmCustomHeaders: import.meta.env.VITE_LLM_CUSTOM_HEADERS || '', - geminiApiKey: import.meta.env.VITE_GEMINI_API_KEY || '', - openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || '', - claudeApiKey: import.meta.env.VITE_CLAUDE_API_KEY || '', - qwenApiKey: import.meta.env.VITE_QWEN_API_KEY || '', - deepseekApiKey: import.meta.env.VITE_DEEPSEEK_API_KEY || '', - zhipuApiKey: import.meta.env.VITE_ZHIPU_API_KEY || '', - moonshotApiKey: import.meta.env.VITE_MOONSHOT_API_KEY || '', - baiduApiKey: import.meta.env.VITE_BAIDU_API_KEY || '', - minimaxApiKey: import.meta.env.VITE_MINIMAX_API_KEY || '', - doubaoApiKey: import.meta.env.VITE_DOUBAO_API_KEY || '', - ollamaBaseUrl: import.meta.env.VITE_OLLAMA_BASE_URL || 'http://localhost:11434/v1', - githubToken: import.meta.env.VITE_GITHUB_TOKEN || '', - gitlabToken: import.meta.env.VITE_GITLAB_TOKEN || '', - maxAnalyzeFiles: Number(import.meta.env.VITE_MAX_ANALYZE_FILES) || 50, - llmConcurrency: Number(import.meta.env.VITE_LLM_CONCURRENCY) || 3, - llmGapMs: Number(import.meta.env.VITE_LLM_GAP_MS) || 2000, - outputLanguage: import.meta.env.VITE_OUTPUT_LANGUAGE || 'zh-CN', - }; - }; - const saveConfig = async () => { - if (!config) { - toast.error('配置未加载,请稍候再试'); - return; - } + if (!config) return; try { - // 保存到后端 - const llmConfig = { - llmProvider: config.llmProvider, - llmApiKey: config.llmApiKey, - llmModel: config.llmModel, - llmBaseUrl: config.llmBaseUrl, - llmTimeout: config.llmTimeout, - llmTemperature: config.llmTemperature, - llmMaxTokens: config.llmMaxTokens, - llmCustomHeaders: config.llmCustomHeaders, - geminiApiKey: config.geminiApiKey, - openaiApiKey: config.openaiApiKey, - claudeApiKey: config.claudeApiKey, - qwenApiKey: config.qwenApiKey, - deepseekApiKey: config.deepseekApiKey, - zhipuApiKey: config.zhipuApiKey, - moonshotApiKey: config.moonshotApiKey, - baiduApiKey: config.baiduApiKey, - minimaxApiKey: config.minimaxApiKey, - doubaoApiKey: config.doubaoApiKey, - ollamaBaseUrl: config.ollamaBaseUrl, - }; - - const otherConfig = { - githubToken: config.githubToken, - gitlabToken: config.gitlabToken, - maxAnalyzeFiles: config.maxAnalyzeFiles, - llmConcurrency: config.llmConcurrency, - llmGapMs: config.llmGapMs, - outputLanguage: config.outputLanguage, - }; - await api.updateUserConfig({ - llmConfig, - otherConfig, + llmConfig: { + llmProvider: config.llmProvider, llmApiKey: config.llmApiKey, + llmModel: config.llmModel, llmBaseUrl: config.llmBaseUrl, + llmTimeout: config.llmTimeout, llmTemperature: config.llmTemperature, + llmMaxTokens: config.llmMaxTokens, + }, + otherConfig: { + githubToken: config.githubToken, gitlabToken: config.gitlabToken, + maxAnalyzeFiles: config.maxAnalyzeFiles, llmConcurrency: config.llmConcurrency, + llmGapMs: config.llmGapMs, outputLanguage: config.outputLanguage, + }, }); - setHasChanges(false); - setConfigSource('runtime'); - - // 记录用户操作 - import('@/shared/utils/logger').then(({ logger }) => { - logger.logUserAction('保存系统配置', { - provider: config.llmProvider, - hasApiKey: !!config.llmApiKey, - maxFiles: config.maxAnalyzeFiles, - concurrency: config.llmConcurrency, - language: config.outputLanguage, - }); - }).catch(() => { }); - - toast.success("配置已保存到后端!刷新页面后生效"); - - // 提示用户刷新页面 - setTimeout(() => { - if (window.confirm("配置已保存。是否立即刷新页面使配置生效?")) { - window.location.reload(); - } - }, 1000); + toast.success("配置已保存!"); } catch (error) { - console.error('Failed to save config:', error); - const errorMessage = error instanceof Error ? error.message : '未知错误'; - toast.error(`保存配置失败: ${errorMessage}`); + toast.error(`保存失败: ${error instanceof Error ? error.message : '未知错误'}`); } }; const resetConfig = async () => { - if (window.confirm("确定要重置为默认配置吗?这将清除所有用户配置。")) { - try { - // 删除后端配置 - await api.deleteUserConfig(); - - // 重新加载配置(会使用后端默认配置) - await loadConfig(); - setHasChanges(false); - - // 记录用户操作 - import('@/shared/utils/logger').then(({ logger }) => { - logger.logUserAction('重置系统配置', { action: 'reset_to_default' }); - }).catch(() => { }); - - toast.success("已重置为默认配置"); - } catch (error) { - console.error('Failed to reset config:', error); - const errorMessage = error instanceof Error ? error.message : '未知错误'; - toast.error(`重置配置失败: ${errorMessage}`); - } + if (!window.confirm("确定要重置为默认配置吗?")) return; + try { + await api.deleteUserConfig(); + await loadConfig(); + setHasChanges(false); + toast.success("已重置为默认配置"); + } catch (error) { + toast.error(`重置失败: ${error instanceof Error ? error.message : '未知错误'}`); } }; - const updateConfig = (key: keyof SystemConfigData, value: any) => { + const updateConfig = (key: keyof SystemConfigData, value: string | number) => { if (!config) return; setConfig(prev => prev ? { ...prev, [key]: value } : null); setHasChanges(true); }; - const toggleShowApiKey = (field: string) => { - setShowApiKeys(prev => ({ ...prev, [field]: !prev[field] })); + const testLLMConnection = async () => { + if (!config) return; + if (!config.llmApiKey && config.llmProvider !== 'ollama') { + toast.error('请先配置 API Key'); + return; + } + setTestingLLM(true); + setLlmTestResult(null); + try { + const result = await api.testLLMConnection({ + provider: config.llmProvider, + apiKey: config.llmApiKey, + model: config.llmModel || undefined, + baseUrl: config.llmBaseUrl || undefined, + }); + setLlmTestResult(result); + if (result.success) toast.success(`连接成功!模型: ${result.model}`); + else toast.error(`连接失败: ${result.message}`); + } catch (error) { + const msg = error instanceof Error ? error.message : '未知错误'; + setLlmTestResult({ success: false, message: msg }); + toast.error(`测试失败: ${msg}`); + } finally { + setTestingLLM(false); + } }; - const getCurrentApiKey = () => { - if (!config) return ''; - const provider = config.llmProvider.toLowerCase(); - const keyMap: Record = { - gemini: config.geminiApiKey, - openai: config.openaiApiKey, - claude: config.claudeApiKey, - qwen: config.qwenApiKey, - deepseek: config.deepseekApiKey, - zhipu: config.zhipuApiKey, - moonshot: config.moonshotApiKey, - baidu: config.baiduApiKey, - minimax: config.minimaxApiKey, - doubao: config.doubaoApiKey, - ollama: 'ollama', - }; - - return config.llmApiKey || keyMap[provider] || ''; - }; - - const isConfigured = config ? getCurrentApiKey() !== '' : false; - - // 如果正在加载或配置为 null,显示加载状态 if (loading || !config) { return (
-

正在从后端加载配置...

+

加载配置中...

); } + const currentProvider = LLM_PROVIDERS.find(p => p.value === config.llmProvider); + const isConfigured = config.llmApiKey !== '' || config.llmProvider === 'ollama'; + return (
- {/* 配置状态提示 */} -
- -
-
- 当前配置来源: - {configSource === 'runtime' ? ( - 运行时配置 + {/* 状态栏 */} +
+
+ + + {isConfigured ? ( + + LLM 已配置 ({currentProvider?.label}) + ) : ( - 构建时配置 + + 请配置 LLM API Key + )} - - {isConfigured ? ( - - LLM 已配置 - - ) : ( - - 未配置 LLM - - )} - -
-
- {hasChanges && ( - - )} - {configSource === 'runtime' && ( - - )} -
+ +
+
+ {hasChanges && ( + + )} +
- + - - LLM 配置 - - - - 平台密钥 + LLM 配置 - - 分析参数 + 分析参数 - - - 其他配置 + + Git 集成 - {/* LLM 基础配置 */} + {/* LLM 配置 - 简化版 */} -
-
-

LLM 提供商配置

-

选择和配置大语言模型服务

+
+ {/* 提供商选择 */} +
+ +
-
-
- - -
+ {/* API Key */} + {config.llmProvider !== 'ollama' && (
- +
updateConfig('llmApiKey', e.target.value)} - placeholder="留空则使用平台专用 API Key" - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" + placeholder={config.llmProvider === 'baidu' ? 'API_KEY:SECRET_KEY 格式' : '输入你的 API Key'} + className="h-12 bg-gray-50 border-2 border-black rounded-none font-mono" /> -
-

- 如果设置,将优先使用此 API Key;否则使用下方对应平台的专用 API Key -

+ )} + {/* 模型和 Base URL */} +
- + updateConfig('llmModel', e.target.value)} - placeholder={`默认:${DEFAULT_MODELS[config.llmProvider as keyof typeof DEFAULT_MODELS] || '自动'}`} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" + placeholder={`默认: ${DEFAULT_MODELS[config.llmProvider] || 'auto'}`} + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" /> -

- 留空使用默认模型 -

-
- + updateConfig('llmBaseUrl', e.target.value)} - placeholder="例如:https://api.example.com/v1" - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" + placeholder="留空使用官方地址,或填入中转站地址" + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" /> -
-

💡 使用 API 中转站?在这里填入中转站地址。配置保存后会在实际使用时自动验证。

-
- 查看常见 API 中转示例 -
-

OpenAI 兼容格式:

-

• https://your-proxy.com/v1

-

• https://api.openai-proxy.org/v1

-

其他中转格式:

-

• https://your-api-gateway.com/openai

-

• https://custom-endpoint.com/api

-

⚠️ 确保中转站支持你选择的 LLM 平台

-
-
+
+
+ + {/* 测试连接 */} +
+
+ 测试连接 + 验证配置是否正确 +
+ +
+ {llmTestResult && ( +
+
+ {llmTestResult.success ? : } + {llmTestResult.message}
+ )} -
+ {/* 高级参数 - 折叠 */} +
+ 高级参数 +
- - updateConfig('llmTimeout', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> + + updateConfig('llmTimeout', Number(e.target.value))} + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
- - 温度 (0-2) + updateConfig('llmTemperature', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
- - updateConfig('llmMaxTokens', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> + + updateConfig('llmMaxTokens', Number(e.target.value))} + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
+
+
-
- - updateConfig('llmCustomHeaders', e.target.value)} - placeholder='{"X-Custom-Header": "value", "Another-Header": "value2"}' - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> -

- JSON 格式,用于某些中转站或自建服务的特殊要求。例如:{"X-API-Version": "v1"} -

-
-
+ {/* 使用说明 */} +
+

💡 配置说明

+

LiteLLM 统一适配: 大多数提供商通过 LiteLLM 统一处理,支持自动重试和负载均衡

+

原生适配器: 百度、MiniMax、豆包因 API 格式特殊,使用专用适配器

+

API 中转站: 在 Base URL 填入中转站地址即可,API Key 填中转站提供的 Key

- {/* 平台专用密钥 */} - -
- -
-
-

配置各平台的 API Key

-

方便快速切换。如果设置了通用 API Key,将优先使用通用配置。

-

- 💡 使用 API 中转站的用户注意:这里填入的应该是中转站提供的 API Key,而不是官方 Key。 - 中转站地址请在「LLM 配置」标签页的「API 基础 URL」中填写。 -

-
-
-
- -
- {[ - { key: 'geminiApiKey', label: 'Google Gemini API Key', icon: '🔵', hint: '官方:https://makersuite.google.com/app/apikey | 或使用中转站 Key' }, - { key: 'openaiApiKey', label: 'OpenAI API Key', icon: '🟢', hint: '官方:https://platform.openai.com/api-keys | 或使用中转站 Key' }, - { key: 'claudeApiKey', label: 'Claude API Key', icon: '🟣', hint: '官方:https://console.anthropic.com/ | 或使用中转站 Key' }, - { key: 'qwenApiKey', label: '通义千问 API Key', icon: '🟠', hint: '官方:https://dashscope.console.aliyun.com/ | 或使用中转站 Key' }, - { key: 'deepseekApiKey', label: 'DeepSeek API Key', icon: '🔷', hint: '官方:https://platform.deepseek.com/ | 或使用中转站 Key' }, - { key: 'zhipuApiKey', label: '智谱AI API Key', icon: '🔴', hint: '官方:https://open.bigmodel.cn/ | 或使用中转站 Key' }, - { key: 'moonshotApiKey', label: 'Moonshot API Key', icon: '🌙', hint: '官方:https://platform.moonshot.cn/ | 或使用中转站 Key' }, - { key: 'baiduApiKey', label: '百度文心 API Key', icon: '🔵', hint: '官方格式:API_KEY:SECRET_KEY | 或使用中转站 Key' }, - { key: 'minimaxApiKey', label: 'MiniMax API Key', icon: '⚡', hint: '官方:https://www.minimaxi.com/ | 或使用中转站 Key' }, - { key: 'doubaoApiKey', label: '字节豆包 API Key', icon: '🎯', hint: '官方:https://console.volcengine.com/ark | 或使用中转站 Key' }, - ].map(({ key, label, icon, hint }) => ( -
-
-

- {icon} - {label} -

-

{hint}

-
-
-
- updateConfig(key as keyof SystemConfigData, e.target.value)} - placeholder={`输入 ${label}`} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono text-xs" - /> - -
-
-
- ))} -
- -
-
-

- 🖥️ - Ollama 基础 URL -

-

本地 Ollama 服务的 API 端点

-
-
- updateConfig('ollamaBaseUrl', e.target.value)} - placeholder="http://localhost:11434/v1" - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> -
-
-
- - {/* 分析参数配置 */} + {/* 分析参数 */} -
-
-

代码分析参数

-

调整代码分析的行为和性能

-
-
+
+
- - 最大分析文件数 + updateConfig('maxAnalyzeFiles', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> -

- 单次分析任务最多处理的文件数量 -

+ className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" /> +

单次任务最多处理的文件数量

-
- - LLM 并发数 + updateConfig('llmConcurrency', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> -

- 同时发送给 LLM 的请求数量(降低可避免速率限制) -

+ className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" /> +

同时发送的 LLM 请求数量

-
- - 请求间隔 (毫秒) + updateConfig('llmGapMs', Number(e.target.value))} - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> -

- 每个 LLM 请求之间的延迟时间 -

+ className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" /> +

每个请求之间的延迟时间

-
- - updateConfig('outputLanguage', v)}> + - - 🇨🇳 中文 - 🇺🇸 English + + 🇨🇳 中文 + 🇺🇸 English +

代码审查结果的输出语言

- {/* 其他配置 */} - -
-
-

GitHub 集成

-

配置 GitHub Personal Access Token 以访问私有仓库

+ {/* Git 集成 */} + +
+
+ + updateConfig('githubToken', e.target.value)} + placeholder="ghp_xxxxxxxxxxxx" + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" + /> +

+ 用于访问私有仓库。获取: github.com/settings/tokens +

-
-
- -
- updateConfig('githubToken', e.target.value)} - placeholder="ghp_xxxxxxxxxxxx" - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> - -
-

- 获取:https://github.com/settings/tokens -

-
+
+ + updateConfig('gitlabToken', e.target.value)} + placeholder="glpat-xxxxxxxxxxxx" + className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" + /> +

+ 用于访问私有仓库。获取: gitlab.com/-/profile/personal_access_tokens +

-
- -
-
-

GitLab 集成

-

配置 GitLab Personal Access Token 以访问私有仓库

-
-
-
- -
- updateConfig('gitlabToken', e.target.value)} - placeholder="glpat-xxxxxxxxxxxx" - className="retro-input h-10 bg-gray-50 border-2 border-black text-black placeholder:text-gray-500 focus:ring-0 focus:border-primary rounded-none font-mono" - /> - -
-

- 获取:https://gitlab.com/-/profile/personal_access_tokens -

-
-
-
- -
-
-

配置说明

-
-
-
- -
-

用户配置

-

- 配置保存在后端数据库中,与用户账号绑定。 - 可以在不重新构建 Docker 镜像的情况下修改配置,配置会在所有分析任务中生效。 -

-
-
-
- -
-

配置优先级

-

- 运行时配置 > 构建时配置。如果设置了运行时配置,将覆盖构建时的环境变量。 -

-
-
-
- -
-

安全提示

-

- API Keys 存储在浏览器本地,其他网站无法访问。但清除浏览器数据会删除所有配置。 -

-
-
+
+

💡 提示

+

• 公开仓库无需配置 Token

+

• 私有仓库需要配置对应平台的 Token

- {/* 底部操作按钮 */} + {/* 底部保存按钮 */} {hasChanges && ( -
- -
)}
); } - diff --git a/frontend/src/shared/api/database.ts b/frontend/src/shared/api/database.ts index 0ef2c28..36a72e4 100644 --- a/frontend/src/shared/api/database.ts +++ b/frontend/src/shared/api/database.ts @@ -273,6 +273,34 @@ export const api = { await apiClient.delete('/config/me'); }, + async testLLMConnection(params: { + provider: string; + apiKey: string; + model?: string; + baseUrl?: string; + }): Promise<{ + success: boolean; + message: string; + model?: string; + response?: string; + }> { + const res = await apiClient.post('/config/test-llm', params); + return res.data; + }, + + async getLLMProviders(): Promise<{ + providers: Array<{ + id: string; + name: string; + defaultModel: string; + models: string[]; + defaultBaseUrl: string; + }>; + }> { + const res = await apiClient.get('/config/llm-providers'); + return res.data; + }, + // ==================== 数据库管理相关方法 ==================== async exportDatabase(): Promise<{