first
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
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
|
||||
Reference in New Issue
Block a user