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 模型供应商、模型配置和连接测试

+ - {testResult && testingModelId === null && ( - - {testResult.success ? `✓ ${testResult.latency_ms || 0}ms` : '✗ 失败'} + {testResults[model.id] && ( + setExpandedTestResult(expandedTestResult === model.id ? null : model.id)} + style={{ + fontSize: '11px', padding: '4px 8px', cursor: 'pointer', + background: testResults[model.id].success ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)', + color: testResults[model.id].success ? '#22C55E' : '#EF4444', + borderRadius: '4px', border: `1px solid ${testResults[model.id].success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`, + whiteSpace: 'nowrap', + }}> + {testResults[model.id].success ? `✓ ${testResults[model.id].latency_ms || 0}ms` : '✗ 失败'} )} {!model.isDefault && model.isEnabled && ( )} + {expandedTestResult === model.id && testResults[model.id] && ( +
setExpandedTestResult(null)}> +
e.stopPropagation()}> +
+

连接测试详情

+ +
+
+ 模型 + {model.name} +
+
+ 类型 + + {modelTypeLabels[getModelType(model.capabilities)].label} + +
+
+ 状态 + + {testResults[model.id].success ? '✓ 成功' : '✗ 失败'} + +
+ {testResults[model.id].latency_ms !== undefined && testResults[model.id].latency_ms !== null && ( +
+ 延迟 + + {testResults[model.id].latency_ms}ms + +
+ )} +
+ 详情 +
+ {testResults[model.id].message} +
+
+
+
+ )}