diff --git a/cloud/README.md b/cloud/README.md
index 00b4c26..80edb28 100644
--- a/cloud/README.md
+++ b/cloud/README.md
@@ -88,7 +88,10 @@ client_secret(只走环境变量),不是无认证后门。
心跳即注册)、`POST /tasks/{id}/pr`(已 merged 任务的自动 PR,
默认关闭,缺条件时返回 pr_skipped 而不是失败;见下文“云端派发与
自动 PR”一节)。`POST /tasks` 新增可选 `executor`
-(local/docker/cloud,未传表示任意 worker 可认领);
+(local/docker/cloud;未传且无环境继承时默认 docker——空
+executor(任意 worker 可认领)会让任务落到隔离较弱的 local
+worker,是多用户下的隔离缺口;`CLOUD_DEFAULT_EXECUTOR` 可
+覆盖,空字符串恢复旧行为);
`POST /projects` 新增可选 `auto_pr`(0/1,默认 0)。
### OAuth 真实浏览器登录(已实现,部署前置仍需用户动手)
@@ -373,7 +376,7 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
| `POST /tasks/{id}/cancel` | 该项目 `create_task` | `queued` / `running` 改为 `interrupted` 并记审计 `cancel_task`;已是终态(completed / failed / interrupted / merged / merge-failed)返回 `400`;不存在 `404` |
| `POST /tasks/{id}/merge` | 该项目 `approve_merge`(maintainer 以上) | 只同步状态,不执行合并:仅 `completed` 可改为 `merged` 并记审计 `merge_task`,其它状态(含已 merged 的重复调用)返回 `409`;不存在 `404`,越权 `403`,无 key `401` |
| `POST /tasks/{id}/heartbeat` | 该项目 `create_task` | 更新 `last_heartbeat = now`;状态为 `queued` 则改为 `running`;返回任务 |
-| `POST /admin/sweep` | 认证即可 | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` |
+| `POST /admin/sweep` | 任一项目 maintainer+(与备份管理同口径),否则 `403`;扫完记审计 `admin_sweep` | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值,下限钳到 60 秒);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` |
| `POST /admin/backup` | maintainer 以上 | Postgres 时调 `backup_postgres`、SQLite 时调 `backup_db`(body 可传 `out_dir`,否则用 `CLOUD_BACKUP_DIR` 或默认 `cloud-backups`);返回 `{backend, path, size_bytes, task_count}`,绝不返回数据库密码或备份文件内容 |
| `GET /tasks/{id}/cost` | 该任务所属项目 `read` | 成本聚合:`{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens, cached_input_tokens, unpriced_actual_tokens}`;真数与估算分开,无价 actual 行进 `unpriced_actual_tokens`;不存在 `404`,越权 `403` |
| `POST /projects/{id}/memory` | 该项目 `create_task` | 写线程摘要 body `{thread_id?, goal, conclusion, files_changed?, open_issues?}`,返回 `201 {id}`,并记审计 `create_memory` |
@@ -956,11 +959,26 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
- 准入:建任务、会话分支、重试在权限校验通过后、写库之前查
配额,本月用量 >= 配额时回 402 `quota_exceeded`(附 month、
quota_usd_micros、used_usd_micros),不建任务、不改状态。
- 老用户与未设配额的用户默认不限额,行为与过去一致。重试按
+ 老用户与未设配额的用户默认不限额,行为与过去一致。但
+ OAuth 新用户不再是“建号即不限额”:建号(created=True)时
+ 写一行默认配额(`CLOUD_DEFAULT_QUOTA_USD_MICROS`,默认
+ 10_000_000,即 $10/月;`INSERT OR IGNORE` 幂等,不覆盖管理
+ 员后来改过的额度),并记 `quota_default` 审计。重试按
操作人查配额、费用仍记到原任务创建者名下(建任务与分支的
操作人就是创建者,没有这个错位)。系统任务(GitHub webhook
的 /fix-review,created_by 是 'system')不记到任何用户名下、
不占配额;要把它算到项目所有者头上需另做归属。
+- 无价模型 token 上限:价目表目前只有两个 sol 模型有价,默认
+ 模型 gpt-5.6-terra 等中转模型无价、成本行 usd 为 NULL,USD
+ 配额对它们永远不生效(按 0 计)。因此有配额行的用户另受一
+ 道体积护栏:当月无价模型 token(input + output,已记账 +
+ 本轮,usd_micros IS NULL 的行)达到
+ `CLOUD_QUOTA_UNPRICED_TOKEN_CAP`(默认 20_000_000/月)即
+ 停——服务端建任务回 402 `quota_exceeded`(附
+ unpriced_token_cap、unpriced_tokens_used),worker 侧认领/
+ 重试/后续消息轮次同口径拦截。这道是防滥用的体积上限,不
+ 是计价:成本报表里无价 token 仍进 unpriced_actual_tokens,
+ 绝不折算成假美元;无配额行的用户不受限。
- 执行时加固(worker 侧,`Worker._quota_exceeded`,口径与准入
一致:当月已记账 + 本轮已累计用量,按价目表换算):认领后
开跑前再查一次(排队期间配额被花完/调低时不跑,任务标
diff --git a/cloud/console.html b/cloud/console.html
index 41d80fc..5cf1265 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -151,9 +151,8 @@
diff --git a/cloud/cost.py b/cloud/cost.py
index c9a68a1..0c119f8 100644
--- a/cloud/cost.py
+++ b/cloud/cost.py
@@ -111,6 +111,26 @@ def price_usd_micros(model, input_tokens, cached_input_tokens,
return int(round(usd * 1000000))
+def unpriced_token_cap():
+ """Monthly unpriced-token ceiling for users who have a quota row.
+
+ Models with no price entry (today: the default gpt-5.6-terra and
+ the other relay models) record usd as unknown, so a USD quota
+ alone never binds them -- without this cap a quota user could
+ burn unlimited unpriced tokens. The cap is a volume guardrail,
+ NOT a price: cost reports still show those tokens as unpriced,
+ never converted to fake dollars. Only users with a quota row
+ are capped (no row still means unlimited, as before). Env
+ ``CLOUD_QUOTA_UNPRICED_TOKEN_CAP`` overrides the default
+ 20_000_000 tokens/month; unparsable values fall back to it.
+ """
+ try:
+ return max(0, int(os.environ.get(
+ "CLOUD_QUOTA_UNPRICED_TOKEN_CAP", "20000000")))
+ except (TypeError, ValueError):
+ return 20000000
+
+
_USAGE_SUM_KEYS = ("input_tokens", "cached_input_tokens",
"cache_write_input_tokens", "output_tokens",
"reasoning_output_tokens", "total_tokens", "completions")
diff --git a/cloud/server.py b/cloud/server.py
index da15b2f..10b71c6 100644
--- a/cloud/server.py
+++ b/cloud/server.py
@@ -58,7 +58,7 @@ from urllib.parse import parse_qs, quote, urlparse
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
from cloud.coordinator import validate_parts as _coord_validate_parts
-from cloud.cost import CostStore
+from cloud.cost import CostStore, unpriced_token_cap
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import init_postgres_schema as _init_postgres_schema
@@ -936,6 +936,61 @@ def _ensure_quotas_table(conn):
"updated_at DOUBLE PRECISION)")
+def _default_executor():
+ """新任务未指定执行器时的默认值:docker。
+
+ 空 executor(任意 worker 可认领)是多用户下的隔离缺口:
+ 任务可能落到隔离较弱的 local worker 上。默认 docker 后,
+ 只有显式 local/cloud 或系统创建(webhook/修复任务,executor
+ 为 NULL)才走别的路。环境变量 CLOUD_DEFAULT_EXECUTOR 可
+ 覆盖:设为空字符串即恢复旧行为(None,任意 worker),
+ 供单机运维在没有 docker worker 时兜底;非法值按 docker 计。
+ """
+ raw = os.environ.get("CLOUD_DEFAULT_EXECUTOR", "docker")
+ if raw is None:
+ return "docker"
+ raw = raw.strip()
+ if raw == "":
+ return None
+ if raw in ("local", "docker", "cloud"):
+ return raw
+ return "docker"
+
+
+def _default_quota_usd_micros():
+ """OAuth 新用户建号时写入的默认配额(微美元)。
+
+ 无配额行 = 不限额,所以 OAuth 自动建号过去等于无限额入驻。
+ 新号现在建号即写一行默认配额(老用户无配额行不受影响,
+ 行为不变)。环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS 可
+ 覆盖,默认 10_000_000($10/月);非法值按默认计。
+ """
+ try:
+ return max(0, int(os.environ.get(
+ "CLOUD_DEFAULT_QUOTA_USD_MICROS", "10000000")))
+ except (TypeError, ValueError):
+ return 10000000
+
+
+def _apply_default_quota(conn, user_id):
+ """给新用户写默认配额行(已存在则不动,幂等)。
+
+ 只在 OAuth 建号(created=True)时调:INSERT OR IGNORE
+ 保证重复回调不覆盖管理员后来改过的额度。返回 True 表示
+ 本次写了新行(调用方据此记 quota_default 审计)。
+ """
+ _ensure_quotas_table(conn)
+ cur = conn.execute(
+ "INSERT OR IGNORE INTO user_quotas(user_id, quota_usd_micros,"
+ " updated_at) VALUES(?, ?, ?)",
+ (user_id, _default_quota_usd_micros(), time.time()))
+ try:
+ conn.commit()
+ except Exception:
+ pass
+ return cur.rowcount == 1
+
+
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
@@ -1465,7 +1520,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
elif path == "/tasks" and method == "GET":
self._list_tasks(user_id, query)
elif path == "/admin/sweep" and method == "POST":
- self._admin_sweep()
+ self._admin_sweep(user_id)
elif path == "/admin/backup" and method == "POST":
self._admin_backup(user_id)
elif path == "/admin/quotas" and method == "GET":
@@ -2582,6 +2637,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
if executor is None and env["executor"]:
executor = env["executor"]
+ if executor is None:
+ # 未指定且无环境继承时默认 docker:空 executor(任意
+ # worker 可认领)会让任务落到隔离较弱的 local worker,
+ # 是多用户下的隔离缺口;显式 local/cloud 不受影响。
+ executor = _default_executor()
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
@@ -3288,7 +3348,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)",
(new_id, task["project_id"], message, task["priority"],
- user_id, now, now, task["executor"],
+ user_id, now, now,
+ task["executor"] or _default_executor(),
task["goal_max_rounds"] or 3, task["model"],
task["effort"], task["approval"], task["plan"] or 0,
task_id),
@@ -3809,6 +3870,56 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"remaining_usd_micros": (quota - used)
if quota is not None else None}
+ def _quota_unpriced_tokens(self, user_id, month):
+ """当月无价模型 token 数(usd_micros IS NULL 的成本行,
+ input + output 之和;cached 已含在 input 里,不重复计)。
+
+ 与 _quota_status 同款 CostStore 懒建表做法,全新库上
+ cost_records 不存在时先建表再查。无配额行时调用方不
+ 应以此挡人(见 _check_unpriced_token_cap)。
+ """
+ conn = self._db()
+ try:
+ _cost_store = CostStore(self.server.db_path)
+ try:
+ _cost_store._conn.close()
+ except Exception:
+ pass
+ 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",
+ (user_id, month + "%")).fetchone()
+ return int(row[0] if row is not None else 0)
+ finally:
+ conn.close()
+
+ def _check_unpriced_token_cap(self, user_id):
+ """无价 token 上限准入:超限返回 (cap, used, month),否则 None。
+
+ 只有有配额行的用户才受限(无配额行 = 不限额,与 USD
+ 口径一致);USD 配额本身不计量无价模型,所以这道是
+ 补 USD 口径的洞,不是第二套计费。"""
+ month = self._quota_month()
+ conn = self._db()
+ try:
+ _ensure_quotas_table(conn)
+ conn.commit()
+ row = conn.execute(
+ "SELECT quota_usd_micros FROM user_quotas"
+ " WHERE user_id = ?",
+ (user_id,)).fetchone()
+ if row is None:
+ return None
+ finally:
+ conn.close()
+ used = self._quota_unpriced_tokens(user_id, month)
+ cap = unpriced_token_cap()
+ if used >= cap:
+ return cap, used, month
+ return None
+
def _check_quota(self, user_id):
"""配额准入:放行返回 None;超限返回 (quota, used,
month),调用方一律回 402——这是钱用完了,不是请求
@@ -3829,6 +3940,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"quota_usd_micros": quota,
"used_usd_micros": used})
return True
+ capped = self._check_unpriced_token_cap(user_id)
+ if capped is not None:
+ cap, used, month = capped
+ self._send_json(402, {"error": "quota_exceeded",
+ "month": month,
+ "unpriced_token_cap": cap,
+ "unpriced_tokens_used": used})
+ return True
return False
def _is_global_maintainer(self, user_id):
@@ -4115,7 +4234,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, task)
# -- 管理 ----------------------------------------------------------
- def _admin_sweep(self):
+ def _admin_sweep(self, user_id):
+ """POST /admin/sweep:把超阈值的陈旧 running 任务标 interrupted。
+
+ 权限:与 _admin_backup 同口径——调用方须在任一项目里
+ 是 maintainer 以上,否则 403。过去这里没有任何权限检查,
+ 任意持 key 用户都能跨用户改任务状态。threshold_seconds
+ 下限钳到 60 秒:0 会把所有 running 任务一口气扫掉,
+ 只能是管理员的显式紧急操作才需要那么小的值,而 60 秒
+ 以下的“陈旧”本就没有意义。扫完记 admin_sweep 审计。
+ """
+ if not self._is_global_maintainer(user_id):
+ self._send_json(403, {"error": "forbidden"})
+ return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
@@ -4127,7 +4258,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError):
self._send_json(400, {"error": "threshold_seconds must be an integer"})
return
+ threshold = max(60, threshold)
swept = sweep_stale_tasks(self.server.db_path, threshold)
+ with _auth_store(self.server.db_path) as store:
+ store.audit(user_id, "", "admin_sweep",
+ "threshold=%d swept=%d" % (threshold, len(swept)))
self._send_json(200, {"swept": swept})
def _admin_backup(self, user_id):
@@ -4366,6 +4501,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
+ if created and _apply_default_quota(auth.conn, local_id):
+ auth.audit(local_id, "", "quota_default",
+ "quota_usd_micros=%d"
+ % _default_quota_usd_micros())
if not created:
auth.audit(local_id, "", "api_key_issued", provider)
return local_id, created, api_key_plain, rotated, identity, False
diff --git a/cloud/tests/test_multiuser_gaps.py b/cloud/tests/test_multiuser_gaps.py
new file mode 100644
index 0000000..aecf232
--- /dev/null
+++ b/cloud/tests/test_multiuser_gaps.py
@@ -0,0 +1,279 @@
+"""多用户生产级缺口批单测:sweep 鉴权、默认执行器、新用户默认配额、
+无价模型 token 上限(服务端准入)。
+
+只用本地临时端口 + 临时 SQLite,不跑真实模型,不联网。
+环境变量(默认执行器、默认配额额、token 上限)是服务端每次
+请求现读的,测试里用 try/finally 改回,不影响其他测试。
+"""
+
+import http.client
+import json
+import os
+import sqlite3
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from datetime import datetime, timezone
+from pathlib import Path
+
+try:
+ from cloud.auth import AuthStore
+ from cloud.cost import CostStore
+ from cloud.server import _apply_default_quota, _default_quota_usd_micros
+ from cloud.server import create_server
+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 _apply_default_quota, _default_quota_usd_micros
+ from cloud.server import create_server
+
+
+def _req(port, method, path, body=None, key=None):
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
+ hdrs = {}
+ payload = None
+ if body is not None:
+ payload = json.dumps(body).encode("utf-8")
+ hdrs["Content-Type"] = "application/json"
+ if key is not None:
+ hdrs["X-API-Key"] = key
+ try:
+ conn.request(method, path, body=payload, headers=hdrs)
+ 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 _utc_today():
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+
+def _setenv(name, value):
+ old = os.environ.get(name)
+ if value is None:
+ os.environ.pop(name, None)
+ else:
+ os.environ[name] = value
+ return old
+
+
+class MultiuserGapsTestCase(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.viewer_id, self.viewer_key = self.auth.create_user("viewer")
+ self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
+ 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()
+ s, proj = _req(self.port, "POST", "/projects",
+ {"name": "gproj"}, self.owner_key)
+ self.assertEqual(s, 201, proj)
+ self.project_id = proj["id"]
+ self.auth.set_member(
+ self.project_id, self.owner_id, self.viewer_id, "viewer")
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+ try:
+ self.auth.conn.close()
+ except Exception:
+ pass
+ self.tmp.cleanup()
+
+ # -- helpers -------------------------------------------------------
+ def _create_task(self, key=None, body_extra=None):
+ body = {"project_id": self.project_id, "prompt": "gap probe"}
+ if body_extra:
+ body.update(body_extra)
+ return _req(self.port, "POST", "/tasks", body,
+ key if key is not None else self.owner_key)
+
+ def _insert_quota(self, user_id, micros):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS user_quotas("
+ "user_id TEXT PRIMARY KEY, quota_usd_micros BIGINT,"
+ " updated_at DOUBLE PRECISION)")
+ conn.execute(
+ "INSERT OR REPLACE INTO user_quotas"
+ "(user_id, quota_usd_micros, updated_at) VALUES(?, ?, ?)",
+ (user_id, micros, time.time()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _insert_unpriced_cost(self, task_id, input_tokens, output_tokens):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "INSERT INTO tasks(id, project_id, prompt, status,"
+ " idempotency_key, depends_on, priority, created_by,"
+ " created_at, updated_at, last_heartbeat)"
+ " VALUES(?, ?, ?, 'completed', NULL, '[]', 0, ?, ?, ?, NULL)",
+ (task_id, self.project_id, "donor", self.owner_id,
+ time.time(), time.time()))
+ conn.commit()
+ finally:
+ conn.close()
+ # CostStore.record 建表并按 UTC 今天记 day,与服务端查询口径一致。
+ store = CostStore(self.db_path)
+ try:
+ store.record(task_id, self.project_id, "gpt-5.6-terra",
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ usd_micros=None, source="actual")
+ finally:
+ try:
+ store._conn.close()
+ except Exception:
+ pass
+
+ def _make_running_task(self):
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ s, _ = _req(self.port, "POST",
+ "/tasks/%s/heartbeat" % task["id"], {},
+ self.owner_key)
+ self.assertEqual(s, 200)
+ return task["id"]
+
+ # -- sweep 鉴权 -----------------------------------------------------
+ def test_sweep_viewer_and_outsider_forbidden(self):
+ for key in (self.viewer_key, self.outsider_key):
+ s, body = _req(self.port, "POST", "/admin/sweep", {},
+ key)
+ self.assertEqual(s, 403, body)
+
+ def test_sweep_owner_maintainer_ok_and_sweeps_stale(self):
+ tid = self._make_running_task()
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "UPDATE tasks SET last_heartbeat = ? WHERE id = ?",
+ (time.time() - 3600, tid))
+ conn.commit()
+ finally:
+ conn.close()
+ s, body = _req(self.port, "POST", "/admin/sweep",
+ {"threshold_seconds": 300}, self.owner_key)
+ self.assertEqual(s, 200, body)
+ self.assertIn(tid, body["swept"])
+
+ def test_sweep_threshold_clamped_to_60(self):
+ tid = self._make_running_task()
+ s, body = _req(self.port, "POST", "/admin/sweep",
+ {"threshold_seconds": 0}, self.owner_key)
+ self.assertEqual(s, 200, body)
+ self.assertNotIn(tid, body["swept"])
+
+ # -- 默认执行器 ------------------------------------------------------
+ def test_default_executor_is_docker(self):
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ self.assertEqual(task["executor"], "docker")
+
+ def test_explicit_executor_kept(self):
+ s, task = self._create_task(body_extra={"executor": "local"})
+ self.assertEqual(s, 201, task)
+ self.assertEqual(task["executor"], "local")
+
+ def test_default_executor_env_override(self):
+ old = _setenv("CLOUD_DEFAULT_EXECUTOR", "local")
+ try:
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ self.assertEqual(task["executor"], "local")
+ finally:
+ _setenv("CLOUD_DEFAULT_EXECUTOR", old)
+ old = _setenv("CLOUD_DEFAULT_EXECUTOR", "")
+ try:
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ self.assertIsNone(task["executor"])
+ finally:
+ _setenv("CLOUD_DEFAULT_EXECUTOR", old)
+
+ # -- 新用户默认配额 ---------------------------------------------------
+ def test_apply_default_quota_idempotent(self):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ self.assertTrue(_apply_default_quota(conn, "newbie"))
+ row = conn.execute(
+ "SELECT quota_usd_micros FROM user_quotas"
+ " WHERE user_id = 'newbie'").fetchone()
+ self.assertEqual(row[0], _default_quota_usd_micros())
+ # 第二次不再写,不覆盖管理员后来改过的额度。
+ conn.execute(
+ "UPDATE user_quotas SET quota_usd_micros = 12345"
+ " WHERE user_id = 'newbie'")
+ conn.commit()
+ self.assertFalse(_apply_default_quota(conn, "newbie"))
+ row = conn.execute(
+ "SELECT quota_usd_micros FROM user_quotas"
+ " WHERE user_id = 'newbie'").fetchone()
+ self.assertEqual(row[0], 12345)
+ finally:
+ conn.close()
+
+ def test_default_quota_env_override(self):
+ old = _setenv("CLOUD_DEFAULT_QUOTA_USD_MICROS", "5000")
+ try:
+ self.assertEqual(_default_quota_usd_micros(), 5000)
+ finally:
+ _setenv("CLOUD_DEFAULT_QUOTA_USD_MICROS", old)
+
+ # -- 无价 token 上限(服务端准入)--------------------------------------
+ def test_unpriced_token_cap_blocks_create(self):
+ self._insert_quota(self.owner_id, 10 ** 12) # USD 很大,不挡
+ for i in range(7): # 7 * 150 = 1050 token
+ self._insert_unpriced_cost("donor-%d" % i, 100, 50)
+ old = _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", "1000")
+ try:
+ s, body = self._create_task()
+ self.assertEqual(s, 402, body)
+ self.assertEqual(body["error"], "quota_exceeded")
+ self.assertEqual(body["unpriced_tokens_used"], 1050)
+ finally:
+ _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", old)
+
+ def test_unpriced_token_cap_under_cap_passes(self):
+ self._insert_quota(self.owner_id, 10 ** 12)
+ for i in range(6): # 6 * 150 = 900 < 1000
+ self._insert_unpriced_cost("donor-%d" % i, 100, 50)
+ old = _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", "1000")
+ try:
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ finally:
+ _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", old)
+
+ def test_unpriced_tokens_unlimited_without_quota_row(self):
+ for i in range(7):
+ self._insert_unpriced_cost("donor-%d" % i, 100, 50)
+ old = _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", "1000")
+ try:
+ s, task = self._create_task()
+ self.assertEqual(s, 201, task)
+ finally:
+ _setenv("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", old)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/cloud/tests/test_worker_quota.py b/cloud/tests/test_worker_quota.py
index 1fb6017..556abe6 100644
--- a/cloud/tests/test_worker_quota.py
+++ b/cloud/tests/test_worker_quota.py
@@ -80,6 +80,26 @@ def _insert_donor_cost(db_path, micros):
pass
+def _insert_unpriced_cost(db_path, tokens):
+ """给 tester 记一笔无价成本:已完成任务 + usd_micros NULL 的行。
+
+ tokens 拆成 input/output 两半记(input + output = tokens,
+ 与服务端/ worker 的无价 token 口径一致)。
+ """
+ _insert_task(db_path, "donor-unpriced", status="completed")
+ store = CostStore(db_path)
+ try:
+ store.record("donor-unpriced", "p1", "gpt-5.6-terra",
+ input_tokens=tokens // 2,
+ output_tokens=tokens - tokens // 2,
+ usd_micros=None, source="actual")
+ finally:
+ try:
+ store._conn.close()
+ except Exception:
+ pass
+
+
def _insert_followup(db_path, fu_id, task_id, body="follow up"):
now = time.time()
conn = sqlite3.connect(db_path)
@@ -229,6 +249,73 @@ class WorkerQuotaTestCase(unittest.TestCase):
result = worker.run_one()
self.assertEqual(result["status"], "completed")
+ # -- 无价 token 上限 -------------------------------------------------
+ def test_unpriced_token_cap_blocks_claim(self):
+ _insert_quota(self.db_path, "tester", 10 ** 12) # USD 很大,不挡
+ _insert_unpriced_cost(self.db_path, 150)
+ _insert_task(self.db_path, "T1")
+ worker = self._worker(_usage_runner(
+ {"input_tokens": 10, "output_tokens": 1, "total_tokens": 11},
+ self.calls))
+ old = os.environ.get("CLOUD_QUOTA_UNPRICED_TOKEN_CAP")
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = "100"
+ try:
+ result = worker.run_one()
+ finally:
+ if old is None:
+ os.environ.pop("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", None)
+ else:
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = old
+ self.assertEqual(result["status"], "interrupted")
+ self.assertIn("无价模型 token", result["error"])
+ self.assertEqual(self.calls, [])
+
+ def test_unpriced_round_usage_counts_toward_cap(self):
+ _insert_quota(self.db_path, "tester", 10 ** 12)
+ _insert_unpriced_cost(self.db_path, 90)
+ _insert_task(self.db_path, "T1")
+ _insert_followup(self.db_path, "fu1", "T1")
+ worker = Worker(self.db_path, "/fake/host.py", runner=_usage_runner(
+ {"input_tokens": 50, "cached_input_tokens": 0,
+ "output_tokens": 0, "total_tokens": 50},
+ self.calls), env=self.env, model="gpt-5.6-terra",
+ retry_backoff_seconds=0)
+ old = os.environ.get("CLOUD_QUOTA_UNPRICED_TOKEN_CAP")
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = "100"
+ try:
+ result = worker.run_one()
+ finally:
+ if old is None:
+ os.environ.pop("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", None)
+ else:
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = old
+ # 首轮无价 50 token:90 + 50 = 140 >= 100,排空时停。
+ self.assertEqual(result["status"], "interrupted")
+ self.assertIn("quota_exceeded", result["error"])
+ self.assertEqual(_followup_status(self.db_path, "fu1"), "pending")
+
+ def test_priced_usage_ignores_token_cap(self):
+ _insert_quota(self.db_path, "tester", 10 ** 12)
+ _insert_unpriced_cost(self.db_path, 90)
+ _insert_task(self.db_path, "T1")
+ _insert_followup(self.db_path, "fu1", "T1")
+ worker = self._worker(_usage_runner(
+ {"input_tokens": 100, "cached_input_tokens": 0,
+ "output_tokens": 10, "total_tokens": 110},
+ self.calls))
+ old = os.environ.get("CLOUD_QUOTA_UNPRICED_TOKEN_CAP")
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = "100"
+ try:
+ result = worker.run_one()
+ finally:
+ if old is None:
+ os.environ.pop("CLOUD_QUOTA_UNPRICED_TOKEN_CAP", None)
+ else:
+ os.environ["CLOUD_QUOTA_UNPRICED_TOKEN_CAP"] = old
+ # 有价模型用量不计入无价上限:90 < 100,正常跑完。
+ self.assertEqual(result["status"], "completed")
+ self.assertEqual(_followup_status(self.db_path, "fu1"), "done")
+
# -- 轮询掐断 ------------------------------------------------------
def test_poll_loop_quota_kill(self):
_insert_task(self.db_path, "T1")
diff --git a/cloud/worker.py b/cloud/worker.py
index 9353db9..ec9b12f 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -139,7 +139,8 @@ from cloud.coordinator import DEFAULT_POLL_INTERVAL as _COORD_POLL_INTERVAL
from cloud.coordinator import DEFAULT_TIMEOUT_SECONDS as _COORD_TIMEOUT
from cloud.coordinator import ensure_columns as _coord_ensure_columns
from cloud.coordinator import parse_parts as _coord_parse_parts
-from cloud.cost import CostStore, estimate_tokens, price_usd_micros, sum_usage
+from cloud.cost import CostStore, estimate_tokens, price_usd_micros, \
+ sum_usage, unpriced_token_cap
from cloud.db import connect as _db_connect
from cloud.logging_config import get_logger, log_event, setup_logging
from cloud.models import ModelRouter, is_fallbackable, record_fallback
@@ -832,9 +833,14 @@ class Worker:
口径与服务端准入(_quota_status)一致:按任务创建者算,
当月(UTC)已记账用量 + 本轮已累计用量(按价目表换算,
无价模型按 0 计,与服务端 SUM(usd NULL 不计)一致)。
- 无配额行 = 不限额。查询本身异常时按不限额处理(fail-open
- 并记一条警告):配额是第二道网,绝不能因查询抖动把全部
- 任务挡在门外;服务端建任务/分支/重试时的准入仍是第一道。
+ 另有一道无价 token 上限(与服务端
+ _check_unpriced_token_cap 同口径):USD 配额不计量无价
+ 模型,默认模型 terra 无价,若无此上限则配额对默认模型
+ 永远不生效;有配额行的用户当月无价 token(input +
+ output,已记账 + 本轮)达到上限即停。无配额行 = 不限额。
+ 查询本身异常时按不限额处理(fail-open 并记一条警告):
+ 配额是第二道网,绝不能因查询抖动把全部任务挡在门外;
+ 服务端建任务/分支/重试时的准入仍是第一道。
"""
owner = (task or {}).get("created_by")
if not owner:
@@ -873,6 +879,14 @@ class Worker:
" WHERE t.created_by = ? AND c.day LIKE ?",
(owner, month + "%")).fetchone()
used = used_row[0] if used_row else 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",
+ (owner, month + "%")).fetchone()
+ unpriced_used = unpriced_row[0] if unpriced_row else 0
finally:
conn.close()
if usage_total:
@@ -882,9 +896,20 @@ class Worker:
usage_total.get("cached_input_tokens", 0),
usage_total.get("output_tokens", 0))
used += micros or 0
+ if micros is None:
+ # 无价模型:本轮 token 计入无价上限(USD 口
+ # 径里它是 0,不会在上面那道里被拦住)。
+ unpriced_used += (
+ max(0, int(usage_total.get("input_tokens", 0)))
+ + max(0, int(usage_total.get("output_tokens", 0))))
if used >= quota:
return ("quota_exceeded: 本月配额已用完(已用 %d / "
"配额 %d 微美元,%s)" % (used, quota, month))
+ cap = unpriced_token_cap()
+ if unpriced_used >= cap:
+ return ("quota_exceeded: 本月无价模型 token 已用完"
+ "(已用 %d / 上限 %d,%s)"
+ % (unpriced_used, cap, month))
return None
except Exception as exc:
try: