first
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# LLM Summary
|
||||
|
||||
## Available LLM backends (`--llm_backend`)
|
||||
|
||||
Runtime-supported values in `s2s_pipeline.py`:
|
||||
|
||||
- `transformers` → `language_model.py` (Transformers backend)
|
||||
- `mlx-lm` → `language_model.py` (MLX backend)
|
||||
- `responses-api` → `responses_api_language_model.py`
|
||||
|
||||
## Usage
|
||||
|
||||
### 1) Transformers (`--llm_backend transformers`)
|
||||
|
||||
- Handler: `LanguageModelHandler`
|
||||
- Typical use: local GPU/CPU inference using Hugging Face Transformers
|
||||
- Backend-specific args prefix: `--llm_*`
|
||||
- Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt`
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--llm_backend transformers \
|
||||
--model_name Qwen/Qwen3-4B-Instruct-2507 \
|
||||
--llm_device cuda \
|
||||
--llm_torch_dtype float16 \
|
||||
--llm_gen_max_new_tokens 128
|
||||
```
|
||||
|
||||
Common options:
|
||||
- `--llm_gen_min_new_tokens`
|
||||
- `--llm_gen_temperature`
|
||||
- `--llm_gen_do_sample`
|
||||
- `--chat_size`
|
||||
- `--init_chat_prompt`
|
||||
|
||||
### 2) MLX-LM (`--llm_backend mlx-lm`)
|
||||
|
||||
- Handler: `LanguageModelHandler`
|
||||
- Typical use: Apple Silicon local inference
|
||||
- Backend-specific args prefix: same as Transformers (`--llm_*`)
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--llm_backend mlx-lm \
|
||||
--model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \
|
||||
--llm_device mps \
|
||||
--llm_gen_max_new_tokens 128
|
||||
```
|
||||
|
||||
Common options:
|
||||
- `--llm_gen_temperature`
|
||||
- `--llm_gen_do_sample`
|
||||
- `--chat_size`
|
||||
- `--init_chat_prompt`
|
||||
|
||||
### 3) OpenAI-compatible API (`--llm_backend responses-api`)
|
||||
|
||||
- Handler: `ResponsesApiModelHandler`
|
||||
- Typical use: remote model serving via OpenAI-compatible endpoints
|
||||
- Backend-specific args prefix: `--responses_api_*`
|
||||
- Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt`
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--llm_backend responses-api \
|
||||
--model_name gpt-5.4-mini \
|
||||
--responses_api_api_key YOUR_API_KEY \
|
||||
--responses_api_base_url https://api.example.com/v1 \
|
||||
--responses_api_stream true
|
||||
```
|
||||
|
||||
Common options:
|
||||
- `--chat_size`
|
||||
- `--init_chat_prompt`
|
||||
- `--user_role`
|
||||
|
||||
## LLM Behavior
|
||||
|
||||
When STT is set to language auto-detection (`--language auto`), LLM handlers can receive `(text, language_code)` and prepend a language control instruction like:
|
||||
|
||||
- `Please reply to my message in <language>.`
|
||||
|
||||
This helps the assistant respond in the detected language. The behavior is opt-in via `--enable_lang_prompt` (shared across all backends); it defaults to `False`.
|
||||
|
||||
## Setup
|
||||
|
||||
### CUDA setup
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--llm_backend transformers \
|
||||
--model_name microsoft/Phi-3-mini-4k-instruct
|
||||
```
|
||||
|
||||
### Local Mac setup
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--local_mac_optimal_settings \
|
||||
--model_name mlx-community/Qwen3-4B-Instruct-2507-bf16
|
||||
```
|
||||
|
||||
`--local_mac_optimal_settings` already sets `--llm_backend mlx-lm` and will default the model to `mlx-community/Qwen3-4B-Instruct-2507-bf16` if not overridden.
|
||||
|
||||
### Realtime (OpenAI-compatible) setup
|
||||
|
||||
Run the server in realtime mode, then connect with the realtime client:
|
||||
|
||||
```bash
|
||||
# 1. Start the pipeline in realtime mode
|
||||
python s2s_pipeline.py \
|
||||
--mode realtime \
|
||||
--llm_backend mlx-lm \
|
||||
--model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \
|
||||
--ws_host 0.0.0.0 \
|
||||
--ws_port 8765
|
||||
|
||||
# 2. Connect with the realtime client
|
||||
python listen_and_play_realtime.py --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Or with `--local_mac_optimal_settings` on Apple Silicon:
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--local_mac_optimal_settings \
|
||||
--mode realtime \
|
||||
--ws_host 0.0.0.0 \
|
||||
--ws_port 8765
|
||||
```
|
||||
|
||||
### Remote API setup
|
||||
|
||||
```bash
|
||||
python s2s_pipeline.py \
|
||||
--llm_backend responses-api \
|
||||
--model_name gpt-5.4-mini \
|
||||
--responses_api_api_key YOUR_API_KEY
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,760 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from openai.types.realtime import ConversationItem
|
||||
from openai.types.realtime.conversation_item import (
|
||||
RealtimeConversationItemAssistantMessage,
|
||||
RealtimeConversationItemFunctionCall,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
RealtimeConversationItemSystemMessage,
|
||||
RealtimeConversationItemUserMessage,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_assistant_message import (
|
||||
Content as AssistantContent,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_system_message import Content as SystemContent
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import Content as UserContent
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
from openai.types.responses.response_input_image_param import ResponseInputImageParam
|
||||
from openai.types.responses.response_input_message_content_list_param import (
|
||||
ResponseInputMessageContentListParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import (
|
||||
FunctionCallOutput,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseInputItemParam,
|
||||
ResponseInputParam,
|
||||
ResponseOutputMessageParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import (
|
||||
Message as ResponseMessage,
|
||||
)
|
||||
from openai.types.responses.response_input_text_param import ResponseInputTextParam
|
||||
from openai.types.responses.response_output_text_param import ResponseOutputTextParam
|
||||
from pydantic import BaseModel
|
||||
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatItemError(Exception):
|
||||
"""Raised when a conversation item fails validation in :meth:`Chat.add_item`."""
|
||||
|
||||
|
||||
class CompactionResult(BaseModel):
|
||||
"""Output of a :data:`CompactFn` summarization run."""
|
||||
|
||||
user_summary: str
|
||||
assistant_summary: str
|
||||
|
||||
|
||||
def _ensure_id(value: str | None, prefix: str) -> str:
|
||||
if value is None:
|
||||
return _generate_id(prefix)
|
||||
if not value.startswith(f"{prefix}_"):
|
||||
raise ChatItemError(f"ID must start with '{prefix}_', got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SupportedItem = Union[
|
||||
RealtimeConversationItemSystemMessage,
|
||||
RealtimeConversationItemUserMessage,
|
||||
RealtimeConversationItemAssistantMessage,
|
||||
RealtimeConversationItemFunctionCall,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
]
|
||||
|
||||
|
||||
CompactFn = Callable[[ResponseInputParam], CompactionResult]
|
||||
|
||||
|
||||
class Chat:
|
||||
"""Manages conversation history with bounded size to avoid OOM issues.
|
||||
|
||||
The buffer stores ``ConversationItem`` objects (user messages, assistant
|
||||
messages, function calls, function call outputs). System messages are
|
||||
stored separately in ``init_chat_message`` and never placed in the buffer.
|
||||
|
||||
History bounding is decided per ``add_item`` call via the ``compactor``
|
||||
argument:
|
||||
|
||||
- ``compactor=None``: when the user-turn count exceeds ``size`` the oldest
|
||||
complete turn is evicted in place. Synchronous, lossy, no LLM involvement.
|
||||
- ``compactor=<fn>``: when ``size`` is exceeded, ``fn`` is invoked in a
|
||||
background thread to summarize older turns into a single user/assistant
|
||||
pair (with pending function calls preserved). Single-flight: while a
|
||||
compaction is running, additional triggers are silently bypassed.
|
||||
"""
|
||||
|
||||
def __init__(self, size: int) -> None:
|
||||
self.size = size
|
||||
self.init_chat_message: RealtimeConversationItemSystemMessage | None = None
|
||||
# ``size`` is the number of user turns to keep. When exceeded the
|
||||
# oldest complete turn (everything up to the next user message)
|
||||
# is evicted -- or, with a compactor, summarized in the background.
|
||||
self.buffer: list[SupportedItem] = []
|
||||
self._pending_tool_calls: dict[str, RealtimeConversationItemFunctionCall] = {}
|
||||
self._user_turn_count: int = 0
|
||||
|
||||
# All state mutations and serializations go through _lock. Public methods
|
||||
# acquire it once; internal callers that already hold it use the
|
||||
# ``_locked`` helpers, so no reentry is needed (regular Lock is safe).
|
||||
self._lock = threading.Lock()
|
||||
self._compact_in_flight: bool = False
|
||||
self._compact_thread: threading.Thread | None = None
|
||||
self._shutdown = threading.Event()
|
||||
self._gen_counter = 0
|
||||
|
||||
# ── Internal mutators (caller holds _lock) ─────────────────
|
||||
|
||||
def _evict_oldest_turn(self) -> None:
|
||||
"""Remove items from the front until the next user message boundary."""
|
||||
if not self.buffer:
|
||||
return
|
||||
first = self.buffer.pop(0)
|
||||
if isinstance(first, RealtimeConversationItemUserMessage):
|
||||
self._user_turn_count -= 1
|
||||
while self.buffer and not isinstance(self.buffer[0], RealtimeConversationItemUserMessage):
|
||||
self.buffer.pop(0)
|
||||
|
||||
def _has_call_id_in_buffer(self, call_id: str) -> bool:
|
||||
for entry in self.buffer:
|
||||
if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _mark_call_completed(
|
||||
self, call_id: str, status: Literal["completed", "incomplete", "in_progress"] | None = None
|
||||
) -> None:
|
||||
"""Set ``status`` to ``"completed"`` on the matching function_call."""
|
||||
for entry in self.buffer:
|
||||
if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id:
|
||||
entry.status = "completed" if status is None else status
|
||||
return
|
||||
|
||||
def append_tool_output(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None:
|
||||
"""Append a ``function_call_output``, re-injecting its ``function_call`` if evicted.
|
||||
|
||||
Also marks the paired ``function_call`` as ``"completed"`` if its
|
||||
status was ``None``.
|
||||
|
||||
Raises :class:`ChatItemError` if *call_id* is unknown.
|
||||
"""
|
||||
with self._lock:
|
||||
self._append_tool_output_locked(call_id, output_item)
|
||||
|
||||
def _append_tool_output_locked(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None:
|
||||
"""Body of :meth:`append_tool_output`. Caller must hold ``_lock``."""
|
||||
if self._has_call_id_in_buffer(call_id):
|
||||
self._pending_tool_calls.pop(call_id, None)
|
||||
self._mark_call_completed(call_id, output_item.status)
|
||||
self.buffer.append(output_item)
|
||||
return
|
||||
|
||||
if call_id in self._pending_tool_calls:
|
||||
logger.info("Re-injecting evicted function_call for call_id=%s", call_id)
|
||||
fc = self._pending_tool_calls.pop(call_id)
|
||||
fc.status = "completed" if output_item.status is None else output_item.status
|
||||
self.buffer.append(fc)
|
||||
self.buffer.append(output_item)
|
||||
return
|
||||
|
||||
raise ChatItemError(f"No function_call with call_id '{call_id}' found in conversation history.")
|
||||
|
||||
def init_chat(self, message: RealtimeConversationItemSystemMessage) -> None:
|
||||
with self._lock:
|
||||
self.init_chat_message = message
|
||||
|
||||
def add_item(self, item: SupportedItem) -> SupportedItem:
|
||||
"""Validate and route a conversation item into the chat buffer.
|
||||
|
||||
Does not enforce the soft size limit — call :meth:`trim_if_needed`
|
||||
explicitly after each successful generation to evict or compact old
|
||||
turns. A hard upper bound at ``2 * size`` is enforced inline as a
|
||||
runaway-client safety net: if the user-turn count exceeds it, the
|
||||
oldest complete turn is evicted (lossy, no compaction).
|
||||
|
||||
Raises :class:`ChatItemError` if the item fails validation.
|
||||
"""
|
||||
with self._lock:
|
||||
if isinstance(item, RealtimeConversationItemSystemMessage):
|
||||
item.id = _ensure_id(item.id, "sys")
|
||||
self.init_chat_message = item
|
||||
logger.debug("Set system message via conversation item")
|
||||
|
||||
elif isinstance(item, RealtimeConversationItemUserMessage):
|
||||
item.id = _ensure_id(item.id, "msg")
|
||||
item.content = [
|
||||
p
|
||||
for p in item.content
|
||||
if (p.type == "input_text" and p.text) or (p.type == "input_image" and p.image_url)
|
||||
]
|
||||
if not item.content:
|
||||
raise ChatItemError(
|
||||
"Message has no supported content. Supported modalities: input_text, input_image."
|
||||
)
|
||||
self.buffer.append(item)
|
||||
self._user_turn_count += 1
|
||||
logger.debug("Added user message to chat (%d parts)", len(item.content))
|
||||
|
||||
elif isinstance(item, RealtimeConversationItemAssistantMessage):
|
||||
item.id = _ensure_id(item.id, "msg")
|
||||
item.content = [p for p in item.content if p.type == "output_text" and p.text]
|
||||
if not item.content:
|
||||
return item
|
||||
self.buffer.append(item)
|
||||
logger.debug("Added assistant message to chat (%d parts)", len(item.content))
|
||||
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCall):
|
||||
item.id = _ensure_id(item.id, "fc")
|
||||
item.call_id = _ensure_id(item.call_id, "call")
|
||||
self._pending_tool_calls[item.call_id] = item
|
||||
logger.debug("Added function_call to chat (call_id=%s)", item.call_id)
|
||||
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
|
||||
item.id = _ensure_id(item.id, "fco")
|
||||
self._append_tool_output_locked(item.call_id, item)
|
||||
logger.debug("Added function_call_output to chat (call_id=%s)", item.call_id)
|
||||
|
||||
else:
|
||||
raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}")
|
||||
|
||||
if self.size > 0 and self._user_turn_count > 2 * self.size:
|
||||
logger.warning(
|
||||
"Chat buffer exceeded hard cap (%d > 2 * size=%d); evicting oldest turn",
|
||||
self._user_turn_count,
|
||||
self.size,
|
||||
)
|
||||
while self._user_turn_count > 2 * self.size:
|
||||
self._evict_oldest_turn()
|
||||
|
||||
return item
|
||||
|
||||
def trim_if_needed(self, compactor: CompactFn | None = None) -> None:
|
||||
"""Enforce the size limit after a generation completes. Fires when
|
||||
``user_turn_count > size``.
|
||||
|
||||
- ``compactor=None``: synchronous eviction of the oldest complete turn.
|
||||
- ``compactor=<fn>``: launch a background compaction (single-flight).
|
||||
|
||||
Call once after each successful generation, not inside :meth:`add_item`.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._user_turn_count <= self.size:
|
||||
return
|
||||
if compactor is not None:
|
||||
self._maybe_trigger_compaction(compactor)
|
||||
else:
|
||||
while self._user_turn_count > self.size:
|
||||
self._evict_oldest_turn()
|
||||
|
||||
def replace_user_message_text(self, item_id: str, text: str) -> bool:
|
||||
"""Replace the text content of an existing user message.
|
||||
|
||||
Used by speculative turn revisions: the conversation turn remains the
|
||||
same, but the STT transcript is superseded by a transcription of a
|
||||
longer raw-audio buffer.
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
for item in self.buffer:
|
||||
if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id:
|
||||
continue
|
||||
item.content = [UserContent(type="input_text", text=text)]
|
||||
logger.debug("Replaced speculative user message %s", item_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_user_message(self, item_id: str) -> bool:
|
||||
"""Remove an existing user message from the bounded chat buffer."""
|
||||
|
||||
with self._lock:
|
||||
for index, item in enumerate(self.buffer):
|
||||
if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id:
|
||||
continue
|
||||
del self.buffer[index]
|
||||
self._user_turn_count -= 1
|
||||
logger.debug("Removed speculative user message %s", item_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_responses_api_chat(self, items: list[SupportedItem] | None = None) -> ResponseInputParam:
|
||||
"""Serialize the chat (system prompt + buffer) for the OpenAI Responses API.
|
||||
|
||||
If *items* is provided, serialize that slice instead of the live buffer
|
||||
(used by the compaction snapshot).
|
||||
"""
|
||||
with self._lock:
|
||||
return self._to_responses_api_chat_locked(items if items is not None else self.buffer)
|
||||
|
||||
def _to_responses_api_chat_locked(self, items: list[SupportedItem]) -> ResponseInputParam:
|
||||
"""Body of :meth:`to_responses_api_chat`. Caller must hold ``_lock``."""
|
||||
buffer_items = list(items)
|
||||
result: list[ResponseInputItemParam] = []
|
||||
if self.init_chat_message:
|
||||
result.append(
|
||||
ResponseMessage(
|
||||
content=[
|
||||
ResponseInputTextParam(text=p.text or "A helpful AI assistant.", type="input_text")
|
||||
for p in self.init_chat_message.content
|
||||
],
|
||||
role="system",
|
||||
type="message",
|
||||
)
|
||||
)
|
||||
for item in buffer_items:
|
||||
assert item.id is not None and item.id != "", f"item.id is {item.id}"
|
||||
if isinstance(item, RealtimeConversationItemUserMessage):
|
||||
content: ResponseInputMessageContentListParam = []
|
||||
for user_part in item.content:
|
||||
if user_part.type == "input_text" and user_part.text is not None:
|
||||
content.append(ResponseInputTextParam(text=user_part.text or "", type="input_text"))
|
||||
elif user_part.type == "input_image" and user_part.image_url is not None:
|
||||
img = ResponseInputImageParam(type="input_image", detail=user_part.detail or "auto")
|
||||
if user_part.image_url is not None:
|
||||
img["image_url"] = user_part.image_url
|
||||
content.append(img)
|
||||
if content:
|
||||
result.append(ResponseMessage(content=content, role="user", type="message"))
|
||||
elif isinstance(item, RealtimeConversationItemAssistantMessage):
|
||||
assistant_content: list[ResponseOutputTextParam] = []
|
||||
for assistant_part in item.content:
|
||||
if assistant_part.type == "output_text" and assistant_part.text is not None:
|
||||
assistant_content.append(
|
||||
ResponseOutputTextParam(text=assistant_part.text, type="output_text", annotations=[])
|
||||
)
|
||||
if assistant_content:
|
||||
result.append(
|
||||
ResponseOutputMessageParam(
|
||||
id=item.id,
|
||||
content=assistant_content,
|
||||
role="assistant",
|
||||
status=item.status or "completed",
|
||||
type="message",
|
||||
)
|
||||
)
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCall) and item.call_id is not None:
|
||||
assert item.call_id is not None and item.call_id != ""
|
||||
function_call = ResponseFunctionToolCallParam(
|
||||
arguments=item.arguments,
|
||||
call_id=item.call_id,
|
||||
name=item.name,
|
||||
type="function_call",
|
||||
id=item.id,
|
||||
)
|
||||
if item.id is not None:
|
||||
function_call["id"] = item.id
|
||||
if item.status is not None:
|
||||
function_call["status"] = item.status
|
||||
result.append(function_call)
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
|
||||
function_call_output = FunctionCallOutput(
|
||||
call_id=item.call_id,
|
||||
output=item.output,
|
||||
type="function_call_output",
|
||||
)
|
||||
if item.id is not None:
|
||||
function_call_output["id"] = item.id
|
||||
if item.status is not None:
|
||||
function_call_output["status"] = item.status
|
||||
result.append(function_call_output)
|
||||
return result
|
||||
|
||||
def to_transformers_chat(self) -> list[dict[str, Any]]:
|
||||
"""Serialize the full chat for HuggingFace transformers ``apply_chat_template``.
|
||||
|
||||
User messages with only text produce a plain string ``content`` value.
|
||||
User messages containing images keep ``content`` as a list of dicts so
|
||||
VLM pipelines can process them.
|
||||
"""
|
||||
with self._lock:
|
||||
messages: list[TransformersChatMessage] = []
|
||||
if self.init_chat_message:
|
||||
text = " ".join(p.text for p in self.init_chat_message.content if p.text)
|
||||
messages.append(TransformersSystemMessage(content=text))
|
||||
for item in self.buffer:
|
||||
if isinstance(item, RealtimeConversationItemUserMessage):
|
||||
has_images = any(p.type == "input_image" for p in item.content)
|
||||
if has_images:
|
||||
messages.append(
|
||||
TransformersUserMessage(content=[p.model_dump(exclude_none=True) for p in item.content])
|
||||
)
|
||||
else:
|
||||
text = " ".join(p.text for p in item.content if p.type == "input_text" and p.text)
|
||||
messages.append(TransformersUserMessage(content=text))
|
||||
elif isinstance(item, RealtimeConversationItemAssistantMessage):
|
||||
text = " ".join(p.text for p in item.content if p.text)
|
||||
messages.append(TransformersAssistantMessage(content=text))
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCall):
|
||||
assert item.call_id is not None and item.call_id != ""
|
||||
args: Any = item.arguments
|
||||
try:
|
||||
args = json.loads(args) if isinstance(args, str) else args
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
messages.append(
|
||||
TransformersFunctionCallMessage(
|
||||
tool_calls=[
|
||||
TransformersToolCall(
|
||||
id=item.call_id,
|
||||
function=TransformersToolCallFunction(name=item.name, arguments=args),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
|
||||
name = ""
|
||||
for prev in reversed(messages):
|
||||
if isinstance(prev, TransformersFunctionCallMessage):
|
||||
for tc in prev.tool_calls:
|
||||
if tc.id == item.call_id:
|
||||
name = tc.function.name
|
||||
break
|
||||
if name:
|
||||
break
|
||||
messages.append(
|
||||
TransformersToolMessage(
|
||||
tool_call_id=item.call_id,
|
||||
name=name,
|
||||
content=item.output,
|
||||
)
|
||||
)
|
||||
return [m.model_dump() for m in messages]
|
||||
|
||||
def copy(self) -> Chat:
|
||||
"""Return a shallow snapshot safe for concurrent read access."""
|
||||
with self._lock:
|
||||
clone = Chat(self.size)
|
||||
clone.init_chat_message = self.init_chat_message
|
||||
clone.buffer = list(self.buffer)
|
||||
clone._pending_tool_calls = dict(self._pending_tool_calls)
|
||||
clone._user_turn_count = self._user_turn_count
|
||||
return clone
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all conversation state. Cancels any in-flight compaction splice."""
|
||||
with self._lock:
|
||||
self._gen_counter += 1
|
||||
self._compact_in_flight = False
|
||||
self.buffer = []
|
||||
self.init_chat_message = None
|
||||
self._pending_tool_calls = {}
|
||||
self._user_turn_count = 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Permanently shut down the chat. In-flight compaction splice is suppressed.
|
||||
|
||||
The compaction worker (a daemon thread) is not joined: it may be blocked
|
||||
in an LLM call. Process exit reaps it.
|
||||
"""
|
||||
self._shutdown.set()
|
||||
with self._lock:
|
||||
self._gen_counter += 1
|
||||
self._compact_in_flight = False
|
||||
|
||||
def image_message_ids(self) -> set[str]:
|
||||
"""IDs of user messages currently carrying ``input_image`` content."""
|
||||
with self._lock:
|
||||
return {
|
||||
item.id
|
||||
for item in self.buffer
|
||||
if isinstance(item, RealtimeConversationItemUserMessage)
|
||||
and item.id is not None
|
||||
and any(p.type == "input_image" for p in item.content)
|
||||
}
|
||||
|
||||
def strip_images(self, only_ids: set[str] | None = None) -> None:
|
||||
"""Remove image content parts from user messages in the buffer.
|
||||
|
||||
Called after appending the assistant response so images don't persist
|
||||
across turns. With *only_ids*, strip only those message IDs — the images
|
||||
the just-completed response actually consumed (captured before the
|
||||
request was sent). This leaves intact an image a fast client injected
|
||||
mid-generation for the *next* turn, which the current response never saw.
|
||||
Without *only_ids*, every image is stripped.
|
||||
"""
|
||||
with self._lock:
|
||||
for item in self.buffer:
|
||||
if isinstance(item, RealtimeConversationItemUserMessage):
|
||||
if only_ids is not None and item.id not in only_ids:
|
||||
continue
|
||||
item.content = [p for p in item.content if p.type != "input_image"]
|
||||
|
||||
# ── Compaction internals ──────────────────────────────────
|
||||
|
||||
def _snapshot_for_compaction(
|
||||
self,
|
||||
) -> tuple[ResponseInputParam, set[str], int]:
|
||||
"""Compute the snapshot of items eligible for compaction.
|
||||
|
||||
Caller must hold ``_lock``. Returns
|
||||
``(serialized_snapshot, marker_ids, n_turns)``. ``marker_ids``
|
||||
identifies the buffer items that may be removed when the splice runs.
|
||||
Always leaves the most recent user turn untouched (it may be in-flight).
|
||||
Returns an empty result if there are fewer than 2 compactable turns.
|
||||
"""
|
||||
n_turns = max(0, self._user_turn_count - 1)
|
||||
if n_turns < 2:
|
||||
return [], set(), n_turns
|
||||
|
||||
# Slice up to (but not including) the (n_turns + 1)-th user message.
|
||||
user_seen = 0
|
||||
end_idx = len(self.buffer)
|
||||
for i, entry in enumerate(self.buffer):
|
||||
if isinstance(entry, RealtimeConversationItemUserMessage):
|
||||
user_seen += 1
|
||||
if user_seen == n_turns + 1:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
items_to_compact = self.buffer[:end_idx]
|
||||
marker_ids = {entry.id for entry in items_to_compact if entry.id is not None}
|
||||
snapshot = self._to_responses_api_chat_locked(items=items_to_compact)
|
||||
# Strip image parts so the summarizer doesn't have to handle them.
|
||||
for raw in snapshot:
|
||||
if not isinstance(raw, dict) or raw.get("role") != "user":
|
||||
continue
|
||||
msg: dict[str, Any] = raw # type: ignore[assignment]
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
msg["content"] = [c for c in content if not (isinstance(c, dict) and c.get("type") == "input_image")]
|
||||
return snapshot, marker_ids, n_turns
|
||||
|
||||
def _maybe_trigger_compaction(self, compactor: CompactFn) -> None:
|
||||
"""Start a background compaction worker. Bypass silently if one is running.
|
||||
|
||||
Caller must hold ``_lock``.
|
||||
"""
|
||||
if self._shutdown.is_set() or self._compact_in_flight:
|
||||
return
|
||||
snapshot, marker_ids, n_turns = self._snapshot_for_compaction()
|
||||
if n_turns < 2 or not marker_ids:
|
||||
return
|
||||
gen = self._gen_counter
|
||||
self._compact_in_flight = True
|
||||
thread = threading.Thread(
|
||||
target=self._compact_worker,
|
||||
args=(compactor, snapshot, marker_ids, gen),
|
||||
daemon=True,
|
||||
name="chat-compact",
|
||||
)
|
||||
self._compact_thread = thread
|
||||
logger.info(
|
||||
"Chat compaction triggered: compacting %d turn(s) (%d item(s)), buffer size=%d",
|
||||
n_turns,
|
||||
len(marker_ids),
|
||||
len(self.buffer),
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _compact_worker(
|
||||
self,
|
||||
compactor: CompactFn,
|
||||
snapshot: ResponseInputParam,
|
||||
marker_ids: set[str],
|
||||
gen: int,
|
||||
) -> None:
|
||||
"""Worker thread entry point."""
|
||||
try:
|
||||
if self._shutdown.is_set() or self._gen_counter != gen:
|
||||
return
|
||||
try:
|
||||
result = compactor(snapshot)
|
||||
except Exception:
|
||||
logger.exception("Chat compaction failed; chat unchanged")
|
||||
return
|
||||
if not isinstance(result, CompactionResult):
|
||||
logger.error("Compactor must return a CompactionResult, got %r", type(result).__name__)
|
||||
return
|
||||
if self._shutdown.is_set() or self._gen_counter != gen:
|
||||
return
|
||||
self._apply_compaction(result, marker_ids, gen)
|
||||
finally:
|
||||
# Don't clobber the flag if reset/close has advanced the gen.
|
||||
with self._lock:
|
||||
if self._gen_counter == gen:
|
||||
self._compact_in_flight = False
|
||||
|
||||
def _apply_compaction(
|
||||
self,
|
||||
result: CompactionResult,
|
||||
marker_ids: set[str],
|
||||
gen: int,
|
||||
) -> None:
|
||||
"""Splice the summary in front of items not consumed by compaction.
|
||||
|
||||
FC/FCO pairing is left entirely to :meth:`add_item` / :meth:`append_tool_output`.
|
||||
Compaction only drops items; it never inserts an FC into the buffer.
|
||||
Pending FCs (no FCO yet) stay in ``_pending_tool_calls`` and will be
|
||||
appended adjacent to their FCO when it arrives.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._shutdown.is_set() or self._gen_counter != gen:
|
||||
return
|
||||
# Keep FC if its FCO is outside the compacted range -- otherwise
|
||||
# the FCO in `remaining` would be orphaned.
|
||||
fco_call_ids_in_range = {
|
||||
x.call_id
|
||||
for x in self.buffer
|
||||
if isinstance(x, RealtimeConversationItemFunctionCallOutput) and x.id in marker_ids
|
||||
}
|
||||
fc_ids_to_keep = {
|
||||
x.id
|
||||
for x in self.buffer
|
||||
if x.id in marker_ids
|
||||
and isinstance(x, RealtimeConversationItemFunctionCall)
|
||||
and x.call_id not in fco_call_ids_in_range
|
||||
}
|
||||
drop_ids = marker_ids - fc_ids_to_keep
|
||||
remaining = [x for x in self.buffer if x.id not in drop_ids]
|
||||
|
||||
user_msg = make_user_message(result.user_summary)
|
||||
user_msg.id = _generate_id("msg")
|
||||
asst_msg = make_assistant_message(result.assistant_summary)
|
||||
asst_msg.id = _generate_id("msg")
|
||||
|
||||
self.buffer = [user_msg, asst_msg, *remaining]
|
||||
self._user_turn_count = sum(1 for x in self.buffer if isinstance(x, RealtimeConversationItemUserMessage))
|
||||
logger.info(
|
||||
"Chat compaction applied: buffer now %d item(s), %d user turn(s)",
|
||||
len(self.buffer),
|
||||
self._user_turn_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transformers chat message models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TransformersToolCallFunction(BaseModel):
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
|
||||
class TransformersToolCall(BaseModel):
|
||||
type: Literal["function"] = "function"
|
||||
id: str
|
||||
function: TransformersToolCallFunction
|
||||
|
||||
|
||||
class TransformersSystemMessage(BaseModel):
|
||||
role: Literal["system"] = "system"
|
||||
content: str
|
||||
|
||||
|
||||
class TransformersUserMessage(BaseModel):
|
||||
role: Literal["user"] = "user"
|
||||
content: str | list[dict[str, Any]]
|
||||
|
||||
|
||||
class TransformersAssistantMessage(BaseModel):
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class TransformersFunctionCallMessage(BaseModel):
|
||||
role: Literal["assistant"] = "assistant"
|
||||
tool_calls: list[TransformersToolCall]
|
||||
|
||||
|
||||
class TransformersToolMessage(BaseModel):
|
||||
role: Literal["tool"] = "tool"
|
||||
tool_call_id: str
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
TransformersChatMessage = Union[
|
||||
TransformersSystemMessage,
|
||||
TransformersUserMessage,
|
||||
TransformersAssistantMessage,
|
||||
TransformersFunctionCallMessage,
|
||||
TransformersToolMessage,
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory helpers -- hide verbose constructors behind simple calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_user_message(text: str) -> RealtimeConversationItemUserMessage:
|
||||
return RealtimeConversationItemUserMessage(
|
||||
type="message",
|
||||
role="user",
|
||||
content=[UserContent(type="input_text", text=text)],
|
||||
)
|
||||
|
||||
|
||||
def make_assistant_message(text: str) -> RealtimeConversationItemAssistantMessage:
|
||||
return RealtimeConversationItemAssistantMessage(
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[AssistantContent(type="output_text", text=text)],
|
||||
)
|
||||
|
||||
|
||||
def make_system_message(text: str) -> RealtimeConversationItemSystemMessage:
|
||||
return RealtimeConversationItemSystemMessage(
|
||||
type="message",
|
||||
role="system",
|
||||
content=[SystemContent(type="input_text", text=text)],
|
||||
)
|
||||
|
||||
|
||||
def add_supported_item(chat: Chat, item: ConversationItem) -> None:
|
||||
"""Narrow a protocol ``ConversationItem`` to a :data:`SupportedItem` and add it to *chat*.
|
||||
|
||||
Raises :class:`ChatItemError` on validation failure or unsupported type. Shared
|
||||
by the conversation handler (in-band item injection) and the language-model
|
||||
handlers (seeding an out-of-band response's throwaway chat from ``response.input``).
|
||||
"""
|
||||
# call_id on function_call items must be client-supplied: it is referenced later by
|
||||
# function_call_output items, so we cannot silently generate one here.
|
||||
if isinstance(item, RealtimeConversationItemFunctionCall) and (
|
||||
item.call_id is None or not item.call_id.startswith("call_")
|
||||
):
|
||||
raise ChatItemError("function_call item is missing a call_id. The call_id should start with 'call_'.")
|
||||
|
||||
if isinstance(
|
||||
item,
|
||||
(
|
||||
RealtimeConversationItemSystemMessage,
|
||||
RealtimeConversationItemUserMessage,
|
||||
RealtimeConversationItemAssistantMessage,
|
||||
RealtimeConversationItemFunctionCall,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
),
|
||||
):
|
||||
chat.add_item(item)
|
||||
return
|
||||
|
||||
raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}")
|
||||
|
||||
|
||||
def build_active_chat(original_chat: Chat, response: RealtimeResponseCreateParams | None) -> Chat:
|
||||
"""Build the chat an *out-of-band* response generates against (caller ensures out-of-band).
|
||||
|
||||
Mirrors the OpenAI realtime semantics for ``input``:
|
||||
|
||||
- ``input is None`` -> a read-only **copy of the default conversation** (the
|
||||
out-of-band response reads history but never commits back).
|
||||
- ``input == []`` -> a **fresh, empty chat** (context cleared; only the
|
||||
system prompt, added later by the handler, will be present).
|
||||
- ``input == [...]`` -> a **fresh chat seeded** with those items.
|
||||
|
||||
Raises :class:`ChatItemError` if an ``input`` item fails validation.
|
||||
"""
|
||||
if response is not None and response.input is not None:
|
||||
fresh = Chat(original_chat.size)
|
||||
for item in response.input:
|
||||
add_supported_item(fresh, item)
|
||||
return fresh
|
||||
return original_chat.copy()
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, cast
|
||||
|
||||
from openai import Stream
|
||||
from openai.types.chat import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionContentPartImageParam,
|
||||
ChatCompletionContentPartParam,
|
||||
ChatCompletionContentPartTextParam,
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
ChatCompletionToolChoiceOptionParam,
|
||||
ChatCompletionToolParam,
|
||||
)
|
||||
from openai.types.chat.chat_completion_content_part_image_param import ImageURL
|
||||
from openai.types.chat.chat_completion_named_tool_choice_param import Function as NamedToolChoiceFunction
|
||||
from openai.types.realtime.realtime_conversation_item_assistant_message import (
|
||||
Content as AssistantContent,
|
||||
)
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
|
||||
from speech_to_speech.LLM.base_openai_compatible_language_model import (
|
||||
WARMUP_MAX_RETRIES,
|
||||
AssistantMessage,
|
||||
BaseOpenAICompatibleHandler,
|
||||
ProviderEvent,
|
||||
TextDelta,
|
||||
ToolCall,
|
||||
Usage,
|
||||
)
|
||||
from speech_to_speech.LLM.chat import Chat
|
||||
from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_chat_tools(req_tools: Any) -> list[ChatCompletionToolParam] | None:
|
||||
"""Convert Responses-API function tools to Chat-Completions tool format.
|
||||
|
||||
Responses tools are flat ``{type:"function", name, description, parameters}``;
|
||||
Chat Completions nests them under a ``function`` key. Items already in the
|
||||
nested form (or non-function tools) are passed through untouched.
|
||||
"""
|
||||
if not req_tools:
|
||||
return None
|
||||
chat_tools: list[ChatCompletionToolParam] = []
|
||||
for t in req_tools:
|
||||
d = t if isinstance(t, dict) else t.model_dump(exclude_none=True)
|
||||
if d.get("type") == "function" and "function" not in d:
|
||||
fn = FunctionDefinition(name=d["name"])
|
||||
if d.get("description") is not None:
|
||||
fn["description"] = d["description"]
|
||||
if d.get("parameters") is not None:
|
||||
fn["parameters"] = d["parameters"]
|
||||
chat_tools.append(ChatCompletionToolParam(type="function", function=fn))
|
||||
else:
|
||||
chat_tools.append(cast("ChatCompletionToolParam", d))
|
||||
return chat_tools
|
||||
|
||||
|
||||
def _to_chat_tool_choice(tool_choice: Any) -> ChatCompletionToolChoiceOptionParam:
|
||||
"""Convert a Responses-API tool_choice to Chat-Completions form.
|
||||
|
||||
The string forms ("auto"/"required"/"none") are identical across both APIs;
|
||||
only the forced-function object differs (flat ``name`` vs nested ``function``).
|
||||
"""
|
||||
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function" and "name" in tool_choice:
|
||||
return ChatCompletionNamedToolChoiceParam(
|
||||
type="function", function=NamedToolChoiceFunction(name=tool_choice["name"])
|
||||
)
|
||||
if tool_choice is not None and not isinstance(tool_choice, (str, dict)):
|
||||
d = tool_choice.model_dump(exclude_none=True)
|
||||
if d.get("type") == "function" and "name" in d:
|
||||
return ChatCompletionNamedToolChoiceParam(type="function", function=NamedToolChoiceFunction(name=d["name"]))
|
||||
return cast("ChatCompletionToolChoiceOptionParam", d)
|
||||
return cast("ChatCompletionToolChoiceOptionParam", tool_choice)
|
||||
|
||||
|
||||
class ChatCompletionsApiModelHandler(BaseOpenAICompatibleHandler):
|
||||
"""LLM handler that talks to an OpenAI-compatible ``/v1/chat/completions`` server.
|
||||
|
||||
Functionally mirrors :class:`ResponsesApiModelHandler` but uses the mature
|
||||
Chat Completions streaming tool-call protocol (``choices[].delta.tool_calls``)
|
||||
instead of ``/v1/responses``. This is the robust path for vLLM + Qwen tool
|
||||
calling. The conversation is serialised with :meth:`Chat.to_transformers_chat`,
|
||||
which already emits OpenAI chat messages including ``tool_calls``/``tool`` roles.
|
||||
"""
|
||||
|
||||
def warmup(self) -> None:
|
||||
logger.info(f"Warming up {self.__class__.__name__}")
|
||||
start = time.time()
|
||||
self.client.with_options(max_retries=WARMUP_MAX_RETRIES).chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
extra_body=self._extra_body,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
end = time.time()
|
||||
logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s")
|
||||
|
||||
def _build_compaction_generate_fn(self) -> CompactGenerateFn:
|
||||
"""Return a generate fn that calls Chat Completions for compaction."""
|
||||
client = self.client
|
||||
model_name = self.model_name
|
||||
timeout = self.request_timeout
|
||||
extra_body = self._extra_body
|
||||
|
||||
def generate(system: str, user: str) -> str:
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
return generate
|
||||
|
||||
@staticmethod
|
||||
def _to_chat_content_part(part: dict[str, Any]) -> ChatCompletionContentPartParam:
|
||||
"""Convert one transformers content part to Chat-Completions shape.
|
||||
|
||||
``to_transformers_chat`` keeps Realtime-style parts (``input_text`` /
|
||||
``input_image`` with a bare-string ``image_url``). The Chat Completions
|
||||
HTTP API instead wants ``{type:"text", text}`` and
|
||||
``{type:"image_url", image_url:{url, detail}}``. Unknown parts pass through.
|
||||
"""
|
||||
ptype = part.get("type")
|
||||
if ptype == "input_text":
|
||||
return ChatCompletionContentPartTextParam(type="text", text=part.get("text") or "")
|
||||
if ptype == "input_image":
|
||||
raw_url: Any = part.get("image_url")
|
||||
if isinstance(raw_url, dict):
|
||||
image_url = cast("ImageURL", raw_url)
|
||||
else:
|
||||
image_url = ImageURL(url=raw_url)
|
||||
detail = part.get("detail")
|
||||
if detail is not None:
|
||||
image_url["detail"] = detail
|
||||
return ChatCompletionContentPartImageParam(type="image_url", image_url=image_url)
|
||||
return cast("ChatCompletionContentPartParam", part)
|
||||
|
||||
@classmethod
|
||||
def _chat_messages(cls, chat: Chat) -> list[dict[str, Any]]:
|
||||
"""Serialise the chat for the Chat Completions API.
|
||||
|
||||
``Chat.to_transformers_chat`` targets HuggingFace ``apply_chat_template``,
|
||||
so two shapes need fixing up for the OpenAI Chat Completions HTTP API:
|
||||
tool-call ``arguments`` must be a JSON *string* (not a parsed object), and
|
||||
multimodal ``content`` parts must use the Chat Completions ``text`` /
|
||||
``image_url`` shape rather than the Realtime ``input_text`` /
|
||||
``input_image`` shape.
|
||||
"""
|
||||
messages = chat.to_transformers_chat()
|
||||
for message in messages:
|
||||
for tool_call in message.get("tool_calls") or []:
|
||||
fn = tool_call.get("function")
|
||||
if fn is not None and not isinstance(fn.get("arguments"), str):
|
||||
fn["arguments"] = json.dumps(fn.get("arguments") or {}, ensure_ascii=False)
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
message["content"] = [cls._to_chat_content_part(p) for p in content]
|
||||
if message.get("role") == "tool":
|
||||
message.pop("name", None)
|
||||
return messages
|
||||
|
||||
# ── base hooks ──────────────────────────────────────────────────────────--
|
||||
|
||||
def _serialize(self, active_chat: Chat) -> list[dict[str, Any]]:
|
||||
return self._chat_messages(active_chat)
|
||||
|
||||
def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
|
||||
optional_kwargs: dict[str, Any] = {}
|
||||
chat_tools = _to_chat_tools(req_tools)
|
||||
if chat_tools is not None:
|
||||
optional_kwargs["tools"] = chat_tools
|
||||
if req_tool_choice is not None:
|
||||
optional_kwargs["tool_choice"] = _to_chat_tool_choice(req_tool_choice)
|
||||
return optional_kwargs
|
||||
|
||||
|
||||
def _request(self, api_input: list[dict[str, Any]], optional_kwargs: dict[str, Any]) -> Any:
|
||||
create_kwargs: dict[str, Any] = dict(optional_kwargs)
|
||||
if self.stream:
|
||||
create_kwargs["stream_options"] = {"include_usage": True}
|
||||
|
||||
# Svuota i parametri dei tool per evitare il blocco 400 di NVIDIA NIM
|
||||
create_kwargs.pop("tools", None)
|
||||
create_kwargs.pop("tool_choice", None)
|
||||
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=api_input, # type: ignore[arg-type] # runtime dicts match the Chat Completions message shape
|
||||
stream=self.stream,
|
||||
extra_body=self._extra_body,
|
||||
timeout=self.request_timeout,
|
||||
**create_kwargs,
|
||||
)
|
||||
|
||||
# def _request(self, api_input: list[dict[str, Any]], optional_kwargs: dict[str, Any]) -> Any:
|
||||
# create_kwargs: dict[str, Any] = dict(optional_kwargs)
|
||||
# if self.stream:
|
||||
# create_kwargs["stream_options"] = {"include_usage": True}
|
||||
# return self.client.chat.completions.create(
|
||||
# model=self.model_name,
|
||||
# messages=api_input, # type: ignore[arg-type] # runtime dicts match the Chat Completions message shape
|
||||
# stream=self.stream,
|
||||
# extra_body=self._extra_body,
|
||||
# timeout=self.request_timeout,
|
||||
# **create_kwargs,
|
||||
# )
|
||||
|
||||
def _iter_stream_events(self, api_response: Stream[ChatCompletionChunk]) -> Iterator[ProviderEvent]:
|
||||
# Accumulate streamed tool-call deltas, keyed by their stream index, and the
|
||||
# raw assistant text, then emit assistant message + tool calls + usage once
|
||||
# the stream is exhausted.
|
||||
tool_accum: dict[int, dict[str, str]] = {}
|
||||
usage: Usage | None = None
|
||||
raw_text = ""
|
||||
for chunk in api_response:
|
||||
# Usage-only trailing chunk (choices == []) when include_usage is set.
|
||||
if chunk.usage is not None:
|
||||
usage = Usage(
|
||||
input_tokens=chunk.usage.prompt_tokens or 0, output_tokens=chunk.usage.completion_tokens or 0
|
||||
)
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
entry = tool_accum.setdefault(tc.index, {"name": "", "args": "", "id": ""})
|
||||
if tc.id:
|
||||
entry["id"] = tc.id
|
||||
if tc.function is not None:
|
||||
if tc.function.name:
|
||||
entry["name"] = tc.function.name
|
||||
if tc.function.arguments:
|
||||
entry["args"] += tc.function.arguments
|
||||
# A refusal streams as `delta.refusal` with `delta.content` None;
|
||||
# surface it as assistant text so it is spoken and stored.
|
||||
text_piece = delta.content or getattr(delta, "refusal", None)
|
||||
if text_piece:
|
||||
raw_text += text_piece
|
||||
yield TextDelta(text=text_piece)
|
||||
|
||||
if raw_text.strip():
|
||||
yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_text)])
|
||||
yield from self._tool_calls_from_accum(tool_accum)
|
||||
if usage is not None:
|
||||
yield usage
|
||||
|
||||
def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]:
|
||||
usage = api_response.usage
|
||||
if usage:
|
||||
yield Usage(input_tokens=usage.prompt_tokens or 0, output_tokens=usage.completion_tokens or 0)
|
||||
# A valid-but-empty response (e.g. content filter) returns no choices;
|
||||
# complete cleanly with no assistant text rather than raising IndexError.
|
||||
message = api_response.choices[0].message if api_response.choices else None
|
||||
if message is None:
|
||||
return
|
||||
# A refusal arrives as `message.refusal` with `message.content` None; treat
|
||||
# it as assistant text so it is spoken and stored.
|
||||
raw_content = message.content or getattr(message, "refusal", None)
|
||||
if raw_content:
|
||||
yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_content)])
|
||||
yield TextDelta(text=raw_content)
|
||||
tool_accum: dict[int, dict[str, str]] = {}
|
||||
for tc in message.tool_calls or []:
|
||||
tool_accum[len(tool_accum)] = {
|
||||
"name": tc.function.name or "",
|
||||
"args": tc.function.arguments or "",
|
||||
"id": tc.id or "",
|
||||
}
|
||||
yield from self._tool_calls_from_accum(tool_accum)
|
||||
|
||||
@staticmethod
|
||||
def _tool_calls_from_accum(tool_accum: dict[int, dict[str, str]]) -> Iterator[ToolCall]:
|
||||
"""Turn accumulated tool-call deltas into ToolCall events.
|
||||
|
||||
IDs are regenerated (mirroring the Responses handler) so the rest of the
|
||||
pipeline pairs each call_id with its function_call_output consistently.
|
||||
"""
|
||||
for index in sorted(tool_accum):
|
||||
entry = tool_accum[index]
|
||||
if not entry["name"]:
|
||||
continue
|
||||
yield ToolCall(
|
||||
item=ResponseFunctionToolCall(
|
||||
type="function_call",
|
||||
name=entry["name"],
|
||||
arguments=entry["args"] or "{}",
|
||||
call_id=_generate_id("call"),
|
||||
id=_generate_id("fc"),
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
def on_session_end(self) -> None:
|
||||
logger.debug("Chat Completions API language model session state reset")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Prompt template and factory for the conversation compaction (history summarization) function.
|
||||
|
||||
Compaction reduces an unbounded conversation history to a tight user/assistant
|
||||
summary pair, letting the pipeline continue indefinitely without running out of
|
||||
context window. The factory :func:`build_compactor` returns a :data:`CompactFn`
|
||||
compatible with :meth:`~speech_to_speech.LLM.chat.Chat.trim_if_needed`.
|
||||
|
||||
:data:`CompactGenerateFn` is the backend-agnostic generation interface:
|
||||
``(system_prompt: str, user_prompt: str) -> response_text: str``.
|
||||
Each handler wraps its own model into this interface and passes it to
|
||||
:func:`build_compactor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from speech_to_speech.LLM.chat import CompactFn, CompactionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Callable[[system_prompt, user_prompt], response_text]
|
||||
CompactGenerateFn = Callable[[str, str], str]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMPACTION_SYSTEM_PROMPT = """\
|
||||
You are a conversation memory compressor for a real-time voice AI assistant.
|
||||
|
||||
Your task: read a multi-turn conversation and produce a dense summary so the
|
||||
assistant can continue naturally, as if it remembers everything that was said.
|
||||
|
||||
Output a single JSON object with exactly two string fields:
|
||||
"user_summary" — 1–5 sentences capturing what the user has been asking
|
||||
about, any preferences or constraints they have stated,
|
||||
and where the conversation stands from their perspective.
|
||||
"assistant_summary" — 1–5 sentences capturing what the assistant has
|
||||
explained, decided, or done (including tool calls and
|
||||
their results), plus any open questions or commitments.
|
||||
|
||||
Rules:
|
||||
- Be information-dense: preserve names, numbers, file paths, error messages, and
|
||||
other specifics that would be needed to continue the conversation correctly.
|
||||
- Omit small-talk and filler that carries no forward context.
|
||||
- Write in third person, past tense
|
||||
(e.g. "The user asked about…", "The assistant provided…").
|
||||
- Emit only the JSON object — no markdown, no code fences, no extra keys.\
|
||||
"""
|
||||
|
||||
COMPACTION_USER_TEMPLATE = """\
|
||||
Summarize the following conversation. Return only the JSON object.
|
||||
|
||||
--- CONVERSATION START ---
|
||||
{conversation}
|
||||
--- CONVERSATION END ---\
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_transcript(snapshot: list[Any]) -> str:
|
||||
"""Render a ResponseInputParam snapshot as a readable plain-text transcript."""
|
||||
lines: list[str] = []
|
||||
for item in snapshot:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = item.get("type", "message")
|
||||
role: str = item.get("role", "")
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
if item_type == "function_call":
|
||||
name = item.get("name", "")
|
||||
args = item.get("arguments", "")
|
||||
lines.append(f"[Tool call: {name}({args})]")
|
||||
continue
|
||||
|
||||
if item_type == "function_call_output":
|
||||
out = item.get("output", "")
|
||||
lines.append(f"[Tool result: {out}]")
|
||||
continue
|
||||
|
||||
# Regular user / assistant message
|
||||
content = item.get("content", "")
|
||||
if isinstance(content, list):
|
||||
text = " ".join(
|
||||
c.get("text", "")
|
||||
for c in content
|
||||
if isinstance(c, dict) and c.get("type") in ("input_text", "output_text")
|
||||
).strip()
|
||||
elif isinstance(content, str):
|
||||
text = content.strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if text:
|
||||
label = role.capitalize() if role else "Unknown"
|
||||
lines.append(f"{label}: {text}")
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict[str, Any]:
|
||||
"""Extract the first JSON object from *text*, stripping markdown code fences."""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
m = _JSON_BLOCK_RE.search(text)
|
||||
if m:
|
||||
return json.loads(m.group(1))
|
||||
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
|
||||
raise ValueError(f"No JSON object found in compaction response: {text!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_compactor(generate_fn: CompactGenerateFn) -> CompactFn:
|
||||
"""Return a :data:`CompactFn` that summarizes history using *generate_fn*.
|
||||
|
||||
*generate_fn* is the only model-specific dependency: it receives
|
||||
``(system_prompt, user_prompt)`` and returns the model's text response.
|
||||
Both :class:`~speech_to_speech.LLM.openai_api_language_model.OpenApiModelHandler`
|
||||
and :class:`~speech_to_speech.LLM.language_model.BaseLanguageModelHandler`
|
||||
subclasses expose a ``_build_compaction_generate_fn()`` method that wraps
|
||||
their respective backend into this interface.
|
||||
|
||||
The returned callable is safe to call from a background thread.
|
||||
|
||||
Args:
|
||||
generate_fn: Backend-agnostic text generation callable.
|
||||
|
||||
Returns:
|
||||
A callable ``(snapshot: ResponseInputParam) -> CompactionResult``.
|
||||
"""
|
||||
|
||||
def compact(snapshot: list[Any]) -> CompactionResult:
|
||||
transcript = _render_transcript(snapshot)
|
||||
if not transcript.strip():
|
||||
logger.warning("Compaction called with an empty transcript; returning empty summaries")
|
||||
return CompactionResult(user_summary="", assistant_summary="")
|
||||
|
||||
user_content = COMPACTION_USER_TEMPLATE.format(conversation=transcript)
|
||||
raw_text = generate_fn(COMPACTION_SYSTEM_PROMPT, user_content)
|
||||
|
||||
data = _extract_json(raw_text)
|
||||
user_summary = str(data.get("user_summary", "")).strip()
|
||||
assistant_summary = str(data.get("assistant_summary", "")).strip()
|
||||
|
||||
if not user_summary or not assistant_summary:
|
||||
raise ValueError(f"Compaction response missing required fields. Got: {data!r}")
|
||||
|
||||
logger.debug(
|
||||
"Compaction complete. user=%d chars assistant=%d chars",
|
||||
len(user_summary),
|
||||
len(assistant_summary),
|
||||
)
|
||||
return CompactionResult(user_summary=user_summary, assistant_summary=assistant_summary)
|
||||
|
||||
return compact
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
LLM Output Processor
|
||||
|
||||
Intercepts LLM output to:
|
||||
1. Extract tool calls and send them via text_output_queue
|
||||
2. Forward clean text to TTS pipeline
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from queue import Queue
|
||||
|
||||
from speech_to_speech.baseHandler import BaseHandler
|
||||
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
|
||||
from speech_to_speech.pipeline.handler_types import LLMOut, TTSIn
|
||||
from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput
|
||||
from speech_to_speech.pipeline.queue_types import TextEventItem
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
from speech_to_speech.utils.utils import response_wants_audio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
|
||||
"""
|
||||
Processes LLM output to extract tool calls and forward clean text to TTS.
|
||||
|
||||
Input: :class:`LLMResponseChunk`, :class:`TokenUsage`, or :class:`EndOfResponse` from LLM
|
||||
Output: :class:`TTSInput` or :class:`EndOfResponse` to TTS
|
||||
Side effect: Sends :class:`AssistantTextEvent` / :class:`TokenUsageEvent` to text_output_queue
|
||||
"""
|
||||
|
||||
def setup(
|
||||
self,
|
||||
text_output_queue: Queue[TextEventItem] | None = None,
|
||||
speculative_turns: SpeculativeTurnTracker | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the processor.
|
||||
|
||||
Args:
|
||||
text_output_queue: Queue to send text messages and tool calls
|
||||
"""
|
||||
self.text_output_queue = text_output_queue
|
||||
self.speculative_turns = speculative_turns
|
||||
|
||||
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 process(self, lm_output: LLMOut) -> Iterator[TTSIn]:
|
||||
"""
|
||||
Process LLM output: send text/tools to WebSocket, forward clean text to TTS.
|
||||
|
||||
Yields:
|
||||
:class:`TTSInput` or :class:`EndOfResponse` for TTS
|
||||
"""
|
||||
if isinstance(lm_output, TokenUsage):
|
||||
if not self._turn_output_allowed(
|
||||
lm_output.turn_id,
|
||||
lm_output.turn_revision,
|
||||
):
|
||||
logger.debug(
|
||||
"Dropping stale token usage for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision
|
||||
)
|
||||
return
|
||||
if self.text_output_queue is not None:
|
||||
self.text_output_queue.put(
|
||||
TokenUsageEvent(
|
||||
input_tokens=lm_output.input_tokens or 0,
|
||||
output_tokens=lm_output.output_tokens or 0,
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(lm_output, EndOfResponse):
|
||||
if not self._turn_output_allowed(
|
||||
lm_output.turn_id,
|
||||
lm_output.turn_revision,
|
||||
):
|
||||
logger.debug(
|
||||
"Dropping stale end-of-response for turn=%s rev=%s",
|
||||
lm_output.turn_id,
|
||||
lm_output.turn_revision,
|
||||
)
|
||||
return
|
||||
# A failed generation (e.g. invalid out-of-band input) closes the response as
|
||||
# "failed" via the text side-channel, then falls through to emit the normal
|
||||
# EndOfResponse so the audio path still re-enables listening / releases the slot.
|
||||
if lm_output.error and self.text_output_queue is not None:
|
||||
self.text_output_queue.put(
|
||||
ResponseFailedEvent(
|
||||
message=lm_output.error,
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
)
|
||||
)
|
||||
yield EndOfResponse(
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
cancel_generation=lm_output.cancel_generation,
|
||||
)
|
||||
return
|
||||
|
||||
if not isinstance(lm_output, LLMResponseChunk):
|
||||
logger.warning("LMOutputProcessor received unexpected type: %s", type(lm_output))
|
||||
return
|
||||
|
||||
if not self._turn_output_allowed(
|
||||
lm_output.turn_id,
|
||||
lm_output.turn_revision,
|
||||
):
|
||||
logger.debug("Dropping stale LLM chunk for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision)
|
||||
return
|
||||
|
||||
logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
|
||||
|
||||
if self.text_output_queue is not None:
|
||||
event = AssistantTextEvent(
|
||||
text=lm_output.text,
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
cancel_generation=lm_output.cancel_generation,
|
||||
)
|
||||
if lm_output.tools:
|
||||
event.tools = lm_output.tools
|
||||
logger.info(f"Sending to clients: text='{lm_output.text}', tools={[t.name for t in lm_output.tools]}")
|
||||
else:
|
||||
logger.debug(f"Sending to clients: text='{lm_output.text}' (no tools)")
|
||||
self.text_output_queue.put(event)
|
||||
|
||||
if lm_output.text and response_wants_audio(lm_output.response):
|
||||
logger.debug(f"Forwarding to TTS: '{lm_output.text}'")
|
||||
yield TTSInput(
|
||||
text=lm_output.text,
|
||||
language_code=lm_output.language_code,
|
||||
runtime_config=lm_output.runtime_config,
|
||||
response=lm_output.response,
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
speech_stopped_at_s=lm_output.speech_stopped_at_s,
|
||||
cancel_generation=lm_output.cancel_generation,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from openai import Stream
|
||||
from openai.types.realtime.realtime_conversation_item_assistant_message import (
|
||||
Content as AssistantContent,
|
||||
)
|
||||
from openai.types.responses import (
|
||||
ResponseCompletedEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseTextDeltaEvent,
|
||||
)
|
||||
|
||||
from speech_to_speech.LLM.base_openai_compatible_language_model import (
|
||||
WARMUP_MAX_RETRIES,
|
||||
AssistantMessage,
|
||||
BaseOpenAICompatibleHandler,
|
||||
ProviderEvent,
|
||||
TextDelta,
|
||||
ToolCall,
|
||||
Usage,
|
||||
)
|
||||
from speech_to_speech.LLM.chat import Chat
|
||||
from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponsesApiModelHandler(BaseOpenAICompatibleHandler):
|
||||
"""LLM handler that talks to an OpenAI ``/v1/responses`` server."""
|
||||
|
||||
def warmup(self) -> None:
|
||||
logger.info(f"Warming up {self.__class__.__name__}")
|
||||
start = time.time()
|
||||
self.client.with_options(max_retries=WARMUP_MAX_RETRIES).responses.create(
|
||||
model=self.model_name,
|
||||
input=[
|
||||
{
|
||||
"type": "message",
|
||||
"role": "system",
|
||||
"content": [{"type": "input_text", "text": "You are a helpful assistant"}],
|
||||
},
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]},
|
||||
],
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
end = time.time()
|
||||
logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s")
|
||||
|
||||
def _build_compaction_generate_fn(self) -> CompactGenerateFn:
|
||||
"""Return a generate fn that calls the Responses API for compaction."""
|
||||
client = self.client
|
||||
model_name = self.model_name
|
||||
timeout = self.request_timeout
|
||||
|
||||
def generate(system: str, user: str) -> str:
|
||||
response = client.responses.create(
|
||||
model=model_name,
|
||||
input=[
|
||||
{
|
||||
"type": "message",
|
||||
"role": "system",
|
||||
"content": [{"type": "input_text", "text": system}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": user}],
|
||||
},
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
return response.output_text
|
||||
|
||||
return generate
|
||||
|
||||
# ── base hooks ──────────────────────────────────────────────────────────--
|
||||
|
||||
def _serialize(self, active_chat: Chat) -> Any:
|
||||
return active_chat.to_responses_api_chat()
|
||||
|
||||
def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
|
||||
optional_kwargs: dict[str, Any] = {}
|
||||
if req_tools is not None:
|
||||
optional_kwargs["tools"] = req_tools
|
||||
if req_tool_choice is not None:
|
||||
optional_kwargs["tool_choice"] = req_tool_choice
|
||||
return optional_kwargs
|
||||
|
||||
def _request(self, api_input: Any, optional_kwargs: dict[str, Any]) -> Any:
|
||||
return self.client.responses.create(
|
||||
model=self.model_name,
|
||||
input=api_input,
|
||||
stream=self.stream,
|
||||
extra_body=self._extra_body,
|
||||
timeout=self.request_timeout,
|
||||
**optional_kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assistant_content(content: Any) -> list[AssistantContent]:
|
||||
return [
|
||||
AssistantContent(type="output_text", text=c.text if c.type == "output_text" else c.refusal) for c in content
|
||||
]
|
||||
|
||||
def _iter_stream_events(self, api_response: Stream) -> Iterator[ProviderEvent]:
|
||||
for raw_event in api_response:
|
||||
if isinstance(raw_event, ResponseTextDeltaEvent):
|
||||
yield TextDelta(text=raw_event.delta)
|
||||
elif isinstance(raw_event, ResponseOutputItemDoneEvent):
|
||||
item = raw_event.item
|
||||
if isinstance(item, ResponseFunctionToolCall):
|
||||
item.call_id = _generate_id("call")
|
||||
item.id = _generate_id("fc")
|
||||
yield ToolCall(item=item)
|
||||
elif isinstance(item, ResponseOutputMessage):
|
||||
yield AssistantMessage(content=self._assistant_content(item.content))
|
||||
elif isinstance(raw_event, ResponseCompletedEvent):
|
||||
usage = getattr(raw_event.response, "usage", None)
|
||||
if usage:
|
||||
yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0)
|
||||
|
||||
def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]:
|
||||
usage = api_response.usage
|
||||
if usage:
|
||||
yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0)
|
||||
for message in api_response.output:
|
||||
if isinstance(message, ResponseFunctionToolCall):
|
||||
message.call_id = _generate_id("call")
|
||||
message.id = _generate_id("fc")
|
||||
yield ToolCall(item=message)
|
||||
elif isinstance(message, ResponseOutputMessage):
|
||||
yield AssistantMessage(content=self._assistant_content(message.content))
|
||||
# Text-only keeps every character; the base applies remove_unspeechable
|
||||
# for audio. Only output_text parts are spoken (refusals are stored).
|
||||
raw = "".join(c.text for c in message.content if c.type == "output_text")
|
||||
yield TextDelta(text=raw)
|
||||
else:
|
||||
logger.warning(f"Not supported message type: {message.type}")
|
||||
|
||||
def on_session_end(self) -> None:
|
||||
logger.debug("OpenAI API language model session state reset")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Text-channel system prompt: lead + session prompt + tail (strongest constraints last)."""
|
||||
|
||||
TEXT_SYSTEM_PROMPT_LEAD = """\
|
||||
You are a helpful assistant in a text conversation.
|
||||
"""
|
||||
|
||||
TEXT_SYSTEM_PROMPT_TAIL = """\
|
||||
## Text Rules
|
||||
- Write clearly and directly. Match length to the request: concise for simple questions, fuller when the task genuinely needs it.
|
||||
- Use markdown when it helps (lists, code blocks, tables, emphasis); don't over-format simple answers.
|
||||
- This is a written channel: no spoken-style filler and no action/emote text like *laughs*.
|
||||
- Use tools when they help fulfill the request. No preamble sentence is required before a tool call.
|
||||
- For slow or external tools, just call the tool and use the result; you don't need to announce it.
|
||||
- If unsure whether a tool is needed, just answer directly.
|
||||
"""
|
||||
|
||||
# Skeleton for the assembled system message (placeholders filled in build_text_system_prompt).
|
||||
_TEXT_SYSTEM_PROMPT_FULL = """\
|
||||
{lead}
|
||||
|
||||
Session Prompt:
|
||||
{session_prompt}{optional_tools}
|
||||
|
||||
{tail}
|
||||
"""
|
||||
|
||||
|
||||
def build_text_system_prompt(session_prompt: str, *, tool_section: str = "") -> str:
|
||||
"""Context → session prompt → optional tool block → strongest text rules last."""
|
||||
tools = tool_section.strip()
|
||||
optional_tools = f"\n\n{tools}" if tools else ""
|
||||
return _TEXT_SYSTEM_PROMPT_FULL.format(
|
||||
lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(),
|
||||
session_prompt=session_prompt.strip(),
|
||||
optional_tools=optional_tools,
|
||||
tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(),
|
||||
)
|
||||
|
||||
|
||||
# Full text instructions without a separate session block (legacy / rare direct use).
|
||||
TEXT_SYSTEM_PROMPT = "{lead}\n\n{tail}".format(
|
||||
lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(),
|
||||
tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(),
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Function parser for extracting function names, parameter names, and values from string function calls.
|
||||
|
||||
Uses Python's ``tokenize`` and ``ast`` modules so that nested parentheses,
|
||||
strings containing ')' characters, tuples, dicts, etc. are handled correctly.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tokenize
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
from pydantic import BaseModel
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POSITIONAL_RE = re.compile(r"^__arg_\d+__$")
|
||||
_LENIENT_CALL_RE = re.compile(
|
||||
r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*"
|
||||
r"\((?:[^()\"']+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')*\)"
|
||||
)
|
||||
|
||||
|
||||
# ── AST / tokenize helpers ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _split_top_level_calls(source: str) -> List[str]:
|
||||
"""Split *source* into individual ``name(...)`` expression strings.
|
||||
|
||||
Uses the tokenizer to walk tokens and track parenthesis depth so that
|
||||
nested parens, strings with ')' chars, etc. are handled correctly.
|
||||
"""
|
||||
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
||||
calls: List[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok.type != tokenize.NAME:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
j = i + 1
|
||||
|
||||
# Walk past dotted attribute access (e.g. ``mobile.click``)
|
||||
while j + 1 < len(tokens) and tokens[j].string == "." and tokens[j + 1].type == tokenize.NAME:
|
||||
j += 2
|
||||
|
||||
if j >= len(tokens) or tokens[j].string != "(":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Track balanced parens
|
||||
depth = 0
|
||||
end = None
|
||||
k = j
|
||||
while k < len(tokens):
|
||||
t = tokens[k]
|
||||
if t.type == tokenize.OP and t.string == "(":
|
||||
depth += 1
|
||||
elif t.type == tokenize.OP and t.string == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = k
|
||||
break
|
||||
k += 1
|
||||
|
||||
if end is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
calls.append(tokenize.untokenize(tokens[start : end + 1]).strip())
|
||||
i = end + 1
|
||||
|
||||
return calls
|
||||
|
||||
|
||||
def _split_simple_calls_with_regex(source: str) -> List[str]:
|
||||
"""Extract complete simple ``name(args)`` spans from malformed model output.
|
||||
|
||||
This fallback can recover well-formed siblings before a tokenizer error,
|
||||
but not the incomplete call that caused the tokenizer error.
|
||||
"""
|
||||
return [match.group(0).strip() for match in _LENIENT_CALL_RE.finditer(source)]
|
||||
|
||||
|
||||
def _parse_function_exprs(
|
||||
expressions: List[str],
|
||||
pattern_to_match: list[str],
|
||||
*,
|
||||
skip_invalid: bool = False,
|
||||
) -> List["FunctionToolCall"]:
|
||||
results: List[FunctionToolCall] = []
|
||||
for expr in expressions:
|
||||
try:
|
||||
call = _parse_call_expr(expr)
|
||||
except Exception:
|
||||
if skip_invalid:
|
||||
continue
|
||||
raise
|
||||
if pattern_to_match and all(pattern not in call.function_name for pattern in pattern_to_match):
|
||||
continue
|
||||
results.append(call)
|
||||
return results
|
||||
|
||||
|
||||
def _extract_function_name(node: ast.expr) -> str:
|
||||
"""Return the dotted function name from a Call node's ``func`` attribute."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
base = _extract_function_name(node.value)
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
raise ValueError(f"Unsupported function target: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _literal_from_ast(node: ast.AST) -> Any:
|
||||
"""Convert an AST node to a Python literal value."""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
|
||||
if isinstance(node, ast.List):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Tuple):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
_literal_from_ast(key): _literal_from_ast(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
if key is not None
|
||||
}
|
||||
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
||||
value = _literal_from_ast(node.operand)
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(f"Unsupported unary literal: {ast.dump(node)}")
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
|
||||
raise ValueError(f"Unsupported literal: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _parse_call_expr(expr: str) -> "FunctionToolCall":
|
||||
"""Parse a single ``name(args...)`` expression string into a FunctionToolCall."""
|
||||
parsed = ast.parse(expr, mode="eval").body
|
||||
if not isinstance(parsed, ast.Call):
|
||||
raise ValueError(f"Expression is not a function call: {expr!r}")
|
||||
|
||||
parameters: "OrderedDict[str, Any]" = OrderedDict()
|
||||
|
||||
for idx, arg in enumerate(parsed.args):
|
||||
parameters[f"__arg_{idx}__"] = _literal_from_ast(arg)
|
||||
|
||||
for kw in parsed.keywords:
|
||||
if kw.arg is None:
|
||||
raise ValueError("**kwargs are not supported")
|
||||
parameters[kw.arg] = _literal_from_ast(kw.value)
|
||||
|
||||
return FunctionToolCall(
|
||||
function_name=_extract_function_name(parsed.func),
|
||||
parameters=parameters,
|
||||
original_string=expr,
|
||||
)
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FunctionToolCall(BaseModel):
|
||||
"""Represents a parsed function call with its parameters."""
|
||||
|
||||
function_name: str
|
||||
parameters: Dict[str, Any]
|
||||
original_string: str
|
||||
description: str = ""
|
||||
|
||||
def to_realtime_function_tool_call(
|
||||
self,
|
||||
function_tools: list[FunctionTool] | None = None,
|
||||
) -> ResponseFunctionToolCall:
|
||||
positional = {k for k in self.parameters if _POSITIONAL_RE.match(k)}
|
||||
if positional:
|
||||
logger.warning(
|
||||
"Dropping positional arguments for '%s': %s",
|
||||
self.function_name,
|
||||
positional,
|
||||
)
|
||||
arguments = {k: v for k, v in self.parameters.items() if not _POSITIONAL_RE.match(k)}
|
||||
|
||||
if function_tools is not None:
|
||||
tool = next(
|
||||
(t for t in function_tools if t.name == self.function_name),
|
||||
None,
|
||||
)
|
||||
if tool is None:
|
||||
available = [t.name for t in function_tools]
|
||||
raise ValueError(f"Function '{self.function_name}' not found in available tools: {available}")
|
||||
|
||||
schema = tool.parameters if isinstance(tool.parameters, dict) else {}
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
|
||||
undeclared = {k for k in arguments if k not in properties}
|
||||
if undeclared:
|
||||
logger.warning(
|
||||
"Dropping undeclared parameters for '%s': %s",
|
||||
self.function_name,
|
||||
undeclared,
|
||||
)
|
||||
arguments = {k: v for k, v in arguments.items() if k in properties}
|
||||
|
||||
missing = required - set(arguments.keys())
|
||||
if missing:
|
||||
raise ValueError(f"Missing required parameters for '{self.function_name}': {missing}")
|
||||
|
||||
return ResponseFunctionToolCall(
|
||||
name=self.function_name,
|
||||
arguments=json.dumps(arguments),
|
||||
call_id=_generate_id("call"),
|
||||
type="function_call",
|
||||
id=_generate_id("fc"),
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_function_call(function_string: str, pattern_to_match: list[str] = []) -> List[FunctionToolCall]:
|
||||
"""Parse a function call string and extract all function calls found.
|
||||
|
||||
Args:
|
||||
function_string: String representation of function calls.
|
||||
pattern_to_match: If non-empty, only calls whose function name
|
||||
contains at least one of these substrings are returned.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects with parsed information.
|
||||
"""
|
||||
function_string = function_string.strip()
|
||||
if not function_string:
|
||||
return []
|
||||
|
||||
try:
|
||||
expressions = _split_top_level_calls(function_string)
|
||||
except tokenize.TokenError:
|
||||
return _parse_function_exprs(
|
||||
_split_simple_calls_with_regex(function_string),
|
||||
pattern_to_match,
|
||||
skip_invalid=True,
|
||||
)
|
||||
|
||||
return _parse_function_exprs(expressions, pattern_to_match)
|
||||
|
||||
|
||||
def parse_multiple_functions(function_strings: List[str]) -> List[FunctionToolCall]:
|
||||
"""Parse multiple function call strings.
|
||||
|
||||
Args:
|
||||
function_strings: List of function call strings.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects.
|
||||
"""
|
||||
results: List[FunctionToolCall] = []
|
||||
for func_str in function_strings:
|
||||
try:
|
||||
results.extend(parse_function_call(func_str))
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def extract_function_calls_from_text(text: str, block_regex: str = ".*") -> Tuple[str, List[FunctionToolCall]]:
|
||||
"""Extract function calls from delimited code blocks inside *text*.
|
||||
|
||||
The LLM is prompted to wrap tool calls inside code-block delimiters
|
||||
(e.g. ``<code>func(x=1)</code>``). This function finds those blocks,
|
||||
parses the function calls within them, and returns the remaining text
|
||||
(with blocks stripped) alongside the parsed calls.
|
||||
|
||||
Args:
|
||||
text: Full model output potentially containing code blocks.
|
||||
block_regex: Regex matching the code-block delimiters **and** their
|
||||
content (e.g. ``r"<code>.*?</code>"``). Only text **inside**
|
||||
matched blocks is scanned for function calls.
|
||||
|
||||
Returns:
|
||||
``(outside_text, function_calls)`` -- the text with blocks stripped
|
||||
and the parsed function calls found inside the blocks.
|
||||
"""
|
||||
if not block_regex:
|
||||
return text, []
|
||||
|
||||
matches = list(re.finditer(block_regex, text, flags=re.DOTALL))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
outside = re.sub(block_regex, "", text, flags=re.DOTALL)
|
||||
inside = " ".join(match.group(0) for match in matches).strip()
|
||||
if not inside:
|
||||
return outside, []
|
||||
|
||||
try:
|
||||
return outside, parse_function_call(inside)
|
||||
except Exception:
|
||||
return outside, []
|
||||
@@ -0,0 +1,34 @@
|
||||
import textwrap
|
||||
|
||||
from openai.types.realtime import RealtimeFunctionTool
|
||||
|
||||
from speech_to_speech.LLM.tool_call.signature_from_schema import signature_from_schema
|
||||
|
||||
|
||||
class FunctionTool(RealtimeFunctionTool):
|
||||
def to_code_prompt(self, include_args_doc: bool = True) -> str:
|
||||
"""Generate a code-style prompt string for this function tool.
|
||||
|
||||
Args:
|
||||
include_args_doc: If True, include argument descriptions in the docstring.
|
||||
⚠️ This lets the model see each argument's purpose but significantly increases
|
||||
token usage (e.g. 906 tokens without vs 3434 with for the default Reachy Mini
|
||||
tool profile). Enable depending on the model's capabilities and context limit.
|
||||
"""
|
||||
signature = signature_from_schema(self.parameters)
|
||||
|
||||
tool_doc = self.description or ""
|
||||
|
||||
if isinstance(self.parameters, dict) and include_args_doc:
|
||||
props = self.parameters.get("properties", {})
|
||||
if props:
|
||||
arg_lines = []
|
||||
for arg_name, arg_schema in props.items():
|
||||
desc = arg_schema.get("description", "") if isinstance(arg_schema, dict) else ""
|
||||
arg_lines.append(f"{arg_name}: {desc}")
|
||||
args_doc = f"Args:\n{textwrap.indent(chr(10).join(arg_lines), ' ')}"
|
||||
tool_doc += f"\n\n{args_doc}"
|
||||
|
||||
tool_doc = f'"""{tool_doc}\n"""'
|
||||
|
||||
return f"def {self.name}{signature}:\n{textwrap.indent(tool_doc, ' ')}"
|
||||
@@ -0,0 +1,108 @@
|
||||
import inspect
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
JSON_TYPE_TO_PYTHON_TYPE = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"boolean": bool,
|
||||
"integer": int,
|
||||
"object": dict,
|
||||
"array": list,
|
||||
"null": type(None),
|
||||
}
|
||||
|
||||
|
||||
def _dedupe_types(types: list[Any]) -> list[Any]:
|
||||
seen = []
|
||||
for t in types:
|
||||
if t not in seen:
|
||||
seen.append(t)
|
||||
return seen
|
||||
|
||||
|
||||
def _annotation_from_spec(spec: dict[str, Any]) -> Any:
|
||||
if not spec or not isinstance(spec, dict):
|
||||
return Any
|
||||
|
||||
# const → Literal[value]
|
||||
if "const" in spec:
|
||||
return Literal[spec["const"]]
|
||||
|
||||
# enum → Literal[val1, val2, ...]
|
||||
if "enum" in spec:
|
||||
values = spec["enum"]
|
||||
if not values:
|
||||
return Any
|
||||
return Literal[tuple(values)]
|
||||
|
||||
# anyOf / oneOf → Union[Type1, Type2, ...]
|
||||
for key in ("anyOf", "oneOf"):
|
||||
if key in spec:
|
||||
variants = [_annotation_from_spec(s) for s in spec[key]]
|
||||
unique = _dedupe_types(variants)
|
||||
if len(unique) == 0:
|
||||
return Any
|
||||
if len(unique) == 1:
|
||||
return unique[0]
|
||||
return Union[tuple(unique)]
|
||||
|
||||
# allOf → merge sub-schemas then resolve
|
||||
if "allOf" in spec:
|
||||
merged = {}
|
||||
for sub in spec["allOf"]:
|
||||
merged.update(sub)
|
||||
return _annotation_from_spec(merged)
|
||||
|
||||
json_type = spec.get("type")
|
||||
|
||||
if json_type is None:
|
||||
return Any
|
||||
|
||||
# type as list, e.g. ["string", "null"] → Union[str, None] (Optional)
|
||||
if isinstance(json_type, list):
|
||||
types = [JSON_TYPE_TO_PYTHON_TYPE.get(t, Any) for t in json_type]
|
||||
unique = _dedupe_types(types)
|
||||
if len(unique) == 0:
|
||||
return Any
|
||||
if len(unique) == 1:
|
||||
return unique[0]
|
||||
return Union[tuple(unique)]
|
||||
|
||||
# array with items → list[ItemType]
|
||||
if json_type == "array" and "items" in spec:
|
||||
item_type = _annotation_from_spec(spec["items"])
|
||||
return list[item_type] # type: ignore[valid-type]
|
||||
|
||||
return JSON_TYPE_TO_PYTHON_TYPE.get(json_type, Any)
|
||||
|
||||
|
||||
def signature_from_schema(schema: object | None) -> inspect.Signature:
|
||||
if not schema or not isinstance(schema, dict):
|
||||
return inspect.Signature()
|
||||
|
||||
props = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
params = []
|
||||
|
||||
for name, spec in props.items():
|
||||
annotation = _annotation_from_spec(spec)
|
||||
|
||||
has_schema_default = "default" in spec if isinstance(spec, dict) else False
|
||||
|
||||
if name in required and not has_schema_default:
|
||||
default = inspect.Parameter.empty
|
||||
elif has_schema_default:
|
||||
default = spec["default"]
|
||||
else:
|
||||
default = None
|
||||
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
name,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
default=default,
|
||||
annotation=annotation,
|
||||
)
|
||||
)
|
||||
|
||||
return inspect.Signature(params)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Optional system-prompt builder that instructs a local LLM to output tool calls
|
||||
inside delimited code blocks (e.g. ``<code>func(arg='val')</code>``).
|
||||
|
||||
The prompt is rendered from a Jinja2 template and relies on
|
||||
``FunctionTool.to_code_prompt()`` to expose each tool's Python-style signature.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default delimiters
|
||||
# ---------------------------------------------------------------------------
|
||||
ENTER_CODE = "<code>"
|
||||
END_CODE = "</code>"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 template
|
||||
# ---------------------------------------------------------------------------
|
||||
# ``enter_code`` / ``end_code`` are the block delimiters the model must wrap
|
||||
# every tool call in. ``tools`` is a list of FunctionTool instances whose
|
||||
# ``.to_code_prompt()`` is called inside the template.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_PROMPT_TEMPLATE = Template(
|
||||
"""\
|
||||
Available tools:
|
||||
|
||||
{% for tool in tools %}
|
||||
{{ tool.to_code_prompt() }}
|
||||
|
||||
{% endfor %}
|
||||
To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
|
||||
{{ enter_code }}function_name(required_arg='value'){{ end_code }}
|
||||
|
||||
Rules:
|
||||
- You may say one brief natural sentence before the tool call; for slow information tools, briefly say that you will check.
|
||||
- For expression/background tools, always speak first. For requested expressions, use a short pattern like "Sure, here's my best <emotion>."; otherwise use a fitting empathetic sentence.
|
||||
- Do not mention tags, functions, or tools. Keep prose outside tags brief, and do not claim tool results before a tool result is available.
|
||||
- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
|
||||
- Only one tool call may appear in a response.\
|
||||
""",
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
# Text-channel variant: same call format and structural rules, without the
|
||||
# voice-specific "speak first" prose.
|
||||
TEXT_TOOL_PROMPT_TEMPLATE = Template(
|
||||
"""\
|
||||
Available tools:
|
||||
|
||||
{% for tool in tools %}
|
||||
{{ tool.to_code_prompt() }}
|
||||
|
||||
{% endfor %}
|
||||
To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
|
||||
{{ enter_code }}function_name(required_arg='value'){{ end_code }}
|
||||
|
||||
Rules:
|
||||
- Call a tool directly when it helps fulfill the request; no preamble sentence is required.
|
||||
- Do not mention tags, functions, or tools in your prose, and do not claim tool results before a tool result is available.
|
||||
- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
|
||||
- Only one tool call may appear in a response.\
|
||||
""",
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_tool_system_prompt(
|
||||
tools: list[FunctionTool],
|
||||
enter_code: str = ENTER_CODE,
|
||||
end_code: str = END_CODE,
|
||||
*,
|
||||
text_only: bool = False,
|
||||
) -> str:
|
||||
"""Render the tool-calling system-prompt section.
|
||||
|
||||
Returns an empty string when *tools* is empty so it can be
|
||||
unconditionally appended to a base system prompt. When *text_only* is set,
|
||||
the written-channel variant is used (no voice "speak first" prose).
|
||||
"""
|
||||
if not tools:
|
||||
return ""
|
||||
|
||||
template = TEXT_TOOL_PROMPT_TEMPLATE if text_only else TOOL_PROMPT_TEMPLATE
|
||||
return template.render(
|
||||
tools=tools,
|
||||
enter_code=enter_code,
|
||||
end_code=end_code,
|
||||
)
|
||||
|
||||
|
||||
def build_block_regex(
|
||||
enter_code: str = ENTER_CODE,
|
||||
end_code: str = END_CODE,
|
||||
) -> str:
|
||||
"""Build a regex that matches a single code block (non-greedy).
|
||||
|
||||
>>> build_block_regex("<code>", "</code>")
|
||||
'<code>.*?</code>'
|
||||
"""
|
||||
return f"{re.escape(enter_code)}.*?{re.escape(end_code)}"
|
||||
@@ -0,0 +1,75 @@
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import requests # type: ignore[import-untyped]
|
||||
from PIL import Image
|
||||
|
||||
SMART_PUNCT_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"\u2018": "'",
|
||||
"\u2019": "'",
|
||||
"\u201c": '"',
|
||||
"\u201d": '"',
|
||||
}
|
||||
)
|
||||
|
||||
SPEECHABLE_PATTERN = re.compile(
|
||||
r"[^\w\s.,!?;:'\"\-()\/\\@#%&*+=$€£¥₹₽¢\[\]{}<>~`^|…—–\n\r\t]",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def remove_unspeechable(text: str) -> str:
|
||||
"""Keep only speechable characters: letters, digits, punctuation, whitespace.
|
||||
support unicode characters (english, arabic, chinese, japanese, korean, etc.)
|
||||
"""
|
||||
text = text.translate(SMART_PUNCT_TRANSLATION)
|
||||
return SPEECHABLE_PATTERN.sub("", text)
|
||||
|
||||
|
||||
WHISPER_LANGUAGE_TO_LLM_LANGUAGE = {
|
||||
"en": "english",
|
||||
"fr": "french",
|
||||
"es": "spanish",
|
||||
"zh": "chinese",
|
||||
"ja": "japanese",
|
||||
"ko": "korean",
|
||||
"hi": "hindi",
|
||||
"de": "german",
|
||||
"pt": "portuguese",
|
||||
"pl": "polish",
|
||||
"it": "italian",
|
||||
"nl": "dutch",
|
||||
}
|
||||
|
||||
|
||||
def resolve_auto_language(language_code: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Strip the ``-auto`` suffix and resolve the human-readable language name.
|
||||
|
||||
Returns ``(clean_code, language_name)``. ``language_name`` is non-None
|
||||
when the code (with or without ``-auto``) maps to a known language.
|
||||
"""
|
||||
if not language_code:
|
||||
return language_code, None
|
||||
if language_code.endswith("-auto"):
|
||||
language_code = language_code[:-5]
|
||||
if language_code not in WHISPER_LANGUAGE_TO_LLM_LANGUAGE:
|
||||
return language_code, None
|
||||
return language_code, WHISPER_LANGUAGE_TO_LLM_LANGUAGE.get(language_code)
|
||||
|
||||
|
||||
def image_url_to_pil(image_url: str) -> Image.Image:
|
||||
"""Convert an image URL or base64 data URI to a PIL Image.
|
||||
|
||||
Accepts:
|
||||
- 'data:image/...;base64,<b64>' data URIs
|
||||
- 'https://...`` or ``http://...' URLs (fetched with a 10s timeout)
|
||||
"""
|
||||
if image_url.startswith("data:"):
|
||||
_, b64_data = image_url.split(",", 1)
|
||||
return Image.open(io.BytesIO(base64.b64decode(b64_data)))
|
||||
resp = requests.get(image_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return Image.open(io.BytesIO(resp.content))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Voice-channel system prompt: lead + session prompt + tail (strongest constraints last)."""
|
||||
|
||||
VOICE_SYSTEM_PROMPT_LEAD = """\
|
||||
You are in a spoken conversation. The user speaks and hears you.
|
||||
The session prompt defines persona, facts, goals, and tool descriptions. These channel rules only control spoken output and tool-use behavior.
|
||||
"""
|
||||
|
||||
VOICE_SYSTEM_PROMPT_TAIL = """\
|
||||
## Voice Rules
|
||||
- Keep replies brief by default: usually one spoken sentence, two if needed. Go longer only when asked.
|
||||
- Speak naturally. No markdown, bullets, headings, visual formatting, or action/emote text like *laughs*.
|
||||
- Treat transcripts as noisy. Correct likely mishearings only if asked or meaning depends on it.
|
||||
- Speech is the default. Use at most one tool when it helps fulfill the request or clearly fits the moment.
|
||||
- Before a tool call, use a brief natural utterance unless the user asked for silence or tool-only output. For slow information tools, briefly say that you will check.
|
||||
- For expression/background tools, speak first. If asked to show an expression, use a short pattern like "Sure, here's my best <emotion>." Otherwise use a fitting empathetic sentence. Never mention tools.
|
||||
- After completed expression/background/physical-action tools, do not add a second spoken comment unless the result has user-facing information.
|
||||
- Use motion, dance, emotion, and similar tools sparingly when they add empathy, celebration, playfulness, or a requested physical action.
|
||||
- If unsure whether a tool is needed, just speak.
|
||||
"""
|
||||
|
||||
# Skeleton for the assembled system message (placeholders filled in build_voice_system_prompt).
|
||||
_VOICE_SYSTEM_PROMPT_FULL = """\
|
||||
{lead}
|
||||
|
||||
Session Prompt:
|
||||
{session_prompt}{optional_tools}
|
||||
|
||||
{tail}
|
||||
"""
|
||||
|
||||
|
||||
def build_voice_system_prompt(session_prompt: str, *, tool_section: str = "") -> str:
|
||||
"""Context → session prompt → optional tool block → strongest voice rules last."""
|
||||
tools = tool_section.strip()
|
||||
optional_tools = f"\n\n{tools}" if tools else ""
|
||||
return _VOICE_SYSTEM_PROMPT_FULL.format(
|
||||
lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(),
|
||||
session_prompt=session_prompt.strip(),
|
||||
optional_tools=optional_tools,
|
||||
tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(),
|
||||
)
|
||||
|
||||
|
||||
# Full voice instructions without a separate session block (legacy / rare direct use).
|
||||
VOICE_SYSTEM_PROMPT = "{lead}\n\n{tail}".format(
|
||||
lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(),
|
||||
tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(),
|
||||
)
|
||||
Reference in New Issue
Block a user