187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import socket
|
||
import urllib.error
|
||
import urllib.request
|
||
from collections.abc import Callable, Iterable, Sequence
|
||
from typing import Any
|
||
|
||
from .config import AppConfig
|
||
from .models import ErrorCode, Message, ProviderError, ReplyDelta
|
||
|
||
|
||
class MockLlmProvider:
|
||
def __init__(self, chunks: list[str] | None = None, fail: ProviderError | None = None) -> None:
|
||
self.chunks = chunks or ["好的,我听到了。"]
|
||
self.fail = fail
|
||
self.calls: list[list[Message]] = []
|
||
|
||
def stream_reply(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
|
||
self.calls.append(list(messages))
|
||
if self.fail:
|
||
raise self.fail
|
||
for chunk in self.chunks:
|
||
yield ReplyDelta(chunk, is_sentence_boundary=_ends_sentence(chunk))
|
||
yield ReplyDelta("", finish_reason="stop")
|
||
|
||
|
||
class OpenAICompatibleLlmProvider:
|
||
def __init__(
|
||
self,
|
||
config: AppConfig,
|
||
timeout_s: float = 20.0,
|
||
urlopen: Callable[..., Any] | None = None,
|
||
) -> None:
|
||
self.config = config
|
||
self.timeout_s = timeout_s
|
||
self.urlopen = urlopen or urllib.request.urlopen
|
||
|
||
def stream_reply(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
|
||
self.config.require_llm_credentials()
|
||
if self.config.llm_api_style == "responses":
|
||
yield from self._responses(messages)
|
||
else:
|
||
yield from self._chat_completions(messages)
|
||
|
||
def _chat_completions(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
|
||
payload = {
|
||
"model": self.config.llm_model,
|
||
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
||
"stream": self.config.llm_stream,
|
||
}
|
||
yield from self._post_stream(
|
||
self.config.api_url("/v1/chat/completions"),
|
||
payload,
|
||
parser=_parse_chat_completion_sse,
|
||
)
|
||
|
||
def _responses(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
|
||
payload = {
|
||
"model": self.config.llm_model,
|
||
"input": [{"role": m.role, "content": m.content} for m in messages],
|
||
"stream": self.config.llm_stream,
|
||
}
|
||
yield from self._post_stream(
|
||
self.config.api_url("/v1/responses"),
|
||
payload,
|
||
parser=_parse_responses_sse,
|
||
)
|
||
|
||
def _post_stream(
|
||
self,
|
||
url: str,
|
||
payload: dict[str, Any],
|
||
parser: Callable[[dict[str, Any]], ReplyDelta | None],
|
||
) -> Iterable[ReplyDelta]:
|
||
body = json.dumps(payload).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
url,
|
||
data=body,
|
||
headers={
|
||
"Authorization": f"Bearer {self.config.llm_api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with self.urlopen(request, timeout=self.timeout_s) as response:
|
||
raw_body = _read_response_body(response)
|
||
emitted = False
|
||
if raw_body.lstrip().startswith(b"data:"):
|
||
for raw_line in raw_body.splitlines():
|
||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||
if not line or not line.startswith("data:"):
|
||
continue
|
||
data = line.removeprefix("data:").strip()
|
||
if data == "[DONE]":
|
||
yield ReplyDelta("", finish_reason="stop")
|
||
return
|
||
event = json.loads(data)
|
||
delta = parser(event)
|
||
if delta is not None:
|
||
emitted = True
|
||
yield delta
|
||
else:
|
||
for delta in _parse_non_stream_json(json.loads(raw_body.decode("utf-8"))):
|
||
emitted = True
|
||
yield delta
|
||
if not emitted:
|
||
raise ProviderError(
|
||
ErrorCode.LLM_EMPTY_REPLY,
|
||
"LLM stream ended without text",
|
||
True,
|
||
"openai-compatible",
|
||
"llm",
|
||
)
|
||
except ProviderError:
|
||
raise
|
||
except urllib.error.HTTPError as exc:
|
||
code = ErrorCode.LLM_RATE_LIMITED if exc.code == 429 else ErrorCode.LLM_NETWORK_ERROR
|
||
raise ProviderError(code, f"LLM HTTP error {exc.code}", exc.code >= 500, "openai-compatible", "llm") from exc
|
||
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
||
raise ProviderError(
|
||
ErrorCode.LLM_REQUEST_TIMEOUT,
|
||
f"LLM request failed or timed out: {exc}",
|
||
True,
|
||
"openai-compatible",
|
||
"llm",
|
||
) from exc
|
||
|
||
|
||
def _parse_chat_completion_sse(event: dict[str, Any]) -> ReplyDelta | None:
|
||
choices = event.get("choices") or []
|
||
if not choices:
|
||
return None
|
||
choice = choices[0]
|
||
text = str((choice.get("delta") or {}).get("content") or "")
|
||
finish = choice.get("finish_reason")
|
||
if not text and not finish:
|
||
return None
|
||
return ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason=finish)
|
||
|
||
|
||
def _parse_responses_sse(event: dict[str, Any]) -> ReplyDelta | None:
|
||
event_type = event.get("type")
|
||
if event_type == "response.output_text.delta":
|
||
text = str(event.get("delta") or "")
|
||
return ReplyDelta(text, is_sentence_boundary=_ends_sentence(text))
|
||
if event_type in {"response.completed", "response.output_text.done"}:
|
||
return ReplyDelta("", finish_reason="stop")
|
||
return None
|
||
|
||
|
||
def _parse_non_stream_json(event: dict[str, Any]) -> Iterable[ReplyDelta]:
|
||
if "choices" in event:
|
||
choices = event.get("choices") or []
|
||
if choices:
|
||
message = choices[0].get("message") or {}
|
||
text = str(message.get("content") or "")
|
||
if text:
|
||
yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop")
|
||
return
|
||
if "output_text" in event:
|
||
text = str(event.get("output_text") or "")
|
||
if text:
|
||
yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop")
|
||
return
|
||
output = event.get("output") or []
|
||
parts: list[str] = []
|
||
for item in output:
|
||
for content in item.get("content", []):
|
||
if content.get("type") in {"output_text", "text"}:
|
||
parts.append(str(content.get("text") or ""))
|
||
text = "".join(parts)
|
||
if text:
|
||
yield ReplyDelta(text, is_sentence_boundary=_ends_sentence(text), finish_reason="stop")
|
||
|
||
|
||
def _read_response_body(response: Any) -> bytes:
|
||
if hasattr(response, "read"):
|
||
return response.read()
|
||
return b"".join(response)
|
||
|
||
|
||
def _ends_sentence(text: str) -> bool:
|
||
return text.endswith(("。", "!", "?", ".", "!", "?", "\n"))
|