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

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

+79 -3
View File
@@ -59,6 +59,56 @@ HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_STATE = os.path.join(HERE, "host-state.json")
def _sum_turn_usage(events, thread_id, turn_id):
"""从事件窗口里汇总一回合的上游真实用量。
app-server 在每个上游补全结束时发 thread/tokenUsage/updated,
params.tokenUsage.last 是该次补全的用量(2026-10-26 中继实测:
同一 turn 的多次 last 之和等于最后一次的 total,input/output/
cached/reasoning 四路都对得上)。按 threadId + turnId 过滤后
累加 last,得到本回合的真实用量;无匹配通知时返回 None(老
版本 server 或回合未产生补全时调用方回落估算,绝不把 0 当成
真实用量)。注意 totalTokens = inputTokens + outputTokens,
reasoning 包含在 output 里,累加时不另算。
"""
fields = (("input_tokens", "inputTokens"),
("cached_input_tokens", "cachedInputTokens"),
("cache_write_input_tokens", "cacheWriteInputTokens"),
("output_tokens", "outputTokens"),
("reasoning_output_tokens", "reasoningOutputTokens"),
("total_tokens", "totalTokens"))
total = None
for ev in events or []:
if ev.get("method") != "thread/tokenUsage/updated":
continue
p = ev.get("params") or {}
if p.get("threadId") != thread_id:
continue
if turn_id is not None and p.get("turnId") != turn_id:
continue
last = (p.get("tokenUsage") or {}).get("last") or {}
if total is None:
total = {k: 0 for k, _ in fields}
total["completions"] = 0
for key, wire in fields:
total[key] += int(last.get(wire) or 0)
total["completions"] += 1
return total
def _add_usage(a, b):
"""两份用量相加(任一为空时返回另一份;都为空返回 None)。"""
if not a:
return dict(b) if b else None
if not b:
return dict(a)
out = dict(a)
for k, v in b.items():
if isinstance(v, int):
out[k] = out.get(k, 0) + v
return out
def default_codex_bin():
if os.environ.get("CODEX_BIN"):
return os.environ["CODEX_BIN"]
@@ -298,7 +348,10 @@ class Server:
try:
done = q.get(timeout=timeout)
except queue.Empty:
return {"status": "timeout", "text": "", "turn_id": turn_id}
with self.events_lock:
evs = list(self.events[start_events:])
return {"status": "timeout", "text": "", "turn_id": turn_id,
"usage": _sum_turn_usage(evs, thread_id, turn_id)}
turn = (done.get("params") or {}).get("turn") or {}
text = ""
for item in turn.get("items") or []:
@@ -329,10 +382,16 @@ class Server:
# 没有原因,绝不因此把回合本身搞失败。
error = self._turn_error_from_history(
thread_id, turn.get("id") or turn_id)
# 真实用量:本回合窗口内的 tokenUsage/updated 通知累加。
# 失败/超时回合也一样记(已产生的补全就是已花的钱)。
with self.events_lock:
evs = list(self.events[start_events:])
usage = _sum_turn_usage(
evs, thread_id, turn.get("id") or turn_id)
return {"status": status, "text": text,
"turn_id": turn.get("id") or turn_id,
"root_turn_id": turn.get("rootTurnId"),
"error": error}
"error": error, "usage": usage}
finally:
self.turn_queues.pop(thread_id, None)
@@ -1261,6 +1320,7 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort,
每次重试前先查预算,超预算直接停,不烧钱。"""
max_retries = int(task.get("max_retries") or 0)
result = None
usage = None
for attempt in range(max_retries + 1):
if attempt > 0:
task["retry_count"] = attempt
@@ -1277,8 +1337,13 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort,
break
result = run_task_turn(server, state, task, prompt_try,
model, effort, sandbox)
# 各次尝试的真实用量都要算(失败尝试花的钱也是钱),
# 最后一次的结果里带汇总,不只带最后一次。
usage = _add_usage(usage, result.get("usage"))
if result["status"] == "completed":
break
if usage is not None and result is not None:
result = dict(result, usage=usage)
return result
@@ -1670,7 +1735,8 @@ def main():
print(json.dumps({"task": args.name, "thread_id": task["thread_id"],
"cwd": task["cwd"], "status": task["status"],
"text": result.get("text", ""),
"error": result.get("error")},
"error": result.get("error"),
"usage": result.get("usage")},
indent=2, ensure_ascii=False))
elif args.cmd == "list":
for name, t in state["tasks"].items():
@@ -1847,6 +1913,7 @@ def main():
save_state(args.state, state)
ok = False
last_verify = ""
goal_usage = None
for round_no in range(1, args.max_rounds + 1):
if round_no == 1:
prompt = args.task
@@ -1860,6 +1927,7 @@ def main():
f"上一轮验收输出:\n{last_verify}")
result = run_task_turn(srv, state, task, prompt,
args.model, args.effort, args.sandbox)
goal_usage = _add_usage(goal_usage, result.get("usage"))
v = subprocess.run(args.verify, shell=True, cwd=task["cwd"],
capture_output=True, text=True)
last_verify = (v.stdout + v.stderr).strip()[:1000] or \
@@ -1875,6 +1943,14 @@ def main():
{"status": "goal-unmet", "text": last_verify[:200]})
save_state(args.state, state)
print(f"goal {'met' if ok else 'unmet'} after rounds")
if goal_usage:
print(f"usage input={goal_usage['input_tokens']} "
f"cached={goal_usage['cached_input_tokens']} "
f"cache_write={goal_usage['cache_write_input_tokens']} "
f"output={goal_usage['output_tokens']} "
f"reasoning={goal_usage['reasoning_output_tokens']} "
f"total={goal_usage['total_tokens']} "
f"completions={goal_usage['completions']}")
elif args.cmd == "parallel":
jobs = json.load(open(args.jobs))
# 依赖 + 优先级:先拓扑排序,缺依赖/成环直接报错不跑;