Files
my_bookmate/backend/app/api/models.py
T

183 lines
6.2 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.
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.ai_model import AiModel
from app.llm.hub import builtin_models, resolve_provider, _mask_key
router = APIRouter(prefix="/models", tags=["models"])
class AddModelRequest(BaseModel):
label: str
kind: str = "openai_compatible" # openai_compatible | anthropic
base_url: str = ""
api_key: str = ""
model_id: str
class UpdateModelRequest(BaseModel):
label: str | None = None
kind: str | None = None
base_url: str | None = None
api_key: str | None = None
model_id: str | None = None
class ImportModelsRequest(BaseModel):
api_key: str
base_url: str = "https://openrouter.ai/api/v1"
class ImportOpenRouterRequest(BaseModel):
api_key: str
base_url: str = "https://openrouter.ai/api/v1"
OPENROUTER_BLOCKLIST = {
# 一些非文本/量级过大的模型 id 特征,导入时快速排除
"image", "tts", "whisper", "embed", "rerank", "moderation", "dall-e", "flux", "playground",
}
def _to_out(m: AiModel) -> dict:
return {
"name": m.name,
"label": m.label,
"kind": m.kind,
"base_url": m.base_url,
"api_key_masked": _mask_key(m.api_key),
"model_id": m.model_id,
"builtin": False,
}
@router.get("")
async def list_models(db: AsyncSession = Depends(get_db)):
"""内置(.env)+ 用户自定义(DB)模型列表。api_key 只回显掩码。"""
result = await db.execute(select(AiModel).order_by(AiModel.sort_order, AiModel.created_at))
custom = [_to_out(m) for m in result.scalars().all()]
return builtin_models() + custom
@router.post("")
async def add_model(req: AddModelRequest, db: AsyncSession = Depends(get_db)):
label = (req.label or "").strip() or req.model_id
# name = 请求模型 id;冲突时追加序号(-2, -3 ...)
base_name = req.model_id.strip() or "custom-model"
name = base_name
suffix = 1
while True:
exists = (await db.execute(select(AiModel.id).where(AiModel.name == name))).scalar()
if exists is None:
break
suffix += 1
name = f"{base_name}-{suffix}"
row = AiModel(
name=name, label=label, kind=req.kind,
base_url=(req.base_url or "").strip(), api_key=(req.api_key or "").strip(),
model_id=req.model_id.strip(), sort_order=100,
)
db.add(row)
await db.commit()
await db.refresh(row)
return _to_out(row)
@router.put("/{name}")
async def update_model(name: str, req: UpdateModelRequest, db: AsyncSession = Depends(get_db)):
for m in builtin_models():
if m["name"] == name:
raise HTTPException(400, "内置模型不可修改,请在页面添加同名模型")
row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar()
if not row:
raise HTTPException(404, "模型不存在")
if req.label is not None:
row.label = req.label.strip() or row.label
if req.kind is not None:
row.kind = req.kind
if req.base_url is not None:
row.base_url = req.base_url.strip()
if req.api_key is not None:
row.api_key = req.api_key.strip()
if req.model_id is not None:
row.model_id = req.model_id.strip()
await db.commit()
await db.refresh(row)
return _to_out(row)
@router.delete("/{name}")
async def delete_model(name: str, db: AsyncSession = Depends(get_db)):
for m in builtin_models():
if m["name"] == name:
raise HTTPException(400, "内置模型不可删除")
row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar()
if not row:
raise HTTPException(404, "模型不存在")
await db.delete(row)
await db.commit()
return {"deleted": name}
@router.post("/{name}/test")
async def test_model(name: str, db: AsyncSession = Depends(get_db)):
"""用该模型的配置发一条极短请求,验证 base_url/api_key/model_id 是否可用。"""
provider = await resolve_provider(db, name)
if provider is None:
raise HTTPException(404, "模型不存在")
try:
resp = await provider.chat(
[{"role": "user", "content": "hi"}],
temperature=0,
)
return {"ok": True, "model": resp.model, "reply": resp.content[:80]}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
@router.post("/import")
async def import_models(req: ImportModelsRequest):
"""通用 OpenAI 兼容 /models 拉取(OpenRouter / 硅基流动 / SenseNova / 任取 base_url),
供前端挑选后批量添加(不落库)。"""
key = (req.api_key or "").strip()
if not key:
raise HTTPException(400, "需要 API Key")
base = (req.base_url or "").strip().rstrip("/")
if not base:
raise HTTPException(400, "需要 Base URL")
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(
f"{base}/models",
headers={"Authorization": f"Bearer {key}"},
)
if resp.status_code >= 400:
raise HTTPException(resp.status_code, f"OpenRouter 返回 {resp.status_code}: {resp.text[:200]}")
data = resp.json()
except httpx.HTTPError as e:
raise HTTPException(502, f"请求模型列表失败: {type(e).__name__}: {e}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"解析模型列表失败: {e}")
models = []
for m in data.get("data", []):
mid = (m.get("id") or "").strip()
if not mid:
continue
low = mid.lower()
if any(b in low for b in OPENROUTER_BLOCKLIST):
continue
models.append({"id": mid, "name": (m.get("name") or mid)})
return {"models": models, "count": len(models)}
@router.post("/import-openrouter")
async def import_openrouter_models(req: ImportOpenRouterRequest, db: AsyncSession = Depends(get_db)):
"""兼容旧调用:等价于通用 /import(默认 base_url = OpenRouter)。"""
return await import_models(ImportModelsRequest(api_key=req.api_key, base_url=req.base_url))