|
@@ -0,0 +1,121 @@
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+"""复现 + 验证:OpenRouter 返回 HTTP 200 但 body 非合法 JSON 时,
|
|
|
|
|
+_openrouter_anthropic_call 应像 429/5xx 一样退避重试,而非让 json.JSONDecodeError
|
|
|
|
|
+穿透到上层被误记成 "LLM 调用异常"。
|
|
|
|
|
+
|
|
|
|
|
+根因见 agent/llm/openrouter.py:648 (response.json() 未保护)。
|
|
|
|
|
+标准库 test,直接 `python tests/test_openrouter_bad_json_retry.py` 跑。
|
|
|
|
|
+"""
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import json
|
|
|
|
|
+import sys
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
|
+
|
|
|
|
|
+import agent.llm.openrouter as orm
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+VALID_RESULT = {
|
|
|
|
|
+ "content": [{"type": "text", "text": '{"ok": 1}'}],
|
|
|
|
|
+ "stop_reason": "end_turn",
|
|
|
|
|
+ "usage": {"input_tokens": 1, "output_tokens": 1},
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _FakeResp:
|
|
|
|
|
+ def __init__(self, raise_times: int):
|
|
|
|
|
+ # .json() 前 raise_times 次抛 JSONDecodeError,之后返回合法响应
|
|
|
|
|
+ self._raise_times = raise_times
|
|
|
|
|
+ self._calls = 0
|
|
|
|
|
+ self.status_code = 200
|
|
|
|
|
+ self.text = " " # 模拟非 JSON 残片
|
|
|
|
|
+
|
|
|
|
|
+ def raise_for_status(self):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def json(self):
|
|
|
|
|
+ self._calls += 1
|
|
|
|
|
+ if self._calls <= self._raise_times:
|
|
|
|
|
+ raise json.JSONDecodeError("Expecting value", " ", 3)
|
|
|
|
|
+ return VALID_RESULT
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _FakeClient:
|
|
|
|
|
+ """每个 attempt 新建一个;post 返回的 resp.json() 行为由外部计数器决定。"""
|
|
|
|
|
+ def __init__(self, *a, **k):
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ async def __aenter__(self):
|
|
|
|
|
+ return self
|
|
|
|
|
+
|
|
|
|
|
+ async def __aexit__(self, *a):
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ async def post(self, *a, **k):
|
|
|
|
|
+ return _FakeClient._resp_factory()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _run(raise_times: int):
|
|
|
|
|
+ # 每次调用都新建一个 resp(模拟每个 attempt 是一次全新 HTTP 请求)
|
|
|
|
|
+ resp_holder = {"n": 0}
|
|
|
|
|
+
|
|
|
|
|
+ def factory():
|
|
|
|
|
+ # 前 raise_times 次请求的 resp.json() 抛错,之后成功
|
|
|
|
|
+ idx = resp_holder["n"]
|
|
|
|
|
+ resp_holder["n"] += 1
|
|
|
|
|
+ return _FakeResp(raise_times=1 if idx < raise_times else 0)
|
|
|
|
|
+
|
|
|
|
|
+ _FakeClient._resp_factory = staticmethod(factory)
|
|
|
|
|
+
|
|
|
|
|
+ async def _nosleep(*_a, **_k): # 退避不真等(注意不能再调用被替换的 asyncio.sleep)
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ orig_client = orm.httpx.AsyncClient
|
|
|
|
|
+ orig_sleep = orm.asyncio.sleep
|
|
|
|
|
+ orm.httpx.AsyncClient = _FakeClient
|
|
|
|
|
+ orm.asyncio.sleep = _nosleep
|
|
|
|
|
+ try:
|
|
|
|
|
+ return asyncio.run(orm._openrouter_anthropic_call(
|
|
|
|
|
+ messages=[{"role": "user", "content": "hi"}],
|
|
|
|
|
+ model="anthropic/claude-sonnet-4-6",
|
|
|
|
|
+ tools=None,
|
|
|
|
|
+ api_key="dummy",
|
|
|
|
|
+ ))
|
|
|
|
|
+ finally:
|
|
|
|
|
+ orm.httpx.AsyncClient = orig_client
|
|
|
|
|
+ orm.asyncio.sleep = orig_sleep
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ failures = []
|
|
|
|
|
+
|
|
|
|
|
+ # 用例 1:前 2 次 body 非 JSON,第 3 次成功 → 应退避重试后成功
|
|
|
|
|
+ try:
|
|
|
|
|
+ out = _run(raise_times=2)
|
|
|
|
|
+ assert out["content"] == '{"ok": 1}', out
|
|
|
|
|
+ print("✅ case1 一过性 bad-JSON:退避重试后成功")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ failures.append(f"case1 FAILED: {type(e).__name__}: {e}")
|
|
|
|
|
+ print(f"❌ case1 FAILED: {type(e).__name__}: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ # 用例 2:3 次全是 bad-JSON → 应抛清晰错误(重试耗尽),而非裸 JSONDecodeError
|
|
|
|
|
+ try:
|
|
|
|
|
+ _run(raise_times=3)
|
|
|
|
|
+ failures.append("case2 FAILED: 预期重试耗尽抛错,但没有抛")
|
|
|
|
|
+ print("❌ case2 FAILED: 预期抛错但没抛")
|
|
|
|
|
+ except json.JSONDecodeError as e:
|
|
|
|
|
+ failures.append(f"case2 FAILED: 仍是裸 JSONDecodeError 穿透: {e}")
|
|
|
|
|
+ print(f"❌ case2 FAILED: 裸 JSONDecodeError 穿透 → {e}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"✅ case2 持续 bad-JSON:重试耗尽抛清晰错误 [{type(e).__name__}] {str(e)[:80]}")
|
|
|
|
|
+
|
|
|
|
|
+ print("-" * 50)
|
|
|
|
|
+ if failures:
|
|
|
|
|
+ print(f"FAIL ({len(failures)})")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+ print("ALL PASS")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|