diff --git a/DESIGN.md b/DESIGN.md index 8345a06..ec6b085 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -322,6 +322,9 @@ done {} ```sql users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, + -- plan:模型档位名(0001 起就有列,0.31 起启用;之前休眠)。值是 config/agent.yaml + -- model_tiers 的 key(如 'pro');NULL/未知 → 落 'default' 档。控制该用户能用哪些模型, + -- 详见 core/model_access.py。role=admin 始终全开,不受档位限制。无需 migration。 name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名); -- platform_key 入口 ensure_user_row upsert 写, -- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构 diff --git a/PROGRESS.md b/PROGRESS.md index db8186d..a23ef14 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,17 @@ ## 已完成关键能力 +### 2026-06-26 / per-account 模型访问控制(档位制,复用 plan 列)(bump 0.31.0) + +- 需求:管理后台按账户控制可调用哪些模型。deepseek flash/pro + seedream/seedance + 内网 local 对所有人开放,doubao/glm 按账户分配。 +- 架构决策(与用户对齐):**档位制**而非逐账户逐模型授予 —— 复用 `users.plan`(0001 起休眠列,无需 migration),「档位→模型集合」配在 `config/agent.yaml` `model_tiers`,用户只挂一个 plan。管理成本 O(档位) 而非 O(用户×模型)。`plan` 空/未知 → `default` 档;`role=admin` 始终全开。`"*"` 通配支持全开档(当前未用)。 +- 起始两档:`default`(deepseek flash/pro + local r1/qwen3 + seedream + seedance)、`pro`(+ doubao turbo/pro/evolving + glm pro/pro52)。 +- 后端 `core/model_access.py`:`allowed_set(plan,role)`(None=全开)/ `is_allowed`。三个 list 端点(`/v1/models` `/v1/image_models` `/v1/video_models`)按档过滤 → 用户只看到本档模型(chat 前端无改动,下拉自动收窄)。三个 resolve(文本/图/视频)加 `user_id` 门控:**显式选模型**(建 task / 切模型 / 发媒体)档外 → 403;**老 task 下次发消息**若存量模型已不在档位内 → 持久落回 `deepseek_v4.flash`(send 路径锁行内 UPDATE;optimize_prompt 同降级但不持久);定时任务执行(user_id=None)grandfather 不门控。 +- 管理端 `web/admin.py`:`GET /v1/admin/tiers`(档位定义 + 全模型目录,给 UI 图例)、`PATCH /v1/admin/users/{uid}/plan`(校验档位名存在,写 `users.plan`);`/v1/admin/usage/users` 行补 `plan` 字段。 +- 管理 UI `admin.js`:各用户用量表加「档位」列(内联下拉选档 → PATCH → 刷新)+ 档位图例(每档含哪些模型,id→显示名);加 `apiSend`(PATCH/POST)助手。 +- 已知边界:媒体 **tool 注册**不按档(seedream/seedance tool 仍随 ARK key 注册,只门控 variant 选择),当前各档都含媒体基线故无实际影响;待有付费媒体 variant 再收口 tool 层。 +- 文件:`core/model_access.py`(新)、`config/agent.yaml`(model_tiers)、`web/app.py`(门控+过滤+降级)、`web/admin.py`(tiers/set-plan 端点)、`web/static/js/admin.js`(档位列+图例)、`DESIGN.md`(plan 列语义)。 + ### 2026-06-26 / 新增豆包 Seed 2.1 + GLM 5.2 文本模型档案(bump 0.30.0) - 背景:用户要接入火山方舟豆包 Seed 2.1(turbo/pro)、自进化版 doubao-seed-evolving,以及智谱 GLM 5.2。`/v1/models` 自动扫 `config/models/*.yaml`,加档案即在 UI 下拉出现,无需改代码。 diff --git a/RUN.md b/RUN.md index c92d429..1c4307a 100644 --- a/RUN.md +++ b/RUN.md @@ -789,6 +789,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt - **工具**:`tools/{fs, shell, run_python, skill_tool}.py` - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx) - **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile) + - **模型档位(per-account 模型访问)**:`config/agent.yaml` `model_tiers` 段定义「档位→可用模型 id 集合」;`users.plan` 存档位名,空/未知 → `default` 档,`role=admin` 全开。管理后台「各用户用量」表的「档位」下拉改 plan(`PATCH /v1/admin/users/{uid}/plan`);档位定义见 `GET /v1/admin/tiers`。改 `model_tiers` 后**重启 web** 生效;无需 migration(`plan` 列 0001 起就有)。模型 id:文本=`family.variant`,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回 `deepseek_v4.flash`。 - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Workspace**(per-user 子树,user_id 来自 JWT `sub`): - `workspace/users//.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离 diff --git a/config/agent.yaml b/config/agent.yaml index 4250af1..7632a45 100644 --- a/config/agent.yaml +++ b/config/agent.yaml @@ -2,6 +2,35 @@ default_model: deepseek_v4.flash models_dir: config/models + +# 模型档位(per-account 模型访问控制,见 core/model_access.py)。users.plan 存档位名; +# plan 为空 / 未知 → 落 `default` 档;role=admin 始终全开,不受此限制。 +# 每档列出可用的模型 id:文本 = `family.variant`(config/models/);图/视频 = variant key +# (config/media/doubao.yaml)。成员含 `"*"` = 全开(含未来新增模型)。 +# 三个 list 端点(/v1/models、/v1/image_models、/v1/video_models)按档过滤,用户只看到本档模型; +# 新建/切换/发媒体时再硬校验(老 task 续跑读 task.model_profile 不打断)。改后重启 web 生效。 +model_tiers: + default: # 基线:所有未分配档位的用户(= 公测期默认可用) + - deepseek_v4.flash + - deepseek_v4.pro + - local.r1 # 内网模型(涉密任务) + - local.qwen3 + - seedream_5 # 图(config/media/doubao.yaml image 段) + - seedance_2_fast # 视频 + - seedance_2_pro + pro: # 基线 + 豆包 Seed 2.1 + GLM + - deepseek_v4.flash + - deepseek_v4.pro + - local.r1 # 内网模型(涉密任务) + - local.qwen3 + - doubao.turbo + - doubao.pro + - doubao.evolving + - glm.pro + - glm.pro52 + - seedream_5 + - seedance_2_fast + - seedance_2_pro skills_dir: skills workspace_dir: workspace system_prompt: prompts/system/general_v1.md diff --git a/core/__init__.py b/core/__init__.py index 4bf557f..1904194 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.30.0" +__version__ = "0.31.0" diff --git a/core/model_access.py b/core/model_access.py new file mode 100644 index 0000000..dd8a7dc --- /dev/null +++ b/core/model_access.py @@ -0,0 +1,58 @@ +"""Per-account 模型访问控制(档位制)。 + +`users.plan` 存档位名;「档位 → 可用模型集合」定义在 `config/agent.yaml` 的 `model_tiers`。 +- plan 为空 / 未知档位 → 落 `default` 档(= 基线,所有未分配用户)。 +- `role == 'admin'` 始终全开,不受档位限制(管理员要能测所有模型)。 +- 某档成员里出现 `"*"` → 该档全开(含未来新增模型),给内部档用。 + +模型 id 约定(与 list 端点 / resolve 校验一致): +- 文本模型 = `family.variant`(config/models/.yaml),如 `doubao.pro`、`glm.pro52` +- 图 / 视频模型 = variant key(config/media/doubao.yaml),如 `seedream_5`、`seedance_2_fast` +两者命名不冲突(文本带点、媒体 variant 不带点),同一档集合里混放即可。 + +纯函数 + 读 yaml 配置,不碰 DB / HTTP —— 调用方(web 层)负责取 user 的 plan/role +并把"拒绝"翻译成 HTTP 403。这样 core 不耦合 fastapi。 +""" +from __future__ import annotations + +from typing import Optional + +DEFAULT_TIER = "default" +WILDCARD = "*" + + +def _tiers() -> dict[str, list[str]]: + """从 agent.yaml 读 model_tiers;缺失 → 空 dict(→ 所有人落 default,而 default 也空 → 全禁)。 + + 开发期不缓存,每次现读(load_config 自身轻量);改 yaml 重启 web 生效。 + """ + from core.agent_builder import load_config + + return load_config().get("model_tiers") or {} + + +def tier_name(plan: Optional[str], tiers: Optional[dict] = None) -> str: + """plan → 实际生效的档位名;plan 为空 / 不在 tiers 里 → DEFAULT_TIER。""" + tiers = _tiers() if tiers is None else tiers + p = (plan or "").strip() + return p if p in tiers else DEFAULT_TIER + + +def allowed_set(plan: Optional[str], role: Optional[str]) -> Optional[set[str]]: + """该用户可用模型 id 集合;返回 None = 全开(admin 或档位含 '*')。 + + None 与 空 set 语义不同:None=不设限(放行一切),空 set=一个都不许。 + """ + if (role or "") == "admin": + return None + tiers = _tiers() + members = tiers.get(tier_name(plan, tiers)) or [] + if WILDCARD in members: + return None + return set(members) + + +def is_allowed(model_id: str, plan: Optional[str], role: Optional[str]) -> bool: + """该用户能否使用某模型 id(文本 profile 或媒体 variant)。""" + allowed = allowed_set(plan, role) + return allowed is None or model_id in allowed diff --git a/web/admin.py b/web/admin.py index 563f4a0..d8ccb60 100644 --- a/web/admin.py +++ b/web/admin.py @@ -16,8 +16,9 @@ from datetime import datetime, timedelta, timezone from typing import Any from uuid import UUID -from fastapi import Depends, FastAPI -from sqlalchemy import BigInteger, and_, cast, func, select +from fastapi import Depends, FastAPI, HTTPException +from pydantic import BaseModel +from sqlalchemy import BigInteger, and_, cast, func, select, update from core.storage import session_scope from core.storage.models import Task, UsageEvent, User, UserDiskUsage @@ -206,21 +207,22 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di "name": name or "", "user_name": uname or "", "role": role or "user", + "plan": plan or "", # 模型档位(空 → default 档),admin UI 内联下拉用 "cost_cny": float(c or 0), "tokens_in": int(ti or 0), "tokens_out": int(to or 0), "tokens_cache_hit": int(h or 0), "n_events": int(n or 0), } - for uid, email, name, uname, role, c, ti, to, h, n in s.execute( + for uid, email, name, uname, role, plan, c, ti, to, h, n in s.execute( select( - User.user_id, User.email, User.name, User.user_name, User.role, + User.user_id, User.email, User.name, User.user_name, User.role, User.plan, cost_sum, tin_sum, tout_sum, func.coalesce(func.sum(hit).filter(chat), 0), func.count(UsageEvent.event_id), ) .join(UsageEvent, join_cond, isouter=True) - .group_by(User.user_id, User.email, User.name, User.user_name, User.role) + .group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan) .order_by(order, User.user_id) .limit(page_size) .offset(page * page_size) @@ -271,6 +273,47 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict: } +def _model_catalog() -> list[dict]: + """全部可门控模型清单 [{id, display_name, kind}]:文本(config/models/*.yaml)+ + 图/视频(config/media/doubao.yaml)。给档位编辑 UI 画图例(id → 显示名)。 + """ + from core.capabilities import ModelCapabilities + from core.paths import ROOT + import yaml as _yaml + + out: list[dict] = [] + models_dir = ROOT / "config" / "models" + 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}" + try: + caps = ModelCapabilities.load(profile, models_dir) + except (ValueError, FileNotFoundError): + continue + out.append({"id": profile, "display_name": caps.display_name or profile, "kind": "text"}) + media = ROOT / "config" / "media" / "doubao.yaml" + if media.exists(): + try: + mdata = _yaml.safe_load(media.read_text(encoding="utf-8")) or {} + except Exception: + mdata = {} + for kind in ("image", "video"): + for key, cfg in (mdata.get(kind) or {}).items(): + if isinstance(cfg, dict): + out.append({"id": key, "display_name": cfg.get("display_name") or key, "kind": kind}) + return out + + +class SetPlanRequest(BaseModel): + plan: str = "" # 档位名(config/agent.yaml model_tiers 的 key);空串 = 清空 → 落 default 档 + + def register_admin_routes(app: FastAPI, require_admin) -> None: """把 /v1/admin/* 管理路由挂到 app 上,整组走 require_admin gate。""" @@ -331,3 +374,45 @@ def register_admin_routes(app: FastAPI, require_admin) -> None: page_size = min(100, max(1, page_size)) with session_scope() as s: return _storage_page(s, page, page_size) + + @app.get("/v1/admin/tiers", tags=["admin"]) + def admin_tiers(user_id: UUID = Depends(require_admin)): + """模型档位定义 + 全模型目录。admin-only。 + + UI:用户行的「档位」下拉用 tier 名;图例把每档 member id 映射成显示名。 + default_tier 标出 plan 为空 / 未知时落的档。role=admin 始终全开(不在 tiers 里体现)。 + """ + from core.model_access import DEFAULT_TIER + from core.agent_builder import load_config + tiers = load_config().get("model_tiers") or {} + return { + "tiers": tiers, # {name: [model_id, ...]} + "default_tier": DEFAULT_TIER, + "catalog": _model_catalog(), # [{id, display_name, kind}] + } + + @app.patch("/v1/admin/users/{uid}/plan", tags=["admin"]) + def admin_set_user_plan( + uid: str, body: SetPlanRequest, user_id: UUID = Depends(require_admin) + ): + """设置某用户的模型档位(写 users.plan)。admin-only。 + + plan 必须是 config/agent.yaml model_tiers 里存在的档位名;空串 = 清空(落 default 档)。 + 非法档位 → 400;用户不存在 → 404。 + """ + from core.agent_builder import load_config + try: + target = UUID(uid) + except ValueError: + raise HTTPException(400, f"invalid user id: {uid!r}") + plan = (body.plan or "").strip() + tiers = load_config().get("model_tiers") or {} + if plan and plan not in tiers: + raise HTTPException(400, f"unknown tier {plan!r}; available: {sorted(tiers)}") + with session_scope() as s: + result = s.execute( + update(User).where(User.user_id == target).values(plan=plan or None) + ) + if result.rowcount == 0: + raise HTTPException(404, f"user not found: {uid}") + return {"user_id": str(target), "plan": plan} diff --git a/web/app.py b/web/app.py index 26756be..5bef6de 100644 --- a/web/app.py +++ b/web/app.py @@ -42,7 +42,7 @@ from core.storage import ( check_no_subtask, session_scope, ) -from core.storage.models import Message, ScheduledJob, Task, UsageEvent +from core.storage.models import Message, ScheduledJob, Task, User, UsageEvent from core.storage.utils import ensure_local_task_row from .auth import ( @@ -412,12 +412,50 @@ def _sse_event(event_type: str, payload: dict) -> bytes: return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8") -def _resolve_model_profile(profile: str) -> tuple[str, str]: +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" + + +# 档位降级目标:存量 task 的模型已不在用户档位内时,下次起 run 落回这个(基线必含)。 +FALLBACK_MODEL_PROFILE = "deepseek_v4.flash" + + +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 @@ -429,6 +467,7 @@ def _resolve_model_profile(profile: str) -> tuple[str, str]: 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 @@ -527,11 +566,11 @@ def _list_image_variants() -> list[tuple[str, dict]]: return [(k, v) for k, v in image_cfg.items() if isinstance(v, dict)] -def _resolve_image_model(variant: str) -> str: +def _resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> str: """校验 image_model variant key。 传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在 - 于 config/media/doubao.yaml image 段,否则 400。 + 于 config/media/doubao.yaml image 段,否则 400。user_id 非空 → 额外过档位门控。 """ name = (variant or "").strip() if not name: @@ -539,6 +578,7 @@ def _resolve_image_model(variant: str) -> str: 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 @@ -561,7 +601,7 @@ def _list_video_variants() -> list[tuple[str, dict]]: return [(k, v) for k, v in video_cfg.items() if isinstance(v, dict)] -def _resolve_video_model(variant: str) -> str: +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: @@ -569,6 +609,7 @@ def _resolve_video_model(variant: str) -> str: 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 @@ -1382,11 +1423,15 @@ def create_app() -> FastAPI: """ 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(): @@ -1398,6 +1443,8 @@ def create_app() -> FastAPI: 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): @@ -1416,19 +1463,22 @@ def create_app() -> FastAPI: def list_image_models(user_id: UUID = Depends(require_user)): """图像生成模型清单(扫 config/media/doubao.yaml image 段)。 - 前端顶栏第二个下拉拉这个;空列表 → 没配 image variant,UI 隐藏下拉。 - `is_default` 标第一个 variant(=agent_builder fallback 目标)。开发期不缓存, - 改 YAML 加新 variant 立即生效。 + 前端顶栏第二个下拉拉这个;空列表 → 没配 image variant 或本档无授权,UI 隐藏下拉。 + 按用户档位过滤;`is_default` 标过滤后第一个 variant。开发期不缓存,改 YAML 立即生效。 """ - variants = _list_image_variants() + from core.model_access import allowed_set + plan, role = _user_plan_role(user_id) + allowed = allowed_set(plan, role) out: list[dict] = [] - for i, (key, cfg) in enumerate(variants): + 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": i == 0, + "is_default": not out, # 过滤后第一个 }) return {"models": out} @@ -1438,10 +1488,15 @@ def create_app() -> FastAPI: 与 /v1/image_models 同范式;空列表 → UI 隐藏第三下拉。展示信息包括默认分辨率 与 token 单价(¥/Mtok 文生视频路径),方便用户在下拉选项里直接看到 cost 量级。 + 按用户档位过滤;`is_default` 标过滤后第一个 variant。 """ - variants = _list_video_variants() + from core.model_access import allowed_set + plan, role = _user_plan_role(user_id) + allowed = allowed_set(plan, role) out: list[dict] = [] - for i, (key, cfg) in enumerate(variants): + 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, @@ -1451,7 +1506,7 @@ def create_app() -> FastAPI: "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": i == 0, + "is_default": not out, # 过滤后第一个 }) return {"models": out} @@ -1602,7 +1657,7 @@ def create_app() -> FastAPI: # 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True) fs_dir.mkdir(parents=True, exist_ok=True) - profile, model_id = _resolve_model_profile(body.model_profile) + profile, model_id = _resolve_model_profile(body.model_profile, user_id=user_id) ensure_local_task_row( task_id=tid, name=name, working_dir=fs_dir_db, skill=skill, description=description, user_id=user_id, @@ -2104,8 +2159,8 @@ def create_app() -> FastAPI: raise HTTPException(400, f"name 不合法: {e}") if body.model_profile is not None: # 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前 - # in-flight run 不受影响(build_agent resume 时下次重读)。 - profile, model_id = _resolve_model_profile(body.model_profile) + # in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。 + profile, model_id = _resolve_model_profile(body.model_profile, user_id=user_id) updates["model_profile"] = profile updates["model"] = model_id if not updates: @@ -2268,7 +2323,7 @@ def create_app() -> FastAPI: raise HTTPException(400, "empty content") with session_scope() as s: row = s.execute( - select(Task.run_status) + select(Task.run_status, Task.model_profile) .where(Task.task_id == tid, Task.user_id == user_id) .with_for_update() ).first() @@ -2280,14 +2335,30 @@ def create_app() -> FastAPI: f"task already has an active run (status={row.run_status}); " f"wait for it to finish or cancel", ) + values: dict = {"run_status": "running", "run_error": None} + # 档位门控:存量 task 的模型已不在用户档位内(如管理员下调了档位)→ 本次起 + # 持久落回 flash(基线必含),UI 下拉随之显示 flash。不报错、不打断会话历史, + # 符合"老 task 下次发消息直接切 flash"。当前 task 模型仍在档内则原样不动。 + # plan/role 用同一 session 读(避免在 FOR UPDATE 事务里再开嵌套 session)。 + cur_profile = row.model_profile or "" + if cur_profile: + from core.model_access import is_allowed + urow = s.execute( + select(User.plan, User.role).where(User.user_id == user_id) + ).first() + plan = (urow.plan if urow else "") or "" + role = (urow.role if urow else "user") or "user" + if not is_allowed(cur_profile, plan, role): + fb_profile, fb_model_id = _resolve_model_profile(FALLBACK_MODEL_PROFILE) + values["model_profile"] = fb_profile + values["model"] = fb_model_id s.execute( - update(Task).where(Task.task_id == tid).values( - run_status="running", run_error=None, - ) + update(Task).where(Task.task_id == tid).values(**values) ) # image_model / video_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。 - image_variant = _resolve_image_model(body.image_model) - video_variant = _resolve_video_model(body.video_model) + # 显式选中非空 variant 时过档位门控(档外 → 403);空串不查(走 yaml 默认)。 + image_variant = _resolve_image_model(body.image_model, user_id=user_id) + video_variant = _resolve_video_model(body.video_model, user_id=user_id) broker.start(tid) # 清上一轮 done 标记,新订阅者才能看到流式 # commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)。 # 登记到 app.state.inflight:① 关停 drain 时 await 它收尾 ② 持强引用防 task 被 GC @@ -2431,6 +2502,10 @@ def create_app() -> FastAPI: cfg = load_config() chosen_profile = task_model_profile or cfg["default_model"] + # 档位门控:task 存量模型已不在用户档位内 → 润色也落回 flash(与 send 路径一致, + # 不持久改 task,仅本次润色调用降级)。 + if chosen_profile and not _model_allowed_for_user(chosen_profile, user_id): + chosen_profile = FALLBACK_MODEL_PROFILE try: caps = ModelCapabilities.load(chosen_profile, ROOT / cfg["models_dir"]) except (FileNotFoundError, ValueError) as e: diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 92f9b4f..01e222d 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -47,6 +47,7 @@ let timer = null; let modelRange = "all", modelSort = "cost"; let userRange = "all", userSort = "cost", userPage = 0; let storagePage = 0; +let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用) // ───── 格式化 ───── function fmtCNY(n) { @@ -181,25 +182,61 @@ function renderUserUsage(d) { return `` + `${userCellHTML(r)}` + (r.role === "admin" ? ` admin` : "") + `` + + `${planSelectHTML(r)}` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` + `${fmtTokens(r.tokens_out)}` + `${hitRate}%` + `${r.n_events || 0}` + ``; - }).join("") || `无数据`; + }).join("") || `无数据`; $("s-users").innerHTML = `
` + `

各用户用量(${rangeLabel(d.range)})

${ctrlHTML("u", d.range, d.sort)}
` + + tierLegendHTML() + `
` - + `` + + `` + `${body}
用户成本输入输出缓存命中事件
用户档位成本输入输出缓存命中事件
` + pagerHTML("uu", page, maxPage, from, to, total) + `
`; $("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); }; $("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); }; + // 档位下拉:选中即 PATCH(admin 看到全部模型,改档不影响 admin 自己的可见性) + $("s-users").querySelectorAll(".plan-sel").forEach(sel => { + sel.onchange = (e) => setUserPlan(e.target.dataset.uid, e.target.value, e.target); + }); wirePager("uu", page, maxPage, (p) => loadUserUsage(p)); } +// 档位下拉(每行一个);plan 为空 → 选中 default 档。tiers 未加载好 → 退化为纯文本。 +function planSelectHTML(r) { + const tiers = (tiersData && tiersData.tiers) || {}; + const names = Object.keys(tiers); + if (!names.length) return escapeHtml(r.plan || "default"); + const def = (tiersData && tiersData.default_tier) || "default"; + const cur = r.plan || def; + const opts = names.map(n => + `` + ).join(""); + return ``; +} + +// 档位图例:每档含哪些模型(id → 显示名)。tiersData 未加载 → 空。 +function tierLegendHTML() { + if (!tiersData || !tiersData.tiers) return ""; + const cat = {}; + (tiersData.catalog || []).forEach(m => { cat[m.id] = m.display_name; }); + const def = tiersData.default_tier || "default"; + const rows = Object.keys(tiersData.tiers).map(name => { + const members = (tiersData.tiers[name] || []).map(id => id === "*" ? "全部模型" : (cat[id] || id)); + return `
${escapeHtml(name)}${name === def ? "(默认)" : ""}:` + + `${members.map(escapeHtml).join("、") || "(空)"}
`; + }).join(""); + return `
` + + `
档位说明(改 config/agent.yaml model_tiers;admin 始终全开)
` + + rows + `
`; +} + // 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows function renderStorage(d) { const quota = d.quota_bytes; @@ -323,6 +360,29 @@ async function apiGet(path) { return r.json(); } +// 写操作(PATCH/POST):带 JSON body;401/403 同 apiGet 提示;其余抛错由调用方 alert。 +async function apiSend(method, path, body) { + const t = token(); + if (!t) { + showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`); + stopAuto(); + throw Object.assign(new Error("no token"), { code: "auth" }); + } + const r = await fetch(path, { + method, + headers: { Authorization: "Bearer " + t, "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + if (r.status === 401 || r.status === 403) { + throw Object.assign(new Error(String(r.status)), { code: "auth" }); + } + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || String(r.status)); + } + return r.json(); +} + async function loadModels() { try { renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`)); @@ -331,11 +391,30 @@ async function loadModels() { async function loadUserUsage(page) { page = Math.max(0, page); try { + await loadTiers(); // 渲染档位下拉 / 图例前确保 tiers 在手(只拉一次) const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`); userPage = d.page || 0; renderUserUsage(d); } catch (e) { /* 同上 */ } } +// 档位定义 + 模型目录:加载一次缓存(管理动作 / 图例用);失败退化为空(下拉降级纯文本)。 +async function loadTiers() { + if (tiersData) return tiersData; + try { tiersData = await apiGet("/v1/admin/tiers"); } + catch (e) { tiersData = { tiers: {}, default_tier: "default", catalog: [] }; } + return tiersData; +} +// 设置某用户档位(PATCH);成功后刷新当前页。失败 alert 并复位下拉。 +async function setUserPlan(uid, plan, selectEl) { + if (selectEl) selectEl.disabled = true; + try { + await apiSend("PATCH", `/v1/admin/users/${uid}/plan`, { plan }); + loadUserUsage(userPage); + } catch (e) { + if (e.code !== "auth") alert("设置档位失败:" + (e.message || String(e))); + if (selectEl) selectEl.disabled = false; + } +} async function loadStorage(page) { page = Math.max(0, page); try {