feat(chv): 对齐 Muse P11 用量计量与配额执行——四项计量 + 三档档位门 + 哈希链账本

- cloud/usage.py(新):TIER_LIMITS 三档提案(Free $0/Paid $19/Heavy $99,
  提案非定价,payload 带 proposal 标注)、TierStore(无档位行不受档位门
  约束)、UsageLedger(追加写哈希链,篡改可发现)、UsageMeter(token 读
  cost_records、消息读账本、并发/存储注入实测)、QuotaGate(纯准入判定,
  402,不碰在跑任务)、80% 提醒每维度每窗口一次
- cloud/server.py:_reject_if_over_quota 内加档位门(同一漏斗,双人门
  自动覆盖;查询异常 fail-closed 503)、GET /users/me/usage、
  GET/POST /admin/tiers、存储实测复用 _task_worktree_for_files
- docs/chv-muse-p11-gap.md:差距表(先写表再动手)+ 诚实边界(USD 路径
  30 秒轮询掐断与“不掐断”语义不一致是已知待决;并发即拒与设计文档
  排队不同;cloud 任务存储量不到;Heavy 消息/天提案未给,本轨定不限)
- 测试 22/22,全量 1889 OK(skipped=8);mypy 在 usage.py 上干净,
  server.py 5 错均为基线旧错(marker_tools/sub2api/_row_get,与本轨无关)
- 真机验证排队在 round-11 与 P4~P9 之后;不接真实计费
This commit is contained in:
host committed 2026-11-06 22:42:30 +00:00
1 parent 06c3e172fe
commit 60c371d9d5
5 files changed
+1474

No files matched your search

+499
View File
@@ -0,0 +1,499 @@
"""P11 用量计量与配额执行:档位门、计量口径、账本、服务端点。
不跑真实模型、不联网:本地临时 SQLite + 本地临时端口。
档位数值钉住提案(Free $0 / Paid $19 / Heavy $99,未确认定价),
token 门只计 actual、estimated 只展示不拦人,账本哈希链防篡改,
准入判定不碰在跑任务。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.cost import CostStore
from cloud.server import create_server
from cloud.usage import (GB, TIER_LIMITS, QuotaGate, TierStore,
UsageLedger, UsageMeter, disk_usage_bytes,
utc_day)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.cost import CostStore
from cloud.server import create_server
from cloud.usage import (GB, TIER_LIMITS, QuotaGate, TierStore,
UsageLedger, UsageMeter, disk_usage_bytes,
utc_day)
def _make_db(tmp):
"""临时库文件 + 最小 tasks 表(monthly_tokens 要 JOIN 它)。"""
db_path = os.path.join(tmp, "usage.db")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE IF NOT EXISTS tasks ("
"id TEXT PRIMARY KEY, created_by TEXT, status TEXT)")
conn.commit()
conn.close()
return db_path
def _seed_tokens(db_path, user_id, actual=0, estimated=0):
"""给 user 记一笔 actual 与一笔 estimated(同一任务行)。"""
conn = sqlite3.connect(db_path)
conn.execute("INSERT OR IGNORE INTO tasks (id, created_by, status)"
" VALUES ('task-1', ?, 'completed')", (user_id,))
conn.commit()
conn.close()
store = CostStore(db_path)
if actual:
store.record("task-1", "proj-1", "gpt-6.1-terra",
input_tokens=actual, output_tokens=0,
source="actual")
if estimated:
store.record("task-1", "proj-1", "gpt-6.1-terra",
input_tokens=estimated, output_tokens=0,
source="estimated")
class TierProposalTestCase(unittest.TestCase):
def test_proposal_numbers_pinned(self):
self.assertEqual(TIER_LIMITS["free"]["price_usd_per_month"], 0)
self.assertEqual(TIER_LIMITS["free"]["messages_per_day"], 50)
self.assertEqual(TIER_LIMITS["free"]["tokens_per_month"], 200000)
self.assertEqual(TIER_LIMITS["free"]["concurrent_tasks"], 1)
self.assertEqual(TIER_LIMITS["free"]["storage_bytes"], 1 * GB)
self.assertEqual(TIER_LIMITS["paid"]["price_usd_per_month"], 19)
self.assertEqual(TIER_LIMITS["paid"]["messages_per_day"], 500)
self.assertEqual(TIER_LIMITS["paid"]["tokens_per_month"], 2000000)
self.assertEqual(TIER_LIMITS["paid"]["concurrent_tasks"], 5)
self.assertEqual(TIER_LIMITS["paid"]["storage_bytes"], 10 * GB)
self.assertEqual(TIER_LIMITS["heavy"]["price_usd_per_month"], 99)
self.assertIsNone(TIER_LIMITS["heavy"]["messages_per_day"])
self.assertEqual(TIER_LIMITS["heavy"]["tokens_per_month"],
20000000)
self.assertEqual(TIER_LIMITS["heavy"]["concurrent_tasks"], 20)
self.assertEqual(TIER_LIMITS["heavy"]["storage_bytes"], 100 * GB)
def test_unknown_tier_rejected_and_clear(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
store = TierStore(db_path)
self.assertIsNone(store.get_tier("u1"))
self.assertIsNone(store.limits_for("u1"))
with self.assertRaises(ValueError):
store.set_tier("u1", "platinum")
store.set_tier("u1", "paid")
self.assertEqual(store.get_tier("u1"), "paid")
self.assertEqual(store.limits_for("u1")["tokens_per_month"],
2000000)
store.clear_tier("u1")
self.assertIsNone(store.get_tier("u1"))
class TokenMeteringTestCase(unittest.TestCase):
def test_actual_estimated_split_and_gate_counts_actual_only(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
_seed_tokens(db_path, "u1", actual=1000, estimated=500)
tokens = meter.monthly_tokens("u1")
self.assertEqual(tokens["actual"], 1000)
self.assertEqual(tokens["estimated"], 500)
self.assertEqual(tokens["total"], 1500)
# estimated 199500 + actual 1000:门只看 actual,不拦
_seed_tokens(db_path, "u1", actual=0, estimated=199500)
self.assertIsNone(gate.check_message_admission("u1"))
# actual 到 200000:拦,维度 tokens
_seed_tokens(db_path, "u1", actual=199000, estimated=0)
denied = gate.check_message_admission("u1")
self.assertIsNotNone(denied)
self.assertEqual(denied["error"], "quota_exceeded")
self.assertEqual(denied["dimension"], "tokens")
self.assertEqual(denied["limit"], 200000)
self.assertEqual(denied["used"], 200000)
def test_unassigned_user_never_tier_gated(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
_seed_tokens(db_path, "u1", actual=99999999)
for _ in range(60):
meter.record_message("u1")
self.assertIsNone(gate.check_message_admission("u1"))
self.assertIsNone(gate.check_task_admission(
"u1", running_tasks=99, storage_bytes=10 ** 12))
class MessageGateTestCase(unittest.TestCase):
def test_daily_fixed_window_and_limit(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
for _ in range(49):
meter.record_message("u1")
self.assertEqual(meter.daily_messages("u1"), 49)
self.assertIsNone(gate.check_message_admission("u1"))
meter.record_message("u1")
denied = gate.check_message_admission("u1")
self.assertEqual(denied["dimension"], "messages")
self.assertEqual(denied["limit"], 50)
self.assertEqual(denied["used"], 50)
# 其它日期的消息不计入今天(用固定过去日期,避免 UTC 日界)
meter.ledger.record("u1", "message", 1, day="2026-01-01")
self.assertEqual(meter.daily_messages("u1"), 50)
self.assertEqual(meter.daily_messages("u1", day="2026-01-01"),
1)
class ConcurrencyStorageGateTestCase(unittest.TestCase):
def test_concurrency_gate(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
self.assertIsNone(gate.check_task_admission(
"u1", running_tasks=0, storage_bytes=0))
denied = gate.check_task_admission(
"u1", running_tasks=1, storage_bytes=0)
self.assertEqual(denied["dimension"], "concurrent_tasks")
self.assertEqual(denied["limit"], 1)
self.assertIn("wait for a running task", denied["detail"])
def test_storage_gate_with_cleanup_guidance(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
limit = TIER_LIMITS["free"]["storage_bytes"]
self.assertIsNone(gate.check_task_admission(
"u1", running_tasks=0, storage_bytes=limit - 1))
denied = gate.check_task_admission(
"u1", running_tasks=0, storage_bytes=limit)
self.assertEqual(denied["dimension"], "storage")
self.assertIn("free space", denied["detail"])
self.assertIn("worktrees", denied["detail"])
def test_token_denial_precedes_concurrency(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
_seed_tokens(db_path, "u1", actual=200000)
denied = gate.check_task_admission(
"u1", running_tasks=99, storage_bytes=10 ** 12)
self.assertEqual(denied["dimension"], "tokens")
def test_unwired_measurer_raises_not_silent_pass(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
with self.assertRaises(ValueError):
gate.check_task_admission("u1")
def test_gate_is_pure_no_ledger_writes(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
gate = QuotaGate(meter)
meter.tiers.set_tier("u1", "free")
meter.record_message("u1")
before = meter.ledger.sum_kind("u1", "message")
gate.check_message_admission("u1")
gate.check_task_admission("u1", running_tasks=5,
storage_bytes=10 ** 12)
after = meter.ledger.sum_kind("u1", "message")
self.assertEqual(before, after)
class DiskUsageTestCase(unittest.TestCase):
def test_sums_files_skips_missing_and_links(self):
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(disk_usage_bytes(
os.path.join(tmp, "nope")), 0)
sub = os.path.join(tmp, "sub")
os.makedirs(sub)
with open(os.path.join(tmp, "a.txt"), "wb") as f:
f.write(b"x" * 100)
with open(os.path.join(sub, "b.txt"), "wb") as f:
f.write(b"y" * 50)
os.symlink(os.path.join(tmp, "a.txt"),
os.path.join(tmp, "link.txt"))
self.assertEqual(disk_usage_bytes(tmp), 150)
class LedgerTestCase(unittest.TestCase):
def test_chain_verifies_and_tamper_detected(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
ledger = UsageLedger(db_path)
ids = [ledger.record("u1", "message", 1)
for _ in range(3)]
self.assertEqual(ids, sorted(ids))
ok, bad = ledger.verify_chain("u1")
self.assertTrue(ok)
self.assertIsNone(bad)
conn = sqlite3.connect(db_path)
conn.execute("UPDATE usage_ledger SET amount = 999"
" WHERE id = ?", (ids[1],))
conn.commit()
conn.close()
ok, bad = ledger.verify_chain("u1")
self.assertFalse(ok)
self.assertEqual(bad, ids[1])
def test_prev_hash_tamper_detected(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
ledger = UsageLedger(db_path)
first = ledger.record("u1", "message", 1)
second = ledger.record("u1", "message", 1)
conn = sqlite3.connect(db_path)
conn.execute("UPDATE usage_ledger SET prev_hash = 'x'"
" WHERE id = ?", (second,))
conn.commit()
conn.close()
ok, bad = ledger.verify_chain("u1")
self.assertFalse(ok)
self.assertEqual(bad, second)
def test_unknown_kind_rejected_and_no_secrets_field(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
ledger = UsageLedger(db_path)
with self.assertRaises(ValueError):
ledger.record("u1", "chat_text", 1)
row_id = ledger.record("u1", "message", 1, task_id="t1")
conn = sqlite3.connect(db_path)
cols = [r[1] for r in conn.execute(
"PRAGMA table_info(usage_ledger)").fetchall()]
conn.close()
self.assertNotIn("content", cols)
self.assertNotIn("token", cols)
self.assertGreater(row_id, 0)
class WarningTestCase(unittest.TestCase):
def test_once_per_window_at_80_percent(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
meter.tiers.set_tier("u1", "free")
for _ in range(39):
meter.record_message("u1")
self.assertEqual(meter.summary("u1")["warnings"], [])
meter.record_message("u1")
summary = meter.summary("u1")
self.assertEqual(len(summary["warnings"]), 1)
self.assertEqual(summary["warnings"][0]["dimension"],
"messages")
# 同一窗口第二次 summary:提醒仍在,但账本只记一次
meter.summary("u1")
self.assertEqual(
meter.ledger.sum_kind("u1", "warning"), 1)
def test_warn_ratio_env_override(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
meter.tiers.set_tier("u1", "free")
for _ in range(25):
meter.record_message("u1")
self.assertEqual(meter.summary("u1")["warnings"], [])
old = os.environ.get("CLOUD_USAGE_WARN_RATIO")
os.environ["CLOUD_USAGE_WARN_RATIO"] = "0.5"
try:
warnings = meter.summary("u1")["warnings"]
finally:
if old is None:
os.environ.pop("CLOUD_USAGE_WARN_RATIO", None)
else:
os.environ["CLOUD_USAGE_WARN_RATIO"] = old
self.assertEqual(len(warnings), 1)
class SummaryTestCase(unittest.TestCase):
def test_summary_shape_and_remaining(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(
db_path, running_task_count=lambda uid: 0,
storage_bytes=lambda uid: 123)
meter.tiers.set_tier("u1", "paid")
_seed_tokens(db_path, "u1", actual=1000, estimated=200)
meter.record_message("u1")
summary = meter.summary("u1")
self.assertTrue(summary["proposal"])
self.assertEqual(summary["tier"], "paid")
self.assertTrue(summary["ledger_verified"])
self.assertIsNone(summary["ledger_first_bad_id"])
self.assertEqual(summary["used"]["tokens_actual"], 1000)
self.assertEqual(summary["used"]["tokens_estimated"], 200)
self.assertEqual(summary["used"]["messages"], 1)
self.assertEqual(summary["remaining"]["tokens"], 1999000)
self.assertEqual(summary["remaining"]["messages"], 499)
self.assertEqual(summary["remaining"]["storage_bytes"],
10 * GB - 123)
def test_summary_unassigned_user(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = _make_db(tmp)
meter = UsageMeter(db_path)
summary = meter.summary("u1")
self.assertIsNone(summary["tier"])
self.assertIsNone(summary["limits"])
self.assertIsNone(summary["remaining"]["tokens"])
class UsageServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user(
"outsider")
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
if self._old_admins is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
headers = {}
if key:
headers["X-API-Key"] = key
raw = json.dumps(body) if body is not None else None
if raw is not None:
headers["Content-Type"] = "application/json"
try:
conn.request(method, path, body=raw, headers=headers)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _set_tier(self, tier, key=None):
return self._req("POST", "/admin/tiers",
{"user_id": self.owner_id, "tier": tier},
key or self.owner_key)
def test_usage_endpoint_unassigned(self):
status, payload = self._req("GET", "/users/me/usage",
key=self.owner_key)
self.assertEqual(status, 200, payload)
self.assertTrue(payload["proposal"])
self.assertIsNone(payload["tier"])
self.assertIsNone(payload["limits"])
self.assertTrue(payload["ledger_verified"])
self.assertEqual(payload["used"]["tokens_actual"], 0)
def test_admin_tiers_set_get_clear_and_auth(self):
status, payload = self._set_tier("free")
self.assertEqual(status, 200, payload)
self.assertEqual(payload["tier"], "free")
self.assertEqual(payload["limits"]["messages_per_day"], 50)
self.assertTrue(payload["proposal"])
status, payload = self._req(
"GET", "/admin/tiers?user_id=%s" % self.owner_id,
key=self.owner_key)
self.assertEqual(status, 200, payload)
self.assertEqual(payload["tier"], "free")
status, _ = self._set_tier("platinum")
self.assertEqual(status, 400)
status, _ = self._set_tier("free", key=self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/admin/tiers?user_id=%s" % self.owner_id,
key=self.outsider_key)
self.assertEqual(status, 403)
status, payload = self._set_tier(None)
self.assertEqual(status, 200, payload)
self.assertIsNone(payload["tier"])
def test_concurrency_gate_blocks_second_task(self):
status, _ = self._set_tier("free")
self.assertEqual(status, 200)
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "做事"},
self.owner_key)
self.assertEqual(status, 201, task)
status, denied = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "再做事"},
self.owner_key)
self.assertEqual(status, 402, denied)
self.assertEqual(denied["error"], "quota_exceeded")
self.assertEqual(denied["dimension"], "concurrent_tasks")
# 清档后放行
status, _ = self._set_tier(None)
self.assertEqual(status, 200)
status, task2 = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "再做事"},
self.owner_key)
self.assertEqual(status, 201, task2)
def test_usage_endpoint_tiered_reflects_running_task(self):
self._set_tier("free")
status, _ = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "做事"},
self.owner_key)
self.assertEqual(status, 201)
status, payload = self._req("GET", "/users/me/usage",
key=self.owner_key)
self.assertEqual(status, 200, payload)
self.assertEqual(payload["used"]["concurrent_tasks"], 1)
self.assertEqual(payload["remaining"]["concurrent_tasks"], 0)
self.assertEqual(payload["storage_total_tasks"], 1)
self.assertEqual(payload["storage_measured_tasks"], 0)
dims = [w["dimension"] for w in payload["warnings"]]
self.assertIn("concurrent_tasks", dims)
if __name__ == "__main__":
unittest.main()