"""模型清单路由:/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_enabled": caps.thinking_enabled, "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}