""" 大模型库管理 API(管理员) """ from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session from sqlalchemy import func from typing import List, Optional from pydantic import BaseModel from ...database import get_db, SessionLocal from ...models import AIModel, ModelProvider from ...auth import get_current_admin from ...middleware.log_service import LogService import uuid import json import logging import httpx from datetime import datetime router = APIRouter() # ============================================================ # 协议预设列表 # ============================================================ PROTOCOLS = [ {"id": "openai_compatible", "name": "OpenAI兼容", "base_path": "/v1", "description": "兼容OpenAI API格式的模型服务"}, {"id": "new_api", "name": "New API", "base_path": "/v1", "description": "新一代API聚合平台"}, {"id": "volcengine", "name": "火山引擎", "base_path": "/v1", "description": "字节跳动火山引擎模型服务"}, {"id": "aliyun_bailian", "name": "阿里云百炼", "base_path": "/v1", "description": "阿里云百炼大模型平台"}, {"id": "ollama", "name": "Ollama", "base_path": "/v1", "description": "本地Ollama模型服务"}, {"id": "xiaoma", "name": "小马算力", "base_path": "/v1", "description": "小马算力模型服务"}, ] # ============================================================ # 模型列表(用于 fetch-models 的回退数据) # ============================================================ FETCHABLE_MODELS = [ {"id": "gpt-4o", "name": "GPT-4o", "capabilities": ["text", "vision", "tool"], "pricing": "paid"}, {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "capabilities": ["text", "vision"], "pricing": "paid"}, {"id": "deepseek-v3", "name": "DeepSeek-V3", "capabilities": ["text", "thinking"], "pricing": "free"}, {"id": "deepseek-r1", "name": "DeepSeek-R1", "capabilities": ["text", "thinking"], "pricing": "paid"}, {"id": "qwen-max", "name": "Qwen-Max", "capabilities": ["text", "tool"], "pricing": "free"}, {"id": "qwen-vl", "name": "Qwen-VL", "capabilities": ["text", "vision"], "pricing": "paid"}, {"id": "whisper", "name": "Whisper", "capabilities": ["audio"], "pricing": "paid"}, {"id": "dalle", "name": "DALL-E", "capabilities": ["image"], "pricing": "paid"}, ] # 支持通过 API 获取模型列表的协议类型 FETCHABLE_PROTOCOLS = {"openai_compatible", "new_api", "volcengine", "aliyun_bailian", "ollama", "xiaoma"} # ============================================================ # 辅助函数 # ============================================================ def provider_to_dict(p: ModelProvider) -> dict: return { "id": p.id, "name": p.name, "protocol": p.protocol, "api_key": p.api_key, "api_base_url": p.api_base_url, "apiKey": p.api_key, "apiBaseUrl": p.api_base_url, "created_at": p.created_at.isoformat() if p.created_at else None, "updated_at": p.updated_at.isoformat() if p.updated_at else None, "createdAt": p.created_at.isoformat() if p.created_at else None, "updatedAt": p.updated_at.isoformat() if p.updated_at else None, } def model_to_dict(m: AIModel) -> dict: return { "id": m.id, "provider_id": m.provider_id, "name": m.name, "api_key": m.api_key, "endpoint": m.endpoint, "capabilities": m.capabilities, "timeout": m.timeout, "status": m.status, "price_per_million_tokens": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, "is_enabled": m.is_enabled, "is_default": bool(m.is_default), "total_calls": m.total_calls or 0, "total_tokens": m.total_tokens or 0, "created_at": m.created_at.isoformat() if m.created_at else None, "updated_at": m.updated_at.isoformat() if m.updated_at else None, } # ============================================================ # 协议列表 # ============================================================ @router.get("/protocols") async def get_protocols( admin_user=Depends(get_current_admin), ): """获取支持的协议列表""" return PROTOCOLS # ============================================================ # 模型供应商管理 # ============================================================ @router.get("/providers") async def get_providers( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取模型供应商列表""" providers = db.query(ModelProvider).order_by( ModelProvider.created_at.desc() ).all() return [provider_to_dict(p) for p in providers] @router.post("/providers") async def create_provider( name: str = Body(...), protocol: str = Body(...), api_key: str = Body(default=""), api_base_url: str = Body(default=None), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """添加模型供应商""" # 验证协议 valid_protocols = [p["id"] for p in PROTOCOLS] if protocol not in valid_protocols: raise HTTPException(status_code=400, detail=f"无效的协议类型,支持: {', '.join(valid_protocols)}") provider = ModelProvider( id=str(uuid.uuid4()), name=name, protocol=protocol, api_key=api_key, api_base_url=api_base_url, ) db.add(provider) db.commit() db.refresh(provider) LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="create_provider", target_type="model_provider", target_id=provider.id, detail=f"添加模型供应商 {name} (协议: {protocol})", ) return {"message": "供应商已创建", "id": provider.id, "provider": provider_to_dict(provider)} @router.put("/providers/{id}") async def update_provider( id: str, name: str = Body(default=None), protocol: str = Body(default=None), api_key: str = Body(default=None), api_base_url: str = Body(default=None), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新模型供应商""" provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") if name is not None: provider.name = name if protocol is not None: valid_protocols = [p["id"] for p in PROTOCOLS] if protocol not in valid_protocols: raise HTTPException(status_code=400, detail=f"无效的协议类型") provider.protocol = protocol if api_key is not None: provider.api_key = api_key if api_base_url is not None: provider.api_base_url = api_base_url provider.updated_at = datetime.now() db.commit() db.refresh(provider) LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="update_provider", target_type="model_provider", target_id=id, detail=f"更新模型供应商 {provider.name}", ) return {"message": "供应商已更新", "provider": provider_to_dict(provider)} @router.delete("/providers/{id}") async def delete_provider( id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除模型供应商""" provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") # 检查是否有模型关联 model_count = db.query(func.count(AIModel.id)).filter( AIModel.provider_id == id ).scalar() if model_count > 0: raise HTTPException(status_code=400, detail=f"该供应商下有 {model_count} 个模型,请先删除模型") db.delete(provider) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_provider", target_type="model_provider", target_id=id, detail=f"删除模型供应商 {provider.name}", ) return {"message": "供应商已删除"} @router.post("/providers/{id}/fetch-models") async def fetch_models( id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取供应商可用模型列表(优先从真实API获取)""" provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") logger = logging.getLogger(__name__) logger.info(f"Fetching models for provider {provider.name} (protocol: {provider.protocol})") api_base = (provider.api_base_url or "").rstrip("/") api_key = provider.api_key or "" # 尝试从真实API获取模型列表(只要有 api_base 就尝试调用,api_key 可选) if api_base and provider.protocol in FETCHABLE_PROTOCOLS: try: async with httpx.AsyncClient(timeout=15) as client: headers = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" resp = await client.get(f"{api_base}/v1/models", headers=headers) if resp.status_code == 200: data = resp.json() models_list = data.get("data", []) if models_list: result = [] for m in models_list: mid = m.get("id", "") if not mid: continue result.append({ "id": mid, "name": mid, "capabilities": ["text"], "pricing": "paid", }) logger.info(f"Successfully fetched {len(result)} models from provider API") return {"models": result, "source": "api"} else: logger.warning(f"Provider API returned empty model list") return {"models": [], "source": "api", "message": "API返回的模型列表为空"} elif resp.status_code == 401 or resp.status_code == 403: logger.warning(f"Provider API returned {resp.status_code}: auth error") return {"models": [], "source": "error", "message": "API认证失败,请检查API Key是否正确"} else: logger.warning(f"Provider API returned status {resp.status_code}: {resp.text[:200]}") return {"models": [], "source": "error", "message": f"API请求失败(HTTP {resp.status_code}),请检查API地址和密钥"} except httpx.TimeoutException: logger.warning(f"Timeout fetching models from provider API: {api_base}") return {"models": [], "source": "error", "message": "请求API超时,请检查API地址是否正确"} except Exception as e: logger.warning(f"Failed to fetch models from provider API: {e}") return {"models": [], "source": "error", "message": f"请求API失败: {str(e)}"} # 回退:返回固定模型列表(供无 API 的离线环境使用) logger.info(f"Using fallback model list for provider {provider.name}") return {"models": FETCHABLE_MODELS, "source": "fallback"} class AddModelsRequest(BaseModel): model_ids: List[str] @router.post("/providers/{id}/add-models") async def add_models( id: str, req: AddModelsRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """批量添加模型到供应商""" provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") # 找到协议对应的 base_path protocol_info = next((p for p in PROTOCOLS if p["id"] == provider.protocol), None) base_path = protocol_info["base_path"] if protocol_info else "/v1" added = [] for model_id in req.model_ids: # 在 FETCHABLE_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 # 检查是否已存在同名模型 existing = db.query(AIModel).filter( AIModel.provider_id == id, AIModel.name == model_name ).first() if existing: continue # 自动生成 endpoint if provider.api_base_url: endpoint = f"{provider.api_base_url.rstrip('/')}{base_path}/chat/completions" else: endpoint = "" model = AIModel( id=str(uuid.uuid4()), provider_id=id, name=model_name, api_key=provider.api_key or "", endpoint=endpoint, capabilities=model_info.get("capabilities") if model_info else ["text"], timeout=60, status="free" if (model_info and model_info.get("pricing") == "free") else "paid", price_per_million_tokens=0, is_enabled=True, ) db.add(model) added.append(model_name) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="add_models", target_type="model_provider", target_id=id, detail=f"为供应商 {provider.name} 批量添加 {len(added)} 个模型", ) return {"message": f"成功添加 {len(added)} 个模型", "added": added} # ============================================================ # 模型管理 # ============================================================ @router.get("/") async def get_models( provider_id: str = Query(None), status: str = Query(None, description="模型状态筛选: free/paid"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取模型列表(管理员)""" query = db.query(AIModel) if provider_id: query = query.filter(AIModel.provider_id == provider_id) if status: query = query.filter(AIModel.status == status) total = query.count() models = query.order_by(AIModel.created_at.desc()).offset( (page - 1) * page_size ).limit(page_size).all() return { "total": total, "page": page, "page_size": page_size, "items": [model_to_dict(m) for m in models], } @router.post("/") async def create_model( provider_id: str, name: str, api_key: str = None, endpoint: str = None, capabilities: List[str] = None, timeout: int = 60, status: str = "paid", price_per_million_tokens: float = 0, is_enabled: bool = True, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """添加模型""" # 验证供应商存在 provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") model = AIModel( id=str(uuid.uuid4()), provider_id=provider_id, name=name, api_key=api_key, endpoint=endpoint, capabilities=capabilities, timeout=timeout, status=status, price_per_million_tokens=price_per_million_tokens, is_enabled=is_enabled, ) db.add(model) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="create_model", target_type="model", target_id=model.id, detail=f"添加模型 {name}", ) return {"message": "模型已创建", "id": model.id} @router.put("/{id}") async def update_model( id: str, provider_id: str = None, name: str = None, api_key: str = None, endpoint: str = None, capabilities: List[str] = None, timeout: int = None, status: str = None, price_per_million_tokens: float = None, is_enabled: bool = None, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新模型""" model = db.query(AIModel).filter(AIModel.id == id).first() if not model: raise HTTPException(status_code=404, detail="模型不存在") if provider_id is not None: provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first() if not provider: raise HTTPException(status_code=404, detail="供应商不存在") model.provider_id = provider_id if name is not None: model.name = name if api_key is not None: model.api_key = api_key if endpoint is not None: model.endpoint = endpoint if capabilities is not None: model.capabilities = capabilities if timeout is not None: model.timeout = timeout if status is not None: model.status = status if price_per_million_tokens is not None: model.price_per_million_tokens = price_per_million_tokens if is_enabled is not None: model.is_enabled = is_enabled model.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="update_model", target_type="model", target_id=id, detail=f"更新模型 {model.name}", ) return {"message": "模型已更新"} @router.delete("/{id}") async def delete_model( id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除模型""" model = db.query(AIModel).filter(AIModel.id == id).first() if not model: raise HTTPException(status_code=404, detail="模型不存在") model_name = model.name db.delete(model) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_model", target_type="model", target_id=id, detail=f"删除模型 {model_name}", ) return {"message": "模型已删除"} @router.put("/{id}/toggle") async def toggle_model( id: str, is_enabled: bool = Query(...), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """开关模型状态""" model = db.query(AIModel).filter(AIModel.id == id).first() if not model: raise HTTPException(status_code=404, detail="模型不存在") model.is_enabled = is_enabled model.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="toggle_model", target_type="model", target_id=id, detail=f"将模型 {model.name} {'启用' if is_enabled else '禁用'}", ) return {"message": f"模型已{'启用' if is_enabled else '禁用'}"} @router.put("/{id}/set-default") async def set_default_model( id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """设为全局默认模型(取消其他模型的默认状态)""" model = db.query(AIModel).filter(AIModel.id == id).first() if not model: raise HTTPException(status_code=404, detail="模型不存在") # 取消所有模型的默认状态 db.query(AIModel).filter(AIModel.is_default == True).update({"is_default": False}) # 设置当前模型为默认 model.is_default = True model.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="set_default_model", target_type="model", target_id=id, detail=f"设置模型 {model.name} 为全局默认", ) return {"message": f"已将 {model.name} 设为全局默认模型"} @router.get("/stats") async def get_model_stats( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取模型调用统计""" total_calls = db.query(func.sum(AIModel.total_calls)).scalar() or 0 total_tokens = db.query(func.sum(AIModel.total_tokens)).scalar() or 0 return { "total_calls": int(total_calls), "total_tokens": int(total_tokens), } # ============================================================ # 模型连接测试 API (Phase 2) # ============================================================ @router.post("/models/{model_id}/test-connection") async def test_model_connection( model_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """ 测试指定模型的连接是否正常 支持从模型记录或提供商记录读取配置 """ 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="模型不存在") provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() 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" if not api_key: return { "success": False, "message": "未配置 API Key,请在模型或供应商设置中添加", "latency_ms": None, } if not api_url: return { "success": False, "message": "未配置 API 地址,请在模型或供应商设置中添加", "latency_ms": None, } timeout = ai_model.timeout or 60 import time 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: data = resp.json() model_used = data.get("model", ai_model.name) return { "success": True, "message": f"连接成功,使用模型: {model_used}", "latency_ms": latency, "model_in_response": model_used, } except Exception: return { "success": True, "message": f"连接成功 (HTTP {resp.status_code})", "latency_ms": latency, } elif resp.status_code == 401: return { "success": False, "message": "认证失败:API Key 无效或已过期", "latency_ms": latency, } 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, } 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, } except httpx.TimeoutException: latency = int((time.time() - start) * 1000) return { "success": False, "message": f"连接超时({timeout}s):请检查 API 地址是否正确,或服务是否可访问", "latency_ms": latency, } except Exception as e: latency = int((time.time() - start) * 1000) logger.error(f"Model connection test failed: {e}") return { "success": False, "message": f"连接失败: {str(e)}", "latency_ms": latency, } @router.post("/models/{model_id}/set-default") async def set_default_model( model_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """ 设置全局默认模型(同时关闭其他模型的默认标记) """ ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() if not ai_model: raise HTTPException(status_code=404, detail="模型不存在") if not ai_model.is_enabled: raise HTTPException(status_code=400, detail="只能将已启用的模型设为默认") # 关闭所有其他模型的默认标记 db.query(AIModel).filter(AIModel.is_default == True).update({"is_default": False}) # 开启此模型 ai_model.is_default = True ai_model.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="set_default_model", target_type="ai_model", target_id=model_id, detail=f"设置默认模型为 {ai_model.name}", ) return {"message": f"已将 {ai_model.name} 设为默认模型"} class CreateModelRequest(BaseModel): name: str provider_id: str api_key: Optional[str] = None endpoint: Optional[str] = None capabilities: Optional[List[str]] = ["text"] timeout: int = 60 status: str = "paid" price_per_million_tokens: float = 0.0 is_enabled: bool = True class UpdateModelRequest(BaseModel): name: Optional[str] = None api_key: Optional[str] = None endpoint: Optional[str] = None capabilities: Optional[List[str]] = None timeout: Optional[int] = None status: Optional[str] = None price_per_million_tokens: Optional[float] = None is_enabled: Optional[bool] = None from typing import Optional