## Root cause: ImportError sync_health_check not defined. ## Also IndentationError on L467/L590 in nested sync blocks. ## Container stable, public URL 200 OK.
1246 lines
47 KiB
Python
1246 lines
47 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, 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
|
||
|