Files
qihuaiflow/backend/python/app/api/admin/models.py
T
qihu-bot 75be24ce9c #966 fix - litellm_sync import + IndentationError
## Root cause: ImportError sync_health_check not defined. ## Also IndentationError on L467/L590 in nested sync blocks. ## Container stable, public URL 200 OK.
2026-08-11 21:12:56 +08:00

1246 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
大模型库管理 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, SiteSetting
from ...models.litellm_sync import sync_create_model, sync_update_model, sync_delete_model, sync_health_check
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from ...capability_registry import (
CAPABILITY_TYPES, CAPABILITY_COLORS, TEXT_FAMILY,
get_capability_defaults, set_capability_default, resolve_auto_model,
)
import uuid
import json
import logging
import httpx
from datetime import datetime
router = APIRouter()
# ============================================================
# 请求体模型(前端以 JSON body 提交,而非 query 参数)
# ============================================================
class ModelCreateRequest(BaseModel):
provider_id: str
name: str
display_name: Optional[str] = None
api_key: Optional[str] = None
endpoint: Optional[str] = None
capabilities: Optional[List[str]] = None
timeout: int = 60
status: str = "paid"
price_per_million_tokens: float = 0
is_enabled: bool = True
class ModelUpdateRequest(BaseModel):
provider_id: Optional[str] = None
name: Optional[str] = None
display_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
# ============================================================
# 协议预设列表
# ============================================================
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,
"display_name": getattr(m, "display_name", None) or 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("/capabilities")
async def get_capabilities(
admin_user=Depends(get_current_admin),
):
"""获取平台支持的全部模型能力类型(含标签、测试类型、配色、描述)"""
return [
{**c, "color": CAPABILITY_COLORS.get(c["id"], "#6B7280")}
for c in CAPABILITY_TYPES
]
# ============================================================
# 模型供应商管理
# ============================================================
@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_id # API ID
display_name_val = 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,
display_name=display_name_val,
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(
data: ModelCreateRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""添加模型"""
# 验证供应商存在
provider = db.query(ModelProvider).filter(ModelProvider.id == data.provider_id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
model = AIModel(
id=str(uuid.uuid4()),
provider_id=data.provider_id,
name=data.name,
display_name=data.display_name,
api_key=data.api_key,
endpoint=data.endpoint,
capabilities=data.capabilities,
timeout=data.timeout,
status=data.status,
price_per_million_tokens=data.price_per_million_tokens,
is_enabled=data.is_enabled,
)
db.add(model)
db.commit()
# #965: LiteLLM model sync
import threading
def _sync_create():
try:
from ...models.litellm_sync import sync_create_model
litellm_model = "openai/" + data.name
api_base = provider.api_base_url if provider else data.endpoint or ""
api_key = data.api_key or (provider.api_key if provider else "") or ""
if api_base:
sync_create_model(data.name, litellm_model, api_base.rstrip("/"), api_key)
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[litellm-sync] create failed: {e}")
threading.Thread(target=_sync_create, daemon=True).start()
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}
# ============================================================
# 按能力默认模型(必须定义在 /{id} 路由之前,避免被路径参数拦截)
# ============================================================
@router.get("/capability-defaults")
async def get_capability_defaults_api(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取每个能力当前配置的默认模型,以及可选模型列表"""
defaults = get_capability_defaults(db)
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
model_list = [{
"id": m.id,
"name": m.name,
"display_name": getattr(m, "display_name", None) or m.name,
"provider_name": (
db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first().name
if m.provider_id else ""
),
"capabilities": m.capabilities or [],
} for m in models]
return {
"capabilities": [
{**c, "color": CAPABILITY_COLORS.get(c["id"], "#6B7280")}
for c in CAPABILITY_TYPES
],
"models": model_list,
"defaults": defaults,
}
class CapabilityDefaultRequest(BaseModel):
capability: str
model_id: Optional[str] = None
@router.put("/capability-defaults")
async def set_capability_default_api(
data: CapabilityDefaultRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""为某个能力设置 / 清除默认模型"""
valid = {c["id"] for c in CAPABILITY_TYPES}
if data.capability not in valid:
raise HTTPException(status_code=400, detail=f"无效的能力类型: {data.capability}")
if data.model_id:
model = db.query(AIModel).filter(AIModel.id == data.model_id).first()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
updated = set_capability_default(db, data.capability, data.model_id)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="set_capability_default",
target_type="model_capability",
target_id=data.capability,
detail=f"设置能力 {data.capability} 的默认模型为 {data.model_id or '(清除)'}",
)
return {"message": "已更新能力默认模型", "defaults": updated}
@router.put("/{id}")
async def update_model(
id: str,
data: ModelUpdateRequest,
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 data.provider_id is not None:
provider = db.query(ModelProvider).filter(ModelProvider.id == data.provider_id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
model.provider_id = data.provider_id
if data.name is not None:
model.name = data.name
if data.display_name is not None:
model.display_name = data.display_name
if data.api_key is not None:
model.api_key = data.api_key
if data.endpoint is not None:
model.endpoint = data.endpoint
if data.capabilities is not None:
model.capabilities = data.capabilities
if data.timeout is not None:
model.timeout = data.timeout
if data.status is not None:
model.status = data.status
if data.price_per_million_tokens is not None:
model.price_per_million_tokens = data.price_per_million_tokens
if data.is_enabled is not None:
model.is_enabled = data.is_enabled
model.updated_at = datetime.now()
db.commit()
# #965: LiteLLM model sync
import threading
def _sync_update():
try:
from ...models.litellm_sync import sync_update_model
litellm_model = "openai/" + (data.name if data.name else model.name)
provider = db.query(ModelProvider).filter(ModelProvider.id == model.provider_id).first()
api_base = provider.api_base_url if provider else model.endpoint or ""
api_key = data.api_key if data.api_key else (provider.api_key if provider else "") or ""
old_name = model.name
new_name = data.name if data.name else old_name
if api_base:
sync_update_model(old_name, new_name, litellm_model, api_base.rstrip("/"), api_key)
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[litellm-sync] update failed: {e}")
threading.Thread(target=_sync_update, daemon=True).start()
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
# #965: 同步删除到 LiteLLM(异步)
delete_model_name = model.name
import threading
def _sync_delete():
try:
sync_delete_model(delete_model_name)
except Exception as e:
logging.getLogger("aiflow").warning(f"[litellm-sync] delete failed for {delete_model_name}: {e}")
threading.Thread(target=_sync_delete, daemon=True).start()
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),
}
# ============================================================
# 按能力指定默认模型(供技能 / 应用 / IM Bot 自动匹配)
# ============================================================
# (按能力默认模型路由已移至 /{id} 路由之前,避免路径参数冲突)
# ============================================================
# 模型连接测试 API (Phase 2)
# ============================================================
def _detect_model_type(capabilities):
"""根据 capabilities 判断模型类型,返回测试类型。
优先级(从高到低):image > video > audio > asr > ocr > embedding > rerank > vision > thinking > tool > text
"""
if not capabilities:
return "text"
caps = [c.lower() for c in capabilities]
if "image" in caps:
return "image"
if "video" in caps:
return "video"
if "audio" in caps:
return "audio"
if "asr" in caps or "speech" in caps:
return "asr"
if "ocr" in caps:
return "ocr"
if "embedding" in caps:
return "embedding"
if "rerank" in caps:
return "rerank"
if "vision" in caps:
return "vision"
if "thinking" in caps:
return "thinking"
if "tool" in caps:
return "tool"
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
# ============================================================
# 批量连接测试(必须定义在 /{model_id}/test-connection 之前,避免被路径参数拦截)
# ============================================================
@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,
}
@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),
):
"""
测试指定模型的连接是否正常
支持多模态: text/image/audio/vision 自动检测模型类型并使用对应API测试
"""
import time
logger = logging.getLogger(__name__)
logger.info(f"Testing connection for model_id: {model_id}")
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, 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 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
# 图像生成实际会出图,单张耗时较长;连接测试需给足超时,否则会误报"失败"
if model_type == "image":
timeout = max(min(ai_model.timeout or 60, 200), 120)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
start = time.time()
try:
async with httpx.AsyncClient(timeout=timeout) as client:
# ============================================================
# 文本模型 / 视觉模型 / OCR模型 / 思考模型 → chat/completions
# ============================================================
if model_type in ("text", "vision", "ocr", "thinking", "tool"):
chat_url = f"{base_url}/v1/chat/completions"
payload = {
"model": ai_model.name,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 5,
}
# 视觉 / OCR 模型附带图片测试(用 base64 data-URI,避免远端 URL 不可达)
if model_type in ("vision", "ocr"):
_test_png_b64 = (
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAjUlEQVR4nO3PoRHAMADDwMzS/XdsJzAoUnQnYwH/eceesdv6c9uhAPShAPShAPSh3wDL0dUHoPsAdB+A7v0Ay9HVB6D7AHQfgO79AMvR1Qeg+wB0H4Du/QDL0dUHoPsAdB+A7v0Ay9HVB6D7AHQfgO79AMvR1Qeg+wB0H4Du/QDL0dUHoPsAdB+A7vWAD38HGZb2HO9/AAAAAElFTkSuQmCC"
)
payload["messages"] = [{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_test_png_b64}"}},
],
}]
payload["max_tokens"] = 64
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)
# ============================================================
# 图像生成模型 → 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": "1024x1024",
}
# 与运行时媒体生成一致:用 _proxy_for_url 解析代理并 trust_env=False;
# 图像端点会被 _proxy_for_url 判定为直连(公网 API 直连比代理更稳定)
from ..chat_utils import _proxy_for_url
async with httpx.AsyncClient(
timeout=timeout, proxy=_proxy_for_url(image_url), trust_env=False
) as img_client:
resp = await img_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"图像生成成功 ({model_type}),返回 {img_count} 张图片",
"latency_ms": latency,
"model_type": model_type,
}
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",
}
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)
# ============================================================
# 嵌入向量模型 → embeddings
# ============================================================
elif model_type == "embedding":
embed_url = f"{base_url}/v1/embeddings"
payload = {
"model": ai_model.name,
"input": "hello world",
}
resp = await client.post(embed_url, json=payload, headers=headers)
latency = int((time.time() - start) * 1000)
if resp.status_code == 200:
data = resp.json()
emb = data.get("data") or []
dim = len(emb[0].get("embedding", [])) if emb else 0
return {
"success": True,
"message": f"嵌入服务连接成功 ({model_type}),向量维度: {dim}",
"latency_ms": latency,
"model_type": model_type,
}
else:
return _build_error_response(resp, latency, model_type)
# ============================================================
# 重排序模型 → rerank
# ============================================================
elif model_type == "rerank":
rerank_url = f"{base_url}/v1/rerank"
payload = {
"model": ai_model.name,
"query": "什么是人工智能",
"documents": ["人工智能是计算机科学的一个分支", "今天天气晴朗"],
}
resp = await client.post(rerank_url, json=payload, headers=headers)
latency = int((time.time() - start) * 1000)
if resp.status_code == 200:
data = resp.json()
results = data.get("results") or data.get("data") or []
return {
"success": True,
"message": f"重排序服务连接成功 ({model_type}),返回 {len(results)} 条结果",
"latency_ms": latency,
"model_type": model_type,
}
else:
return _build_error_response(resp, latency, model_type)
# ============================================================
# 语音识别模型 → audio/transcriptions (ASR)
# ============================================================
elif model_type == "asr":
asr_url = f"{base_url}/v1/audio/transcriptions"
# 一段极短的静音 WAV(base64),仅用于探活端点是否可达
silent_wav_b64 = (
"UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA="
)
files = {
"file": ("test.wav", silent_wav_b64, "audio/wav"),
"model": (None, ai_model.name),
}
asr_headers = {"Authorization": headers["Authorization"]}
try:
resp = await client.post(asr_url, files=files, headers=asr_headers)
except Exception:
# 某些实现要求 JSON 而非表单,回退尝试 JSON
resp = await client.post(
asr_url,
json={"model": ai_model.name, "audio": silent_wav_b64},
headers=headers,
)
latency = int((time.time() - start) * 1000)
if resp.status_code == 200:
return {
"success": True,
"message": f"语音识别服务连接成功 ({model_type})",
"latency_ms": latency,
"model_type": model_type,
}
elif resp.status_code in (400, 422):
# 端点可达但音频数据无效(探活目的达成)
return {
"success": True,
"message": f"语音识别端点可达 ({model_type}),返回 {resp.status_code}(需真实音频)",
"latency_ms": latency,
"model_type": model_type,
}
elif resp.status_code in (404, 405):
# OpenAI transcriptions 路径不存在 → 可能是 MiMo 风格
# 的 /v1/chat/completions + input_audio(如 mimo-v2.5-asr)
chat_url = f"{base_url}/v1/chat/completions"
asr_payload = {
"model": ai_model.name,
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {
"data": f"data:audio/wav;base64,{silent_wav_b64}"
},
}],
}],
"asr_options": {"language": "auto"},
"max_tokens": 5,
}
try:
resp2 = await client.post(chat_url, json=asr_payload, headers=headers)
except Exception:
resp2 = resp
latency2 = int((time.time() - start) * 1000)
if resp2.status_code == 200:
return {
"success": True,
"message": f"语音识别服务连接成功 ({model_type})",
"latency_ms": latency2,
"model_type": model_type,
}
elif resp2.status_code in (400, 422):
return {
"success": True,
"message": f"语音识别端点可达 ({model_type}),返回 {resp2.status_code}(需真实音频)",
"latency_ms": latency2,
"model_type": model_type,
}
else:
return _build_error_response(resp, latency, 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:
return {
"success": False,
"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)
logger.error(f"Model connection test failed: {e}")
return {
"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,
}
class UpdateModelRequest(BaseModel):
"""更新模型请求(保留兼容,实际更新使用 ModelUpdateRequest)"""
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