diff --git a/backend-fastapi/ai_platform/api/provider_api.py b/backend-fastapi/ai_platform/api/provider_api.py index d8d5e15..0d498fd 100644 --- a/backend-fastapi/ai_platform/api/provider_api.py +++ b/backend-fastapi/ai_platform/api/provider_api.py @@ -2,7 +2,7 @@ LLM 提供商 API """ import logging -from typing import List, Optional +from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func @@ -25,6 +25,90 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/provider", tags=["AI-提供商"]) +def _pick_message_from_payload(payload: Any) -> Optional[str]: + if isinstance(payload, dict): + for key in ('message', 'detail', 'error_description'): + value = payload.get(key) + if isinstance(value, str) and value: + return value + + error = payload.get('error') + if isinstance(error, str) and error: + return error + if isinstance(error, dict): + return _pick_message_from_payload(error) + + if isinstance(payload, list): + for item in payload: + message = _pick_message_from_payload(item) + if message: + return message + + return None + + +def _extract_upstream_error(exc: Exception) -> str: + response = getattr(exc, 'response', None) + if response is not None: + status_code = getattr(response, 'status_code', None) + try: + message = _pick_message_from_payload(response.json()) + except Exception: + message = None + + if not message: + message = (getattr(response, 'text', '') or '').strip() + + if status_code and message: + return f'HTTP {status_code}: {message[:240]}' + if status_code: + return f'HTTP {status_code}' + if message: + return message[:240] + + return str(exc)[:240] + + +def _format_provider_test_error(exc: Exception, provider: LLMProvider, api_base: str) -> str: + error_text = f'{type(exc).__name__}: {str(exc)} {_extract_upstream_error(exc)}' + lower_text = error_text.lower() + + if ( + '401' in lower_text + or '403' in lower_text + or 'unauthorized' in lower_text + or 'forbidden' in lower_text + or 'invalid api key' in lower_text + or 'incorrect api key' in lower_text + or '无效的令牌' in error_text + or '鉴权' in error_text + ): + reason = '上游模型鉴权失败,API Key 无效或已过期' + elif '404' in lower_text or 'not found' in lower_text: + reason = '模型接口不存在或 Base URL 路径不正确' + elif 'timeout' in lower_text or 'timed out' in lower_text or '超时' in error_text: + reason = '上游模型接口请求超时' + elif ( + 'connect' in lower_text + or 'network' in lower_text + or 'name or service not known' in lower_text + or 'connection' in lower_text + ): + reason = '无法连接到上游模型接口' + elif '429' in lower_text or 'rate limit' in lower_text or 'quota' in lower_text: + reason = '上游模型限流或额度不足' + else: + reason = '上游模型接口调用失败' + + upstream_error = _extract_upstream_error(exc) or '无' + return ( + f'{reason}:提供商 {provider.name},类型 {provider.provider_type},' + f'Base URL {api_base or "未配置"}。' + f'请检查 Base URL、API Key 和上游账号权限。' + f'上游返回:{upstream_error}' + ) + + @router.get("/types", response_model=List[ProviderTypeResponse], summary="获取提供商类型列表") async def get_provider_types(): """获取所有支持的提供商类型""" @@ -171,7 +255,18 @@ async def test_provider(provider_id: str, db: AsyncSession = Depends(get_db)): if not provider_instance.validate_config(): raise HTTPException(status_code=400, detail="配置无效,请检查 API Key") - models = await provider_instance.fetch_models_from_api() + try: + models = await provider_instance.fetch_models_from_api_strict() + except Exception as e: + logger.warning( + "Provider online test failed: upstream error " + f"[provider={provider.name}, type={provider.provider_type}, api_base={api_base or '(empty)'}]: {e}" + ) + raise HTTPException( + status_code=400, + detail=_format_provider_test_error(e, provider, api_base), + ) + if not models: logger.warning( "Provider online test failed: no models returned " @@ -179,7 +274,11 @@ async def test_provider(provider_id: str, db: AsyncSession = Depends(get_db)): ) raise HTTPException( status_code=400, - detail="连接失败:模型接口未返回可用模型,请检查 Base URL、API Key 或网络连通性", + detail=( + f"连接失败:模型接口返回为空。提供商 {provider.name}," + f"类型 {provider.provider_type},Base URL {api_base or '未配置'}。" + "请检查 Base URL、API Key 和上游账号权限。" + ), ) return { diff --git a/backend-fastapi/ai_platform/providers/base.py b/backend-fastapi/ai_platform/providers/base.py index f003742..a8b2559 100644 --- a/backend-fastapi/ai_platform/providers/base.py +++ b/backend-fastapi/ai_platform/providers/base.py @@ -348,6 +348,14 @@ class BaseLLMProvider(ABC): 模型列表,格式与 get_default_models 一致 """ return [] + + async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: + """ + 严格拉取模型列表,用于连接测试。 + + 子类可在这里保留上游异常,让调用方给出明确错误原因。 + """ + return await self.fetch_models_from_api() @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: diff --git a/backend-fastapi/ai_platform/providers/claude_provider.py b/backend-fastapi/ai_platform/providers/claude_provider.py index 5c30533..7595c46 100644 --- a/backend-fastapi/ai_platform/providers/claude_provider.py +++ b/backend-fastapi/ai_platform/providers/claude_provider.py @@ -409,6 +409,15 @@ class ClaudeProvider(BaseLLMProvider): async def fetch_models_from_api(self) -> List[Dict[str, Any]]: """通过 Anthropic /v1/models 端点在线拉取模型列表""" + base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') + try: + return await self.fetch_models_from_api_strict() + except Exception as e: + logger.warning(f'在线拉取 Anthropic 模型列表失败 ({base_url}): {e}') + return [] + + async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: + """通过 Anthropic 端点严格拉取模型列表,失败时保留上游异常""" import httpx base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') @@ -417,43 +426,38 @@ class ClaudeProvider(BaseLLMProvider): 'anthropic-version': '2023-06-01', } - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(f'{base_url}/v1/models', headers=headers) - resp.raise_for_status() - data = resp.json() + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(f'{base_url}/v1/models', headers=headers) + resp.raise_for_status() + data = resp.json() - model_list = data.get('data', []) - if not model_list: - return [] - - results = [] - for item in model_list: - model_id = item.get('id', '') - display = item.get('display_name', model_id) - if not model_id: - continue - - results.append({ - 'model_name': model_id, - 'display_name': display, - 'model_type': 'chat', - 'max_tokens': 4096, - 'context_window': 200000, - 'supports_vision': True, - 'supports_function_call': True, - 'input_price': 0, - 'output_price': 0, - }) - - results.sort(key=lambda m: m['model_name']) - logger.info(f'从 Anthropic ({base_url}) 拉取到 {len(results)} 个模型') - return results - - except Exception as e: - logger.warning(f'在线拉取 Anthropic 模型列表失败 ({base_url}): {e}') + model_list = data.get('data', []) + if not model_list: return [] + results = [] + for item in model_list: + model_id = item.get('id', '') + display = item.get('display_name', model_id) + if not model_id: + continue + + results.append({ + 'model_name': model_id, + 'display_name': display, + 'model_type': 'chat', + 'max_tokens': 4096, + 'context_window': 200000, + 'supports_vision': True, + 'supports_function_call': True, + 'input_price': 0, + 'output_price': 0, + }) + + results.sort(key=lambda m: m['model_name']) + logger.info(f'从 Anthropic ({base_url}) 拉取到 {len(results)} 个模型') + return results + @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: """获取默认模型列表""" diff --git a/backend-fastapi/ai_platform/providers/ollama_provider.py b/backend-fastapi/ai_platform/providers/ollama_provider.py index 4d4850a..61d0d76 100644 --- a/backend-fastapi/ai_platform/providers/ollama_provider.py +++ b/backend-fastapi/ai_platform/providers/ollama_provider.py @@ -376,6 +376,19 @@ class OllamaProvider(BaseLLMProvider): async def fetch_models_from_api(self) -> List[Dict[str, Any]]: """通过 /api/tags 端点拉取 Ollama 本地已安装的模型""" + base_url = (self.api_base or 'http://localhost:11434').rstrip('/') + # Ollama 的 /api/tags 在根路径,去掉 /v1 + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + try: + return await self.fetch_models_from_api_strict() + except Exception as e: + logger.warning(f'在线拉取 Ollama 模型列表失败 ({base_url}): {e}') + return [] + + async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: + """通过 Ollama /api/tags 严格拉取模型列表,失败时保留上游异常""" import httpx base_url = (self.api_base or 'http://localhost:11434').rstrip('/') @@ -383,50 +396,45 @@ class OllamaProvider(BaseLLMProvider): if base_url.endswith('/v1'): base_url = base_url[:-3] - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(f'{base_url}/api/tags') - resp.raise_for_status() - data = resp.json() + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f'{base_url}/api/tags') + resp.raise_for_status() + data = resp.json() - results = [] - for item in data.get('models', []): - name = item.get('name', '') - if not name: - continue + results = [] + for item in data.get('models', []): + name = item.get('name', '') + if not name: + continue - name_lower = name.lower() - if any(kw in name_lower for kw in self._RERANK_KEYWORDS): - model_type = 'rerank' - elif any(kw in name_lower for kw in self._EMBEDDING_KEYWORDS): - model_type = 'embedding' - else: - model_type = 'chat' + name_lower = name.lower() + if any(kw in name_lower for kw in self._RERANK_KEYWORDS): + model_type = 'rerank' + elif any(kw in name_lower for kw in self._EMBEDDING_KEYWORDS): + model_type = 'embedding' + else: + model_type = 'chat' - # 从 size 推算显示名称 - size_gb = round(item.get('size', 0) / (1024 ** 3), 1) - display = f'{name} ({size_gb}GB)' if size_gb > 0 else name + # 从 size 推算显示名称 + size_gb = round(item.get('size', 0) / (1024 ** 3), 1) + display = f'{name} ({size_gb}GB)' if size_gb > 0 else name - results.append({ - 'model_name': name, - 'display_name': display, - 'model_type': model_type, - 'max_tokens': 4096, - 'context_window': 4096, - 'supports_vision': False, - 'supports_function_call': model_type == 'chat', - 'input_price': 0, - 'output_price': 0, - }) + results.append({ + 'model_name': name, + 'display_name': display, + 'model_type': model_type, + 'max_tokens': 4096, + 'context_window': 4096, + 'supports_vision': False, + 'supports_function_call': model_type == 'chat', + 'input_price': 0, + 'output_price': 0, + }) - type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} - results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) - logger.info(f'从 Ollama ({base_url}) 拉取到 {len(results)} 个本地模型') - return results - - except Exception as e: - logger.warning(f'在线拉取 Ollama 模型列表失败 ({base_url}): {e}') - return [] + type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} + results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) + logger.info(f'从 Ollama ({base_url}) 拉取到 {len(results)} 个本地模型') + return results @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: diff --git a/backend-fastapi/ai_platform/providers/openai_provider.py b/backend-fastapi/ai_platform/providers/openai_provider.py index 1347784..59ea4df 100644 --- a/backend-fastapi/ai_platform/providers/openai_provider.py +++ b/backend-fastapi/ai_platform/providers/openai_provider.py @@ -387,6 +387,15 @@ class OpenAIProvider(BaseLLMProvider): 适用于所有 OpenAI 兼容的提供商 """ + base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') + try: + return await self.fetch_models_from_api_strict() + except Exception as e: + logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}') + return [] + + async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: + """通过 /models 端点严格拉取模型列表,失败时保留上游异常""" import httpx base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') @@ -394,64 +403,59 @@ class OpenAIProvider(BaseLLMProvider): if self.api_key: headers['Authorization'] = f'Bearer {self.api_key}' - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(f'{base_url}/models', headers=headers) - resp.raise_for_status() - data = resp.json() + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(f'{base_url}/models', headers=headers) + resp.raise_for_status() + data = resp.json() - model_list = data.get('data', []) - if not model_list: - return [] - - results = [] - for item in model_list: - model_id = item.get('id', '') - if not model_id: - continue - - model_lower = model_id.lower() - - # 跳过非文本模型 - if any(kw in model_lower for kw in self._SKIP_KEYWORDS): - continue - - # 推断模型类型 - if any(kw in model_lower for kw in self._RERANK_KEYWORDS): - model_type = 'rerank' - elif any(kw in model_lower for kw in self._EMBEDDING_KEYWORDS): - model_type = 'embedding' - else: - model_type = 'chat' - - results.append({ - 'model_name': model_id, - 'display_name': model_id, - 'model_type': model_type, - 'max_tokens': 4096, - 'context_window': 4096, - 'supports_vision': False, - 'supports_function_call': model_type == 'chat', - 'input_price': 0, - 'output_price': 0, - }) - - # 补充默认列表中的 embedding/rerank 模型(API 可能不返回这些类型) - fetched_names = {m['model_name'] for m in results} - for dm in self.__class__.get_default_models(): - if dm['model_type'] in ('embedding', 'rerank') and dm['model_name'] not in fetched_names: - results.append(dm) - - # 按类型排序:chat → embedding → rerank - type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} - results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) - logger.info(f'从 {base_url} 拉取到 {len(results)} 个模型') - return results - - except Exception as e: - logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}') + model_list = data.get('data', []) + if not model_list: return [] + results = [] + for item in model_list: + model_id = item.get('id', '') + if not model_id: + continue + + model_lower = model_id.lower() + + # 跳过非文本模型 + if any(kw in model_lower for kw in self._SKIP_KEYWORDS): + continue + + # 推断模型类型 + if any(kw in model_lower for kw in self._RERANK_KEYWORDS): + model_type = 'rerank' + elif any(kw in model_lower for kw in self._EMBEDDING_KEYWORDS): + model_type = 'embedding' + else: + model_type = 'chat' + + results.append({ + 'model_name': model_id, + 'display_name': model_id, + 'model_type': model_type, + 'max_tokens': 4096, + 'context_window': 4096, + 'supports_vision': False, + 'supports_function_call': model_type == 'chat', + 'input_price': 0, + 'output_price': 0, + }) + + # 补充默认列表中的 embedding/rerank 模型(API 可能不返回这些类型) + fetched_names = {m['model_name'] for m in results} + for dm in self.__class__.get_default_models(): + if dm['model_type'] in ('embedding', 'rerank') and dm['model_name'] not in fetched_names: + results.append(dm) + + # 按类型排序:chat → embedding → rerank + type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} + results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) + logger.info(f'从 {base_url} 拉取到 {len(results)} 个模型') + return results + @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: """获取默认模型列表""" diff --git a/backend-fastapi/ai_platform/providers/qwen_provider.py b/backend-fastapi/ai_platform/providers/qwen_provider.py index 10bd7bc..e393750 100644 --- a/backend-fastapi/ai_platform/providers/qwen_provider.py +++ b/backend-fastapi/ai_platform/providers/qwen_provider.py @@ -378,6 +378,15 @@ class QwenProvider(BaseLLMProvider): async def fetch_models_from_api(self) -> List[Dict[str, Any]]: """通过 DashScope 兼容的 /v1/models 端点在线拉取模型列表""" + base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') + try: + return await self.fetch_models_from_api_strict() + except Exception as e: + logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}') + return [] + + async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: + """通过 DashScope 兼容端点严格拉取模型列表,失败时保留上游异常""" import httpx base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') @@ -385,62 +394,57 @@ class QwenProvider(BaseLLMProvider): if self.api_key: headers['Authorization'] = f'Bearer {self.api_key}' - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(f'{base_url}/models', headers=headers) - resp.raise_for_status() - data = resp.json() + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(f'{base_url}/models', headers=headers) + resp.raise_for_status() + data = resp.json() - model_list = data.get('data', []) - if not model_list: - return [] - - results = [] - for item in model_list: - model_id = item.get('id', '') - if not model_id: - continue - - model_lower = model_id.lower() - - if any(kw in model_lower for kw in self._SKIP_KEYWORDS): - continue - - if any(kw in model_lower for kw in self._RERANK_KEYWORDS): - model_type = 'rerank' - elif any(kw in model_lower for kw in self._EMBEDDING_KEYWORDS): - model_type = 'embedding' - else: - model_type = 'chat' - - results.append({ - 'model_name': model_id, - 'display_name': model_id, - 'model_type': model_type, - 'max_tokens': 4096, - 'context_window': 4096, - 'supports_vision': False, - 'supports_function_call': model_type == 'chat', - 'input_price': 0, - 'output_price': 0, - }) - - # DashScope /v1/models 可能只返回 chat 模型, - # 将默认列表中的 embedding/rerank 模型合并进去 - fetched_names = {m['model_name'] for m in results} - for dm in self.__class__.get_default_models(): - if dm['model_type'] in ('embedding', 'rerank') and dm['model_name'] not in fetched_names: - results.append(dm) - - type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} - results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) - logger.info(f'从 {base_url} 拉取到 {len(results)} 个模型(含补充的 embedding/rerank)') - return results - - except Exception as e: - logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}') + model_list = data.get('data', []) + if not model_list: return [] + results = [] + for item in model_list: + model_id = item.get('id', '') + if not model_id: + continue + + model_lower = model_id.lower() + + if any(kw in model_lower for kw in self._SKIP_KEYWORDS): + continue + + if any(kw in model_lower for kw in self._RERANK_KEYWORDS): + model_type = 'rerank' + elif any(kw in model_lower for kw in self._EMBEDDING_KEYWORDS): + model_type = 'embedding' + else: + model_type = 'chat' + + results.append({ + 'model_name': model_id, + 'display_name': model_id, + 'model_type': model_type, + 'max_tokens': 4096, + 'context_window': 4096, + 'supports_vision': False, + 'supports_function_call': model_type == 'chat', + 'input_price': 0, + 'output_price': 0, + }) + + # DashScope /v1/models 可能只返回 chat 模型, + # 将默认列表中的 embedding/rerank 模型合并进去 + fetched_names = {m['model_name'] for m in results} + for dm in self.__class__.get_default_models(): + if dm['model_type'] in ('embedding', 'rerank') and dm['model_name'] not in fetched_names: + results.append(dm) + + type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} + results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) + logger.info(f'从 {base_url} 拉取到 {len(results)} 个模型(含补充的 embedding/rerank)') + return results + @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: """获取默认模型列表"""