fix(llm): 收编波五件——L4/L5 累积降级链+配平起点避字符串内左括号+响应体单次解析连接复用+400 自愈不占重试预算+usage 上游未给 None 化 (审计 P3-1..5 收编) [vps] [no-doc]

This commit is contained in:
2026-10-05 22:26:17 +08:00
parent ce5b8f59c2
commit 472559380e
5 changed files with 181 additions and 64 deletions
+78
View File
@@ -203,3 +203,81 @@ class TestWantUsage:
client, _, _ = _make(lambda r: _ok())
out = asyncio.run(client.chat_json(MSGS))
assert out == {"a": 1} and not isinstance(out, tuple)
class TestEfficiency:
"""P3-3: 同一 200 响应体只解析一次 + 整轮对话(含重试/strict)复用单个
AsyncClient(此前每 attempt 新建连接池,响应体 json.loads 两遍)."""
def test_200_body_parsed_once(self, monkeypatch):
import sanguo_api.llm.client as client_mod
class _Shim:
count = 0
@staticmethod
def loads(*a, **k):
_Shim.count += 1
return real_loads(*a, **k)
real_loads = client_mod.json.loads
monkeypatch.setattr(client_mod, "json", _Shim)
client, _, _ = _make(lambda r: _ok())
assert asyncio.run(client.chat_json(MSGS)) == {"a": 1}
assert _Shim.count == 1
def test_async_client_reused_across_attempts(self, monkeypatch):
import sanguo_api.llm.client as client_mod
real_cls = client_mod.httpx.AsyncClient
built = []
class _Counting(real_cls):
def __init__(self, *a, **k):
built.append(1)
super().__init__(*a, **k)
monkeypatch.setattr(client_mod.httpx, "AsyncClient", _Counting)
def h(request):
calls.append(request)
if len(calls) == 1:
return httpx.Response(429, json={"error": "rate"})
return _ok()
client, calls, _ = _make(h)
assert asyncio.run(client.chat_json(MSGS)) == {"a": 1}
assert len(calls) == 2 and len(built) == 1
class TestHealBudget:
def test_400_self_heal_does_not_consume_retry_budget(self):
"""P3-4: 参数自愈重发不计 429/5xx 重试预算——自愈吃掉 attempts 后
真 429 提前「重试耗尽」,退避语义混杂."""
def h(request):
calls.append(request)
import json as j
if "response_format" in j.loads(request.content):
return httpx.Response(400,
text='{"error":"response_format x"}')
if len(calls) < 4:
return httpx.Response(429, json={"error": "rate"})
return _ok()
client, calls, sleeps = _make(h)
assert asyncio.run(client.chat_json(MSGS)) == {"a": 1}
assert len(calls) == 4 and sleeps == [2.0, 4.0]
class TestUsageMissing:
def test_missing_usage_returns_none_not_empty_dict(self):
"""P3-5: 上游没给 usage 返 None,与「给了但 0 token」可区分
(漏斗计量口径);None 不得伪装成 {}."""
def h(request):
import json as j
body = {"choices": [{"message": {"content": '{"a": 1}'}}]}
return httpx.Response(200, text=j.dumps(body))
client, _, _ = _make(h)
obj, usage = asyncio.run(client.chat_json(MSGS, want_usage=True))
assert obj == {"a": 1} and usage is None
+12
View File
@@ -49,3 +49,15 @@ def test_non_str_input_raises_json_parse_error():
for bad in (None, 42, {"a": 1}, ["a"], b"{}"):
with pytest.raises(JSONParseError):
robust_json_parse(bad)
def test_l4_l5_fixes_cumulative():
"""P3-1: L4 反斜杠修复与 L5 尾逗号清洗须累积叠加——并行候选下「LaTeX
残留+尾逗号」同现时两级各自单独修都不够,整段降级链全败(假阴性 502)."""
assert robust_json_parse('{"s": "\\(x\\)",}') == {"s": "(x)"}
def test_balanced_start_skips_brace_inside_string():
"""P3-2: 首个 { 落在字符串内时旧 find("{") 起点错位,配平块从头就坏,
后续 L4/L5 全被拖死——起点扫描须字符串感知(复用 in_str/esc 状态机)."""
assert robust_json_parse('他说"{不是json" 然后 {"a": 1}') == {"a": 1}