166 lines
7.8 KiB
Python
166 lines
7.8 KiB
Python
# sanguo_api/llm/client.py
|
|
"""httpx 薄 client(P4):openai 兼容 chat/completions 直调.
|
|
|
|
零新依赖(httpx 0.28.1 in requirements).搬运组合(调研报告 §1):
|
|
- tickflow ai_provider.py:420-465 参数自愈(400 摘参数重试一次)
|
|
- RD-Agent base.py:520-613 重试引擎模式精简(429 Retry-After/5xx 退避/401 立断)
|
|
- QA planning.py:93-106 解析失败追加 strict 指令重试一次
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Awaitable, Callable
|
|
|
|
import httpx
|
|
|
|
from .json_utils import JSONParseError, robust_json_parse
|
|
from .provider import LLMConfig, LLMError, chat_url
|
|
|
|
logger = logging.getLogger("sanguo_api.llm")
|
|
|
|
_MAX_ATTEMPTS = 3
|
|
_BACKOFF_SECONDS = (2.0, 4.0)
|
|
_STRICT_NOTE = "Strictly output valid JSON. No extra text."
|
|
|
|
|
|
class LLMClient:
|
|
def __init__(self, config: LLMConfig,
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
sleep: Callable[[float], Awaitable[None]] | None = None) -> None:
|
|
self._cfg = config
|
|
self._transport = transport
|
|
self._sleep = sleep or asyncio.sleep
|
|
|
|
async def chat_json(self, messages: list[dict], *, temperature: float = 0.2,
|
|
max_tokens: int = 2000,
|
|
want_usage: bool = False) -> dict | tuple[dict, dict | None]:
|
|
"""一轮对话→dict;want_usage=True 返 (dict, usage)(P4-3 漏斗 token 计量,
|
|
usage 含 prompt_tokens/completion_tokens;P3-5: 上游没给 usage 时为
|
|
None——不再伪装 {} 与「0 token」不可分)。默认调用形态零变化。"""
|
|
obj, usage = await self._chat_json(messages, temperature=temperature,
|
|
max_tokens=max_tokens)
|
|
return (obj, usage) if want_usage else obj
|
|
|
|
async def _chat_json(self, messages: list[dict], *, temperature: float,
|
|
max_tokens: int) -> tuple[dict, dict | None]:
|
|
payload: dict = {
|
|
"model": self._cfg.model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
healed = {"response_format": False, "temperature": False}
|
|
attempts = 0
|
|
# P3-3: 单 AsyncClient 复用整轮(含自愈重试/strict 重试),不再每
|
|
# attempt 新建连接池
|
|
async with httpx.AsyncClient(timeout=self._cfg.timeout,
|
|
transport=self._transport) as client:
|
|
while True:
|
|
attempts += 1
|
|
try:
|
|
status, text, headers = await self._post(client, payload)
|
|
except httpx.TransportError as e:
|
|
# 传输级故障(连接拒绝/超时/断连)视作一次失败 attempt,同款退避
|
|
if attempts >= _MAX_ATTEMPTS:
|
|
raise LLMError(f"LLM 连接失败: {e}") from e
|
|
await self._sleep(_BACKOFF_SECONDS[min(attempts - 1, 1)])
|
|
continue
|
|
if status == 200:
|
|
# P3-3: 响应体只 json.loads 一次,usage/content 同源传递
|
|
try:
|
|
body = json.loads(text)
|
|
except ValueError as e:
|
|
raise LLMError(f"LLM 响应非 JSON: {e}") from e
|
|
usage = body.get("usage")
|
|
if not isinstance(usage, dict):
|
|
usage = None # P3-5: 缺失/异形返 None,不伪装 0 token
|
|
logger.info("llm tokens prompt=%s completion=%s",
|
|
(usage or {}).get("prompt_tokens"),
|
|
(usage or {}).get("completion_tokens"))
|
|
try:
|
|
return robust_json_parse(self._content(body)), usage
|
|
except JSONParseError:
|
|
return await self._strict_retry(client, messages,
|
|
payload)
|
|
if status in (401, 403, 404):
|
|
raise LLMError(f"LLM 鉴权/端点错误(HTTP {status}):检查 "
|
|
f"SANGUO_LLM_API_KEY/SANGUO_LLM_BASE_URL")
|
|
if status == 400:
|
|
if ("response_format" in text
|
|
and not healed["response_format"]):
|
|
payload.pop("response_format", None)
|
|
healed["response_format"] = True
|
|
attempts -= 1 # P3-4: 自愈重发不占 429/5xx 重试预算
|
|
continue
|
|
if ("temperature" in text and not healed["temperature"]):
|
|
payload.pop("temperature", None)
|
|
healed["temperature"] = True
|
|
attempts -= 1 # P3-4: 同上
|
|
continue
|
|
raise LLMError(f"LLM 拒绝请求(400): {text[:200]}")
|
|
if status == 429 or status >= 500:
|
|
if attempts >= _MAX_ATTEMPTS:
|
|
raise LLMError(f"LLM 重试耗尽(HTTP {status}): {text[:200]}")
|
|
await self._sleep(self._retry_delay(status, headers, attempts))
|
|
continue
|
|
raise LLMError(f"LLM 未预期状态 HTTP {status}: {text[:200]}")
|
|
|
|
async def _strict_retry(self, client: httpx.AsyncClient,
|
|
messages: list[dict], payload: dict) -> dict:
|
|
payload = dict(payload, messages=messages + [
|
|
{"role": "user", "content": _STRICT_NOTE}])
|
|
try:
|
|
status, text, _headers = await self._post(client, payload)
|
|
except httpx.TransportError as e:
|
|
raise LLMError(f"LLM strict 重试连接失败: {e}") from e
|
|
if status != 200:
|
|
raise LLMError(f"LLM strict 重试失败(HTTP {status}): {text[:200]}")
|
|
try:
|
|
body = json.loads(text) # P3-3: 同响应体单次解析
|
|
except ValueError as e:
|
|
raise LLMError(f"LLM strict 重试响应非 JSON: {e}") from e
|
|
usage = body.get("usage")
|
|
if not isinstance(usage, dict):
|
|
usage = None # P3-5: 同主链口径
|
|
try:
|
|
return robust_json_parse(self._content(body)), usage
|
|
except JSONParseError as e:
|
|
raise LLMError(f"LLM 返回非 JSON(两轮): {e}") from e
|
|
|
|
@staticmethod
|
|
def _retry_delay(status: int, headers: httpx.Headers,
|
|
attempts: int) -> float:
|
|
"""P2-1: 429 优先取 Retry-After 秒数(int/float 均可);非数字或缺失
|
|
回落固定退避(此前 headers 全程丢弃,Retry-After 从未真生效)."""
|
|
if status == 429:
|
|
raw = headers.get("Retry-After")
|
|
if raw:
|
|
try:
|
|
return float(raw)
|
|
except ValueError:
|
|
pass
|
|
return _BACKOFF_SECONDS[min(attempts - 1, 1)]
|
|
|
|
async def _post(self, client: httpx.AsyncClient,
|
|
payload: dict) -> tuple[int, str, httpx.Headers]:
|
|
req_headers = {"Authorization": f"Bearer {self._cfg.api_key}",
|
|
"Content-Type": "application/json"}
|
|
resp = await client.post(chat_url(self._cfg.base_url),
|
|
headers=req_headers, json=payload)
|
|
return resp.status_code, resp.text, resp.headers
|
|
|
|
@staticmethod
|
|
def _content(body: dict) -> str:
|
|
try:
|
|
content = body["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
raise LLMError(f"LLM 响应缺 choices/message: {e}") from e
|
|
if content is None:
|
|
# P2-3: 兼容端点空回复 content:null 不罕见——按空回复降级走
|
|
# strict 重试(JSONParseError 通道),两轮仍空才 LLMError=502 契约
|
|
raise JSONParseError("content 为 null(空回复)")
|
|
return content
|