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

此前每任务只记一条 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
View File
@@ -572,6 +572,85 @@ class GithubPRTest(unittest.TestCase):
self.assertIn("remote", r.stdout + r.stderr)
class TurnUsageSumTest(unittest.TestCase):
def _note(self, thread_id, turn_id, last):
return {"method": "thread/tokenUsage/updated",
"params": {"threadId": thread_id, "turnId": turn_id,
"tokenUsage": {"last": last}}}
def test_sums_last_across_completions(self):
# 2026-10-26 中继实测前两次通知:last 之和等于第二次的 total。
evs = [
self._note("t1", "u1", {
"inputTokens": 10906, "cachedInputTokens": 9192,
"cacheWriteInputTokens": 0, "outputTokens": 346,
"reasoningOutputTokens": 256, "totalTokens": 11252}),
self._note("t1", "u1", {
"inputTokens": 11041, "cachedInputTokens": 9192,
"cacheWriteInputTokens": 0, "outputTokens": 230,
"reasoningOutputTokens": 161, "totalTokens": 11271}),
]
self.assertEqual(host._sum_turn_usage(evs, "t1", "u1"), {
"input_tokens": 21947, "cached_input_tokens": 18384,
"cache_write_input_tokens": 0, "output_tokens": 576,
"reasoning_output_tokens": 417, "total_tokens": 22523,
"completions": 2})
def test_filters_by_thread_and_turn(self):
evs = [
self._note("t1", "u1", {
"inputTokens": 10, "cachedInputTokens": 0,
"cacheWriteInputTokens": 0, "outputTokens": 5,
"reasoningOutputTokens": 0, "totalTokens": 15}),
self._note("t1", "u2", {
"inputTokens": 999, "cachedInputTokens": 0,
"cacheWriteInputTokens": 0, "outputTokens": 999,
"reasoningOutputTokens": 0, "totalTokens": 1998}),
self._note("t2", "u1", {
"inputTokens": 999, "cachedInputTokens": 0,
"cacheWriteInputTokens": 0, "outputTokens": 999,
"reasoningOutputTokens": 0, "totalTokens": 1998}),
{"method": "item/completed", "params": {}},
]
usage = host._sum_turn_usage(evs, "t1", "u1")
self.assertEqual(usage["input_tokens"], 10)
self.assertEqual(usage["completions"], 1)
self.assertIsNone(host._sum_turn_usage(evs, "t1", "u9"))
self.assertIsNone(host._sum_turn_usage([], "t1", "u1"))
def test_retry_sums_usage_across_attempts(self):
class FakeServer:
def __init__(self, results):
self.results = list(results)
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None):
return self.results.pop(0)
task = {"name": "t", "thread_id": "x", "sandbox": None,
"max_retries": 1, "retry_count": 0, "turns": []}
state = {"tasks": {"t": task}, "automations": []}
srv = FakeServer([
{"status": "failed", "text": "", "usage": {
"input_tokens": 100, "cached_input_tokens": 0,
"cache_write_input_tokens": 0, "output_tokens": 10,
"reasoning_output_tokens": 0, "total_tokens": 110,
"completions": 1}},
{"status": "completed", "text": "ok", "usage": {
"input_tokens": 200, "cached_input_tokens": 50,
"cache_write_input_tokens": 0, "output_tokens": 20,
"reasoning_output_tokens": 5, "total_tokens": 220,
"completions": 1}},
])
result = host.run_task_turn_with_retry(
srv, state, task, "do it", "m", "low", cfg={})
self.assertEqual(result["status"], "completed")
self.assertEqual(result["usage"]["input_tokens"], 300)
self.assertEqual(result["usage"]["cached_input_tokens"], 50)
self.assertEqual(result["usage"]["total_tokens"], 330)
self.assertEqual(result["usage"]["completions"], 2)
if __name__ == "__main__":
unittest.main()