- POST /admin/sweep 改走任一项目 maintainer+ 门槛(与备份管理同口径),否则 403,记 admin_sweep 审计;threshold 下限钳 60 秒 - OAuth 建号 created=True 时写默认配额行(默认 $10/月,CLOUD_DEFAULT_QUOTA_USD_MICROS 可调,INSERT OR IGNORE 幂等);老用户无配额行不受影响 - 建任务未指定且无环境继承时默认 executor=docker(分支继承为空时同理);CLOUD_DEFAULT_EXECUTOR 可覆盖,空字符串恢复旧行为;命令台下拉默认改为 docker - 无价模型 token 上限:价目表外模型(默认 terra)成本 usd 为 NULL,USD 配额永不生效;有配额行的用户当月无价 token 达 CLOUD_QUOTA_UNPRICED_TOKEN_CAP(默认 2000 万/月)即停,服务端 402、worker 认领/重试/后续消息同口径;报表仍记 unpriced,不折假美元 - 测试:新增 test_multiuser_gaps 11 个 + test_worker_quota 3 个;全量 cloud 668、host 64、deploy 19 全过
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Cost estimation and cost recording.
|
|
|
|
Zero third-party dependencies; standard library only.
|
|
|
|
Actual token counts come from the model/provider response when
|
|
available: host.py collects the app-server's thread/tokenUsage/updated
|
|
events during each turn (one per upstream completion, summed over the
|
|
turn window) and the worker records them with ``source="actual"``.
|
|
When no usage events arrive (old server, turn produced no completion),
|
|
the worker falls back to ``estimate_tokens`` with
|
|
``source="estimated"``; the two are stored separately so estimates are
|
|
never mixed into actual totals.
|
|
|
|
USD is tokens times a price table, not the relay's bill: the defaults
|
|
are OpenAI list prices (see ``_DEFAULT_MODEL_PRICES``), and the relay
|
|
may charge differently. Override per model with
|
|
``CLOUD_MODEL_PRICES_JSON`` or ``CLOUD_MODEL_PRICES_FILE``; models
|
|
with no price record ``usd_micros=None`` and reports count their
|
|
tokens in ``unpriced_actual_tokens`` instead of pricing them at zero.
|
|
|
|
Dead end, documented so nobody retries it: the app-server's
|
|
account/usage/read method requires ChatGPT account authentication and
|
|
fails on a relay API key (2026-10-26 probe: ``codex account
|
|
authentication required to read token usage``). Account-level usage
|
|
is therefore not a source here; per-task actuals come from turn
|
|
events only.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from cloud.db import connect as _db_connect
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
# OpenAI list prices, USD per 1M tokens, as (input, cached_input,
|
|
# output). gpt-6.1-sol $2.00/$0.10/$10.00 and gpt-6-sol
|
|
# $2.00/$0.20/$10.00, as reported at the 2026-09-30 launch.
|
|
# Again: list prices, not the relay's bill; override below.
|
|
_DEFAULT_MODEL_PRICES = {
|
|
"gpt-6.1-sol": (2.00, 0.10, 10.00),
|
|
"gpt-6-sol": (2.00, 0.20, 10.00),
|
|
}
|
|
|
|
|
|
def _coerce_rates(rates):
|
|
"""Accept [input, cached, output] or {input, cached_input, output}."""
|
|
try:
|
|
if isinstance(rates, dict):
|
|
return (float(rates["input"]), float(rates["cached_input"]),
|
|
float(rates["output"]))
|
|
return (float(rates[0]), float(rates[1]), float(rates[2]))
|
|
except (TypeError, ValueError, KeyError, IndexError):
|
|
return None
|
|
|
|
|
|
def model_prices():
|
|
"""Effective price table: defaults merged with env overrides.
|
|
|
|
``CLOUD_MODEL_PRICES_JSON`` (a JSON object) and
|
|
``CLOUD_MODEL_PRICES_FILE`` (path to a JSON file) both merge over
|
|
the defaults per model; unparsable content is ignored, never fatal.
|
|
"""
|
|
prices = dict(_DEFAULT_MODEL_PRICES)
|
|
raw = os.environ.get("CLOUD_MODEL_PRICES_JSON") or ""
|
|
if raw:
|
|
try:
|
|
override = json.loads(raw)
|
|
except ValueError:
|
|
override = None
|
|
if isinstance(override, dict):
|
|
for model, rates in override.items():
|
|
coerced = _coerce_rates(rates)
|
|
if coerced is not None:
|
|
prices[model] = coerced
|
|
path = os.environ.get("CLOUD_MODEL_PRICES_FILE") or ""
|
|
if path:
|
|
try:
|
|
with open(path) as f:
|
|
override = json.load(f)
|
|
except (OSError, ValueError):
|
|
override = None
|
|
if isinstance(override, dict):
|
|
for model, rates in override.items():
|
|
coerced = _coerce_rates(rates)
|
|
if coerced is not None:
|
|
prices[model] = coerced
|
|
return prices
|
|
|
|
|
|
def price_usd_micros(model, input_tokens, cached_input_tokens,
|
|
output_tokens):
|
|
"""USD micros for provider-measured tokens at the price table.
|
|
|
|
cached is a subset of input (prompt-cache reads): non-cached input
|
|
pays the input rate, cached pays the cached rate, output pays the
|
|
output rate. Reasoning tokens are already inside output_tokens
|
|
(provider total = input + output), never added twice. Returns None
|
|
when the model has no price entry (caller records usd as unknown).
|
|
"""
|
|
rates = model_prices().get(model)
|
|
if rates is None:
|
|
return None
|
|
input_rate, cached_rate, output_rate = rates
|
|
total_in = max(0, int(input_tokens or 0))
|
|
cached = max(0, min(int(cached_input_tokens or 0), total_in))
|
|
non_cached = total_in - cached
|
|
usd = (non_cached * input_rate + cached * cached_rate
|
|
+ max(0, int(output_tokens or 0)) * output_rate) / 1000000
|
|
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")
|
|
|
|
|
|
def sum_usage(a, b):
|
|
"""Add two usage dicts (host.py shape); either side may be None."""
|
|
if not a:
|
|
return dict(b) if b else None
|
|
if not b:
|
|
return dict(a)
|
|
out = dict(a)
|
|
for k in _USAGE_SUM_KEYS:
|
|
if isinstance(b.get(k), int):
|
|
out[k] = out.get(k, 0) + b[k]
|
|
return out
|
|
|
|
|
|
def estimate_tokens(prompt_text, output_text) -> int:
|
|
"""Estimate total tokens as (chars of prompt + output) // 4."""
|
|
prompt_text = prompt_text or ""
|
|
output_text = output_text or ""
|
|
return (len(prompt_text) + len(output_text)) // 4
|
|
|
|
|
|
def fetch_account_usage(usage_client) -> dict:
|
|
"""Fetch account usage through an injected ``usage_client`` callable.
|
|
|
|
This is a call wrapper around the app-server account/usage/read
|
|
concept: the real ``usage_client`` is injected by the control plane
|
|
once the billing route is confirmed available. This function itself
|
|
never runs a model turn and never touches the network.
|
|
|
|
On success it returns ``{"available": True, "data": <raw data>}``.
|
|
If ``usage_client`` raises, or returns None / an empty dict,
|
|
it returns ``{"available": False, "data": None}`` and never raises
|
|
to the caller. On a relay, usage being unreadable is a normal
|
|
outcome (available=False), not a bug.
|
|
"""
|
|
try:
|
|
data = usage_client()
|
|
except Exception:
|
|
return {"available": False, "data": None}
|
|
if data is None:
|
|
return {"available": False, "data": None}
|
|
if isinstance(data, dict) and not data:
|
|
return {"available": False, "data": None}
|
|
return {"available": True, "data": data}
|
|
|
|
|
|
class CostStore:
|
|
"""SQLite store for per-task / per-project / per-day cost records."""
|
|
|
|
_SOURCES = ("actual", "estimated")
|
|
|
|
_COST_DDL = """CREATE TABLE IF NOT EXISTS cost_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT,
|
|
project_id TEXT,
|
|
model TEXT,
|
|
input_tokens INTEGER,
|
|
output_tokens INTEGER,
|
|
cached_input_tokens INTEGER DEFAULT 0,
|
|
total_tokens INTEGER,
|
|
usd_micros INTEGER,
|
|
source TEXT,
|
|
fallback_count INTEGER DEFAULT 0,
|
|
day TEXT,
|
|
created_at REAL
|
|
)"""
|
|
|
|
def _ensure_cached_column(self, conn):
|
|
"""老库补 cached_input_tokens 列(SQLite 与 Postgres 两支:
|
|
|
|
PRAGMA table_info 经 db.py 适配层翻译,缺列时 ALTER 补列;
|
|
并发下两个进程同时补列时第二个 ALTER 报错,直接吞掉)。
|
|
"""
|
|
try:
|
|
cols = {row[1] for row in conn.execute(
|
|
"PRAGMA table_info(cost_records)").fetchall()}
|
|
except Exception:
|
|
return
|
|
if "cached_input_tokens" not in cols:
|
|
try:
|
|
conn.execute(
|
|
"ALTER TABLE cost_records"
|
|
" ADD COLUMN cached_input_tokens INTEGER DEFAULT 0")
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
def __init__(self, db_path):
|
|
self.db_path = db_path
|
|
if db_path != ":memory:":
|
|
parent = os.path.dirname(os.path.abspath(db_path))
|
|
os.makedirs(parent, exist_ok=True)
|
|
self._conn = _db_connect(db_path)
|
|
self._conn.execute(self._COST_DDL)
|
|
self._conn.commit()
|
|
self._ensure_cached_column(self._conn)
|
|
|
|
def _new_conn(self):
|
|
"""每次写/读开一个新连接,用完即关。
|
|
|
|
旧实现在 __init__ 里建一条连接并终身共用,单线程没问题;
|
|
worker 并发后多个线程共用同一条 sqlite 连接会直接报
|
|
ProgrammingError(连接是线程绑定的)。其它 store(Worker
|
|
的 _set_status、_audit 等)本来就是每次开新连接,这里
|
|
向它们看齐。_conn 仍保留(建表 + 旧测试直接用它读),
|
|
但 record/_report 不再走它。
|
|
例外:db_path 为 :memory: 时新连接是另一个空库,必须
|
|
复用 _conn(:memory: 只用于单线程单测,并发 worker
|
|
从不用它)。
|
|
"""
|
|
if self.db_path == ":memory:":
|
|
return self._conn
|
|
conn = _db_connect(self.db_path)
|
|
conn.execute(self._COST_DDL)
|
|
conn.commit()
|
|
self._ensure_cached_column(conn)
|
|
return conn
|
|
|
|
def record(self, task_id, project_id, model, input_tokens=0,
|
|
output_tokens=0, cached_input_tokens=0, usd_micros=None,
|
|
source="actual", fallback_count=0):
|
|
"""Insert one cost record; returns its row id.
|
|
|
|
``total_tokens`` is computed as input + output, and ``day`` is
|
|
the UTC date (YYYY-MM-DD) of ``created_at``. cached is a
|
|
subset of input (prompt-cache reads), stored separately so
|
|
reports can show why a task was cheap; it is never added to
|
|
total on top of input.
|
|
"""
|
|
if source not in self._SOURCES:
|
|
raise ValueError(
|
|
"source must be one of %r, got %r" % (self._SOURCES, source)
|
|
)
|
|
input_tokens = int(input_tokens or 0)
|
|
output_tokens = int(output_tokens or 0)
|
|
cached_input_tokens = int(cached_input_tokens or 0)
|
|
total_tokens = input_tokens + output_tokens
|
|
created_at = time.time()
|
|
day = datetime.fromtimestamp(created_at, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
conn = self._new_conn()
|
|
try:
|
|
cursor = conn.execute(
|
|
"""INSERT INTO cost_records
|
|
(task_id, project_id, model, input_tokens, output_tokens,
|
|
cached_input_tokens, total_tokens, usd_micros, source,
|
|
fallback_count, day, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(task_id, project_id, model, input_tokens, output_tokens,
|
|
cached_input_tokens, total_tokens, usd_micros, source,
|
|
int(fallback_count or 0), day, created_at),
|
|
)
|
|
conn.commit()
|
|
return cursor.lastrowid
|
|
finally:
|
|
# :memory: 时 conn 就是共享的 _conn,绝不能关掉它。
|
|
if conn is not self._conn:
|
|
conn.close()
|
|
|
|
def record_estimated(self, task_id, project_id, model, prompt_text,
|
|
output_text):
|
|
"""Record an estimated cost row; returns its row id.
|
|
|
|
The token split cannot be recovered from a character estimate,
|
|
so ``input_tokens`` holds the whole estimate and
|
|
``output_tokens`` is 0; ``source`` is ``"estimated"`` and
|
|
``usd_micros`` is None.
|
|
"""
|
|
total = estimate_tokens(prompt_text, output_text)
|
|
return self.record(
|
|
task_id, project_id, model,
|
|
input_tokens=total, output_tokens=0,
|
|
usd_micros=None, source="estimated", fallback_count=0,
|
|
)
|
|
|
|
def _report(self, where, params):
|
|
conn = self._new_conn()
|
|
try:
|
|
row = conn.execute(
|
|
"""SELECT
|
|
COALESCE(SUM(total_tokens), 0),
|
|
COALESCE(SUM(usd_micros), 0),
|
|
COUNT(*),
|
|
COALESCE(SUM(fallback_count), 0),
|
|
COALESCE(SUM(CASE WHEN source = 'actual'
|
|
THEN total_tokens ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN source = 'estimated'
|
|
THEN total_tokens ELSE 0 END), 0),
|
|
COALESCE(SUM(cached_input_tokens), 0),
|
|
COALESCE(SUM(CASE WHEN source = 'actual'
|
|
AND usd_micros IS NULL
|
|
THEN total_tokens ELSE 0 END), 0)
|
|
FROM cost_records WHERE %s""" % where,
|
|
params,
|
|
).fetchone()
|
|
finally:
|
|
if conn is not self._conn:
|
|
conn.close()
|
|
(total_tokens, usd_micros, turns, fallback_count, actual,
|
|
estimated, cached, unpriced) = row
|
|
return {
|
|
"total_tokens": int(total_tokens),
|
|
"usd_micros": int(usd_micros),
|
|
"turns": int(turns),
|
|
"fallback_count": int(fallback_count),
|
|
"actual_tokens": int(actual),
|
|
"estimated_tokens": int(estimated),
|
|
"cached_input_tokens": int(cached),
|
|
# usd 求和只覆盖有价的行;无价 actual 行的 token 在此,
|
|
# 不会被静默算成 0 美元。
|
|
"unpriced_actual_tokens": int(unpriced),
|
|
}
|
|
|
|
def report_by_task(self, task_id) -> dict:
|
|
"""Aggregate cost records for one task."""
|
|
return self._report("task_id = ?", (task_id,))
|
|
|
|
def report_by_project(self, project_id) -> dict:
|
|
"""Aggregate cost records for one project."""
|
|
return self._report("project_id = ?", (project_id,))
|
|
|
|
def report_by_day(self, day) -> dict:
|
|
"""Aggregate cost records for one UTC day (YYYY-MM-DD)."""
|
|
return self._report("day = ?", (day,))
|