- 新增标准库叶子层 timeouts/gates/quota/sandbox_policy: 审批 3600 与单轮 600 各有名常量与环境覆盖层,已建项 expires_at 永不重算;目录/控制/合并状态门禁统一判定, 目录违规无任何覆盖参数;配额取数与比较唯一来源 (USD 先于无价、命中均为 >=、无价只计 actual 行); /tmp 隔离矩阵与失联清理判定纯函数化。 - 调用点委托:server/worker/automations/auto_memory 的 配额 SQL 与比较、server/tree/marker 的目录解析、 marker 的控制门、sandbox 的任务容器名正则、两处 300 秒 陈旧阈值默认值均收敛,别名与错误文案不变。 - 统一两个 P0 口径分歧:服务端无价计数由全部 usd IS NULL 行改为 actual-only;显式不限额用户在服务端准入亦不受 无价上限约束(与 worker 运行时一致)。 - 不新增门禁种类与 record 模式(主线无此实现);不改 skill_seed/skills/SKILL.md;不改 v1.1 契约与分支保护。 - 新增 test_arch_converge.py 18 项。
165 lines
6.9 KiB
Python
165 lines
6.9 KiB
Python
"""成本与配额的唯一判定源。
|
||
|
||
收敛前同一口径写在三处,注释里明文写着“改口径时三处
|
||
要一起改”(server._quota_status、worker._quota_exceeded、
|
||
automations._automation_quota_exceeded),且三处已经
|
||
不一致——这是本轨的 P0:
|
||
- 无价 token 上限的计数口径:worker 与 automations 只计
|
||
source='actual' 的行,服务端 _quota_unpriced_tokens 计
|
||
全部 usd IS NULL 的行(含 estimated 估算行);而
|
||
cost.py 的 P1 决议、CostStore._report 的
|
||
unpriced_actual_tokens、服务端自己的项目成本报表
|
||
(actual-only)都站在 actual-only 一边。服务端是孤例。
|
||
本轨以 actual-only 为 canonical:估算行不是真实花费,
|
||
绝不能触发上限;服务端 _quota_unpriced_tokens 改为本
|
||
模块口径(行为变化仅限含 estimated 无价行的用户:此
|
||
前可能被估算行误拦,此后不再误拦;真实花费的拦阻不变)。
|
||
- 默认配额解析:server、worker、automations 各一份
|
||
CLOUD_DEFAULT_QUOTA_USD_MICROS 解析,现统一为
|
||
default_quota_usd_micros(默认值 $10/月 = 10_000_000
|
||
微美元,非法值回落,无配额行按默认值、显式 NULL 行
|
||
才是不限额——三处旧语义一致,收敛不改语义)。
|
||
- 比较判定:used >= quota(USD)先于 unpriced >= cap
|
||
(无价),两者任一命中即阻断;该顺序与三处旧代码一致,
|
||
现收为 decide() 一处。调用方只负责取数与映射错误码
|
||
(服务端 402、worker interrupted、自动化 skipped_quota),
|
||
不得在调用点另写一套 >= 比较。
|
||
|
||
本模块只依赖标准库,是叶子模块:调用方传一个 DBAPI
|
||
连接(? 占位符,Postgres 适配层同款翻译),不 import
|
||
cloud 其它模块,避免循环。无价上限的数值本身仍由
|
||
cost.unpriced_token_cap() 拥有(环境变量
|
||
CLOUD_QUOTA_UNPRICED_TOKEN_CAP),decide() 只接受
|
||
cap 作参数,不再另起一套上限解析。
|
||
|
||
调阈边界(写清,不越界):
|
||
- 本模块只判定、不建议、不生效。没有任何函数会算
|
||
“建议把上限调到 X”,也没有任何函数会写回配额行或
|
||
改环境变量。阈值变化的唯一生效路径是运维改配置:
|
||
改 user_quotas 行(立即对下次查询生效)或改环境变量
|
||
后重启进程(进程内 env 按调用时读取)。未来如需
|
||
“自动算建议”,必须是独立的只读函数,其输出绝不能
|
||
被 decide() 或任何准入点自动采纳。
|
||
- 归属口径冻结:按任务创建者 created_by 算,不按项目、
|
||
不按操作人;协调员子任务无 created_by 时跟父任务走
|
||
(该回查仍在 worker 侧,属主解析不是本模块的职责)。
|
||
- 窗口口径冻结:UTC 自然月,与 cost_records.day 同口径
|
||
(day LIKE 'YYYY-MM%')。
|
||
"""
|
||
|
||
import os
|
||
from datetime import datetime, timezone
|
||
|
||
DEFAULT_QUOTA_USD_MICROS = 10000000
|
||
|
||
|
||
def default_quota_usd_micros():
|
||
"""默认月配额(微美元):环境变量非法时回落 $10。"""
|
||
try:
|
||
return max(0, int(os.environ.get(
|
||
"CLOUD_DEFAULT_QUOTA_USD_MICROS", "10000000")))
|
||
except (TypeError, ValueError):
|
||
return DEFAULT_QUOTA_USD_MICROS
|
||
|
||
|
||
def utc_month(now=None):
|
||
"""UTC 自然月 'YYYY-MM'(与 cost_records.day 同口径)。"""
|
||
moment = datetime.now(timezone.utc) if now is None else now
|
||
return moment.strftime("%Y-%m")
|
||
|
||
|
||
def _row_value(row, name):
|
||
try:
|
||
return row[name]
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
|
||
|
||
def quota_for_owner(conn, owner):
|
||
"""属主的配额行:无行回默认值,显式 NULL 行回 None(不限额)。
|
||
|
||
只读:无行时不在此写行(写行由服务端的
|
||
_apply_default_quota / _backfill_default_quota 负责)。
|
||
表不存在时先幂等建表(与三处旧代码同款)。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS user_quotas("
|
||
"user_id TEXT PRIMARY KEY, quota_usd_micros BIGINT,"
|
||
" updated_at DOUBLE PRECISION)")
|
||
row = conn.execute(
|
||
"SELECT quota_usd_micros FROM user_quotas"
|
||
" WHERE user_id = ?",
|
||
(owner,)).fetchone()
|
||
if row is None:
|
||
return default_quota_usd_micros()
|
||
return _row_value(row, "quota_usd_micros")
|
||
|
||
|
||
def monthly_usage(conn, owner, month):
|
||
"""当月已记账用量:(used_usd_micros, unpriced_actual_tokens)。
|
||
|
||
USD:cost_records 与 tasks 按 created_by 连接,SUM
|
||
usd_micros(estimated 行 usd 为 NULL,SUM 自动跳过,
|
||
与旧三处一致)。无价:usd IS NULL 且 source='actual'
|
||
的行 input + output 之和——estimated 行一律排除(见
|
||
模块头 P0)。调用方须保证 cost_records 表已存在
|
||
(服务端/worker 走 CostStore 懒建,自动化走表存在探针)。
|
||
"""
|
||
used_row = conn.execute(
|
||
"SELECT COALESCE(SUM(c.usd_micros), 0)"
|
||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?",
|
||
(owner, month + "%")).fetchone()
|
||
used = _row_value(used_row, 0)
|
||
if used is None:
|
||
# sqlite3.Row 按列名取不到时按位置取。
|
||
try:
|
||
used = used_row[0]
|
||
except (IndexError, TypeError):
|
||
used = 0
|
||
unpriced_row = conn.execute(
|
||
"SELECT COALESCE(SUM(c.input_tokens + c.output_tokens), 0)"
|
||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?"
|
||
" AND c.usd_micros IS NULL AND c.source = 'actual'",
|
||
(owner, month + "%")).fetchone()
|
||
unpriced = _row_value(unpriced_row, 0)
|
||
if unpriced is None:
|
||
try:
|
||
unpriced = unpriced_row[0]
|
||
except (IndexError, TypeError):
|
||
unpriced = 0
|
||
return int(used or 0), int(unpriced or 0)
|
||
|
||
|
||
def decide(quota, used_usd, unpriced_used, cap, month):
|
||
"""唯一比较判定:放行回 None,否则回决定 dict。
|
||
|
||
quota 为 None(显式不限额)时永远放行,即使无价
|
||
已超——与服务端、worker 旧行为一致(不限额用户不
|
||
受无价上限约束)。USD 门先于无价门;命中条件均为
|
||
>=(用满即拦,不是超过才拦)。
|
||
"""
|
||
if quota is None:
|
||
return None
|
||
if used_usd >= quota:
|
||
return {
|
||
"kind": "usd",
|
||
"quota_usd_micros": quota,
|
||
"used_usd_micros": used_usd,
|
||
"month": month,
|
||
"message": ("quota_exceeded: 本月配额已用完(已用 %d / "
|
||
"配额 %d 微美元,%s)" % (used_usd, quota, month)),
|
||
}
|
||
if unpriced_used >= cap:
|
||
return {
|
||
"kind": "unpriced",
|
||
"unpriced_token_cap": cap,
|
||
"unpriced_tokens_used": unpriced_used,
|
||
"month": month,
|
||
"message": ("quota_exceeded: 本月无价模型 token 已用完"
|
||
"(已用 %d / 上限 %d,%s)"
|
||
% (unpriced_used, cap, month)),
|
||
}
|
||
return None
|