Files
codex-app-server/cloud/cost.py
host c33bfd240e code-to10 v6 A6: fix mypy cost.py:551 tuple annotation
- model_breakdown inferred params as a 1-tuple from the first
  branch, so params + (month,) was tuple[Any] vs tuple[Any, str].
  Annotate params as bare tuple (tuple[Any, ...]) before branching.
- Annotation only: the () initializer is never used (if/elif/else
  always overwrites it), runtime semantics unchanged.
- mypy cloud/: 0 errors in 94 files (was 1 error); ruff clean;
  cost tests 39 pass.

(cherry picked from commit a9fd0692361e8169554413b725c81135be2c5525)
2026-11-03 02:49:26 +00:00

630 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
cache_write_input_tokens: EXPLICITLY NOT BILLED HERE (P1 decision,
not an oversight). host.py collects ``cacheWriteInputTokens`` from
upstream tokenUsage events and sandbox.py carries it in usage dicts
for observability, but it is NOT added to ``total_tokens`` (upstream
total is input + output), NOT stored in ``cost_records`` (``record``
takes input/output/cached only), NOT priced by ``price_usd_micros``,
and NOT counted toward the unpriced-token cap (worker counts input +
output from ``source="actual"`` rows only). Rationale: no price-table
entry and no relay bill evidence treats cache creation as a
separately billed token stream for the OpenAI-style relay models in
use; cache_write here is prompt-cache bookkeeping detail, not a
second charge on top of input. If a future provider bills cache
creation separately, the price table must gain an explicit
cache_write rate FIRST -- only then may ``record``/pricing count it.
Until then, counting it would double-charge input tokens that are
already priced (or inflate the unpriced cap with non-spend).
Unpriced cap scope (P1): ``unpriced_actual_tokens`` and the worker's
cap query count ``source="actual"`` rows only. Estimated rows
(``source="estimated"``, character-count fallback when no usage
events arrive) are never real spend and must not trip the cap.
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
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).
cache_write_input_tokens is deliberately NOT a parameter here:
it is not billed separately (see module docstring); callers must
not add it to input_tokens to “count it anyway”.
"""
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))
# 双列文案:逐字复用 PR #212(feat-cost-quota,5b30c61)的
# BILLING_NOTE,本轨不拥有成本口径、不另造一套说法。
BILLING_NOTE = (
"价格表代理价(经 CLOUD_MODEL_PRICES_JSON / CLOUD_MODEL_PRICES_FILE"
" 覆盖层),非中转实际账单;未定价 token 不计入美元、不等于免费。"
)
def quota_cutoff_reason(quota_usd_micros, used_usd_micros,
unpriced_used_tokens, month, unpriced_cap=None):
"""配额硬切断的唯一判定函数(worker 执行侧与单测共用)。
返回 None 表示未超限、可继续;返回 quota_exceeded 文本表示
必须立即停:调用方不再开新回合、不再派生花钱路径,任务以
interrupted + 该原因收尾(原因写进 error_text 并记审计),
不是只告警。quota 为 None(管理员显式不限额)时不挡;USD
配额与无价 token 上限是两道独立的门,任一超限即停。
文本格式与 worker 旧分支逐字一致,旧单测按此前缀断言。
"""
if quota_usd_micros is None:
return None
if int(used_usd_micros or 0) >= int(quota_usd_micros):
return ("quota_exceeded: 本月配额已用完(已用 %d / "
"配额 %d 微美元,%s)"
% (int(used_usd_micros or 0), int(quota_usd_micros),
month))
cap = unpriced_token_cap() if unpriced_cap is None \
else int(unpriced_cap)
if int(unpriced_used_tokens or 0) >= cap:
return ("quota_exceeded: 本月无价模型 token 已用完"
"(已用 %d / 上限 %d,%s)"
% (int(unpriced_used_tokens or 0), cap, month))
return None
def dual_column_view(report):
"""双列视图:在已有 _report 结果上加定价列别名与账单说明。
不改 _report(旧单测按其键集断言)与旧键:usd_micros 仍是
有价行之和,unpriced_actual_tokens 仍是未定价 actual token;
本函数只加 priced_usd_micros(与 usd_micros 同值)与
billing_note。未定价模型分列(by_model / unpriced_models)
与按创建者口径是 PR #212 的范围,本轨不重复实现——#212
合并后本函数由其 dual_column_* 取代,端点改走 dual_column_*
(同名同值键,切换无语义差);#212 未合并前,本视图是
任务/项目成本端点的双列最小实现。
"""
view = dict(report)
view["priced_usd_micros"] = view["usd_micros"]
view["billing_note"] = BILLING_NOTE
return view
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,
completions 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
if "completions" not in cols:
try:
conn.execute(
"ALTER TABLE cost_records"
" ADD COLUMN completions 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, completions=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, completions, 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), int(completions 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),
COALESCE(SUM(completions), 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, completions) = 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),
# LLM 请求数:host.py 每回合汇总的 completions 之和
# (每个 thread/tokenUsage/updated 算一次上游补全);
# turns 只是记账行数,两者不是一回事。
"llm_requests": int(completions),
}
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,))
def _report_joined(self, where, params):
"""_report 的 JOIN 版:按 tasks.created_by 归属汇总。
cost_records 本身没有创建者列,归属只能经 tasks JOIN;
协调员子任务行无 created_by 时跟父任务走的口径在
worker 侧,库层这里只按行上的 created_by 计——无属主
任务的成本不归任何人,不会被静默算进某个创建者。
"""
conn = self._new_conn()
try:
row = conn.execute(
"""SELECT
COALESCE(SUM(c.total_tokens), 0),
COALESCE(SUM(c.usd_micros), 0),
COUNT(*),
COALESCE(SUM(c.fallback_count), 0),
COALESCE(SUM(CASE WHEN c.source = 'actual'
THEN c.total_tokens ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN c.source = 'estimated'
THEN c.total_tokens ELSE 0 END), 0),
COALESCE(SUM(c.cached_input_tokens), 0),
COALESCE(SUM(CASE WHEN c.source = 'actual'
AND c.usd_micros IS NULL
THEN c.total_tokens ELSE 0 END), 0),
COALESCE(SUM(c.completions), 0)
FROM cost_records c JOIN tasks t
ON c.task_id = t.id 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, completions) = 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),
"unpriced_actual_tokens": int(unpriced),
"llm_requests": int(completions),
}
def report_by_creator(self, creator_id, month=None) -> dict:
"""按任务创建者汇总:当月(UTC,YYYY-MM)或全部。
month 为 None 时不过滤月份;month 格式须为 YYYY-MM,
否则 ValueError(调用方转 400)。返回 _report 同款键
外加 creator_id 与 month,方便分账报表直接按人对账。
"""
if month is not None:
if not isinstance(month, str) or len(month) != 7 \
or month[4] != "-":
raise ValueError("month must be YYYY-MM")
report = self._report_joined(
"t.created_by = ? AND c.day LIKE ?",
(creator_id, month + "%"))
else:
report = self._report_joined(
"t.created_by = ?", (creator_id,))
report["creator_id"] = creator_id
report["month"] = month
return report
def model_breakdown(self, task_id=None, project_id=None,
creator_id=None, month=None) -> list:
"""按模型分列:恰好指定 task / project / creator 其中一种。
每行:model、total_tokens、turns、llm_requests、
priced_usd_micros(该模型无任何有价行时为 None,不写 0)、
unpriced_tokens(source='actual' 且 usd IS NULL 的行,
估算行不计)、priced(是否有任一有价行)。未定价模型
由 unpriced_tokens > 0 识别,调用方用它单独列出,绝不
把 priced_usd_micros=None 的模型按 0 美元合并进合计。
creator 口径经 tasks JOIN,month 同 report_by_creator。
"""
scopes = [s for s in (task_id, project_id, creator_id)
if s is not None]
if len(scopes) != 1:
raise ValueError(
"exactly one of task_id, project_id, creator_id required")
join = ""
# 类型标注而已(A6):首分支会把 params 推断成单元素元组,
# 导致下面 params + (month,) 报 tuple[Any] vs tuple[Any, str];
# 标注成裸 tuple(即 tuple[Any, ...])后三分支与拼接均合法。
# 初值 () 永不被使用——if/elif/else 三分支必居其一重写它,
# 运行时语义不变。
params: tuple = ()
if task_id is not None:
where, params = "c.task_id = ?", (task_id,)
elif project_id is not None:
where, params = "c.project_id = ?", (project_id,)
else:
join = " JOIN tasks t ON c.task_id = t.id"
where, params = "t.created_by = ?", (creator_id,)
if month is not None:
if not isinstance(month, str) or len(month) != 7 \
or month[4] != "-":
raise ValueError("month must be YYYY-MM")
where += " AND c.day LIKE ?"
params = params + (month + "%",)
conn = self._new_conn()
try:
rows = conn.execute(
"""SELECT c.model,
COALESCE(SUM(c.total_tokens), 0),
COUNT(*),
COALESCE(SUM(c.completions), 0),
SUM(c.usd_micros),
SUM(CASE WHEN c.usd_micros IS NOT NULL
THEN 1 ELSE 0 END),
COALESCE(SUM(CASE WHEN c.source = 'actual'
AND c.usd_micros IS NULL
THEN c.total_tokens ELSE 0 END), 0)
FROM cost_records c%s WHERE %s
GROUP BY c.model
ORDER BY SUM(c.total_tokens) DESC, c.model"""
% (join, where),
params,
).fetchall()
finally:
if conn is not self._conn:
conn.close()
out = []
for row in rows:
(model, total, turns, completions, usd_sum, priced_rows,
unpriced) = row
out.append({
"model": model or "unknown",
"total_tokens": int(total),
"turns": int(turns),
"llm_requests": int(completions),
"priced_usd_micros": (int(usd_sum) if priced_rows else None),
"unpriced_tokens": int(unpriced),
"priced": bool(priced_rows),
})
return out
def _dual_column(self, base, breakdown):
"""双列报告:在 _report 结果上加第二列与分列明细。
_report 本身不动(旧单测按其精确键集断言);双列是
新方法返回的扩展:priced_usd_micros 与 usd_micros 同值
(只计有价行之和),unpriced_models 单独列出未定价
模型,billing_note 明确标注代理价、非中转实际账单。
"""
report = dict(base)
report["priced_usd_micros"] = report["usd_micros"]
report["unpriced_models"] = sorted(
row["model"] for row in breakdown
if row["unpriced_tokens"] > 0)
report["by_model"] = breakdown
report["billing_note"] = BILLING_NOTE
return report
def dual_column_report_by_task(self, task_id) -> dict:
"""单任务双列报告(见 _dual_column)。"""
return self._dual_column(
self.report_by_task(task_id),
self.model_breakdown(task_id=task_id))
def dual_column_report_by_project(self, project_id) -> dict:
"""单项目双列报告(见 _dual_column)。"""
return self._dual_column(
self.report_by_project(project_id),
self.model_breakdown(project_id=project_id))
def dual_column_report_by_creator(self, creator_id,
month=None) -> dict:
"""按创建者双列报告(见 _dual_column)。"""
return self._dual_column(
self.report_by_creator(creator_id, month=month),
self.model_breakdown(creator_id=creator_id, month=month))