Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming b4808b0370 feat: per-account 模型访问控制(档位制,复用 plan 列)(bump 0.31.0)
- core/model_access.py(新):档位制访问控制。users.plan 存档位名,
  「档位→模型集合」配在 config/agent.yaml model_tiers;plan 空/未知→default 档,
  role=admin 全开。无需 migration(plan 列 0001 起就有,之前休眠)。
- 两档:default(deepseek+local+seedream+seedance)、pro(+doubao+glm)。
- web/app.py:三个 list 端点按档过滤(用户只看到本档模型);三个 resolve 加
  user_id 门控 —— 显式选档外模型 403;老 task 下次发消息模型已不在档位内→
  持久落回 deepseek_v4.flash;定时任务执行 grandfather 不门控。
- web/admin.py:GET /v1/admin/tiers + PATCH /v1/admin/users/{uid}/plan;
  用户行补 plan 字段。
- web/static/js/admin.js:各用户用量表加「档位」列(内联下拉)+ 档位图例 + apiSend。
- DESIGN.md plan 列语义 / RUN.md model_tiers 配置说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:11:22 +08:00
caoqianming 8263382fd1 feat: 新增豆包 Seed 2.1(turbo/pro/evolving)+ GLM 5.2 文本模型档案(bump 0.30.0)
- config/models/doubao.yaml(新建):Seed 2.1 turbo/pro + 自进化 evolving,
  走 Ark OpenAI 兼容端点(openai/ 前缀 + ARK_API_KEY,同 local.yaml 范式)
- config/models/glm.yaml:加 pro52(GLM 5.2,zai/glm-5.2,1M 上下文),与 glm.pro(5.1)并存
- thinking_mode 均 false(深度思考走 body 协议,非 reasoning_effort 等级,留 TODO)
- 单价按火山/智谱 2026-06 发布价;evolving 单价未公布暂按 pro 估值兜底
- RUN.md 更新 ARK_API_KEY 说明(文本+图像+视频三处共用)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:05:07 +08:00
11 changed files with 494 additions and 33 deletions

View File

@ -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 注入同构

View File

@ -21,6 +21,25 @@
## 已完成关键能力
### 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 下拉出现,无需改代码。
- 新增 `config/models/doubao.yaml`(family=doubao):`turbo`/`pro`/`evolving` 三 variant。走 Ark OpenAI 兼容端点(`openai/` 前缀 + `api_base=ark.cn-beijing.volces.com/api/v3`,复用媒体侧 `ARK_API_KEY`),同 local.yaml 范式。单价按火山 2026-06 发布价:turbo 3/15(缓存 0.6)、pro 6/30(缓存 1.2);evolving 官方未公布单价,暂按 pro 估值兜底(宁高勿低)。context 均 256K。
- `config/models/glm.yaml` 新增 `pro52`(GLM 5.2,model_id `zai/glm-5.2`,1M 上下文,单价 8/28 缓存 2),**与 `glm.pro`(5.1)并存**,线上引 `glm.pro` 的 task 不受影响(公测期兼容)。
- thinking_mode 均设 false:Seed 2.1 / GLM 的深度思考开关走 body 协议(非 OpenAI `reasoning_effort` 等级),透传等级需 core/llm.py 加 family 分支,留 TODO;设 false 不发 reasoning_effort,模型默认仍深度思考,不影响调用。
- 文件:`config/models/doubao.yaml`(新增)、`config/models/glm.yaml`(加 pro52 variant)。
### 2026-06-26 / 定时任务执行历史列表(分页)(bump 0.29.0)
- 背景:isolated 模式每次触发新建一个 task,旧的带 `scheduled_job_id` 被普通列表过滤掉、UI 够不到,只有详情里单个「打开它跑的任务」按钮指向 `last_task_id`(最近一次)。历史 task 一直在库里(不删除),但访问不到。

8
RUN.md
View File

@ -14,8 +14,11 @@
DEEPSEEK_API_KEY=sk-...
# 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
ZHIPUAI_API_KEY=...
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
# 豆包(火山方舟)统一 key,三处共用:可选。
# 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点
# 2) 图像生成 seedream tool(0.22 元/张)
# 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s)
# 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现
ARK_API_KEY=...
# documents skill(内部知识库 document_search API):可选。设了后注册
# document_list_kb / document_search / document_download 三个 host-side tool;
@ -786,6 +789,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /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/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离

View File

@ -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

84
config/models/doubao.yaml Normal file
View File

@ -0,0 +1,84 @@
# 豆包 Seed 2.1 文本/Agent 模型档案(火山方舟 Ark)
# 走 Ark 的 OpenAI 兼容 /chat/completions:litellm 用 `openai/` 前缀 + api_base 覆盖,
# 与 config/models/local.yaml 同范式(避免 litellm volcengine provider 的版本/字段差异)。
# api_key 复用媒体侧的 ARK_API_KEY(同一火山账号),env 见 RUN.md。
#
# thinking_mode 暂设 false:Seed 2.1 是深度思考模型,但开关走 Ark body `thinking:{type:enabled}`,
# 与 OpenAI/DeepSeek 的 `reasoning_effort` 等级协议不同 —— 同 glm.yaml 的处理,要 core/llm.py
# 加 family 分支才能透传等级,留 TODO。设 false 只是不发 reasoning_effort 字段;模型默认仍会
# 深度思考并返回 reasoning_content,不影响调用。
# 单价见各 variant(元/百万 tokens,来源:火山方舟 2026-06 发布价)。
family: doubao
variants:
turbo:
display_name: 豆包 Seed 2.1 Turbo
model_id: openai/doubao-seed-2-1-turbo-260628
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K
reliable_context: 131072
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true # Ark 兼容 parallel_tool_calls,默认 true
tool_calling_quality: good
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good
enable_run_python: true
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 3.0
output_cny_per_mtoken: 15.0
cache_hit_cny_per_mtoken: 0.6
pro:
display_name: 豆包 Seed 2.1 Pro
model_id: openai/doubao-seed-2-1-pro-260628
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K
reliable_context: 131072
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true
tool_calling_quality: excellent
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
evolving:
# 自进化版:统一 model_id `doubao-seed-evolving`,每周至少迭代一次,始终指向最新版。
# 面向 Coding/Agent 持续优化,覆盖全场景(与 pro 旗舰、turbo 低成本并列)。
display_name: 豆包 Seed Evolving(自进化)
model_id: openai/doubao-seed-evolving
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K(随版本可能变,按 Seed 2.1 家族取值)
reliable_context: 131072
max_output: 16384
parallel_tools: true
tool_calling_quality: excellent
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# evolving 官方未单独公布单价,暂按 pro 估值兜底(宁高勿低,不少记成本);公布后校正。
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2

View File

@ -25,3 +25,28 @@ variants:
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# GLM 5.2:与 5.1 并存(新增 variant,不动 glm.pro,线上 task 仍引 5.1 不受影响)。
# 旗舰基座,真正可用的 1M 上下文,适合大仓库/长链路工程任务。thinking 同 pro 留 false(协议同 5.1)。
pro52:
display_name: GLM 5.2
model_id: zai/glm-5.2
api_base: https://open.bigmodel.cn/api/paas/v4
api_key_env: ZHIPUAI_API_KEY
max_context: 1000000 # 真 1M
reliable_context: 262144
max_output: 8192
parallel_tools: false
tool_calling_quality: good
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 50
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 8.0
output_cny_per_mtoken: 28.0
cache_hit_cny_per_mtoken: 2.0

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.29.0"
__version__ = "0.31.0"

58
core/model_access.py Normal file
View File

@ -0,0 +1,58 @@
"""Per-account 模型访问控制(档位制)。
`users.plan` 存档位名;档位 可用模型集合定义在 `config/agent.yaml` `model_tiers`
- plan 为空 / 未知档位 `default` (= 基线,所有未分配用户)
- `role == 'admin'` 始终全开,不受档位限制(管理员要能测所有模型)
- 某档成员里出现 `"*"` 该档全开(含未来新增模型),给内部档用
模型 id 约定( list 端点 / resolve 校验一致):
- 文本模型 = `family.variant`(config/models/<family>.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

View File

@ -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}

View File

@ -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 ,否则 400user_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:

View File

@ -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 `<tr>`
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
+ `<td>${planSelectHTML(r)}</td>`
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="6" class="empty">无数据</td></tr>`;
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
$("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ tierLegendHTML()
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
+ `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>`
+ pagerHTML("uu", page, maxPage, from, to, total)
+ `</div>`;
$("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 =>
`<option value="${escapeHtml(n)}" ${n === cur ? "selected" : ""}>${escapeHtml(n)}${n === def ? "(默认)" : ""}</option>`
).join("");
return `<select class="plan-sel" data-uid="${escapeHtml(r.user_id)}">${opts}</select>`;
}
// 档位图例:每档含哪些模型(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 `<div style="margin:2px 0;"><b>${escapeHtml(name)}${name === def ? "(默认)" : ""}</b>`
+ `<span style="color:var(--muted);">${members.map(escapeHtml).join("、") || "(空)"}</span></div>`;
}).join("");
return `<div class="tier-legend" style="font-size:.85em;margin:0 0 10px;padding:8px 10px;`
+ `background:var(--bg-soft,#f6f6f6);border-radius:6px;">`
+ `<div style="color:var(--muted);margin-bottom:4px;">档位说明(改 config/agent.yaml model_tiersadmin 始终全开)</div>`
+ rows + `</div>`;
}
// 存储用量(分页)。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(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
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 {