"""模型档位门控 + media variant 解析(从 app.py 析出,2026-07-23 拆分)。 web 层的「用户能不能用这个模型」判定收口:plan/role 查询、显式选择点的 403 门控、 image/video variant 的清单列举与解析。核心档位规则在 core/model_access.py, 这里只是 HTTP 语义包装(HTTPException)+ config 扫描。 """ from __future__ import annotations from typing import Optional from uuid import UUID from fastapi import HTTPException from sqlalchemy import select from core.storage import session_scope from core.storage.models import User # 档位降级目标:存量 task 的模型已不在用户档位内时,下次起 run 落回这个(基线必含)。 FALLBACK_MODEL_PROFILE = "deepseek_v4.flash" def user_plan_role(user_id: UUID) -> tuple[str, str]: """取该用户的 (plan, role);行不存在 → ("", "user")。模型访问门控用。""" with session_scope() as s: row = s.execute( select(User.plan, User.role).where(User.user_id == user_id) ).first() if row is None: return "", "user" return row.plan or "", row.role or "user" def model_allowed_for_user(model_id: str, user_id: UUID) -> bool: """该用户(按 plan/role 档位)能否使用 model_id;非抛出版本,供降级判断。""" from core.model_access import is_allowed plan, role = user_plan_role(user_id) return is_allowed(model_id, plan, role) def assert_model_allowed(model_id: str, user_id: Optional[UUID], kind: str) -> None: """档位门控(显式选择点用):user_id 非空时校验该用户能否用 model_id,不许 → 403。 用于"用户主动选模型"的入口(建 task 带 profile / 切模型 / 发媒体)——前端下拉已按档过滤, 选到档外只可能是构造请求,直接 403(纵深防御)。 与之相对,"老 task 下次发消息"走 _downgrade 静默落回 flash(见 send / optimize),不报错。 user_id=None 的内部路径(定时任务执行)不门控。 """ if user_id is None: return if not model_allowed_for_user(model_id, user_id): raise HTTPException(403, f"模型 {model_id!r} 未对你的账户开放({kind});请联系管理员调整档位") def resolve_model_profile(profile: str, user_id: Optional[UUID] = None) -> tuple[str, str]: """校验 model_profile 并返回 (profile, model_id)。 传空 → cfg["default_model"]。profile 走 ModelCapabilities.load: 格式或文件错误一律 400。返 (profile_str, caps.model_id) —— 调 ensure_local_task_row 时 model_profile / model 两列一起填,保持现有 schema 双列约定。 user_id 非空 → 额外过档位门控(assert_model_allowed),档外模型 403。 """ from core.agent_builder import load_config from core.capabilities import ModelCapabilities from core.paths import ROOT cfg = load_config() name = (profile or "").strip() or cfg["default_model"] try: caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"]) except (FileNotFoundError, ValueError) as e: raise HTTPException(400, f"invalid model_profile {name!r}: {e}") assert_model_allowed(name, user_id, "text") return name, caps.model_id def skill_pinned_profiles() -> set: """全部内置 skill 定向的 model profile 集合(send 侧档外降级豁免用)。 task 落到档外模型只有两条路:管理员下调档位(该降),或 skill 定向(load_skill 热切写入,不该降 —— 产品决策放行)。档内模型根本进不了降级分支,所以 "profile ∈ 本集合"即可判定 skill 定向来源,无需追溯该 task 当初怎么切的。 只扫内置来源(与热切 switcher 的"只信内置"一致);registry 现扫 ~3ms 且只在 "当前模型已档外"的罕见分支才调。frontmatter 删掉 `model:` 行 → 集合随之收缩, 存量定向 task 下条消息自然落回 flash。 """ from core.agent_builder import load_config from core.paths import ROOT from core.skills import SkillRegistry, SkillSource cfg = load_config() reg = SkillRegistry(SkillSource(ROOT / cfg.get("skills_dir", "skills"), "builtin")) return {s.model for s in reg.skills.values() if s.model} def list_media_variants(kind: str) -> list[tuple[str, dict]]: """扫 config/media/*.yaml 的 段 → [(variant_key, variant_cfg), ...]。 多 provider(doubao / unifyllm)合并列举,文件名排序保证豆包在前(默认 variant 的选择逻辑另见 default_image_variant)。variant key 约定跨文件唯一;万一撞名先读的生效(后者跳过)。 目录不存在或段空 / 仅注释 → 返 []。不要求对应 API key 已设 —— 仅纯元数据列举, UI 拉这个画下拉。真正调用时 agent_builder 那边再过 `ArkConfig.load()` (没 key → tool 不注册)。 """ from core.paths import ROOT import yaml as _yaml media_dir = ROOT / "config" / "media" if not media_dir.is_dir(): return [] out: list[tuple[str, dict]] = [] seen: set[str] = set() for p in sorted(media_dir.glob("*.yaml")): try: data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {} except Exception: continue for k, v in (data.get(kind) or {}).items(): if isinstance(v, dict) and k not in seen: seen.add(k) out.append((k, v)) return out def list_image_variants() -> list[tuple[str, dict]]: """图像 variant 清单(跨 provider,见 list_media_variants)。""" return list_media_variants("image") def default_image_variant(allowed: Optional[set]) -> str: """该用户(档位过滤后)的默认 image variant key;无可用 → ""。 有 gpt_image 权限(pro 档 / admin)→ 默认 GPT 生图;否则落过滤后第一个 (= 豆包 seedream,沿用 yaml 文件名排序)。用户仍可在顶栏下拉自行切换。 /v1/image_models 的 is_default 与 resolve_image_model 的空串解析共用这里, 保证"下拉默认选中"和"实际起 run 用的"永远一致。 """ keys = [k for k, _ in list_image_variants() if allowed is None or k in allowed] if not keys: return "" return "gpt_image" if "gpt_image" in keys else keys[0] def resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> str: """校验 image_model variant key。 传空 = 客户端未显式选 → 解析成该用户的默认 variant(与 /v1/image_models 的 is_default 同源,见 default_image_variant);无 user_id 的内部路径仍返空 (agent_builder fallback 到第一个 variant)。传非空 → 必须存在于 config/media/*.yaml 的 image 段,否则 400。user_id 非空 → 额外过档位门控。 """ name = (variant or "").strip() if not name: if user_id is None: return "" from core.model_access import allowed_set plan, role = user_plan_role(user_id) return default_image_variant(allowed_set(plan, role)) variants = {k for k, _ in list_image_variants()} if name not in variants: raise HTTPException(400, f"invalid image_model {name!r}; available: {sorted(variants)}") assert_model_allowed(name, user_id, "image") return name def list_video_variants() -> list[tuple[str, dict]]: """视频 variant 清单(跨 provider,见 list_media_variants);空 → UI 隐藏下拉。""" return list_media_variants("video") def resolve_video_model(variant: str, user_id: Optional[UUID] = None) -> str: """校验 video_model variant key(同 resolve_image_model 范式)。""" name = (variant or "").strip() if not name: return "" variants = {k for k, _ in list_video_variants()} if name not in variants: raise HTTPException(400, f"invalid video_model {name!r}; available: {sorted(variants)}") assert_model_allowed(name, user_id, "video") return name