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>
This commit is contained in:
parent
8263382fd1
commit
b4808b0370
|
|
@ -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 注入同构
|
||||||
|
|
|
||||||
11
PROGRESS.md
11
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)
|
### 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 下拉出现,无需改代码。
|
- 背景:用户要接入火山方舟豆包 Seed 2.1(turbo/pro)、自进化版 doubao-seed-evolving,以及智谱 GLM 5.2。`/v1/models` 自动扫 `config/models/*.yaml`,加档案即在 UI 下拉出现,无需改代码。
|
||||||
|
|
|
||||||
1
RUN.md
1
RUN.md
|
|
@ -789,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 隔离
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.30.0"
|
__version__ = "0.31.0"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
95
web/admin.py
95
web/admin.py
|
|
@ -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}
|
||||||
|
|
|
||||||
121
web/app.py
121
web/app.py
|
|
@ -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 段,否则 400。user_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:
|
||||||
|
|
|
||||||
|
|
@ -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_tiers;admin 始终全开)</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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue