Compare commits
No commits in common. "b4808b0370fbaa3bc0a7ccdceefe0c306661280b" and "d633949a66ac11074bb0b1caa4ad917ce3d6ef23" have entirely different histories.
b4808b0370
...
d633949a66
|
|
@ -322,9 +322,6 @@ 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 注入同构
|
||||
|
|
|
|||
19
PROGRESS.md
19
PROGRESS.md
|
|
@ -21,25 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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
8
RUN.md
|
|
@ -14,11 +14,8 @@
|
|||
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=...
|
||||
# 豆包(火山方舟)统一 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 都不出现
|
||||
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool
|
||||
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
|
||||
ARK_API_KEY=...
|
||||
# documents skill(内部知识库 document_search API):可选。设了后注册
|
||||
# document_list_kb / document_search / document_download 三个 host-side tool;
|
||||
|
|
@ -789,7 +786,6 @@ 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 隔离
|
||||
|
|
|
|||
|
|
@ -2,35 +2,6 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
# 豆包 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
|
||||
|
|
@ -25,28 +25,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.31.0"
|
||||
__version__ = "0.29.0"
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
"""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,9 +16,8 @@ from datetime import datetime, timedelta, timezone
|
|||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import BigInteger, and_, cast, func, select, update
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy import BigInteger, and_, cast, func, select
|
||||
|
||||
from core.storage import session_scope
|
||||
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
|
||||
|
|
@ -207,22 +206,21 @@ 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, plan, c, ti, to, h, n in s.execute(
|
||||
for uid, email, name, uname, role, c, ti, to, h, n in s.execute(
|
||||
select(
|
||||
User.user_id, User.email, User.name, User.user_name, User.role, User.plan,
|
||||
User.user_id, User.email, User.name, User.user_name, User.role,
|
||||
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, User.plan)
|
||||
.group_by(User.user_id, User.email, User.name, User.user_name, User.role)
|
||||
.order_by(order, User.user_id)
|
||||
.limit(page_size)
|
||||
.offset(page * page_size)
|
||||
|
|
@ -273,47 +271,6 @@ 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。"""
|
||||
|
||||
|
|
@ -374,45 +331,3 @@ 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}
|
||||
|
|
|
|||
121
web/app.py
121
web/app.py
|
|
@ -42,7 +42,7 @@ from core.storage import (
|
|||
check_no_subtask,
|
||||
session_scope,
|
||||
)
|
||||
from core.storage.models import Message, ScheduledJob, Task, User, UsageEvent
|
||||
from core.storage.models import Message, ScheduledJob, Task, UsageEvent
|
||||
from core.storage.utils import ensure_local_task_row
|
||||
|
||||
from .auth import (
|
||||
|
|
@ -412,50 +412,12 @@ def _sse_event(event_type: str, payload: dict) -> bytes:
|
|||
return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
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]:
|
||||
def _resolve_model_profile(profile: str) -> 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
|
||||
|
|
@ -467,7 +429,6 @@ def _resolve_model_profile(profile: str, user_id: Optional[UUID] = None) -> tupl
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -566,11 +527,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, user_id: Optional[UUID] = None) -> str:
|
||||
def _resolve_image_model(variant: str) -> str:
|
||||
"""校验 image_model variant key。
|
||||
|
||||
传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在
|
||||
于 config/media/doubao.yaml image 段,否则 400。user_id 非空 → 额外过档位门控。
|
||||
于 config/media/doubao.yaml image 段,否则 400。
|
||||
"""
|
||||
name = (variant or "").strip()
|
||||
if not name:
|
||||
|
|
@ -578,7 +539,6 @@ def _resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> 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
|
||||
|
||||
|
||||
|
|
@ -601,7 +561,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, user_id: Optional[UUID] = None) -> str:
|
||||
def _resolve_video_model(variant: str) -> str:
|
||||
"""校验 video_model variant key(同 _resolve_image_model 范式)。"""
|
||||
name = (variant or "").strip()
|
||||
if not name:
|
||||
|
|
@ -609,7 +569,6 @@ def _resolve_video_model(variant: str, user_id: Optional[UUID] = None) -> 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
|
||||
|
||||
|
||||
|
|
@ -1423,15 +1382,11 @@ 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():
|
||||
|
|
@ -1443,8 +1398,6 @@ 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):
|
||||
|
|
@ -1463,22 +1416,19 @@ 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。开发期不缓存,改 YAML 立即生效。
|
||||
前端顶栏第二个下拉拉这个;空列表 → 没配 image variant,UI 隐藏下拉。
|
||||
`is_default` 标第一个 variant(=agent_builder fallback 目标)。开发期不缓存,
|
||||
改 YAML 加新 variant 立即生效。
|
||||
"""
|
||||
from core.model_access import allowed_set
|
||||
plan, role = _user_plan_role(user_id)
|
||||
allowed = allowed_set(plan, role)
|
||||
variants = _list_image_variants()
|
||||
out: list[dict] = []
|
||||
for key, cfg in _list_image_variants():
|
||||
if allowed is not None and key not in allowed:
|
||||
continue
|
||||
for i, (key, cfg) in enumerate(variants):
|
||||
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": not out, # 过滤后第一个
|
||||
"is_default": i == 0,
|
||||
})
|
||||
return {"models": out}
|
||||
|
||||
|
|
@ -1488,15 +1438,10 @@ def create_app() -> FastAPI:
|
|||
|
||||
与 /v1/image_models 同范式;空列表 → UI 隐藏第三下拉。展示信息包括默认分辨率
|
||||
与 token 单价(¥/Mtok 文生视频路径),方便用户在下拉选项里直接看到 cost 量级。
|
||||
按用户档位过滤;`is_default` 标过滤后第一个 variant。
|
||||
"""
|
||||
from core.model_access import allowed_set
|
||||
plan, role = _user_plan_role(user_id)
|
||||
allowed = allowed_set(plan, role)
|
||||
variants = _list_video_variants()
|
||||
out: list[dict] = []
|
||||
for key, cfg in _list_video_variants():
|
||||
if allowed is not None and key not in allowed:
|
||||
continue
|
||||
for i, (key, cfg) in enumerate(variants):
|
||||
out.append({
|
||||
"variant": key,
|
||||
"display_name": cfg.get("display_name") or key,
|
||||
|
|
@ -1506,7 +1451,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": not out, # 过滤后第一个
|
||||
"is_default": i == 0,
|
||||
})
|
||||
return {"models": out}
|
||||
|
||||
|
|
@ -1657,7 +1602,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, user_id=user_id)
|
||||
profile, model_id = _resolve_model_profile(body.model_profile)
|
||||
ensure_local_task_row(
|
||||
task_id=tid, name=name, working_dir=fs_dir_db, skill=skill,
|
||||
description=description, user_id=user_id,
|
||||
|
|
@ -2159,8 +2104,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 时下次重读)。档外模型 → 403。
|
||||
profile, model_id = _resolve_model_profile(body.model_profile, user_id=user_id)
|
||||
# in-flight run 不受影响(build_agent resume 时下次重读)。
|
||||
profile, model_id = _resolve_model_profile(body.model_profile)
|
||||
updates["model_profile"] = profile
|
||||
updates["model"] = model_id
|
||||
if not updates:
|
||||
|
|
@ -2323,7 +2268,7 @@ def create_app() -> FastAPI:
|
|||
raise HTTPException(400, "empty content")
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
select(Task.run_status, Task.model_profile)
|
||||
select(Task.run_status)
|
||||
.where(Task.task_id == tid, Task.user_id == user_id)
|
||||
.with_for_update()
|
||||
).first()
|
||||
|
|
@ -2335,30 +2280,14 @@ 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(**values)
|
||||
update(Task).where(Task.task_id == tid).values(
|
||||
run_status="running", run_error=None,
|
||||
)
|
||||
)
|
||||
# image_model / video_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。
|
||||
# 显式选中非空 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)
|
||||
image_variant = _resolve_image_model(body.image_model)
|
||||
video_variant = _resolve_video_model(body.video_model)
|
||||
broker.start(tid) # 清上一轮 done 标记,新订阅者才能看到流式
|
||||
# commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)。
|
||||
# 登记到 app.state.inflight:① 关停 drain 时 await 它收尾 ② 持强引用防 task 被 GC
|
||||
|
|
@ -2502,10 +2431,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ 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) {
|
||||
|
|
@ -182,61 +181,25 @@ 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="7" class="empty">无数据</td></tr>`;
|
||||
}).join("") || `<tr><td colspan="6" 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><th>事件</th></tr></thead>`
|
||||
+ `<thead><tr><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_tiers;admin 始终全开)</div>`
|
||||
+ rows + `</div>`;
|
||||
}
|
||||
|
||||
// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
|
||||
function renderStorage(d) {
|
||||
const quota = d.quota_bytes;
|
||||
|
|
@ -360,29 +323,6 @@ 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}`));
|
||||
|
|
@ -391,30 +331,11 @@ 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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue