games-development-ai/cheap-worker/tests/test_cheap_service_driver.py
lili e8f7dc8347 feat(worker): WU2 §3.7 worker 取 job.userToken + §3.9 额度耗尽/凭据失效分辨
per-user 额度按用户扣的最后一环:三 worker 入口从「读全局 env NEWAPI_KEY」改「优先
job.userToken、缺失回落全局 env key(WARN 脱敏)」;新失败因 quota_exhausted 干净失败。

§3.7 F(取 job token,默认关时字节不变):
- cheap_service_driver._resolve_key(user_token) 优先 per-user token、缺失回落 env+WARN;
  drive_cheap_generation 从 job.userToken 取、透传给凭据体;token 日志脱敏(前后各 4 位)。
- worker_service 整 job(含 userToken)透传给 driver;do_POST 记 userToken 存否(脱敏)。
- wg1 _client.get_api_key/get_client/chat 支持 user_token override、回落 env(WARN 一进程一次)。

§3.9(分辨两类失效,one-api 惯例初值,确切 message 阶段2 待验):
- result_out 新增 quota_exhausted 入枚举 + _map_failure_reason 认 summary.failureReason +
  classify_newapi_failure_reason/newapi_error_status_text(402/403→quota,401→llm_error 可重试)。
- cheap_service_app collector 崩溃路 best-effort 分辨 402/403→写 sidecar failureReason,
  driver→result_out 落 quota_exhausted;凭据失效/其他维持 llm_error,纯旁路不咬生成。
- wg1 _client.chat 额度耗尽抛 NewapiQuotaExhaustedError(不重试);service.handle_job 映射
  quota_exhausted。注:两 worker 主生成经 AgentScope 模型封装调网关,402 埋框架内,主路
  额度耗尽分辨待阶段2 拦模型封装;本次覆盖直连 _client.chat 路 + cheap 崩溃路分辨。

自证:test_result_out 25/25、test_worker_service 17/17(stdlib 直跑);driver/wg1 token 与
分辨逻辑 standalone 断言全过;八文件 py_compile 绿。test_cheap_service_driver 需 pytest
(本机未装),已同步桩签名待阶段2 e2e 真验(per-user token 调网关/used_quota 按用户增/耗尽落 quota_exhausted)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:44:08 -07:00

306 lines
19 KiB
Python

"""cheap_service_driver 单测:OpenAI 兼容凭据体(base+/v1) + _build_summary 组 result_out 可吃形 + sidecar 往返 +
worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。真起 Service/真门排 mini-desktop 窗口。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_driver.py -v
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import _bootstrap # noqa: E402,F401
import cheap_run # noqa: E402
import cheap_service_driver as D # noqa: E402
import result_out # noqa: E402
import worker_service as W # noqa: E402
def test_credential_payload_openai_compatible_with_v1(monkeypatch):
# 便宜档 M3 走 OpenAI 兼容路:type=openai_credential、base_url 带 /v1、api_key 非空。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
# WU2 §3.7:_resolve_key 现签名 (user_token=None);缺 token 走 env 回落(此桩恒回同一 key)。
monkeypatch.setattr(D, "_resolve_key", lambda user_token=None: "sk-test")
payload = D._cheap_credential_payload()
assert payload["data"]["type"] == "openai_credential"
assert payload["data"]["base_url"].endswith("/v1")
assert payload["data"]["api_key"] == "sk-test"
def test_resolve_key_prefers_user_token_else_env_fallback(monkeypatch):
# WU2 §3.7 F:job 带 userToken 则用它(不碰 env);缺失回落全局 env key(client.get_api_key)。
import worker.client as _wc
monkeypatch.setattr(_wc, "get_api_key", lambda: "sk-env-fallback")
assert D._resolve_key("sk-user-per") == "sk-user-per" # per-user token 优先
assert D._resolve_key(None) == "sk-env-fallback" # 缺失回落 env
assert D._resolve_key("") == "sk-env-fallback" # 空串视同缺失
# 凭据体据 user_token 选 key(有则 per-user、无则回落 env)。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
assert D._cheap_credential_payload("sk-user-per")["data"]["api_key"] == "sk-user-per"
assert D._cheap_credential_payload()["data"]["api_key"] == "sk-env-fallback"
def test_build_summary_shape_feeds_result_out(tmp_path, monkeypatch):
# _build_summary 组的 summary 能被 result_out.build_result_out 吃出 succeeded + trace(costRmb/repairs)。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
gd = tmp_path / "amgen-g9"
gd.mkdir(parents=True)
(gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8")
monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets")
verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "H_progress": {"pass": True}}}
svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False}
turn = {"ended": True, "reason": "REPLY_END"}
summary = D._build_summary("g9", "点击得分小游戏", verdict, svc, turn, t0=0.0)
assert summary["ok"] is True and summary["finished"] is True
assert summary["verdict"]["pass"] is True
assert summary["attempts"] == 3 # repairs=2 → attempts=repairs+1(result_out repairs=attempts-1 反推回 2)
assert summary["costRmb"] == 0.42
assert summary["verdictFull"] == verdict and summary["driverType"] == "tap-targets"
payload = result_out.build_result_out({"traceId": "t", "brief": "点击"}, summary, cheap_run.game_dir("g9"))
assert payload["status"] == "succeeded"
assert payload["trace"]["repairs"] == 2 and payload["trace"]["cost"]["totalRmb"] == 0.42
assert payload["trace"]["gatespec"]["driver"] == "tap-targets"
def test_build_summary_no_verdict_is_failed(tmp_path, monkeypatch):
# 回合没跑出 verdict(续修耗尽/软停无绿)→ ok=False,result_out 走 failed(七值枚举)。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
(tmp_path / "amgen-g8").mkdir(parents=True) # 无 bundle
monkeypatch.setattr(D, "_read_driver_type", lambda gid: None)
summary = D._build_summary("g8", "brief", None, {"costRmb": 0.1, "rmbGate": "active", "repairs": 6},
{"ended": True, "reason": "REPLY_END"}, t0=0.0)
assert summary["ok"] is False
payload = result_out.build_result_out({"traceId": "t"}, summary, cheap_run.game_dir("g8"))
assert payload["status"] == "failed"
assert payload["failureReason"] in result_out._FAILURE_REASONS
def test_session_cfg_roundtrip_maps_session_to_backend_gameid(tmp_path, monkeypatch):
# C2:driver 按 session_id 写会话注册表(含 external_game_id=后端 gameId),Service 工厂读 session_id → 解析回后端 gameId。
import cheap_service_app as A
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# create 路:driver 写映射、白名单 None → restricted False。
D._write_session_cfg("sess-abc", external_game_id="70012", write_whitelist=None, scaffold_template=None)
cfg = A._read_session_cfg("sess-abc")
assert A._resolve_external_game_id("sess-abc", cfg) == "70012", "session_id → 后端 gameId"
assert A._resolve_write_whitelist(cfg) is None, "create 路不收窄"
# reskin 路:driver 传白名单 → restricted True + 工厂收窄成 set。
D._write_session_cfg("sess-def", external_game_id="70013", write_whitelist={"game-logic.js"})
cfg2 = A._read_session_cfg("sess-def")
assert cfg2["restricted"] is True
assert A._resolve_write_whitelist(cfg2) == {"game-logic.js"}
# 空集白名单(Minor#4 fail-closed):restricted 必须由「白名单是否给定(is not None)」决定、非 bool(空集会误成 False)。
# 空集 → restricted True → T2 读侧 I1 收窄成空集禁写,不再退化成 restricted False 放开全写。
D._write_session_cfg("sess-empty", external_game_id="70014", write_whitelist=set())
cfg3 = A._read_session_cfg("sess-empty")
assert cfg3["restricted"] is True, "空集白名单 → restricted True(fail-closed,不因 bool(空集)退成 False)"
assert A._resolve_write_whitelist(cfg3) == set(), "T2 读侧空集禁写(不放开全写)"
def test_build_summary_trace_contract_end_to_end(tmp_path, monkeypatch):
# I4:_build_summary 组的 summary 喂 build_result_out 后,trace 七项 + sevenGateVerdict + gatespec.driver +
# cost.totalRmb 齐备,engineBundle 承重;不空口说「result_out 零改动」,而是端到端断言其当前消费字段。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
gd = tmp_path / "amgen-70012"
gd.mkdir(parents=True)
(gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8")
monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets")
verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "H_progress": {"pass": True}}}
svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False}
summary = D._build_summary("70012", "点击得分小游戏", verdict, svc,
{"ended": True, "reason": "REPLY_END"}, t0=0.0)
payload = result_out.build_result_out({"traceId": "t-1", "brief": "点击"}, summary, cheap_run.game_dir("70012"))
assert payload["status"] == "succeeded"
assert payload["traceId"] == "t-1" # 回调关联键 = job.traceId(非 gameId)
assert "__GameBundle" in payload["engineBundle"] # engineBundle 承重入 feed
tr = payload["trace"]
for k in ("pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"):
assert k in tr, f"trace 必填七项缺 {k}"
assert tr["gameId"] == "70012" # C2:trace.gameId = 后端 gameId(诊断可关联)
assert tr["repairs"] == 2 and tr["cost"]["totalRmb"] == 0.42
assert tr["sevenGateVerdict"]["pass"] is True and "H_progress" in tr["sevenGateVerdict"]["guards"]
assert tr["gatespec"]["driver"] == "tap-targets" # 键名 driver(非 driverType,后端 hasDriver 据此)
def test_read_service_run_summary_bounded_poll(tmp_path, monkeypatch):
# C1:sidecar 在 wait 窗口内出现 → 读到;窗口内始终缺失 → 诚实降级 {}(不阻塞、不伪造)。
import json
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
assert D._read_service_run_summary("miss", wait_s=0.05) == {}, "缺失且过期 → 降级 {}"
ev = tmp_path / "amgen-hit" / "evidence"
ev.mkdir(parents=True)
(ev / "service-run-summary.json").write_text(json.dumps({"costRmb": 0.3, "repairs": 1}), encoding="utf-8")
got = D._read_service_run_summary("hit", wait_s=0.05)
assert got["costRmb"] == 0.3 and got["repairs"] == 1
def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch):
# 契约(fake-SSE):mock httpx + _wait_for_turn_end,驱动真 drive_cheap_generation —— 断言 C2(scaffold 用后端
# gameId、注册表写 external_game_id=后端 gameId)+ 回合真结束读 verdict/sidecar 组 succeeded + 回合未结束诚实降级。
import asyncio
import json
import service.control_plane as CP
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
scaffolded = {}
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: scaffolded.update(gid=gid) or {"ok": True, "output": ""})
# richness 非阻塞:stub 成同步返回(避免真 LLM)。
async def _fake_richness(gid, *, brief=""): return {"score": 5, "max": 8, "degraded": False}
monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness)
# 无网络/无 key 也能跑:stub 凭据体 + key env(真凭据/旁路真跑在 mini-desktop 窗口验)。
import _bootstrap
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
class _Resp:
def __init__(self, d): self._d = d
def json(self): return self._d
posted = {}
class _FakeHttp:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k):
if url.endswith("/sessions/"):
posted["session_body"] = k.get("json") # 捕获 session 体(fix400 主防线断言用)
return _Resp({"credential_id": "c1"} if url.endswith("/credential/") else
{"agent_id": "a1"} if url.endswith("/agent/") else
{"session_id": "sess-9"} if url.endswith("/sessions/") else {})
async def patch(self, url, **k): return _Resp({})
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp)
# 造后端 gameId=70012 的 on-disk 产物:bundle 由 scaffold 阶段就位(engineBundle 承重);verdict + 收口 sidecar
# 改为在 fake turn 内写(模拟 Service 回合中跑门写盘)——driver 现在回合前会清残留(红线③修 b),预铺在 drive
# 之前会被清掉,故必须让「本次回合」写入,才对齐真实时序(先清陈旧、回合中写本次、回合后读本次)。
(tmp_path / "amgen-70012").mkdir(parents=True)
(tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8")
# 有界轮询提速:sidecar 缺失时默认等 5s,测试里压到 50ms(保留真实 bounded-poll 逻辑,只缩等待窗口)。
_orig_rss = D._read_service_run_summary
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s))
# ① 回合真结束(REPLY_END)→ fake turn 写本次 verdict/sidecar → driver 回合后读它组 succeeded 形。
async def _ended(*a, **k):
vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True, exist_ok=True)
(vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {"A_boot": {"pass": True}}}), encoding="utf-8")
sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True, exist_ok=True)
(sev / "service-run-summary.json").write_text(
json.dumps({"costRmb": 0.5, "rmbGate": "active", "repairs": 1}), encoding="utf-8")
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
summary, gdir = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9", "brief": "点球"}))
assert scaffolded["gid"] == "70012", "C2:scaffold 用后端 gameId(str),非 session_id"
cfg = json.loads((tmp_path / "_cheap-sessions" / "sess-9.json").read_text(encoding="utf-8"))
assert cfg["external_game_id"] == "70012", "C2:注册表写 session→后端 gameId 映射"
assert summary["ok"] is True and summary["gameId"] == "70012" and summary["costRmb"] == 0.5
assert gdir == cheap_run.game_dir("70012")
# fix400 主防线锚:session parameters 必带 parallel_tool_calls=False(源头禁并行 call,防 M3 经 new-api
# 并行 call 同 index 拼桶 → 非法 JSON → 孤儿 tool result → 400 code 2013;纵深兜底见 m3_stream_patch)。
params = posted["session_body"]["chat_model_config"]["parameters"]
assert params["parallel_tool_calls"] is False, "fix400:必须源头禁并行 tool call"
# ② 回合未真结束(SSE 超时)→ 诚实降级(不卡死),summary 仍组出、reason 反映未结束。
async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None}
monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended)
s2, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9b", "brief": "点球"}))
assert s2["stoppedReason"] == "total_timeout"
def _install_fake_http(monkeypatch, cred=None):
"""装 fake httpx.AsyncClient:setup 三 POST 返可控体(cred 缺省给全 id),patch 空体。供回合前 unlink / fail-fast 用例复用。"""
import httpx
class _Resp:
def __init__(self, d): self._d = d
def json(self): return self._d
class _FakeHttp:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k):
if url.endswith("/credential/"):
return _Resp(cred if cred is not None else {"credential_id": "c1"})
return _Resp({"agent_id": "a1"} if url.endswith("/agent/") else
{"session_id": "sess-9"} if url.endswith("/sessions/") else {})
async def patch(self, url, **k): return _Resp({})
monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp)
def _install_common_stubs(tmp_path, monkeypatch):
"""装 drive 公共桩:目录重定向到 tmp、scaffold ok、richness/key 旁路、凭据体桩(零网络/LLM)。"""
import _bootstrap
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: {"ok": True, "output": ""})
async def _fake_richness(gid, *, brief=""): return {"score": None, "degraded": True, "reason": "stub"}
monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness)
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
# 有界轮询提速:sidecar 缺失时默认等 5s → 50ms(保留真实 bounded-poll,只缩窗口)。
_orig_rss = D._read_service_run_summary
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s))
def test_drive_unlinks_stale_before_turn(tmp_path, monkeypatch):
# 红线③(修 b,driver 侧):回合前清残留 verdict + service-run-summary。预铺上一局绿 verdict + 旧成本,
# fake SSE not-ended(本回合 Service 零门跑、啥也没写)→ 断言 drive 后 ok=False、costRmb=0(没被上一局绿 verdict
# 顶成假 succeeded、没读回上一局成本)。这封「零门跑路径:setup 静默失败 / SSE 超时未跑一次 check」。
import asyncio
import json
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
_install_fake_http(monkeypatch)
# 预铺上一局残留(模拟同 gameId 重跑局的陈旧盘态):绿 verdict + 旧 summary。
(tmp_path / "amgen-70012").mkdir(parents=True)
(tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8")
vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True)
(vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {"A_boot": {"pass": True}}}), encoding="utf-8")
sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True)
(sev / "service-run-summary.json").write_text(
json.dumps({"costRmb": 0.9, "rmbGate": "active", "repairs": 3}), encoding="utf-8")
async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None}
monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended)
summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tX", "brief": "点球"}))
assert summary["ok"] is False, "陈旧绿 verdict 必须已被回合前清掉,不得顶成假 succeeded"
assert summary["costRmb"] == 0.0, "陈旧 summary 也须清掉,成本不得读回上一局"
def test_drive_setup_failfast_on_missing_id(tmp_path, monkeypatch):
# #2:Service /credential 未返 id(setup 失败)→ driver fail-fast(ok=False + reason 反映 setup 失败),
# 且不进入 _wait_for_turn_end(计数坐实)——不再 id=None 硬挂 ~600s SSE 空等。
import asyncio
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
_install_fake_http(monkeypatch, cred={}) # /credential 返 {} 无 id → fail-fast
called = {"wait": 0}
async def _counting_wait(*a, **k):
called["wait"] += 1
return {"ended": True, "reason": "REPLY_END"}
monkeypatch.setattr(CP, "_wait_for_turn_end", _counting_wait)
summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tf", "brief": "b"}))
assert summary["ok"] is False
assert "setup" in (summary.get("stoppedReason") or ""), "reason 应反映 setup 失败"
assert called["wait"] == 0, "setup 失败必须 fail-fast,不进入 _wait_for_turn_end 空等"
def test_worker_default_run_fn_is_service_driver():
# worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。
state = W.WorkerState()
assert state.run_fn is W._service_run_fn