fix(llm): 收编波五件——L4/L5 累积降级链+配平起点避字符串内左括号+响应体单次解析连接复用+400 自愈不占重试预算+usage 上游未给 None 化 (审计 P3-1..5 收编) [vps] [no-doc]
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user