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 ```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, 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:平台登录注入的档案(显示名 / 平台账号名); name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名);
-- platform_key 入口 ensure_user_row upsert 写, -- platform_key 入口 ensure_user_row upsert 写,
-- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构 -- 邮箱密码 / 历史行留空。未来 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) ### 2026-06-26 / 定时任务执行历史列表(分页)(bump 0.29.0)
- 背景:isolated 模式每次触发新建一个 task,旧的带 `scheduled_job_id` 被普通列表过滤掉、UI 够不到,只有详情里单个「打开它跑的任务」按钮指向 `last_task_id`(最近一次)。历史 task 一直在库里(不删除),但访问不到。 - 背景:isolated 模式每次触发新建一个 task,旧的带 `scheduled_job_id` 被普通列表过滤掉、UI 够不到,只有详情里单个「打开它跑的任务」按钮指向 `last_task_id`(最近一次)。历史 task 一直在库里(不删除),但访问不到。

8
RUN.md
View File

@ -14,8 +14,11 @@
DEEPSEEK_API_KEY=sk-... DEEPSEEK_API_KEY=sk-...
# 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段) # 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
ZHIPUAI_API_KEY=... ZHIPUAI_API_KEY=...
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool # 豆包(火山方舟)统一 key,三处共用:可选。
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现 # 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=... ARK_API_KEY=...
# documents skill(内部知识库 document_search API):可选。设了后注册 # documents skill(内部知识库 document_search API):可选。设了后注册
# document_list_kb / document_search / document_download 三个 host-side tool; # 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` - **工具**:`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) - **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) - **配置**:`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) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`): - **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离 - `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离

View File

@ -2,6 +2,35 @@
default_model: deepseek_v4.flash default_model: deepseek_v4.flash
models_dir: config/models 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 skills_dir: skills
workspace_dir: workspace workspace_dir: workspace
system_prompt: prompts/system/general_v1.md 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 optimal_temperature: 0.3
prompt_caching: false prompt_caching: false
extended_thinking: 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 返回、前端展示都引这里。 # 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 typing import Any
from uuid import UUID from uuid import UUID
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import BigInteger, and_, cast, func, select from pydantic import BaseModel
from sqlalchemy import BigInteger, and_, cast, func, select, update
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import Task, UsageEvent, User, UserDiskUsage 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 "", "name": name or "",
"user_name": uname or "", "user_name": uname or "",
"role": role or "user", "role": role or "user",
"plan": plan or "", # 模型档位(空 → default 档),admin UI 内联下拉用
"cost_cny": float(c or 0), "cost_cny": float(c or 0),
"tokens_in": int(ti or 0), "tokens_in": int(ti or 0),
"tokens_out": int(to or 0), "tokens_out": int(to or 0),
"tokens_cache_hit": int(h or 0), "tokens_cache_hit": int(h or 0),
"n_events": int(n 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( 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, cost_sum, tin_sum, tout_sum,
func.coalesce(func.sum(hit).filter(chat), 0), func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id), func.count(UsageEvent.event_id),
) )
.join(UsageEvent, join_cond, isouter=True) .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) .order_by(order, User.user_id)
.limit(page_size) .limit(page_size)
.offset(page * 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: def register_admin_routes(app: FastAPI, require_admin) -> None:
"""把 /v1/admin/* 管理路由挂到 app 上,整组走 require_admin gate。""" """把 /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)) page_size = min(100, max(1, page_size))
with session_scope() as s: with session_scope() as s:
return _storage_page(s, page, page_size) 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, check_no_subtask,
session_scope, 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 core.storage.utils import ensure_local_task_row
from .auth import ( 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") 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)。 """校验 model_profile 并返回 (profile, model_id)。
传空 cfg["default_model"]profile ModelCapabilities.load: 传空 cfg["default_model"]profile ModelCapabilities.load:
格式或文件错误一律 400 (profile_str, caps.model_id) ensure_local_task_row 格式或文件错误一律 400 (profile_str, caps.model_id) ensure_local_task_row
model_profile / model 两列一起填,保持现有 schema 双列约定 model_profile / model 两列一起填,保持现有 schema 双列约定
user_id 非空 额外过档位门控(_assert_model_allowed),档外模型 403
""" """
from core.agent_builder import load_config from core.agent_builder import load_config
from core.capabilities import ModelCapabilities 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"]) caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, f"invalid model_profile {name!r}: {e}") raise HTTPException(400, f"invalid model_profile {name!r}: {e}")
_assert_model_allowed(name, user_id, "text")
return name, caps.model_id 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)] 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。 """校验 image_model variant key。
传空 返空(agent_builder fallback 到第一个 variant);传非空 必须存在 传空 返空(agent_builder fallback 到第一个 variant);传非空 必须存在
config/media/doubao.yaml image ,否则 400 config/media/doubao.yaml image ,否则 400user_id 非空 额外过档位门控
""" """
name = (variant or "").strip() name = (variant or "").strip()
if not name: if not name:
@ -539,6 +578,7 @@ def _resolve_image_model(variant: str) -> str:
variants = {k for k, _ in _list_image_variants()} variants = {k for k, _ in _list_image_variants()}
if name not in variants: if name not in variants:
raise HTTPException(400, f"invalid image_model {name!r}; available: {sorted(variants)}") raise HTTPException(400, f"invalid image_model {name!r}; available: {sorted(variants)}")
_assert_model_allowed(name, user_id, "image")
return name 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)] 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 范式)。""" """校验 video_model variant key(同 _resolve_image_model 范式)。"""
name = (variant or "").strip() name = (variant or "").strip()
if not name: if not name:
@ -569,6 +609,7 @@ def _resolve_video_model(variant: str) -> str:
variants = {k for k, _ in _list_video_variants()} variants = {k for k, _ in _list_video_variants()}
if name not in variants: if name not in variants:
raise HTTPException(400, f"invalid video_model {name!r}; available: {sorted(variants)}") raise HTTPException(400, f"invalid video_model {name!r}; available: {sorted(variants)}")
_assert_model_allowed(name, user_id, "video")
return name return name
@ -1382,11 +1423,15 @@ def create_app() -> FastAPI:
""" """
from core.agent_builder import load_config from core.agent_builder import load_config
from core.capabilities import ModelCapabilities from core.capabilities import ModelCapabilities
from core.model_access import allowed_set
from core.paths import ROOT from core.paths import ROOT
import yaml as _yaml import yaml as _yaml
cfg = load_config() cfg = load_config()
default = cfg["default_model"] default = cfg["default_model"]
models_dir = ROOT / cfg["models_dir"] 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] = [] out: list[dict] = []
if models_dir.is_dir(): if models_dir.is_dir():
@ -1398,6 +1443,8 @@ def create_app() -> FastAPI:
family = data.get("family") or path.stem family = data.get("family") or path.stem
for variant in (data.get("variants") or {}).keys(): for variant in (data.get("variants") or {}).keys():
profile = f"{family}.{variant}" profile = f"{family}.{variant}"
if allowed is not None and profile not in allowed:
continue
try: try:
caps = ModelCapabilities.load(profile, models_dir) caps = ModelCapabilities.load(profile, models_dir)
except (ValueError, FileNotFoundError): except (ValueError, FileNotFoundError):
@ -1416,19 +1463,22 @@ def create_app() -> FastAPI:
def list_image_models(user_id: UUID = Depends(require_user)): def list_image_models(user_id: UUID = Depends(require_user)):
"""图像生成模型清单(扫 config/media/doubao.yaml image 段)。 """图像生成模型清单(扫 config/media/doubao.yaml image 段)。
前端顶栏第二个下拉拉这个;空列表 没配 image variant,UI 隐藏下拉 前端顶栏第二个下拉拉这个;空列表 没配 image variant 或本档无授权,UI 隐藏下拉
`is_default` 标第一个 variant(=agent_builder fallback 目标)开发期不缓存, 按用户档位过滤;`is_default` 标过滤后第一个 variant开发期不缓存, YAML 立即生效
YAML 加新 variant 立即生效
""" """
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] = [] 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({ out.append({
"variant": key, "variant": key,
"display_name": cfg.get("display_name") or key, "display_name": cfg.get("display_name") or key,
"model_id": cfg.get("model_id") or "", "model_id": cfg.get("model_id") or "",
"price_cny_per_image": cfg.get("price_cny_per_image"), "price_cny_per_image": cfg.get("price_cny_per_image"),
"is_default": i == 0, "is_default": not out, # 过滤后第一个
}) })
return {"models": out} return {"models": out}
@ -1438,10 +1488,15 @@ def create_app() -> FastAPI:
/v1/image_models 同范式;空列表 UI 隐藏第三下拉展示信息包括默认分辨率 /v1/image_models 同范式;空列表 UI 隐藏第三下拉展示信息包括默认分辨率
token 单价(¥/Mtok 文生视频路径),方便用户在下拉选项里直接看到 cost 量级 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] = [] 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({ out.append({
"variant": key, "variant": key,
"display_name": cfg.get("display_name") or key, "display_name": cfg.get("display_name") or key,
@ -1451,7 +1506,7 @@ def create_app() -> FastAPI:
"default_ratio": cfg.get("default_ratio"), "default_ratio": cfg.get("default_ratio"),
"price_cny_per_mtoken_text2video": cfg.get("price_cny_per_mtoken_text2video"), "price_cny_per_mtoken_text2video": cfg.get("price_cny_per_mtoken_text2video"),
"price_cny_per_mtoken_video2video": cfg.get("price_cny_per_mtoken_video2video"), "price_cny_per_mtoken_video2video": cfg.get("price_cny_per_mtoken_video2video"),
"is_default": i == 0, "is_default": not out, # 过滤后第一个
}) })
return {"models": out} return {"models": out}
@ -1602,7 +1657,7 @@ def create_app() -> FastAPI:
# 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True) # 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True)
fs_dir.mkdir(parents=True, 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( ensure_local_task_row(
task_id=tid, name=name, working_dir=fs_dir_db, skill=skill, task_id=tid, name=name, working_dir=fs_dir_db, skill=skill,
description=description, user_id=user_id, description=description, user_id=user_id,
@ -2104,8 +2159,8 @@ def create_app() -> FastAPI:
raise HTTPException(400, f"name 不合法: {e}") raise HTTPException(400, f"name 不合法: {e}")
if body.model_profile is not None: if body.model_profile is not None:
# 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前 # 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前
# in-flight run 不受影响(build_agent resume 时下次重读)。 # in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。
profile, model_id = _resolve_model_profile(body.model_profile) profile, model_id = _resolve_model_profile(body.model_profile, user_id=user_id)
updates["model_profile"] = profile updates["model_profile"] = profile
updates["model"] = model_id updates["model"] = model_id
if not updates: if not updates:
@ -2268,7 +2323,7 @@ def create_app() -> FastAPI:
raise HTTPException(400, "empty content") raise HTTPException(400, "empty content")
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.run_status) select(Task.run_status, Task.model_profile)
.where(Task.task_id == tid, Task.user_id == user_id) .where(Task.task_id == tid, Task.user_id == user_id)
.with_for_update() .with_for_update()
).first() ).first()
@ -2280,14 +2335,30 @@ def create_app() -> FastAPI:
f"task already has an active run (status={row.run_status}); " f"task already has an active run (status={row.run_status}); "
f"wait for it to finish or cancel", 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( s.execute(
update(Task).where(Task.task_id == tid).values( update(Task).where(Task.task_id == tid).values(**values)
run_status="running", run_error=None,
)
) )
# image_model / video_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。 # image_model / video_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。
image_variant = _resolve_image_model(body.image_model) # 显式选中非空 variant 时过档位门控(档外 → 403);空串不查(走 yaml 默认)。
video_variant = _resolve_video_model(body.video_model) 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 标记,新订阅者才能看到流式 broker.start(tid) # 清上一轮 done 标记,新订阅者才能看到流式
# commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)。 # commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)。
# 登记到 app.state.inflight:① 关停 drain 时 await 它收尾 ② 持强引用防 task 被 GC # 登记到 app.state.inflight:① 关停 drain 时 await 它收尾 ② 持强引用防 task 被 GC
@ -2431,6 +2502,10 @@ def create_app() -> FastAPI:
cfg = load_config() cfg = load_config()
chosen_profile = task_model_profile or cfg["default_model"] 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: try:
caps = ModelCapabilities.load(chosen_profile, ROOT / cfg["models_dir"]) caps = ModelCapabilities.load(chosen_profile, ROOT / cfg["models_dir"])
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:

View File

@ -47,6 +47,7 @@ let timer = null;
let modelRange = "all", modelSort = "cost"; let modelRange = "all", modelSort = "cost";
let userRange = "all", userSort = "cost", userPage = 0; let userRange = "all", userSort = "cost", userPage = 0;
let storagePage = 0; let storagePage = 0;
let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)
// ───── 格式化 ───── // ───── 格式化 ─────
function fmtCNY(n) { function fmtCNY(n) {
@ -181,25 +182,61 @@ function renderUserUsage(d) {
return `<tr>` return `<tr>`
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}` + `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>` + (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.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 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">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>` + `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>` + `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`; + `</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">` $("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>` + `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ tierLegendHTML()
+ `<div class="scroll-x"><table>` + `<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>` + `<tbody>${body}</tbody></table></div>`
+ pagerHTML("uu", page, maxPage, from, to, total) + pagerHTML("uu", page, maxPage, from, to, total)
+ `</div>`; + `</div>`;
$("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); }; $("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
$("u-sort").onchange = (e) => { userSort = 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)); 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 // 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
function renderStorage(d) { function renderStorage(d) {
const quota = d.quota_bytes; const quota = d.quota_bytes;
@ -323,6 +360,29 @@ async function apiGet(path) {
return r.json(); 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() { async function loadModels() {
try { try {
renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`)); renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
@ -331,11 +391,30 @@ async function loadModels() {
async function loadUserUsage(page) { async function loadUserUsage(page) {
page = Math.max(0, page); page = Math.max(0, page);
try { try {
await loadTiers(); // 渲染档位下拉 / 图例前确保 tiers 在手(只拉一次)
const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`); const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
userPage = d.page || 0; userPage = d.page || 0;
renderUserUsage(d); renderUserUsage(d);
} catch (e) { /* 同上 */ } } 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) { async function loadStorage(page) {
page = Math.max(0, page); page = Math.max(0, page);
try { try {