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

此前每任务只记一条 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

+59 -4
View File
@@ -44,6 +44,41 @@ class Executor:
raise NotImplementedError("该执行器不支持 goal 模式")
def _clean_usage(raw):
"""校验 host.py 带来的用量 dict:非 dict、非整数、或 input /
output / total 全为 0 时返回 None(调用方回落估算,绝不把
0 或脏数据当成真实用量记 actual)。"""
if not isinstance(raw, dict):
return None
keys = ("input_tokens", "cached_input_tokens",
"cache_write_input_tokens", "output_tokens",
"reasoning_output_tokens", "total_tokens", "completions")
try:
out = {k: int(raw.get(k) or 0) for k in keys}
except (TypeError, ValueError):
return None
if out["input_tokens"] <= 0 and out["output_tokens"] <= 0 \
and out["total_tokens"] <= 0:
return None
return out
_USAGE_LINE_RE = re.compile(
r"^usage input=(\d+) cached=(\d+) cache_write=(\d+) output=(\d+)"
r" reasoning=(\d+) total=(\d+) completions=(\d+)\s*$", re.M)
def _parse_goal_usage(stdout):
"""从 host.py goal 的用量行解析用量 dict,无用量行返回 None。"""
m = _USAGE_LINE_RE.search(stdout or "")
if not m:
return None
keys = ("input_tokens", "cached_input_tokens",
"cache_write_input_tokens", "output_tokens",
"reasoning_output_tokens", "total_tokens", "completions")
return {k: int(v) for k, v in zip(keys, m.groups())}
def _parse_goal_output(stdout):
"""解析 host.py goal 的 stdout,返回 (met, goal_error)。
@@ -155,18 +190,28 @@ class LocalExecutor(Executor):
判断是否可 fallback),取不到 JSON 时按 completed 计。
"""
returncode = getattr(result, "returncode", 1)
stdout = _truncate_stdout(getattr(result, "stdout", ""))
stdout_full = getattr(result, "stdout", "") or ""
stdout = _truncate_stdout(stdout_full)
status = "failed"
usage = None
if returncode == 0:
status = "completed"
# JSON 解析必须用完整 stdout:截头后的前 2000 字在
# text 很长时根本不是合法 JSON,旧代码会把 host 的
# failed 误判成 completed;usage 在 JSON 末尾,截头
# 后同样丢。存库的 stdout 仍用截头版,不变。
try:
parsed = json.loads(stdout)
parsed = json.loads(stdout_full)
except (TypeError, ValueError):
parsed = None
if isinstance(parsed, dict) and parsed.get("status"):
status = ("completed" if parsed["status"] == "completed"
else "failed")
host_status = parsed["status"]
# host.py dispatch/run 打印的 JSON 里带 usage 时
# 原样透传给 worker 记 actual;脏数据由
# _clean_usage 挡掉,不进 actual。
usage = _clean_usage(parsed.get("usage"))
else:
host_status = None
else:
@@ -182,6 +227,7 @@ class LocalExecutor(Executor):
# stderr,worker 判断 dispatch 续跑时需要它;execute()
# 的旧结果 dict 不含此字段,保持不变。
"stderr": _truncate_stdout(getattr(result, "stderr", "")),
"usage": usage,
}
def _host_name(self, task):
@@ -309,6 +355,7 @@ class LocalExecutor(Executor):
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def popen_task(self, task, kind="dispatch"):
@@ -852,6 +899,7 @@ class DockerExecutor(Executor):
"container": self.container_name(task),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def network_gateway(self, network_name):
@@ -935,19 +983,25 @@ def _parse_host_result(task_id, fake):
completed 外的状态均按 failed 计。
"""
returncode = getattr(fake, "returncode", 1)
stdout = _truncate_stdout(getattr(fake, "stdout", ""))
stdout_full = getattr(fake, "stdout", "") or ""
stdout = _truncate_stdout(stdout_full)
status = "failed"
host_status = None
usage = None
if returncode == 0:
status = "completed"
# 与 _result_from_runner 同理:JSON 解析用完整 stdout,
# 否则长 text 会把 host 的 failed 误判成 completed,
# 也会丢掉 JSON 末尾的 usage。
try:
parsed = json.loads(stdout)
parsed = json.loads(stdout_full)
except (TypeError, ValueError):
parsed = None
if isinstance(parsed, dict) and parsed.get("status"):
host_status = parsed["status"]
status = ("completed" if host_status == "completed"
else "failed")
usage = _clean_usage(parsed.get("usage"))
return {
"executor": "docker",
"task_id": task_id,
@@ -956,6 +1010,7 @@ def _parse_host_result(task_id, fake):
"returncode": returncode,
"host_status": host_status,
"stderr": _truncate_stdout(getattr(fake, "stderr", "")),
"usage": usage,
}