from __future__ import annotations import logging from abc import ABC, abstractmethod from collections.abc import Iterator from typing import Any, Optional import httpx from nltk import sent_tokenize from openai import OpenAI from openai.types.realtime.conversation_item import ( RealtimeConversationItemAssistantMessage, RealtimeConversationItemFunctionCall, ) from openai.types.realtime.realtime_conversation_item_assistant_message import ( Content as AssistantContent, ) from openai.types.responses import ResponseFunctionToolCall from pydantic import BaseModel, ConfigDict, Field from speech_to_speech.baseHandler import BaseHandler from speech_to_speech.LLM.chat import ( Chat, ChatItemError, SupportedItem, build_active_chat, make_system_message, make_user_message, ) from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn, build_compactor from speech_to_speech.LLM.text_prompt import build_text_system_prompt from speech_to_speech.LLM.utils import remove_unspeechable, resolve_auto_language from speech_to_speech.LLM.voice_prompt import build_voice_system_prompt from speech_to_speech.pipeline.cancel_scope import CancelScope from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut from speech_to_speech.pipeline.messages import ( EndOfResponse, LLMResponseChunk, TokenUsage, ) from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker from speech_to_speech.utils.utils import is_out_of_band, response_wants_audio logger = logging.getLogger(__name__) # About 18–24 seconds of default SDK backoff before warmup fails. WARMUP_MAX_RETRIES = 6 # ── Normalised provider events ──────────────────────────────────────────────── # Each backend's stream/response is mapped to this small vocabulary so the shared # speech-pipeline logic (sentence batching, cancellation, history, token usage) # lives in one place. Subclasses differ only in how they produce these events. class TextDelta(BaseModel): """Incremental assistant text. Always RAW (unfiltered); the base applies ``remove_unspeechable`` for the audio path.""" text: str class AssistantMessage(BaseModel): """A complete assistant turn to write back to history.""" content: list[AssistantContent] class ToolCall(BaseModel): """A complete function tool call (``call_id`` / ``id`` already regenerated).""" item: ResponseFunctionToolCall class Usage(BaseModel): """Token accounting for the turn.""" input_tokens: int output_tokens: int ProviderEvent = TextDelta | AssistantMessage | ToolCall | Usage class _Turn(BaseModel): """Per-request context threaded through generation (immutable for the turn).""" model_config = ConfigDict(arbitrary_types_allowed=True) language_code: Optional[str] gen: int | None runtime_config: Any response: Any turn_id: str | None turn_revision: int | None speech_stopped_at_s: float | None wants_audio: bool class _GenState(BaseModel): """Mutable accumulators collected while consuming a turn's events.""" model_config = ConfigDict(arbitrary_types_allowed=True) tools: list[ResponseFunctionToolCall] = Field(default_factory=list) pending: list[SupportedItem] = Field(default_factory=list) clean_text: str = "" # filtered text, kept only for the debug log input_tokens: int = 0 output_tokens: int = 0 class BaseOpenAICompatibleHandler(BaseHandler[LLMIn, LLMOut], ABC): """Shared lifecycle for OpenAI-compatible LLM backends (Responses & Chat Completions). Subclasses implement four hooks — :meth:`warmup`, :meth:`_build_compaction_generate_fn`, :meth:`_serialize`, :meth:`_request`, :meth:`_iter_events` and :meth:`_build_optional_kwargs` — and inherit the request/response orchestration: speculative-turn gating, cancellation, sentence batching, text-only vs audio handling, history write-back, token usage, out-of-band handling and error termination. """ # ── setup ───────────────────────────────────────────────────────────────── def setup( self, model_name: str = "gpt-5.4-mini", device: str = "cuda", gen_kwargs: dict[str, Any] = {}, base_url: Optional[str] = None, api_key: Optional[str] = None, stream: bool = True, user_role: str = "user", cancel_scope: CancelScope | None = None, speculative_turns: SpeculativeTurnTracker | None = None, disable_thinking: bool = True, reasoning_effort: Optional[str] = None, request_timeout_s: float = 20.0, stream_batch_sentences: int = 3, enable_lang_prompt: bool = False, compact_history: bool = False, **_kwargs: Any, ) -> None: self.cancel_scope = cancel_scope self.speculative_turns = speculative_turns self.model_name = model_name self.stream = stream self.stream_batch_sentences = max(1, stream_batch_sentences) self.enable_lang_prompt = enable_lang_prompt self.gen_kwargs = dict(gen_kwargs) self.request_timeout_s = float(request_timeout_s) self.request_timeout = httpx.Timeout( self.request_timeout_s, connect=min(10.0, self.request_timeout_s), ) self.user_role = user_role self.client = OpenAI(api_key=api_key, base_url=base_url) self._extra_body = self._build_extra_body(base_url, disable_thinking, reasoning_effort) self.compactor = build_compactor(self._build_compaction_generate_fn()) if compact_history else None self.warmup() @staticmethod def _is_official_openai(base_url: Optional[str]) -> bool: """Whether ``base_url`` points at the official OpenAI server. Normalises a trailing slash so ``https://api.openai.com/v1/`` is also recognised; the official server rejects the provider-specific extra_body keys we send to vLLM / the HF router. """ if base_url is None: return False return base_url.rstrip("/") == "https://api.openai.com/v1" @classmethod def _build_extra_body( cls, base_url: Optional[str], disable_thinking: bool, reasoning_effort: Optional[str], ) -> Optional[dict[str, Any]]: """Build the provider-specific ``extra_body`` used to disable reasoning. Providers differ in how reasoning is turned off: vLLM/Qwen honour ``chat_template_kwargs.enable_thinking=false``, while others (e.g. GLM via the HF router) ignore that and require ``reasoning_effort='none'``. A non-empty ``reasoning_effort`` therefore takes precedence; otherwise we fall back to the chat-template flag. None of this applies to the official OpenAI server, which rejects unknown extra_body keys. """ if base_url is None or cls._is_official_openai(base_url): return None if reasoning_effort: return {"reasoning_effort": reasoning_effort} if disable_thinking: return {"chat_template_kwargs": {"enable_thinking": False}} return None # ── subclass hooks ──────────────────────────────────────────────────────-- @abstractmethod def warmup(self) -> None: """Issue a cheap request so the model/connection is ready before serving.""" ... @abstractmethod def _build_compaction_generate_fn(self) -> CompactGenerateFn: """Return a ``(system, user) -> text`` fn used to compact long histories.""" ... @abstractmethod def _serialize(self, active_chat: Chat) -> Any: """Serialise the chat to the backend's request payload (input/messages).""" ... @abstractmethod def _request(self, api_input: Any, optional_kwargs: dict[str, Any]) -> Any: """Issue the create() call and return the response or stream.""" ... @abstractmethod def _iter_stream_events(self, api_response: Any) -> Iterator[ProviderEvent]: """Map a streaming response to normalised :data:`ProviderEvent`s.""" ... @abstractmethod def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]: """Map a non-streaming response to normalised :data:`ProviderEvent`s.""" ... def _iter_events(self, api_response: Any) -> Iterator[ProviderEvent]: """Dispatch to the stream/non-stream mapper. ``self.stream`` is the single source of truth (it set the request's ``stream=`` flag), so the response type always matches it.""" if self.stream: yield from self._iter_stream_events(api_response) else: yield from self._iter_response_events(api_response) @abstractmethod def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]: """Build the per-request tools/tool_choice kwargs in the backend's shape.""" ... # ── speculative-turn / cancellation gating ───────────────────────────────── def _turn_is_latest(self, turn_id: str | None, turn_revision: int | None) -> bool: return self.speculative_turns is None or self.speculative_turns.is_latest(turn_id, turn_revision) def _generation_is_stale(self, gen: int | None) -> bool: return gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen) def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool: if self.speculative_turns is None: return True return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision) def _apply_config( self, chat: Chat, instructions: Optional[str], wants_audio: bool = True, ) -> None: if instructions: builder = build_voice_system_prompt if wants_audio else build_text_system_prompt full_instructions = builder(instructions) chat.add_item(make_system_message(full_instructions)) # ── output helpers ──────────────────────────────────────────────────────-- def _chunk( self, turn: _Turn, *, text: str = "", tools: list[ResponseFunctionToolCall] | None = None, language_code: Optional[str] = None, ) -> LLMResponseChunk: return LLMResponseChunk( text=text, language_code=language_code if language_code is not None else turn.language_code, tools=tools or [], runtime_config=turn.runtime_config, response=turn.response, turn_id=turn.turn_id, turn_revision=turn.turn_revision, speech_stopped_at_s=turn.speech_stopped_at_s, cancel_generation=turn.gen, ) def _record_tool_call(self, state: _GenState, turn: _Turn, item: ResponseFunctionToolCall) -> Iterator[LLMOut]: """Emit a tool call, persisting it (and any assistant text seen so far) to history *before* it is forwarded to the client. The function_call must already exist in the conversation by the time the client returns its ``function_call_output``; otherwise a fast client races ahead of the deferred end-of-turn write-back and the output is rejected ("No function_call with call_id ... found"), which makes the model re-issue the same tool call. The call lands in ``_pending_tool_calls`` (not serialized until its output pairs it), so eager recording is safe. Out-of-band turns never touch the default conversation, and a stale turn records nothing (it is not forwarded to the client either).""" state.tools.append(item) fc_item = RealtimeConversationItemFunctionCall( type="function_call", name=item.name, arguments=item.arguments, call_id=item.call_id, id=item.id, status=item.status, ) if self._generation_is_stale(turn.gen) or not self._turn_output_allowed(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (stale speculative turn)") return if not is_out_of_band(turn.response): # Flush assistant text accumulated before this call first (so history # order matches what the client received), then persist the call — # all before the chunk leaves for the client. chat = turn.runtime_config.chat for pending_item in state.pending: chat.add_item(pending_item) state.pending.clear() chat.add_item(fc_item) yield self._chunk(turn, tools=[item]) # ── consumption ─────────────────────────────────────────────────────────-- def _consume_streaming(self, events: Iterator[ProviderEvent], state: _GenState, turn: _Turn) -> Iterator[LLMOut]: cancelled = False printable_text = "" sentence_batch: list[str] = [] def _flush(batch: list[str]) -> Iterator[LLMOut]: if not batch: return if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (stale speculative turn)") return yield self._chunk(turn, text=" ".join(batch)) for event in events: if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (interruption)") cancelled = True break if isinstance(event, Usage): state.input_tokens = event.input_tokens state.output_tokens = event.output_tokens elif isinstance(event, AssistantMessage): state.pending.append( RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content) ) elif isinstance(event, ToolCall): # Flush any pending spoken text before emitting the tool call. if printable_text.strip(): sentence_batch.append(printable_text.strip()) printable_text = "" if sentence_batch: if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (stale speculative turn)") cancelled = True break yield from _flush(sentence_batch) sentence_batch = [] yield from self._record_tool_call(state, turn, event.item) elif isinstance(event, TextDelta): if not turn.wants_audio: # Text-only: forward verbatim. Keep every character (no # remove_unspeechable, which strips TTS-unfriendly symbols) and # don't sentence-split (sent_tokenize collapses newlines/markdown). state.clean_text += event.text if event.text: if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (stale speculative turn)") cancelled = True break yield self._chunk(turn, text=event.text) continue new_text = remove_unspeechable(event.text) state.clean_text += new_text printable_text += new_text sentences = sent_tokenize(printable_text) if len(sentences) > 1: for s in sentences[:-1]: sentence_batch.append(s) if len(sentence_batch) >= self.stream_batch_sentences: if not self._turn_output_allowed(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (stale speculative turn)") cancelled = True break yield from _flush(sentence_batch) sentence_batch = [] if cancelled: break printable_text = sentences[-1] if not cancelled: if printable_text.strip(): sentence_batch.append(printable_text.strip()) if sentence_batch: if self._generation_is_stale(turn.gen): logger.info("LLM generation cancelled (interruption)") else: logger.debug(f"Clean text: {state.clean_text}") yield from _flush(sentence_batch) logger.info(f"Tools: {state.tools}") def _consume_nonstreaming(self, events: Iterator[ProviderEvent], state: _GenState, turn: _Turn) -> Iterator[LLMOut]: if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision): logger.info("LLM generation cancelled (interruption)") return for event in events: if isinstance(event, Usage): state.input_tokens = event.input_tokens state.output_tokens = event.output_tokens elif isinstance(event, AssistantMessage): state.pending.append( RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content) ) elif isinstance(event, ToolCall): yield from self._record_tool_call(state, turn, event.item) elif isinstance(event, TextDelta): # Text-only keeps every character verbatim; audio strips # TTS-unfriendly symbols via remove_unspeechable. spoken = event.text if not turn.wants_audio else remove_unspeechable(event.text) state.clean_text += spoken out = spoken if not turn.wants_audio else spoken.strip() if ( out and not self._generation_is_stale(turn.gen) and self._turn_output_allowed(turn.turn_id, turn.turn_revision) ): yield self._chunk(turn, text=out) logger.debug(f"Clean text: {state.clean_text}") logger.info(f"Tools: {state.tools}") # ── orchestration ───────────────────────────────────────────────────────── def _generate( self, active_chat: Chat, original_chat: Chat, turn: _Turn, optional_kwargs: dict[str, Any], ) -> Iterator[LLMOut]: api_response: Any = None state = _GenState() error_message: str | None = None api_input = self._serialize(active_chat) # Images the model actually sees this turn; only these are stripped on # write-back, so an image a fast client injects mid-generation for the # next turn survives (it is not in this serialized snapshot). consumed_image_ids = active_chat.image_message_ids() if not api_input: # Nothing to send: empty `instructions` and no `input` (in the response, # the default conversation, or the out-of-band context). The provider # would reject this; fail with a clear message instead of an opaque error. error_message = "Cannot generate a response: no instructions and no input were provided." try: if error_message is None: api_response = self._request(api_input, optional_kwargs) if api_response is not None: events = self._iter_events(api_response) if self.stream: yield from self._consume_streaming(events, state, turn) else: yield from self._consume_nonstreaming(events, state, turn) except httpx.ReadTimeout: logger.warning( "OpenAI API read timed out after %.1fs; ending the current response", self.request_timeout_s, ) if not self._generation_is_stale(turn.gen) and self._turn_output_allowed(turn.turn_id, turn.turn_revision): # Canned apology carries no language_code (mirrors the prior handlers). yield LLMResponseChunk( text="Wow I'm a bit slow today, could you repeat that?", runtime_config=turn.runtime_config, response=turn.response, turn_id=turn.turn_id, turn_revision=turn.turn_revision, speech_stopped_at_s=turn.speech_stopped_at_s, cancel_generation=turn.gen, ) except Exception as exc: # Any other generation failure must still terminate the response: record # the error and fall through to the EndOfResponse below. Without this the # exception would escape process() and no EndOfResponse would be emitted, # leaving st.in_response stuck and locking every subsequent response. logger.exception("LLM generation failed; ending the current response") if error_message is None: error_message = f"Language model generation failed: {exc}" finally: if api_response is not None and hasattr(api_response, "close"): try: api_response.close() except Exception: pass if ( error_message is None and not self._generation_is_stale(turn.gen) and self._turn_output_allowed(turn.turn_id, turn.turn_revision) ): # Out-of-band responses emit output and usage but never write back to the # default conversation (their context was a throwaway chat). if not is_out_of_band(turn.response): # Tool calls (and any assistant text preceding them) were already # written eagerly in _record_tool_call; only trailing items remain. for item in state.pending: original_chat.add_item(item) original_chat.strip_images(consumed_image_ids) original_chat.trim_if_needed(self.compactor) if state.input_tokens or state.output_tokens: yield TokenUsage( input_tokens=state.input_tokens, output_tokens=state.output_tokens, turn_id=turn.turn_id, turn_revision=turn.turn_revision, ) yield EndOfResponse( turn_id=turn.turn_id, turn_revision=turn.turn_revision, cancel_generation=turn.gen, error=error_message ) def process(self, request: LLMIn) -> Iterator[LLMOut]: """Process a language model request and yield LLMResponseChunks.""" runtime_config = request.runtime_config response = request.response turn_id = request.turn_id turn_revision = request.turn_revision speech_stopped_at_s = request.speech_stopped_at_s if not self._turn_is_latest(turn_id, turn_revision): logger.info("Skipping stale LLM request for turn=%s rev=%s", turn_id, turn_revision) yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision) return original_chat = runtime_config.chat if is_out_of_band(response): try: active_chat = build_active_chat(original_chat, response) except ChatItemError as exc: logger.info("Out-of-band response rejected: %s", exc) yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision, error=str(exc)) return else: active_chat = original_chat.copy() language_code = request.language_code instructions = ( response.instructions if response and response.instructions else runtime_config.session.instructions ) or "" req_tools = response.tools if response and response.tools else runtime_config.session.tools req_tool_choice = ( response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice ) wants_audio = response_wants_audio(response) self._apply_config(active_chat, instructions, wants_audio) language_code, lang_name = resolve_auto_language(language_code) if lang_name and self.enable_lang_prompt: active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}.")) optional_kwargs = self._build_optional_kwargs(req_tools, req_tool_choice) # CancelScope.is_stale(gen) is checked when the stream iterator advances; a # blocked read inside httpx cannot be aborted by cancel_scope.cancel() from # the websocket router. Mitigations: request_timeout_s / ReadTimeout. gen = self.cancel_scope.generation if self.cancel_scope else None turn = _Turn( language_code=language_code, gen=gen, runtime_config=runtime_config, response=response, turn_id=turn_id, turn_revision=turn_revision, speech_stopped_at_s=speech_stopped_at_s, wants_audio=wants_audio, ) yield from self._generate(active_chat, original_chat, turn, optional_kwargs) @property def timing_log_level(self) -> int: return logging.INFO def should_log_timing(self, output: LLMOut) -> bool: return isinstance(output, LLMResponseChunk) and self.last_time > self.min_time_to_debug