fix: report provider test upstream errors

This commit is contained in:
2026-06-22 00:38:04 +08:00
parent 8407232dc6
commit f180fff020
6 changed files with 311 additions and 184 deletions
+102 -3
View File
@@ -2,7 +2,7 @@
LLM 提供商 API LLM 提供商 API
""" """
import logging import logging
from typing import List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -25,6 +25,90 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/provider", tags=["AI-提供商"]) 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="获取提供商类型列表") @router.get("/types", response_model=List[ProviderTypeResponse], summary="获取提供商类型列表")
async def get_provider_types(): 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(): if not provider_instance.validate_config():
raise HTTPException(status_code=400, detail="配置无效,请检查 API Key") 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: if not models:
logger.warning( logger.warning(
"Provider online test failed: no models returned " "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( raise HTTPException(
status_code=400, 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 { return {
@@ -349,6 +349,14 @@ class BaseLLMProvider(ABC):
""" """
return [] return []
async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]:
"""
严格拉取模型列表,用于连接测试。
子类可在这里保留上游异常,让调用方给出明确错误原因。
"""
return await self.fetch_models_from_api()
@classmethod @classmethod
def get_default_models(cls) -> List[Dict[str, Any]]: def get_default_models(cls) -> List[Dict[str, Any]]:
""" """
@@ -409,6 +409,15 @@ class ClaudeProvider(BaseLLMProvider):
async def fetch_models_from_api(self) -> List[Dict[str, Any]]: async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
"""通过 Anthropic /v1/models 端点在线拉取模型列表""" """通过 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 import httpx
base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/')
@@ -417,7 +426,6 @@ class ClaudeProvider(BaseLLMProvider):
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
} }
try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f'{base_url}/v1/models', headers=headers) resp = await client.get(f'{base_url}/v1/models', headers=headers)
resp.raise_for_status() resp.raise_for_status()
@@ -450,10 +458,6 @@ class ClaudeProvider(BaseLLMProvider):
logger.info(f'从 Anthropic ({base_url}) 拉取到 {len(results)} 个模型') logger.info(f'从 Anthropic ({base_url}) 拉取到 {len(results)} 个模型')
return results return results
except Exception as e:
logger.warning(f'在线拉取 Anthropic 模型列表失败 ({base_url}): {e}')
return []
@classmethod @classmethod
def get_default_models(cls) -> List[Dict[str, Any]]: def get_default_models(cls) -> List[Dict[str, Any]]:
"""获取默认模型列表""" """获取默认模型列表"""
@@ -376,6 +376,19 @@ class OllamaProvider(BaseLLMProvider):
async def fetch_models_from_api(self) -> List[Dict[str, Any]]: async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
"""通过 /api/tags 端点拉取 Ollama 本地已安装的模型""" """通过 /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 import httpx
base_url = (self.api_base or 'http://localhost:11434').rstrip('/') base_url = (self.api_base or 'http://localhost:11434').rstrip('/')
@@ -383,7 +396,6 @@ class OllamaProvider(BaseLLMProvider):
if base_url.endswith('/v1'): if base_url.endswith('/v1'):
base_url = base_url[:-3] base_url = base_url[:-3]
try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f'{base_url}/api/tags') resp = await client.get(f'{base_url}/api/tags')
resp.raise_for_status() resp.raise_for_status()
@@ -424,10 +436,6 @@ class OllamaProvider(BaseLLMProvider):
logger.info(f'从 Ollama ({base_url}) 拉取到 {len(results)} 个本地模型') logger.info(f'从 Ollama ({base_url}) 拉取到 {len(results)} 个本地模型')
return results return results
except Exception as e:
logger.warning(f'在线拉取 Ollama 模型列表失败 ({base_url}): {e}')
return []
@classmethod @classmethod
def get_default_models(cls) -> List[Dict[str, Any]]: def get_default_models(cls) -> List[Dict[str, Any]]:
"""获取默认模型列表(常用的开源模型)""" """获取默认模型列表(常用的开源模型)"""
@@ -387,6 +387,15 @@ class OpenAIProvider(BaseLLMProvider):
适用于所有 OpenAI 兼容的提供商 适用于所有 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 import httpx
base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/')
@@ -394,7 +403,6 @@ class OpenAIProvider(BaseLLMProvider):
if self.api_key: if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}' headers['Authorization'] = f'Bearer {self.api_key}'
try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f'{base_url}/models', headers=headers) resp = await client.get(f'{base_url}/models', headers=headers)
resp.raise_for_status() resp.raise_for_status()
@@ -448,10 +456,6 @@ class OpenAIProvider(BaseLLMProvider):
logger.info(f'{base_url} 拉取到 {len(results)} 个模型') logger.info(f'{base_url} 拉取到 {len(results)} 个模型')
return results return results
except Exception as e:
logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}')
return []
@classmethod @classmethod
def get_default_models(cls) -> List[Dict[str, Any]]: def get_default_models(cls) -> List[Dict[str, Any]]:
"""获取默认模型列表""" """获取默认模型列表"""
@@ -378,6 +378,15 @@ class QwenProvider(BaseLLMProvider):
async def fetch_models_from_api(self) -> List[Dict[str, Any]]: async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
"""通过 DashScope 兼容的 /v1/models 端点在线拉取模型列表""" """通过 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 import httpx
base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/')
@@ -385,7 +394,6 @@ class QwenProvider(BaseLLMProvider):
if self.api_key: if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}' headers['Authorization'] = f'Bearer {self.api_key}'
try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(f'{base_url}/models', headers=headers) resp = await client.get(f'{base_url}/models', headers=headers)
resp.raise_for_status() resp.raise_for_status()
@@ -437,10 +445,6 @@ class QwenProvider(BaseLLMProvider):
logger.info(f'{base_url} 拉取到 {len(results)} 个模型(含补充的 embedding/rerank') logger.info(f'{base_url} 拉取到 {len(results)} 个模型(含补充的 embedding/rerank')
return results return results
except Exception as e:
logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}')
return []
@classmethod @classmethod
def get_default_models(cls) -> List[Dict[str, Any]]: def get_default_models(cls) -> List[Dict[str, Any]]:
"""获取默认模型列表""" """获取默认模型列表"""