Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""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
|
||||
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}
|
||||
return {"max_tokens": 2048}
|
||||
|
||||
|
||||
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,
|
||||
max_tokens: int = 16000,
|
||||
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)
|
||||
try:
|
||||
resp = self._post(payload)
|
||||
except httpx.HTTPError as exc: # network / timeout
|
||||
raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc
|
||||
if resp.status_code != 200:
|
||||
raise LLMError(f"调用大模型失败(HTTP {resp.status_code}):{_readable_error(resp)}")
|
||||
return message_text(resp.json())
|
||||
|
||||
# -- 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
|
||||
Reference in New Issue
Block a user