Files
codex-app-server/cloud/cost.py
T
host d51c06a900 Worker concurrency (capacity enforced), survival (heartbeat/sweep/drain), retry endpoint
1. Concurrency: capacity is now a real limit, not display-only.
   run_all with capacity>1 runs a manual thread pool (threading,
   not concurrent.futures: a test in this repo puts cloud/ on
   sys.path, shadowing stdlib queue and breaking
   concurrent.futures' SimpleQueue import). Claim stays CAS, so
   two threads racing one task still yield one owner; max_tasks
   counts dispatched+running to avoid overshoot; stop signals
   stop new claims only, running tasks are drained by run_loop.
   Running state moved from single fields to a _running dict
   (proc/container per task) so cancel kills the right task.
   Docker timeout is now a per-call argument, never a mutated
   shared executor field. CostStore opens a fresh connection per
   record/report (shared sqlite connection is thread-bound and
   raised ProgrammingError under the pool; :memory: keeps the
   shared connection because a fresh one is an empty DB).
   Final status write is CAS on status='running' so a background
   thread cannot overwrite an interrupted task with completed.
2. Survival: run_loop starts a daemon heartbeat thread (every
   30s, refreshes running tasks' last_heartbeat) and sweeps stale
   tasks every round (threshold 300s, best-effort); live tasks
   never go stale now, dead workers' tasks are reclaimed without
   manual /admin/sweep. On exit, run_loop waits up to
   drain_timeout (CLI --drain-timeout, default 60s, inside
   systemd's 90s stop window) for running tasks before killing
   leftovers and marking interrupted.
3. Retry: POST /tasks/{id}/retry (maintainer+, 404/403/409):
   failed/interrupted/merge-failed go back to queued with
   result/error/model cleared, prompt/model/deps kept, CAS
   update, audit task_retried.
Tests: 5 concurrency tests (barrier overlap, serial capacity=1,
max_tasks, wait_for_running, CAS final write), 3 retry tests;
full suites pass.
2026-10-26 06:46:20 +08:00

206 lines
7.8 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; 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.
"""
import os
import sqlite3
from cloud.db import connect as _db_connect
import time
from datetime import datetime, timezone
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")
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 (
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
)"""
)
self._conn.commit()
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(
"""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.commit()
return conn
def record(self, task_id, project_id, model, input_tokens=0,
output_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``.
"""
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)
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,
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),
)
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)
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 = 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),
}
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,))