fix: report provider test upstream errors
This commit is contained in:
@@ -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]]:
|
||||||
"""获取默认模型列表"""
|
"""获取默认模型列表"""
|
||||||
|
|||||||
Reference in New Issue
Block a user