Files
qihuaiflow/backend/python/app/api/admin/models.py
T
qyyfw a682dc687e Phase 2.3: Agent Management Module
Backend:
- agents_admin.py: 10 REST API endpoints for AI Agent CRUD
  - GET/POST/PUT/DELETE /admin/agents/
  - Stats overview, reviews, available skills/models
  - Agent = MarketApp with type='agent'

Frontend:
- Agents.tsx: Full admin page (780 lines)
  - Stats dashboard, filterable table, create/edit modal
  - Config: system prompt, temperature, model, linked skills
- api/index.ts: 10 new Agent API methods
- App.tsx: /admin/agents route
- AdminLayout.tsx: Agent management nav item
2026-07-02 20:18:10 +08:00

755 lines
26 KiB
Python

"""
大模型库管理 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("/{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,
}
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