[LLM/TTS 与对话闭环]:完成语音对话主链路,包含上下文管理、NewAPI 兼容 LLM、分句 TTS、状态机和错误恢复测试
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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": True,
|
||||
}
|
||||
yield from self._post_stream(
|
||||
f"{self.config.llm_base_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": True,
|
||||
}
|
||||
yield from self._post_stream(
|
||||
f"{self.config.llm_base_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:
|
||||
emitted = False
|
||||
for raw_line in response:
|
||||
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
|
||||
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 _ends_sentence(text: str) -> bool:
|
||||
return text.endswith(("。", "!", "?", ".", "!", "?", "\n"))
|
||||
Reference in New Issue
Block a user