zcbot/web/routers/models.py

114 lines
5.0 KiB
Python

"""模型清单路由:/v1/models、/v1/image_models、/v1/video_models(按用户档位过滤)。"""
from __future__ import annotations
from uuid import UUID
from fastapi import Depends
from ..model_gate import (
default_image_variant,
list_image_variants,
list_video_variants,
user_plan_role,
)
def register_model_routes(app, *, require_user) -> None:
@app.get("/v1/models", tags=["misc"])
def list_models(user_id: UUID = Depends(require_user)):
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。
前端顶栏 / 新建对话框的模型下拉拉这个。is_default 标记 cfg["default_model"]
命中项。开发期不缓存,每次扫一遍(几个文件 IO);改 YAML 立即生效。
"""
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.model_access import allowed_set
from core.paths import ROOT
import yaml as _yaml
cfg = load_config()
default = cfg["default_model"]
models_dir = ROOT / cfg["models_dir"]
# 按用户档位过滤:allowed=None → 全开(admin / '*' 档),否则只留集合内 profile。
plan, role = user_plan_role(user_id)
allowed = allowed_set(plan, role)
out: list[dict] = []
if models_dir.is_dir():
for path in sorted(models_dir.glob("*.yaml")):
try:
data = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception:
continue
family = data.get("family") or path.stem
for variant in (data.get("variants") or {}).keys():
profile = f"{family}.{variant}"
if allowed is not None and profile not in allowed:
continue
try:
caps = ModelCapabilities.load(profile, models_dir)
except (ValueError, FileNotFoundError):
continue
out.append({
"profile": profile,
"display_name": caps.display_name or profile,
"family": caps.family,
"variant": caps.variant,
"thinking_mode": caps.thinking_mode,
"is_default": profile == default,
})
return {"models": out}
@app.get("/v1/image_models", tags=["misc"])
def list_image_models(user_id: UUID = Depends(require_user)):
"""图像生成模型清单(扫 config/media/*.yaml image 段,跨 provider)。
前端顶栏第二个下拉拉这个;空列表 → 没配 image variant 或本档无授权,UI 隐藏下拉。
按用户档位过滤;`is_default` 见 default_image_variant(有 gpt_image 权限 →
默认 GPT 生图,否则第一个)。开发期不缓存,改 YAML 立即生效。
"""
from core.model_access import allowed_set
plan, role = user_plan_role(user_id)
allowed = allowed_set(plan, role)
default_key = default_image_variant(allowed)
out: list[dict] = []
for key, cfg in list_image_variants():
if allowed is not None and key not in allowed:
continue
out.append({
"variant": key,
"display_name": cfg.get("display_name") or key,
"model_id": cfg.get("model_id") or "",
"price_cny_per_image": cfg.get("price_cny_per_image"),
"is_default": key == default_key,
})
return {"models": out}
@app.get("/v1/video_models", tags=["misc"])
def list_video_models(user_id: UUID = Depends(require_user)):
"""视频生成模型清单(扫 config/media/doubao.yaml video 段)。
与 /v1/image_models 同范式;空列表 → UI 隐藏第三下拉。展示信息包括默认分辨率
与 token 单价(¥/Mtok 文生视频路径),方便用户在下拉选项里直接看到 cost 量级。
按用户档位过滤;`is_default` 标过滤后第一个 variant。
"""
from core.model_access import allowed_set
plan, role = user_plan_role(user_id)
allowed = allowed_set(plan, role)
out: list[dict] = []
for key, cfg in list_video_variants():
if allowed is not None and key not in allowed:
continue
out.append({
"variant": key,
"display_name": cfg.get("display_name") or key,
"model_id": cfg.get("model_id") or "",
"default_resolution": cfg.get("default_resolution"),
"default_duration": cfg.get("default_duration"),
"default_ratio": cfg.get("default_ratio"),
"price_cny_per_mtoken_text2video": cfg.get("price_cny_per_mtoken_text2video"),
"price_cny_per_mtoken_video2video": cfg.get("price_cny_per_mtoken_video2video"),
"is_default": not out, # 过滤后第一个
})
return {"models": out}