fix: 客户端响应结构损坏归入 LLMResponseError;3xx 不再误判成功
This commit is contained in:
+4
-1
@@ -46,4 +46,7 @@
|
||||
| 2026-08-09 05:57 | Agent 实现 | 里程碑3.1 Task2 实现:推理引擎 token 估算模块。新建 src/genesis/inference/token.py(approximate_token_count 每 4 字符≈1 token 最少 1、_tiktoken_estimator tiktoken 编码未安装返回 None、make_estimator backend="tiktoken" 默认优先 tiktoken 缺失回落 approximate);tests/test_inference_token.py 按 brief 5 用例 + 补充 2 用例(假 tiktoken 成功路径、encoding 抛异常回落)覆盖 tiktoken 成功/异常两分支,保证全量覆盖不回落;TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.token')→ 聚焦 7 passed,token.py 100%;pytest 全量 86 passed 覆盖 100.00%(626 stmts/142 br);提交见 git log | src/genesis/inference/token.py, tests/test_inference_token.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 2026-08-09 08:02 | Agent 实现 | 里程碑3.1 Task3 实现:Prompt 注册表。新建 src/genesis/inference/prompt_registry.py(PromptRegistry:register/get/list_versions/render,version=None 取最新,jinja2 渲染,name/name@version 缺失抛 KeyError);tests/test_inference_prompt_registry.py 按 brief 5 用例 + 补 1 用例(指定不存在的 version 抛 KeyError,覆盖 get 中 key not in templates 分支);TDD 验证 RED(ModuleNotFoundError: No module named genesis.inference.prompt_registry)→ GREEN(聚焦 6 passed);pytest 全量 92 passed 覆盖 100.00%(653 stmts/150 br),fail_under=99 达标 | src/genesis/inference/prompt_registry.py, tests/test_inference_prompt_registry.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 2026-08-09 09:40 | Agent 实现 | 里程碑3.1 Task4 实现:LLM 客户端。新建 src/genesis/inference/client.py(LLMClient Protocol + HttpLLMClient:api_key 空抛 LLMNotConfiguredError,POST {base_url}/v1/chat/completions Bearer 鉴权,指数退避重试 5xx/网络错误(attempts=1+len(retry_backoff)),超时抛 LLMTimeoutError,重试耗尽/4xx 抛 LLMNetworkError);tests/test_inference_client.py 按 brief 6 用例(结构协议兼容/成功/无key/超时/5xx重试耗尽/4xx不重试)+ 补 1 用例(非超时 ConnectError 走 httpx.HTTPError 分支覆盖剩余分支);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.client')→ GREEN(聚焦 7 passed);pytest 全量 99 passed 覆盖 100.00%(699 stmts/162 br),fail_under=99 达标;提交见 git log | src/genesis/inference/client.py, tests/test_inference_client.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 2026-08-09 08:26 | Agent 实现 | 里程碑3.1 Task5 实现:InferenceEngine 编排器。新建 src/genesis/inference/engine.py(InferenceEngine:_render_prompt/_apply_truncation/_model_names(primary→fallback,无配置回退 deepseek-chat)/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed)/chat_structured(schema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.py(FakeLLMClient 校验调)与 tests/test_inference_engine.py 按 brief 10 用例 + 补 5 用例(无 fallback、空 models、默认 registry/estimator、显式 model、truncate_cb 返回 None);2 处 brief 测试数值修正(truncate 内 max_context_tokens=3→2 因渲染 11 字≈3 token 不超限、fallback 断言 calls[-1]→calls[0] 因最后调用为备用模型);__init__.py 补导出 InferenceEngine/PromptRegistry;文档补丁 3 处(agent-runtime §2.2 StructuredResult.status、api-design §7 错误码表 LLM_NOT_CONFIGURED 行并扩表加来源列、config-design §4 token_estimation 注明 tiktoken 缺失回落 approximate);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.engine')→ GREEN(聚焦 16 passed);pytest 全量 115 passed 覆盖 100.00%(779 stmts/184 br),fail_under=99 达标;提交见 git log | src/genesis/inference/engine.py, src/genesis/inference/__init__.py, tests/inference_helpers.py, tests/test_inference_engine.py, docs/agent-runtime-design.md, docs/api-design.md, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 2026-08-09 08:26 | Agent 实现 | 里程碑3.1 Task5 实现:InferenceEngine 编排器。新建 src/genesis/inference/engine.py(InferenceEngine:_render_prompt/_apply_truncation/_model_names(primary→fallback,无配置回退 deepseek-chat)/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed)/chat_structured(schema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.py(FakeLLMClient)与 tests/test_inference_engine.py 按 brief 10 用例 + 补 5 用例(无 fallback、空 models、默认 registry/estimator、显式 model、truncate_cb 返回 None);2 处 brief 测试数值修正(truncate 内 max_context_tokens=3→2 因渲染 11 字≈3 token 不超限、fallback 断言 calls[-1]→calls[0] 因最后调用为备用模型);__init__.py 补导出 InferenceEngine/PromptRegistry;文档补丁 3 处(agent-runtime §2.2 StructuredResult.status、api-design §7 错误码表 LLM_NOT_CONFIGURED 行并扩表加来源列、config-design §4 token_estimation 注明 tiktoken 缺失回落 approximate);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.engine')→ GREEN(聚焦 16 passed);pytest 全量 115 passed 覆盖 100.00%(779 stmts/184 br),fail_under=99 达标;提交见 git log | src/genesis/inference/engine.py, src/genesis/inference/__init__.py, tests/inference_helpers.py, tests/test_inference_engine.py, docs/agent-runtime-design.md, docs/api-design.md, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 2026-08-09 10:40 | 反馈迭代 | 里程碑3.1 最终全分支评审(base 2fbff07..8bc5e5e)修正:评审查到 client 两处缺陷并修复——① 2xx 但响应结构损坏(非 JSON/缺 choices 字段)从裸 KeyError/JSONDecodeError 逃逸出 engine 的 except LLMError,改为捕获 (json.JSONDecodeError, KeyError, IndexError, TypeError) 后抛 LLMResponseError(spec §3.8 既有类型首次被触发,封闭 api-design §7 LLM_PARSE_ERROR 来源列虚指);② 3xx 重定向被当作成功(httpx 不自动跟随),补显式 2xx 判定,非 2xx 一律 LLMNetworkError。TDD:新增 2 用例(test_chat_3xx_no_retry、test_chat_malformed_response_raises_llm_response_error 含 not-json/missing-key 两种 handler)先 RED→实现→GREEN;聚焦 9 passed;pytest 全量 117 passed 覆盖 100.00%(785 stmts/186 br),fail_under=99 达标 | src/genesis/inference/client.py, tests/test_inference_client.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
|
||||
(End of file - total 50 lines)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
from .exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
|
||||
from .exceptions import (
|
||||
LLMNetworkError,
|
||||
LLMNotConfiguredError,
|
||||
LLMResponseError,
|
||||
LLMTimeoutError,
|
||||
)
|
||||
from .types import ChatMessage, TokenUsage
|
||||
|
||||
|
||||
@@ -81,9 +87,16 @@ class HttpLLMClient:
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
if not (200 <= resp.status_code < 300):
|
||||
# 3xx 重定向不自动跟随,不得误判为成功
|
||||
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
try:
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
|
||||
# 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试
|
||||
raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc
|
||||
usage_raw = data.get("usage", {})
|
||||
usage = TokenUsage(
|
||||
input_tokens=usage_raw.get("prompt_tokens", 0),
|
||||
|
||||
@@ -2,7 +2,12 @@ import pytest
|
||||
import httpx
|
||||
|
||||
from genesis.inference.client import HttpLLMClient, LLMClient
|
||||
from genesis.inference.exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
|
||||
from genesis.inference.exceptions import (
|
||||
LLMNetworkError,
|
||||
LLMNotConfiguredError,
|
||||
LLMResponseError,
|
||||
LLMTimeoutError,
|
||||
)
|
||||
from genesis.inference.types import ChatMessage
|
||||
|
||||
|
||||
@@ -99,4 +104,36 @@ def test_chat_4xx_no_retry():
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 1 # 4xx 不重试
|
||||
assert calls["n"] == 1 # 4xx 不重试
|
||||
|
||||
|
||||
def test_chat_3xx_no_retry():
|
||||
# 3xx(重定向,httpx 不自动跟随)不得被当作成功,应立即抛 LLMNetworkError
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(302, headers={"Location": "https://elsewhere"})
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 1 # 3xx 不重试
|
||||
|
||||
|
||||
def test_chat_malformed_response_raises_llm_response_error():
|
||||
# 2xx 但响应体结构损坏(非 JSON / 缺 choices/message/content)→ LLMResponseError
|
||||
def not_json(request):
|
||||
return httpx.Response(200, text="not-json")
|
||||
|
||||
def missing_key(request):
|
||||
return httpx.Response(200, json={"choices": []})
|
||||
|
||||
for handler in (not_json, missing_key):
|
||||
with pytest.raises(LLMResponseError):
|
||||
make_client(handler).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
Reference in New Issue
Block a user