逐任务真实成本:上游用量事件 + 价目表计价

此前每任务只记一条 estimated(字符数/4,金额为空),注释称
host 协议不暴露真实 token 数。本轮实测推翻了该前提:
- account/usage/read 在中继上是死的(要求 ChatGPT 账号登录),
  不是来源,已在 cost.py docstring 写明死胡同
- app-server 每上游补全发 thread/tokenUsage/updated,last 是该次
  补全的用量;中继 5 调用回合实测 last 之和等于最后 total
  (input/output/cached/reasoning 四路都对得上)

实现:host.py run_turn 从事件窗口按 threadId+turnId 汇总用量,
重试/goal 各轮累加,dispatch/run JSON 与 goal 用量行透传;
sandbox 三路解析(dispatch JSON 解析改用完整 stdout,顺带修
了长 text 下 failed 被误判 completed 的潜 bug);worker 每任务
记一条 actual(重试/fallback 用量累加进同一条),无用量时回落
estimated;cost.py 价目表(默认 OpenAI 挂牌价,env 可覆盖,
无价模型 usd 记 None、报表进 unpriced_actual_tokens 而非 0 美元),
cost_records 加 cached_input_tokens 列(SQLite/PG 两支迁移);
报表与按天行新增 cached_input_tokens/unpriced_actual_tokens。
测试:host 32、cloud 483 全过。
This commit is contained in:
host committed 2026-10-26 18:41:27 +08:00
1 parent 353d6c1aa8
commit 63823fc2e3
11 files changed
+709 -67

No files matched your search

+172 -37
View File
@@ -3,11 +3,30 @@
Zero third-party dependencies; standard library only.
Actual token counts come from the model/provider response when
available; when they are not, token counts are estimates (see
``estimate_tokens``) and are stored separately as ``source="estimated"``
so estimates are never mixed into actual totals.
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
@@ -15,6 +34,101 @@ 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))
_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 ""
@@ -52,20 +166,14 @@ class CostStore:
_SOURCES = ("actual", "estimated")
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(
"""CREATE TABLE IF NOT EXISTS cost_records (
_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,
@@ -73,8 +181,36 @@ class CostStore:
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):
"""每次写/读开一个新连接,用完即关。
@@ -92,32 +228,21 @@ class CostStore:
if self.db_path == ":memory:":
return self._conn
conn = _db_connect(self.db_path)
conn.execute(
"""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,
total_tokens INTEGER,
usd_micros INTEGER,
source TEXT,
fallback_count INTEGER DEFAULT 0,
day TEXT,
created_at REAL
)"""
)
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, usd_micros=None, source="actual",
fallback_count=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``.
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(
@@ -125,6 +250,7 @@ class CostStore:
)
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")
@@ -133,12 +259,12 @@ class CostStore:
cursor = conn.execute(
"""INSERT INTO cost_records
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, fallback_count, day,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
cached_input_tokens, total_tokens, usd_micros, source,
fallback_count, day, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, int(fallback_count or 0),
day, created_at),
cached_input_tokens, total_tokens, usd_micros, source,
int(fallback_count or 0), day, created_at),
)
conn.commit()
return cursor.lastrowid
@@ -175,6 +301,10 @@ class CostStore:
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,
@@ -182,7 +312,8 @@ class CostStore:
finally:
if conn is not self._conn:
conn.close()
total_tokens, usd_micros, turns, fallback_count, actual, estimated = row
(total_tokens, usd_micros, turns, fallback_count, actual,
estimated, cached, unpriced) = row
return {
"total_tokens": int(total_tokens),
"usd_micros": int(usd_micros),
@@ -190,6 +321,10 @@ class CostStore:
"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: