逐任务真实成本:上游用量事件 + 价目表计价
此前每任务只记一条 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:
1 parent
353d6c1aa8
commit
63823fc2e3
11 files changed
+709
-67
No files matched your search
+172
-37
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user