282 lines
12 KiB
Python
282 lines
12 KiB
Python
"""OpenRouter chat-completions client (S2 seam).
|
||
|
||
OpenAI-compatible direct client — zero heavy deps (httpx only). Key / base URL
|
||
/ model all come from the environment; the client is injectable for tests
|
||
(transport or a stub subclass). No call-quota logic by design (CONTRACT C3:
|
||
no artificial limits on real calls; only ordinary robustness retries).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
from typing import Any, Callable
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger("hvr.llm")
|
||
|
||
DEFAULT_MODEL = "deepseek/deepseek-v4-flash-0731"
|
||
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
||
|
||
_JSON_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$")
|
||
|
||
|
||
class LLMError(Exception):
|
||
"""User-readable upstream failure (HTTP/network/parse-after-retry)."""
|
||
|
||
|
||
def resolve_env(env: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""Resolve runtime config. Key stays external (CONTRACT C2)."""
|
||
env = env or dict(os.environ)
|
||
api_key = env.get("OPENROUTER_API_KEY") or env.get("PRODREAM_BACKEND_OPENROUTER_API_KEY") or ""
|
||
base_url = (env.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||
# 正向代理(含 Basic Auth),如后端 .env 的 PRODREAM_BACKEND_OPENROUTER_PROXY_URL
|
||
proxy_url = env.get("OPENROUTER_PROXY_URL") or env.get("PRODREAM_BACKEND_OPENROUTER_PROXY_URL") or ""
|
||
model = env.get("HVR_LLM_MODEL") or DEFAULT_MODEL
|
||
return {"api_key": api_key, "base_url": base_url, "proxy_url": proxy_url, "model": model}
|
||
|
||
|
||
def _join_content_parts(content: Any) -> str:
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts: list[str] = []
|
||
for part in content:
|
||
if isinstance(part, str):
|
||
parts.append(part)
|
||
elif isinstance(part, dict):
|
||
parts.append(str(part.get("text") or ""))
|
||
return "".join(parts)
|
||
return ""
|
||
|
||
|
||
def message_text(data: dict[str, Any]) -> str:
|
||
"""Read assistant text from an OpenAI-compatible completion payload.
|
||
|
||
DeepSeek V4 / OpenRouter reasoning models often put thinking in
|
||
``reasoning`` / ``reasoning_content`` and leave ``content`` empty when
|
||
the shared output budget is exhausted.
|
||
"""
|
||
try:
|
||
choice = data["choices"][0]
|
||
message = choice.get("message") or {}
|
||
except (KeyError, IndexError, TypeError) as exc:
|
||
raise LLMError("大模型响应缺少内容字段") from exc
|
||
text = _join_content_parts(message.get("content"))
|
||
if text.strip():
|
||
return text
|
||
finish = str(choice.get("finish_reason") or "")
|
||
has_reasoning = bool(
|
||
message.get("reasoning")
|
||
or message.get("reasoning_content")
|
||
or message.get("reasoning_details")
|
||
)
|
||
extra: list[str] = []
|
||
if finish:
|
||
extra.append(f"finish_reason={finish}")
|
||
if has_reasoning:
|
||
extra.append("思考过程占满了输出额度")
|
||
suffix = ";".join(extra) if extra else "可能被截断或拒绝"
|
||
raise LLMError(f"模型未返回内容({suffix})")
|
||
|
||
|
||
def reasoning_config(disable_reasoning: bool = False) -> dict[str, Any]:
|
||
"""OpenRouter rejects payloads that set both ``effort`` and ``max_tokens``."""
|
||
if disable_reasoning:
|
||
return {"effort": "none", "exclude": True}
|
||
# 思考预算实测(2026-08-25 两次):
|
||
# 2048 → 单次 diagnose 309.8s,模型烧满全部思考 token;DeepSeek 思考
|
||
# token 生成速度远慢于正文(约 1/10),5 分钟几乎全花在想。
|
||
# 512 → 同文书同 prompt 实测 70.2s,输出完整合规(4 briefs / 5 patterns)。
|
||
# 早期 2048→1024 曾因旧版冗长 prompt 引入 502(思考受限→输出不合规→校验
|
||
# 失败);prompt 压紧凑后 512 可行,JSON 校验失败仍有 complete_json 重试兜底。
|
||
return {"max_tokens": 512}
|
||
|
||
|
||
def extract_json(content: str | None) -> dict[str, Any]:
|
||
"""Parse model output as JSON. Strips ```json fences and trims to the
|
||
outermost balanced {...} region."""
|
||
if not content or not content.strip():
|
||
raise LLMError("模型未返回内容(可能被截断或拒绝)")
|
||
text = _JSON_FENCE.sub("", content.strip()).strip()
|
||
try:
|
||
parsed = json.loads(text)
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
except json.JSONDecodeError:
|
||
pass
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
parsed = json.loads(text[start : end + 1])
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
except json.JSONDecodeError:
|
||
pass
|
||
raise LLMError("模型输出不是有效的 JSON(解析失败)")
|
||
|
||
|
||
def _readable_error(resp: httpx.Response) -> str:
|
||
"""从上游错误响应提取一行用户可读信息(优先 error.message),
|
||
不把完整 JSON body 甩给页面(PRD §11 失败兜底)。"""
|
||
try:
|
||
body = resp.json()
|
||
if isinstance(body, dict):
|
||
msg = (body.get("error") or {}).get("message", "") if isinstance(body.get("error"), dict) else ""
|
||
if msg:
|
||
return msg[:300]
|
||
except Exception:
|
||
pass
|
||
return resp.text[:200]
|
||
|
||
|
||
class LlmClient:
|
||
"""Thin OpenAI-compatible client. `transport`/`client` injectable for tests."""
|
||
|
||
def __init__(
|
||
self,
|
||
api_key: str | None = None,
|
||
base_url: str | None = None,
|
||
model: str | None = None,
|
||
proxy: str | None = None,
|
||
timeout: float = 180.0,
|
||
# 实测(2026-08-25):诊断正常输出 ~2.5k tokens(prompt 已压紧凑);
|
||
# 模型偶发啰嗦打满 16000 → 截断 → JSON 失败 → 重试又 258s,页面卡 5 分钟。
|
||
# 8000 给正常输出 3 倍余量,打满时更快失败并走既有重试兜底。
|
||
max_tokens: int = 8000,
|
||
temperature: float = 0.3,
|
||
transport: httpx.BaseTransport | None = None,
|
||
) -> None:
|
||
cfg = resolve_env()
|
||
self.api_key = api_key or cfg["api_key"]
|
||
self.base_url = base_url or cfg["base_url"]
|
||
self.model = model or cfg["model"]
|
||
self.proxy = proxy if proxy is not None else cfg["proxy_url"]
|
||
self.timeout = timeout
|
||
self.max_tokens = max_tokens
|
||
self.temperature = temperature
|
||
if not self.api_key:
|
||
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
|
||
self._transport = transport
|
||
|
||
# -- internal ----------------------------------------------------------
|
||
def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
||
kwargs: dict[str, Any] = {"timeout": self.timeout}
|
||
if self._transport is not None:
|
||
kwargs["transport"] = self._transport
|
||
# trust_env=False + 显式 proxy:直连配置的 base_url,不继承本机 Surge 等系统代理;
|
||
# 区域代理(PRODREAM_BACKEND_OPENROUTER_PROXY_URL)作为正向代理传入
|
||
with httpx.Client(trust_env=False, proxy=self.proxy or None, **kwargs) as client:
|
||
resp = client.post(
|
||
f"{self.base_url}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json=payload,
|
||
)
|
||
return resp
|
||
|
||
def _complete(
|
||
self,
|
||
system: str,
|
||
user: str,
|
||
json_reminder: bool = False,
|
||
disable_reasoning: bool = False,
|
||
) -> str:
|
||
messages = [{"role": "system", "content": system}]
|
||
if json_reminder:
|
||
messages.append(
|
||
{"role": "system", "content": "只输出符合要求的 JSON,不要输出任何其它文字、解释或代码块标记。"}
|
||
)
|
||
messages.append({"role": "user", "content": user})
|
||
payload: dict[str, Any] = {
|
||
"model": self.model,
|
||
"messages": messages,
|
||
"temperature": self.temperature,
|
||
"max_tokens": self.max_tokens,
|
||
"response_format": {"type": "json_object"},
|
||
}
|
||
# DeepSeek V4 Flash 的思考 token 与正文共用输出额度;不限制时
|
||
# diagnose 这类长 JSON 经常只返回空 content(页面上就是 502)。
|
||
# OpenRouter:effort 与 max_tokens 只能二选一。
|
||
payload["reasoning"] = reasoning_config(disable_reasoning)
|
||
# 观测:每次 LLM 调用都打耗时 + usage,慢链路(同事反馈 diagnose 卡 5 分钟)靠它定位
|
||
t0 = time.monotonic()
|
||
try:
|
||
resp = self._post(payload)
|
||
except (httpx.HTTPError, ValueError, TypeError) as exc: # 网络/超时;代理 URL 或配置错误(如 .env 解析出整行变量名)
|
||
dt = time.monotonic() - t0
|
||
logger.warning("llm http-error dt=%.1fs %s", dt, exc.__class__.__name__)
|
||
if isinstance(exc, httpx.HTTPError):
|
||
raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc
|
||
# 2026-08-25 实测:代理 URL 带变量名前缀 → httpx 构造期 ValueError → 裸 500
|
||
# (此前只捕 httpx.HTTPError)。配置类错误同样转可读 LLMError(PRD §11 失败兜底)
|
||
raise LLMError(f"调用大模型失败(配置错误,请检查代理/地址设置):{exc}") from exc
|
||
dt = time.monotonic() - t0
|
||
if resp.status_code != 200:
|
||
logger.warning("llm http %s dt=%.1fs retry-hint=%s", resp.status_code, dt, resp.headers.get("retry-after", ""))
|
||
raise LLMError(f"调用大模型失败(HTTP {resp.status_code}):{_readable_error(resp)}")
|
||
try:
|
||
body = resp.json()
|
||
except Exception as exc:
|
||
raise LLMError("模型未返回内容(可能被截断或拒绝)") from exc
|
||
usage = body.get("usage") or {}
|
||
# uvicorn 默认只给 uvicorn.* logger 配 handler,root 的 info 会被吞;
|
||
# 观测行用 warning 级别保证进入 stderr 日志(慢链路定位依据)
|
||
logger.warning(
|
||
"llm ok model=%s dt=%.1fs prompt=%s completion=%s reasoning=%s total_time_ms=%s",
|
||
self.model, dt,
|
||
usage.get("prompt_tokens"), usage.get("completion_tokens"),
|
||
usage.get("reasoning_tokens"), usage.get("total_time"),
|
||
)
|
||
return message_text(body)
|
||
|
||
# -- public ------------------------------------------------------------
|
||
def complete_json(
|
||
self,
|
||
system: str,
|
||
user: str,
|
||
validate: Callable[[dict[str, Any]], str | None] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Structured completion with robustness retries (SEAMS default:
|
||
any LLMError (parse failure / empty content / transient upstream
|
||
failure) is retried once, the retry carrying a JSON-only reminder
|
||
and turning reasoning off so the answer is not crowded out).
|
||
No quota logic.
|
||
|
||
`validate` is an optional semantic check on the parsed JSON: return
|
||
None when acceptable, or a one-line Chinese explanation when not
|
||
(e.g. per-paragraph arrays must match the input paragraph count).
|
||
A failing check is treated exactly like a parse failure — logged,
|
||
retried once, only surfaced after both attempts fail."""
|
||
last_error: LLMError | None = None
|
||
for attempt in range(2):
|
||
try:
|
||
content = self._complete(
|
||
system,
|
||
user,
|
||
json_reminder=(attempt == 1),
|
||
disable_reasoning=(attempt == 1),
|
||
)
|
||
parsed = extract_json(content)
|
||
if validate is not None:
|
||
problem = validate(parsed)
|
||
if problem:
|
||
raise LLMError(problem)
|
||
if attempt == 1:
|
||
logger.warning("llm retry succeeded after first failure: %s", last_error)
|
||
return parsed
|
||
except LLMError as exc:
|
||
last_error = exc
|
||
if attempt == 0:
|
||
logger.warning("llm call failed, retrying once: %s", exc)
|
||
assert last_error is not None
|
||
logger.error("llm call failed after 2 attempts: %s", last_error)
|
||
raise last_error
|