逐任务真实成本:上游用量事件 + 价目表计价
此前每任务只记一条 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
+59
-4
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user