From 6ccf6d6867664ef929420341c9d8dee93805cb8f Mon Sep 17 00:00:00 2001
From: qyyfw
Date: Sun, 5 Jul 2026 21:44:18 +0800
Subject: [PATCH] =?UTF-8?q?Phase=20C4:=20=E6=A8=A1=E5=9E=8B=E8=BF=9E?=
=?UTF-8?q?=E6=8E=A5=E6=B5=8B=E8=AF=95=E5=A2=9E=E5=BC=BA=20-=20=E5=A4=9A?=
=?UTF-8?q?=E6=A8=A1=E6=80=81=E6=B5=8B=E8=AF=95+=E6=89=B9=E9=87=8F?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95+=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/python/app/api/admin/models.py | 307 ++++++++++++++++++++-----
frontend/src/api/index.ts | 1 +
frontend/src/pages/admin/Models.tsx | 155 +++++++++++--
3 files changed, 392 insertions(+), 71 deletions(-)
diff --git a/backend/python/app/api/admin/models.py b/backend/python/app/api/admin/models.py
index 1fa74ce..5f36f7f 100644
--- a/backend/python/app/api/admin/models.py
+++ b/backend/python/app/api/admin/models.py
@@ -75,6 +75,7 @@ def model_to_dict(m: AIModel) -> dict:
"id": m.id,
"provider_id": m.provider_id,
"name": m.name,
+ "display_name": getattr(m, "display_name", None) or m.name,
"api_key": m.api_key,
"endpoint": m.endpoint,
"capabilities": m.capabilities,
@@ -320,7 +321,8 @@ async def add_models(
model_info = next((m for m in FETCHABLE_MODELS if m["id"] == model_id), None)
# 使用 model_id 作为模型名称
- model_name = model_info["name"] if model_info else model_id
+ model_name = model_id # API ID
+ display_name_val = model_info["name"] if model_info else model_id
# 检查是否已存在同名模型
existing = db.query(AIModel).filter(
@@ -340,6 +342,7 @@ async def add_models(
id=str(uuid.uuid4()),
provider_id=id,
name=model_name,
+ display_name=display_name_val,
api_key=provider.api_key or "",
endpoint=endpoint,
capabilities=model_info.get("capabilities") if model_info else ["text"],
@@ -403,6 +406,7 @@ async def get_models(
async def create_model(
provider_id: str,
name: str,
+ display_name: str = None,
api_key: str = None,
endpoint: str = None,
capabilities: List[str] = None,
@@ -423,6 +427,7 @@ async def create_model(
id=str(uuid.uuid4()),
provider_id=provider_id,
name=name,
+ display_name=display_name,
api_key=api_key,
endpoint=endpoint,
capabilities=capabilities,
@@ -451,6 +456,7 @@ async def update_model(
id: str,
provider_id: str = None,
name: str = None,
+ display_name: str = None,
api_key: str = None,
endpoint: str = None,
capabilities: List[str] = None,
@@ -473,6 +479,8 @@ async def update_model(
model.provider_id = provider_id
if name is not None:
model.name = name
+ if display_name is not None:
+ model.display_name = display_name
if api_key is not None:
model.api_key = api_key
if endpoint is not None:
@@ -606,6 +614,41 @@ async def get_model_stats(
# 模型连接测试 API (Phase 2)
# ============================================================
+def _detect_model_type(capabilities):
+ """根据 capabilities 判断模型类型,返回测试类型"""
+ if not capabilities:
+ return "text"
+ caps = [c.lower() for c in capabilities]
+ # 优先级: image > audio > video > vision > text
+ if "image" in caps:
+ return "image"
+ if "audio" in caps:
+ return "audio"
+ if "video" in caps:
+ return "video"
+ if "vision" in caps:
+ return "vision"
+ return "text"
+
+
+def _build_api_base(provider, ai_model):
+ """构造 API base URL 和 headers"""
+ api_key = ai_model.api_key or (provider.api_key if provider else None)
+ base_url = ""
+ if provider and provider.api_base_url:
+ base_url = provider.api_base_url.rstrip("/")
+ elif ai_model.endpoint:
+ # 从 endpoint 反推 base
+ endpoint = ai_model.endpoint
+ for suffix in ["/v1/chat/completions", "/v1/images/generations", "/v1/audio/speech", "/v1/audio/transcriptions"]:
+ if endpoint.endswith(suffix):
+ base_url = endpoint[:-len(suffix)]
+ break
+ if not base_url:
+ base_url = endpoint.rsplit("/v1", 1)[0] if "/v1" in endpoint else ""
+ return api_key, base_url
+
+
@router.post("/{model_id}/test-connection")
async def test_model_connection(
model_id: str,
@@ -614,12 +657,13 @@ async def test_model_connection(
):
"""
测试指定模型的连接是否正常
- 支持从模型记录或提供商记录读取配置
+ 支持多模态: text/image/audio/vision 自动检测模型类型并使用对应API测试
"""
+ import time
+
logger = logging.getLogger(__name__)
logger.info(f"Testing connection for model_id: {model_id}")
- # 优先从 AIModel 读取
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
if not ai_model:
raise HTTPException(status_code=404, detail="模型不存在")
@@ -628,101 +672,163 @@ async def test_model_connection(
if not provider:
raise HTTPException(status_code=404, detail="模型供应商不存在")
- # 确定最终配置
- api_key = ai_model.api_key or (provider.api_key if provider else None)
- api_url = ai_model.endpoint
-
- # 如果 endpoint 为空,尝试构造
- if not api_url and provider and provider.api_base_url:
- base = provider.api_base_url.rstrip('/')
- if provider.protocol == 'openai_compatible':
- api_url = f"{base}/v1/chat/completions"
- else:
- api_url = f"{base}/v1/chat/completions"
+ api_key, base_url = _build_api_base(provider, ai_model)
if not api_key:
return {
"success": False,
"message": "未配置 API Key,请在模型或供应商设置中添加",
"latency_ms": None,
+ "model_type": "unknown",
}
- if not api_url:
+ if not base_url:
return {
"success": False,
"message": "未配置 API 地址,请在模型或供应商设置中添加",
"latency_ms": None,
+ "model_type": "unknown",
}
+ model_type = _detect_model_type(ai_model.capabilities)
timeout = ai_model.timeout or 60
- import time
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
start = time.time()
try:
async with httpx.AsyncClient(timeout=timeout) as client:
- headers = {
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- }
- # 使用 minimal payload 测试连接
- payload = {
- "model": ai_model.name,
- "messages": [{"role": "user", "content": "hi"}],
- "max_tokens": 5,
- }
- resp = await client.post(api_url, json=payload, headers=headers)
- latency = int((time.time() - start) * 1000)
- if resp.status_code == 200:
- try:
+ # ============================================================
+ # 文本模型 / 视觉模型 / 思考模型 → chat/completions
+ # ============================================================
+ if model_type in ("text", "vision", "thinking", "tool"):
+ chat_url = f"{base_url}/v1/chat/completions"
+ payload = {
+ "model": ai_model.name,
+ "messages": [{"role": "user", "content": "hi"}],
+ "max_tokens": 5,
+ }
+ # 视觉模型附带图片URL测试
+ if model_type == "vision":
+ payload["messages"] = [{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "What is this?"},
+ {"type": "image_url", "image_url": {"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png"}},
+ ],
+ }]
+ payload["max_tokens"] = 10
+
+ resp = await client.post(chat_url, json=payload, headers=headers)
+ latency = int((time.time() - start) * 1000)
+
+ if resp.status_code == 200:
data = resp.json()
model_used = data.get("model", ai_model.name)
return {
"success": True,
- "message": f"连接成功,使用模型: {model_used}",
+ "message": f"连接成功 ({model_type}),模型: {model_used}",
"latency_ms": latency,
+ "model_type": model_type,
"model_in_response": model_used,
}
- except Exception:
+ else:
+ return _build_error_response(resp, latency, model_type)
+
+ # ============================================================
+ # 图像生成模型 → images/generations
+ # ============================================================
+ elif model_type == "image":
+ image_url = f"{base_url}/v1/images/generations"
+ payload = {
+ "model": ai_model.name,
+ "prompt": "a red circle",
+ "n": 1,
+ "size": "256x256",
+ }
+ resp = await client.post(image_url, json=payload, headers=headers)
+ latency = int((time.time() - start) * 1000)
+
+ if resp.status_code == 200:
+ data = resp.json()
+ img_count = len(data.get("data", []))
return {
"success": True,
- "message": f"连接成功 (HTTP {resp.status_code})",
+ "message": f"图像生成成功 ({model_type}),返回 {img_count} 张图片",
"latency_ms": latency,
+ "model_type": model_type,
}
- elif resp.status_code == 401:
- return {
- "success": False,
- "message": "认证失败:API Key 无效或已过期",
- "latency_ms": latency,
+ else:
+ return _build_error_response(resp, latency, model_type)
+
+ # ============================================================
+ # 音频模型 → audio/speech (TTS)
+ # ============================================================
+ elif model_type == "audio":
+ audio_url = f"{base_url}/v1/audio/speech"
+ payload = {
+ "model": ai_model.name,
+ "input": "hello",
+ "voice": "alloy",
}
- elif resp.status_code == 403:
- return {
- "success": False,
- "message": "访问被拒绝:API Key 权限不足",
- "latency_ms": latency,
- }
- elif resp.status_code == 429:
- return {
- "success": False,
- "message": "触发限流:请稍后重试",
- "latency_ms": latency,
+ resp = await client.post(audio_url, json=payload, headers=headers)
+ latency = int((time.time() - start) * 1000)
+
+ if resp.status_code == 200:
+ content_type = resp.headers.get("content-type", "")
+ has_audio = "audio" in content_type or len(resp.content) > 100
+ return {
+ "success": True,
+ "message": f"音频服务连接成功 ({model_type}),响应类型: {content_type}",
+ "latency_ms": latency,
+ "model_type": model_type,
+ }
+ else:
+ return _build_error_response(resp, latency, model_type)
+
+ # ============================================================
+ # 视频模型 → 尝试 chat/completions (可能不支持,回退)
+ # ============================================================
+ elif model_type == "video":
+ chat_url = f"{base_url}/v1/chat/completions"
+ payload = {
+ "model": ai_model.name,
+ "messages": [{"role": "user", "content": "hi"}],
+ "max_tokens": 5,
}
+ resp = await client.post(chat_url, json=payload, headers=headers)
+ latency = int((time.time() - start) * 1000)
+
+ if resp.status_code == 200:
+ data = resp.json()
+ model_used = data.get("model", ai_model.name)
+ return {
+ "success": True,
+ "message": f"连接成功 ({model_type}),模型: {model_used}",
+ "latency_ms": latency,
+ "model_type": model_type,
+ "model_in_response": model_used,
+ }
+ else:
+ return _build_error_response(resp, latency, model_type)
+
else:
- try:
- err_data = resp.json()
- err_msg = err_data.get("error", {}).get("message", resp.text[:200])
- except Exception:
- err_msg = resp.text[:200]
return {
"success": False,
- "message": f"API 错误 (HTTP {resp.status_code}): {err_msg}",
- "latency_ms": latency,
+ "message": f"未知模型类型: {model_type}",
+ "latency_ms": int((time.time() - start) * 1000),
+ "model_type": model_type,
}
+
except httpx.TimeoutException:
latency = int((time.time() - start) * 1000)
return {
"success": False,
"message": f"连接超时({timeout}s):请检查 API 地址是否正确,或服务是否可访问",
"latency_ms": latency,
+ "model_type": model_type,
}
except Exception as e:
latency = int((time.time() - start) * 1000)
@@ -731,9 +837,98 @@ async def test_model_connection(
"success": False,
"message": f"连接失败: {str(e)}",
"latency_ms": latency,
+ "model_type": model_type,
}
+def _build_error_response(resp, latency, model_type):
+ """构造错误响应"""
+ if resp.status_code == 401:
+ msg = "认证失败:API Key 无效或已过期"
+ elif resp.status_code == 403:
+ msg = "访问被拒绝:API Key 权限不足"
+ elif resp.status_code == 429:
+ msg = "触发限流:请稍后重试"
+ elif resp.status_code == 404:
+ msg = f"API 地址不存在 (404):请检查 endpoint 配置是否正确"
+ else:
+ try:
+ err_data = resp.json()
+ err_msg = err_data.get("error", {}).get("message", resp.text[:200])
+ except Exception:
+ err_msg = resp.text[:200]
+ msg = f"API 错误 (HTTP {resp.status_code}): {err_msg}"
+ return {
+ "success": False,
+ "message": msg,
+ "latency_ms": latency,
+ "model_type": model_type,
+ }
+
+
+@router.post("/test-all")
+async def test_all_models(
+ admin_user=Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """批量测试所有已启用模型的连接状态"""
+ import asyncio
+
+ models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
+ results = []
+
+ for m in models:
+ provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first()
+ if not provider:
+ results.append({
+ "model_id": m.id,
+ "model_name": m.name,
+ "display_name": m.display_name or m.name,
+ "success": False,
+ "message": "供应商不存在",
+ "latency_ms": None,
+ "model_type": "unknown",
+ })
+ continue
+
+ # 复用 test_model_connection 逻辑
+ try:
+ result = await test_model_connection(m.id, admin_user, db)
+ if isinstance(result, dict):
+ result["model_id"] = m.id
+ result["model_name"] = m.name
+ result["display_name"] = m.display_name or m.name
+ results.append(result)
+ else:
+ results.append({
+ "model_id": m.id,
+ "model_name": m.name,
+ "display_name": m.display_name or m.name,
+ "success": False,
+ "message": "测试返回异常",
+ "latency_ms": None,
+ "model_type": "unknown",
+ })
+ except Exception as e:
+ results.append({
+ "model_id": m.id,
+ "model_name": m.name,
+ "display_name": m.display_name or m.name,
+ "success": False,
+ "message": f"测试异常: {str(e)}",
+ "latency_ms": None,
+ "model_type": "unknown",
+ })
+
+ success_count = sum(1 for r in results if r.get("success"))
+ return {
+ "total": len(results),
+ "success": success_count,
+ "failed": len(results) - success_count,
+ "results": results,
+ }
+
+
timeout: int = 60
status: str = "paid"
price_per_million_tokens: float = 0.0
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 4408c42..61b049d 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -309,6 +309,7 @@ export const adminAPI = {
setDefaultModel: (id: string) => put(`/admin/models/${id}/set-default`),
// Phase 2: Enhanced model management
testModelConnection: (id: string) => post(`/admin/models/${id}/test-connection`),
+ testAllModels: () => post('/admin/models/test-all'),
fetchProviderModels: (id: string) => post(`/admin/models/providers/${id}/fetch-models`),
createAdminModel: (data: any) => post('/admin/models/', data),
updateAdminModel: (id: string, data: any) => put(`/admin/models/${id}`, data),
diff --git a/frontend/src/pages/admin/Models.tsx b/frontend/src/pages/admin/Models.tsx
index a8d15e2..2da9b29 100644
--- a/frontend/src/pages/admin/Models.tsx
+++ b/frontend/src/pages/admin/Models.tsx
@@ -94,8 +94,10 @@ const Models: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [modelSearch, setModelSearch] = useState('');
- const [testResult, setTestResult] = useState(null);
- const [testingModelId, setTestingModelId] = useState(null);
+ const [testResults, setTestResults] = useState>({});
+ const [testingIds, setTestingIds] = useState>(new Set());
+ const [testingAll, setTestingAll] = useState(false);
+ const [expandedTestResult, setExpandedTestResult] = useState(null);
const [showModelEditor, setShowModelEditor] = useState(false);
const [editingModel, setEditingModel] = useState(null);
const [modelSaving, setModelSaving] = useState(false);
@@ -147,20 +149,64 @@ const Models: React.FC = () => {
);
}, [models, modelSearch]);
- // Test model connection
+ // Test single model connection
const handleTestConnection = async (modelId: string) => {
- setTestingModelId(modelId);
- setTestResult(null);
+ setTestingIds(prev => new Set(prev).add(modelId));
try {
const result = await adminAPI.testModelConnection(modelId) as any;
- setTestResult(result.success !== undefined ? result : (result.data || result as TestResult));
+ const testResult: TestResult = result.success !== undefined ? result : (result.data || result as TestResult);
+ setTestResults(prev => ({ ...prev, [modelId]: testResult }));
} catch (err: any) {
- setTestResult({ success: false, message: err.message || '测试失败' });
+ setTestResults(prev => ({ ...prev, [modelId]: { success: false, message: err.message || '测试失败' } }));
} finally {
- setTestingModelId(null);
+ setTestingIds(prev => { const next = new Set(prev); next.delete(modelId); return next; });
}
};
+ // Test all models
+ const handleTestAll = async () => {
+ setTestingAll(true);
+ setTestResults({});
+ try {
+ const result = await adminAPI.testAllModels() as any;
+ const data = result.data || result;
+ const resultsMap: Record = {};
+ if (data.results) {
+ for (const r of data.results) {
+ resultsMap[r.model_id] = {
+ success: r.success,
+ message: r.message,
+ latency_ms: r.latency_ms,
+ };
+ }
+ }
+ setTestResults(resultsMap);
+ } catch (err: any) {
+ setError('批量测试失败: ' + (err.message || ''));
+ } finally {
+ setTestingAll(false);
+ }
+ };
+
+ // Get model type label
+ const getModelType = (caps: string[] | undefined): string => {
+ if (!caps || caps.length === 0) return 'text';
+ const lower = caps.map(c => c.toLowerCase());
+ if (lower.includes('image')) return 'image';
+ if (lower.includes('audio')) return 'audio';
+ if (lower.includes('video')) return 'video';
+ if (lower.includes('vision')) return 'vision';
+ return 'text';
+ };
+
+ const modelTypeLabels: Record = {
+ text: { label: '文本', color: '#5B5FED' },
+ image: { label: '图像生成', color: '#D9383A' },
+ audio: { label: '音频', color: '#E8922C' },
+ video: { label: '视频', color: '#EC4899' },
+ vision: { label: '视觉理解', color: '#219B5E' },
+ };
+
const handleSetDefault = async (modelId: string) => {
try {
await adminAPI.setDefaultModel(modelId);
@@ -319,6 +365,11 @@ const Models: React.FC = () => {
管理 AI 模型供应商、模型配置和连接测试
+