feat: HttpLLMClient(httpx + 重试退避/超时/鉴权)
This commit is contained in:
@@ -45,3 +45,4 @@
|
||||
| 2026-08-09 05:53 | Agent 实现 | 里程碑3.1 Task1 实现:推理引擎数据模型与异常层。新建 src/genesis/inference/(__init__/types/exceptions:TokenUsage、ChatMessage、ChatResult、StructuredResult、Prompt 五 dataclass 与 LLMError 体系五异常,__init__ 暂不导入 engine/client 防循环);pyproject.toml 增加 httpx>=0.28/jinja2>=3.1 依赖;tests/test_inference_types.py(6 用例)与 tests/test_inference_errors.py(2 用例);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference')→ GREEN(聚焦 8 passed);pytest 全量 79 passed 覆盖 100.00%(608 stmts/140 br),fail_under=99 达标;提交见 git log | src/genesis/inference/__init__.py, src/genesis/inference/types.py, src/genesis/inference/exceptions.py, pyproject.toml, tests/test_inference_types.py, tests/test_inference_errors.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
from .exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
|
||||
from .types import ChatMessage, TokenUsage
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
"""LLM 调用适配器(可注入替换为 Fake)。"""
|
||||
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> tuple[str, TokenUsage]: ...
|
||||
|
||||
|
||||
class HttpLLMClient:
|
||||
"""OpenAI Chat Completions 兼容的 httpx 实现;支持重试(指数退避)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
timeout_sec: float = 60.0,
|
||||
retry_backoff: Sequence[float] = (1.0, 3.0, 7.0),
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
if not api_key:
|
||||
raise LLMNotConfiguredError("LLM API key 未配置(DEEPSEEK_API_KEY / LLM_BASE_URL)")
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._timeout_sec = timeout_sec
|
||||
self._retry_backoff = retry_backoff
|
||||
self._client = httpx.Client(timeout=timeout_sec, transport=transport)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> tuple[str, TokenUsage]:
|
||||
url = f"{self._base_url}/v1/chat/completions"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
attempts = 1 + len(self._retry_backoff)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(attempts):
|
||||
if attempt > 0:
|
||||
time.sleep(self._retry_backoff[attempt - 1])
|
||||
try:
|
||||
resp = self._client.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
except httpx.HTTPError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if resp.status_code >= 500:
|
||||
last_error = LLMNetworkError(f"LLM 5xx: {resp.status_code}")
|
||||
continue
|
||||
if resp.status_code >= 400:
|
||||
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
usage_raw = data.get("usage", {})
|
||||
usage = TokenUsage(
|
||||
input_tokens=usage_raw.get("prompt_tokens", 0),
|
||||
output_tokens=usage_raw.get("completion_tokens", 0),
|
||||
)
|
||||
return content, usage
|
||||
|
||||
if isinstance(last_error, httpx.TimeoutException):
|
||||
raise LLMTimeoutError(f"LLM 超时({self._timeout_sec}s)") from last_error
|
||||
raise LLMNetworkError(f"LLM 调用失败(重试耗尽): {last_error}") from last_error
|
||||
@@ -0,0 +1,102 @@
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from genesis.inference.client import HttpLLMClient, LLMClient
|
||||
from genesis.inference.exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
|
||||
from genesis.inference.types import ChatMessage
|
||||
|
||||
|
||||
def make_client(handler, *, api_key="sk-test", retry_backoff=(0.0, 0.0)):
|
||||
return HttpLLMClient(
|
||||
base_url="https://api.test.local",
|
||||
api_key=api_key,
|
||||
timeout_sec=0.1,
|
||||
retry_backoff=retry_backoff,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
def _ok_handler(request):
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "Hello"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
})
|
||||
|
||||
|
||||
def test_client_implements_protocol():
|
||||
# 结构性断言:HttpLLMClient.chat 的关键字参数签名与 LLMClient Protocol 一致
|
||||
import inspect
|
||||
|
||||
proto_params = set(inspect.signature(LLMClient.chat).parameters)
|
||||
impl_params = set(inspect.signature(HttpLLMClient.chat).parameters)
|
||||
assert proto_params.issubset(impl_params)
|
||||
|
||||
|
||||
def test_chat_success():
|
||||
client = make_client(_ok_handler)
|
||||
text, usage = client.chat(
|
||||
model="deepseek-chat",
|
||||
messages=[ChatMessage(role="user", content="hi")],
|
||||
temperature=0.2,
|
||||
max_tokens=100,
|
||||
)
|
||||
assert text == "Hello"
|
||||
assert usage.input_tokens == 10 and usage.output_tokens == 5
|
||||
|
||||
|
||||
def test_chat_requires_api_key():
|
||||
with pytest.raises(LLMNotConfiguredError):
|
||||
HttpLLMClient(base_url="https://x", api_key="")
|
||||
|
||||
|
||||
def test_chat_timeout():
|
||||
def slow(request):
|
||||
raise httpx.ReadTimeout("slow")
|
||||
|
||||
with pytest.raises(LLMTimeoutError):
|
||||
make_client(slow, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_5xx_retry_then_network_error():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 3 # 初始 + 2 次退避重试(间隔 0/0.01)
|
||||
|
||||
|
||||
def test_chat_network_error_retry_then_network_error():
|
||||
# 非超时的网络错误走 httpx.HTTPError 分支,重试耗尽后抛 LLMNetworkError
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_4xx_no_retry():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(429, text="rate limit")
|
||||
|
||||
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 # 4xx 不重试
|
||||
Reference in New Issue
Block a user