| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121 |
- # -*- 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()
|