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
"""
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 {
@@ -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]]:
@@ -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]]:
"""获取默认模型列表"""
@@ -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]]:
@@ -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]]:
"""获取默认模型列表"""
@@ -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]]:
"""获取默认模型列表"""