refactor: centralize llm provider transport

This commit is contained in:
leefer
2026-08-01 13:37:23 +08:00
parent 104e6aa396
commit f75d9555e0
16 changed files with 507 additions and 260 deletions
+20 -50
View File
@@ -1,11 +1,10 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any
from backend.llm import transport as llm_transport
class HeavenAgentError(RuntimeError):
pass
@@ -24,45 +23,33 @@ def interpret_heaven(
if not api_key or not model:
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
system_prompt = _system_prompt(mode)
payload = json.dumps(
messages = [
{"role": "system", "content": system_prompt},
{
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
],
"stream": False,
"role": "user",
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.7",
},
method="POST",
)
started = time.perf_counter()
]
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
answer = str(result["choices"][0]["message"]["content"]).strip()
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.7",
)
answer = str(result.content).strip()
if not answer:
raise KeyError("empty response")
except urllib.error.HTTPError as exc:
raise HeavenAgentError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
except llm_transport.OpenAIHTTPError as exc:
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
except (llm_transport.OpenAITransportError, KeyError) as exc:
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
return {
"answer": answer,
"model": model,
"latency_ms": round((time.perf_counter() - started) * 1000),
"latency_ms": result.latency_ms,
}
@@ -99,20 +86,3 @@ def _system_prompt(mode: str) -> str:
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
""".strip()
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"问天模型调用失败(HTTP {exc.code}{suffix}"
+14 -63
View File
@@ -3,14 +3,12 @@ from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from llm_stream import OpenAIStreamAccumulator
from backend.llm import transport as llm_transport
class MentorAgentError(RuntimeError):
@@ -195,50 +193,20 @@ def stream_with_mentor(
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history[-10:])
messages.append({"role": "user", "content": question})
payload = json.dumps(
{"model": model, "messages": messages, "stream": True},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.6",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
result = json.loads(line)
except json.JSONDecodeError:
continue
choices = result.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise MentorAgentError("问师模型未返回有效内容。")
except urllib.error.HTTPError as exc:
raise MentorAgentError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
yield from llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.6",
)
except llm_transport.OpenAIEmptyResponseError as exc:
raise MentorAgentError("问师模型未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
raise MentorAgentError(exc.describe("问师模型调用失败")) from exc
except llm_transport.OpenAITransportError as exc:
raise MentorAgentError(f"问师模型调用失败:{exc}") from exc
@@ -298,20 +266,3 @@ def _parse_frontmatter(content: str) -> dict[str, str]:
def _first_sentence(text: str) -> str:
compact = " ".join(line.strip() for line in text.splitlines() if line.strip())
return re.split(r"[。;]", compact, maxsplit=1)[0].strip()
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"问师模型调用失败(HTTP {exc.code}{suffix}"
+13 -43
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
from llm_stream import OpenAIStreamAccumulator
from backend.llm import transport as llm_transport
class ReviewAssistantError(RuntimeError):
@@ -27,48 +25,20 @@ def stream_review_assistant(
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
content = accumulator.feed(choice)
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
yield from llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/1.0",
)
except llm_transport.OpenAIEmptyResponseError as exc:
raise ReviewAssistantError("智能解读未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
except llm_transport.OpenAITransportError as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
+27 -73
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any
from backend.llm import transport as llm_transport
from screener import FACTOR_FIELDS, REGIMES
@@ -21,39 +19,25 @@ def test_llm_connection(
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("API Key 或模型未配置。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": "只回复 OK"}],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.5",
},
method="POST",
)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
reply = str(result["choices"][0]["message"]["content"]).strip()
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=[{"role": "user", "content": "只回复 OK"}],
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.5",
)
reply = str(result.content).strip()
except llm_transport.OpenAIHTTPError as exc:
raise LLMCompilerError(exc.describe("模型连接测试失败")) from exc
except llm_transport.OpenAITransportError as exc:
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
return {
"ok": True,
"model": model,
"reply": reply[:100],
"latency_ms": round((time.perf_counter() - started) * 1000),
"latency_ms": result.latency_ms,
}
@@ -67,7 +51,6 @@ def compile_strategy_with_llm(
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
schema = {
"name": "策略名称",
"description": "策略说明",
@@ -90,57 +73,28 @@ def compile_strategy_with_llm(
"退潮和冰点策略必须提高门槛并允许结果为空。"
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
)
payload = json.dumps(
{
"model": model,
"messages": [
try:
result = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt[:3000]},
],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.4",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"].strip()
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.4",
)
content = result.content.strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:].strip()
compiled = json.loads(content)
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
except llm_transport.OpenAIHTTPError as exc:
raise LLMCompilerError(exc.describe("LLM 策略编译失败")) from exc
except (llm_transport.OpenAITransportError, json.JSONDecodeError) as exc:
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
compiled["compiler"] = "llm"
compiled["model"] = model
return compiled
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"模型连接测试失败(HTTP {exc.code}{suffix}"
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from .stream import OpenAIStreamAccumulator
class OpenAITransportError(RuntimeError):
pass
class OpenAIHTTPError(OpenAITransportError):
def __init__(self, code: int, detail: str = "") -> None:
super().__init__(f"HTTP {code}")
self.code = code
self.detail = detail
def describe(self, label: str) -> str:
suffix = f"{self.detail[:300]}" if self.detail else ""
return f"{label}HTTP {self.code}{suffix}"
class OpenAIEmptyResponseError(OpenAITransportError):
pass
@dataclass(frozen=True)
class OpenAIChatCompletion:
content: Any
latency_ms: int
def chat_completion(
*,
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
timeout: int,
user_agent: str,
) -> OpenAIChatCompletion:
request = _request(api_key, base_url, model, messages, user_agent, stream=False)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"]
except urllib.error.HTTPError as exc:
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
except (
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
KeyError,
IndexError,
) as exc:
raise OpenAITransportError(str(exc)) from exc
return OpenAIChatCompletion(
content=content,
latency_ms=round((time.perf_counter() - started) * 1000),
)
def stream_chat_completion(
*,
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
timeout: int,
user_agent: str,
) -> Iterator[str]:
request = _request(api_key, base_url, model, messages, user_agent, stream=True)
yielded = False
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
accumulator = OpenAIStreamAccumulator()
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
result = json.loads(line)
except json.JSONDecodeError:
continue
choices = result.get("choices") or []
if not choices:
continue
content = accumulator.feed(choices[0] or {})
if content:
yielded = True
yield str(content)
except urllib.error.HTTPError as exc:
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise OpenAITransportError(str(exc)) from exc
if not yielded:
raise OpenAIEmptyResponseError("empty response")
def _request(
api_key: str,
base_url: str,
model: str,
messages: list[dict[str, Any]],
user_agent: str,
*,
stream: bool,
) -> urllib.request.Request:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": user_agent,
}
if stream:
headers["Accept"] = "text/event-stream"
return urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": stream},
ensure_ascii=False,
).encode("utf-8"),
headers=headers,
method="POST",
)
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or error.get("code") or "")
if error:
return str(error)
return str(payload.get("message") or "")
except (json.JSONDecodeError, OSError):
return ""