From 36a2debf039a5413efb0202bb83fb2b7dc9e8325 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Sun, 18 Oct 2026 17:29:00 -0700 Subject: [PATCH 01/12] cascade simulator on live Co-authored-by: tara-servicenow --- .env.example | 30 +- pyproject.toml | 5 +- scripts/measure_barge_in_latency.py | 1285 +++++++++++++++++++ scripts/run_text_only.py | 2 +- src/eva/assistant/services/llm.py | 26 +- src/eva/models/config.py | 43 +- src/eva/orchestrator/preflight.py | 3 + src/eva/user_simulator/cascade/__init__.py | 0 src/eva/user_simulator/cascade/simulator.py | 393 ++++++ src/eva/user_simulator/cascade/stt.py | 140 ++ src/eva/user_simulator/cascade/tts.py | 89 ++ src/eva/user_simulator/factory.py | 11 +- src/eva/utils/llm_client.py | 2 +- src/eva/utils/router.py | 65 +- tests/unit/user_simulator/test_cascade.py | 318 +++++ tests/unit/user_simulator/test_factory.py | 13 +- tests/unit/utils/test_llm_client.py | 9 +- uv.lock | 419 +++--- 18 files changed, 2593 insertions(+), 260 deletions(-) create mode 100644 scripts/measure_barge_in_latency.py create mode 100644 src/eva/user_simulator/cascade/__init__.py create mode 100644 src/eva/user_simulator/cascade/simulator.py create mode 100644 src/eva/user_simulator/cascade/stt.py create mode 100644 src/eva/user_simulator/cascade/tts.py create mode 100644 tests/unit/user_simulator/test_cascade.py diff --git a/.env.example b/.env.example index 1f5c6721b..f5b3d2732 100644 --- a/.env.example +++ b/.env.example @@ -198,6 +198,15 @@ EVA_MODEL_LIST='[ "aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY", "max_parallel_requests": 5 } + }, + { + "model_name": "user-llm", + "litellm_params": { + "model": "openai/gpt-6-luna", + "api_key": "os.environ/OPENAI_API_KEY", + "max_parallel_requests": 5, + "reasoning_effort": "none" + } } ]' @@ -303,7 +312,7 @@ EVA_MODEL_LIST='[ # --- User simulator provider --- #i Caller provider. ElevenLabs is the backward-compatible default. #d enum -#e elevenlabs,openai_realtime +#e elevenlabs,openai_realtime,cascade EVA_USER_SIMULATOR__PROVIDER=elevenlabs #i OpenAI Realtime caller model. Used only when provider=openai_realtime. @@ -321,6 +330,21 @@ EVA_USER_SIMULATOR__PROVIDER=elevenlabs #x EVA_USER_SIMULATOR__PROVIDER=openai_realtime #v EVA_USER_SIMULATOR__MALE_VOICE=cedar +#i Cascade caller streaming STT model (ElevenLabs Scribe realtime). Requires ELEVENLABS_API_KEY. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=cascade +#v EVA_USER_SIMULATOR__STT_MODEL=scribe_v2_realtime + +#i Cascade caller LLM deployment name from EVA_MODEL_LIST. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=cascade +#v EVA_USER_SIMULATOR__LLM=user-llm + +#i Cascade caller Cartesia TTS model. Requires CARTESIA_API_KEY. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=cascade +#v EVA_USER_SIMULATOR__TTS_MODEL=sonic-3.5 + # --- Language (mutually exclusive with Accent and Behavior) --- #i ISO 639-1 language code for the user simulator. Datasets must exist for the selected language. Pattern for the agent ID pairs below: EVA_{LANG}_USER_{F|M}. #d enum @@ -588,3 +612,7 @@ EVA_EN_USER_M=your_elevenlabs_agent_id_for_default_user_m #d enum #e DEBUG,INFO,WARNING,ERROR,CRITICAL #v EVA_LOG_LEVEL=INFO + +#i Enable preflight checks before running benchmarks. +#d bool +#v EVA_PREFLIGHT=false diff --git a/pyproject.toml b/pyproject.toml index b07375296..42198edb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,15 +25,14 @@ dependencies = [ "elevenlabs>=2.53.0", "openai>=2.36.0", "anthropic>=0.83.0", - "litellm==1.85.0", + "litellm==1.105.1", "deepgram-sdk>=7", "onnxruntime>=1.16.0", - "aioboto3>=12.0.0", "google-genai>=1.69.0", "google-cloud-speech>=2.0.0", "google-cloud-texttospeech>=2.0.0", "azure-cognitiveservices-speech>=1.31.0", - "cartesia>=1.0.0", + "cartesia>=3.0.0", "assemblyai>=0.17.0", "setuptools>=65.0.0", "fastapi>=0.100.0", diff --git a/scripts/measure_barge_in_latency.py b/scripts/measure_barge_in_latency.py new file mode 100644 index 000000000..5357a00e5 --- /dev/null +++ b/scripts/measure_barge_in_latency.py @@ -0,0 +1,1285 @@ +"""Controlled measurement of the barge-in reaction path, end to end. + +The question: can the caller go from "the assistant is mid-sentence" to "the caller is +audibly interrupting" in roughly human reaction time, with a pre-rendered opener masking +the time it takes to generate and synthesize what the caller actually says — and with no +audible gap between the opener and that content? + +The path under test: + + one assistant utterance, pre-rendered with Cartesia and streamed in real time + -> the cascade simulator's streaming STT (ScribeStreamingSTT, ElevenLabs Scribe realtime) + -> on the Nth transcript delta: OpenAI Decisions API (choice: interrupt / keep listening) + -> interrupt: the pre-rendered opener starts playing immediately + -> the caller's reply (streaming LLM -> Cartesia) plays when the opener ends + +Several reply STRATEGIES are compared in one invocation; they differ only in *when* the +reply pipeline starts and whether requests are hedged — see STRATEGIES. Every connection +is opened and warmed before the clock starts and torn down after it stops. + +Reply timing does not depend on which opener plays, so every run is scored against every +opener: the caller track is replayed once per opener and saved as its own recording. + +The recordings are a replay of the real-time playout, not an idealised splice: the opener +starts at the moment the verdict arrived, and each content chunk at max(previous audio end, +that chunk's arrival). The headline GAP is measured from that replayed track itself — the +silence between the opener's last audible sample and the content's first. + +Reaction time is measured against Cartesia word timestamps for the assistant turn: from +the end of the last word the STT had transcribed to the opener starting. + +All knobs are module constants below — edit them in place, there are no CLI arguments. + + python scripts/measure_barge_in_latency.py +""" + +from __future__ import annotations + +import asyncio +import audioop +import base64 +import contextlib +import json +import os +import re +import statistics +import time +import wave +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import httpx +from dotenv import load_dotenv +from elevenlabs.realtime import AudioFormat, CommitStrategy, RealtimeEvents, ScribeRealtime + +from eva.models.config import CascadeSimulatorConfig +from eva.user_simulator.cascade.stt import INPUT_SAMPLE_RATE, ScribeStreamingSTT +from eva.user_simulator.cascade.tts import CALLER_SPEED, TTS_SAMPLE_RATE, CartesiaStreamingTTS + +# ══════════════════════════════════════════════════════════════════════════════════ +# KNOBS — edit these +# ══════════════════════════════════════════════════════════════════════════════════ + + +@dataclass(frozen=True) +class Strategy: + """When the reply pipeline starts, what is hedged, and which reply model writes it.""" + + start: str + """after_decision | trigger | rolling | threshold — see STRATEGIES.""" + speculate_tts: bool = True + """False: speculation produces text only; TTS starts on the chosen text after the verdict.""" + hedge_decision: bool = False + hedge_reply: bool = False + """Hedge the LLM and TTS requests too.""" + response_model: str = "gpt-5.6-luna" + reasoning_effort: str | None = "none" + """None omits the parameter (non-reasoning models reject it).""" + service_tier: str = "default" + keep_newest: bool = False + """When ROLLING_MAX_IN_FLIGHT is reached, cancel the oldest speculation instead of skipping the new one.""" + max_stale_deltas: int | None = None + """Only consider speculations at most this many deltas older than the trigger; among those, + the furthest along wins. None: any speculation, furthest along then freshest.""" + tts_on_clause: bool = False + """Send the first clause to TTS as soon as it is written, then the rest of the sentence.""" + + +STRATEGIES = { + "sequential": Strategy("after_decision"), + "rolling_hedged": Strategy("rolling", hedge_decision=True, hedge_reply=True), + "rolling_text": Strategy("rolling", speculate_tts=False, hedge_decision=True), + "threshold_text": Strategy("threshold", speculate_tts=False, hedge_decision=True), + "parallel_priority": Strategy("trigger", hedge_decision=True, service_tier="priority"), + "fresh_text": Strategy("rolling", speculate_tts=False, hedge_decision=True, keep_newest=True, max_stale_deltas=0), + "fresh_text_clause": Strategy( + "rolling", speculate_tts=False, hedge_decision=True, keep_newest=True, max_stale_deltas=0, tts_on_clause=True + ), + "fresh1_text_clause": Strategy( + "rolling", speculate_tts=False, hedge_decision=True, keep_newest=True, max_stale_deltas=1, tts_on_clause=True + ), +} +"""Reply strategies to compare, each run ITERATIONS_PER_STRATEGY times. + +Every strategy behaves as a live caller would: a decision runs on every transcript +delta, and none of them knows where the trigger is. Deltas before TRIGGER_ON_DELTA are +treated as "keep listening" (the model's real choice is recorded in delta_p); the +trigger delta's verdict commits the interruption. So the cost recorded per sample is +the whole job — listen, decide on every delta, then speak. `start`: + + after_decision reply pipeline starts only after a committing verdict (the original design) + trigger a reply starts alongside every delta's decision, and is cancelled when + that decision says keep listening + rolling a speculative reply starts on every delta (at most ROLLING_MAX_IN_FLIGHT) + threshold a speculative reply starts on a delta whose P(interrupt) >= SPECULATE_P; + falls back to `trigger` behaviour at the trigger if none crossed it + +On the verdict, the freshest reply that is furthest along (audio arriving > text done > +generating) is used and the rest are cancelled. +""" + +ITERATIONS_PER_STRATEGY = 5 + +RUN_ONLY = ["fresh_text_clause"] + +STT_BACKENDS = ["production"] +"""Speech-to-text paths to compare, interleaved per run so session drift hits each equally. + + production the simulator's ScribeStreamingSTT, fed 8kHz mu-law as the bridge delivers it + (it decodes and upsamples to 16kHz PCM16 before Scribe) + scribe_pcm16 the same SDK and model fed the original 16kHz PCM16: more audio detail than the + bridge delivers, so an upper bound rather than a production option + +Measured on one trigger (p50 STT lag): sending Scribe the mu-law directly 540ms, the same audio +as 16kHz PCM16 422ms, clean 16kHz PCM16 401ms. A LiveKit-plugin path (16kHz) was ~101 vs ~81ms +for the SDK on another trigger. Format was the difference, so production sends PCM16. +""" +"""Strategy names to run; empty runs all of STRATEGIES. A subset writes to OUTPUT_DIR/subset/ +so it never overwrites a full run's samples.json.""" + +OPENERS = ["Sorry—", "Wait, sorry—", "Sorry, sorry—", "Sorry, hang on a sec—"] +"""Pre-rendered latency masks, shortest to longest. Every run is scored against each.""" + +GAP_TARGET_MS = 100.0 +"""Audible gap at or under which a run counts as seamless.""" + +HEDGE_FANOUT = 2 +"""Identical requests per hedged call; the first to answer wins.""" + +ROLLING_MAX_IN_FLIGHT = 3 +"""Speculative replies allowed in flight at once. A delta arriving when full starts none. + +Skipping the newest rather than cancelling the oldest matters: the oldest is the one +closest to finishing, and cancelling it means that when deltas arrive faster than a +reply takes to write, no speculation ever completes. +""" + +CLAUSE_MIN_WORDS = 3 +"""tts_on_clause: a clause break counts only after this many words, so TTS gets enough to phrase.""" + +CLAUSE_BREAKS = (",", "—", "–", ";", ":") + +SPECULATE_P = 0.1 +"""threshold strategies: P(interrupt) at or above which a delta starts a speculative reply.""" + +SIMULATOR = CascadeSimulatorConfig() +"""The cascade simulator's config: STT model, TTS model and voices come from here, so the +experiment hears and speaks through the same components a benchmark run uses. Caller speed is +fixed in the TTS class (CALLER_SPEED).""" + +SKIP_CONTENT_LEADING_SILENCE = True +"""Drop quiet samples at the very start of the content stream as they arrive. + +Causal — a real-time player can do the same — so it does not flatter the measurement. +""" + +DECISION_MODEL = "gpt-6-luna" +"""Decisions API model. Only gpt-6-luna is supported at launch.""" + +FORCE_INTERRUPT = True +"""Proceed with the interruption whatever the model chooses; choice and P are recorded.""" + +BUDGET_MS = 350.0 +"""Human reaction time: end of the last heard word -> caller audio out. Informational.""" + +ASSISTANT_GAIN = 0.33 +"""Assistant channel volume in the saved recordings, so the caller is easy to hear.""" + +# ── Incoming audio: one rendered assistant utterance ──────────────────────────── + +ASSISTANT_TURN_TEXT = ( + "Understood, you'd like to update your professional liability insurance. That requires " + "provider verification first. Please give me your ten-digit national provider number, the " + "code for your primary facility, and your four-digit personal identification number." +) +"""Rendered once per invocation during setup and reused for every iteration.""" + +ASSISTANT_VOICE_ID = SIMULATOR.male_voice + +TRIGGER_ON_DELTA = 5 +"""Fire the decision on this transcript delta (1-based).""" + +FEED_CHUNK_MS = 20 +"""Audio pushed to STT per step, in real time. Also the polling resolution for deltas.""" + +STT_WARMUP_MS = 1000 +"""Silence fed before the clock starts, so the STT websocket is connected and streaming.""" + +LANGUAGE = "en" +STT_MODEL = SIMULATOR.stt_model +"""Empty uses the provider default from stt_livekit.DEFAULT_MODELS.""" + +CARTESIA_MODEL = SIMULATOR.tts_model +VOICE_ID = SIMULATOR.female_voice + +SILENCE_RMS = 200 +"""PCM16 RMS per 10ms window below which audio counts as silence (trimming and GAP).""" + +OUTPUT_DIR = "output/barge_in_latency" +"""/opener_run.wav and samples.json land here.""" + +# ══════════════════════════════════════════════════════════════════════════════════ +# Fixtures +# ══════════════════════════════════════════════════════════════════════════════════ + +CALLER_SAMPLE_RATE = TTS_SAMPLE_RATE +"""All audio in this script is PCM16 mono at the caller TTS rate; STT input is converted on send.""" +BYTES_PER_SECOND = CALLER_SAMPLE_RATE * 2 # PCM16 mono +WINDOW_MS = 10 +OPENAI_URL = "https://api.openai.com/v1" +CARTESIA_SSE_URL = "https://api.cartesia.ai/tts/sse" +CARTESIA_VERSION = "2024-06-10" +"""Only used to render the assistant turn with word timestamps, which CartesiaStreamingTTS does not expose.""" +SENTENCE_END = (".", "?", "!") +KEEPALIVE = httpx.Limits(keepalive_expiry=300.0, max_keepalive_connections=20) +"""httpx's 5s default would expire the pool during each iteration's idle stretch.""" + +COST = {"decision_calls": 0, "llm_calls": 0, "tts_calls": 0, "tts_chars": 0} +IN_FLIGHT = {"decisions": 0, "llm_streams": 0} +"""OpenAI requests currently open, for diagnosing what the trigger decision competes with.""" +"""Billable requests in the current iteration, hedged duplicates and cancelled speculation included.""" + +LEADING_FILLER = re.compile( + r"^\W*(?:(?:sorry|wait|hang on|hold on|excuse me|one sec(?:ond)?|a sec(?:ond)?)\W+)*", re.IGNORECASE +) +"""Interjections the content must not open with: the opener already said them.""" + +GOAL_BLOCK = """GOAL: Update your professional liability insurance on file before your credentialing deadline. +MUST HAVE: +- The agent confirms the new policy is recorded on your provider profile. +- You know whether anything else is needed to stay credentialed. +VERIFICATION DETAILS YOU HAVE READY: national provider number 4827159360, primary facility +code MRC-07, personal identification number 2291. +HOW THEY EVALUATE OPTIONS: You are between patients and short on time. You already have +every verification detail in hand and want to give them the moment you know what is asked. +RESOLVED WHEN: The agent confirms the updated policy is on file.""" + +DECISION_INSTRUCTIONS = """You are the caller on a live phone call with a support agent, and the agent \ +is speaking right now; the final line of the transcript is still in progress. What should the \ +caller do at this moment?""" + +DECISION_CHOICES = [ + { + "value": "interrupt", + "description": "Cut in now: the caller has heard enough to have a response, question or " + "correction ready, needs to redirect the agent, or already has what they were waiting for.", + }, + { + "value": "keep_listening", + "description": "Let the agent continue: they have said only a few words, or the caller would only be agreeing.", + }, +] + +RESPONSE_PROMPT = """You are the caller in a live phone call with a support agent. You are cutting the \ +agent off mid-sentence, and you have ALREADY said a quick apology like "Sorry—" out loud to get \ +their attention. + + +{goal} + + + +Agent: {transcript} [CURRENTLY SPEAKING] + + +Write only the one short sentence you say right after that apology. Do not begin with an apology, \ +"wait", or any other interjection: that has already been said. Spoken words only, no quotes.""" + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Samples +# ══════════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class Opener: + """A pre-rendered, silence-trimmed opener.""" + + phrase: str + pcm: bytes + raw_ms: float + """Duration as rendered, before trimming.""" + + @property + def ms(self) -> float: + """Cover time its playout buys.""" + return pcm_duration_ms(len(self.pcm)) + + +@dataclass +class Reply: + """One run of the reply pipeline (LLM sentence -> TTS), started at some delta.""" + + transcript: str + started_at_ms: float + delta: int = 0 + """Transcript delta this reply was written from.""" + partial: str = "" + """Text so far while generating.""" + updated: asyncio.Event = field(default_factory=asyncio.Event) + text: str = "" + raw_text: str = "" + gen_first_token_ms: float = 0.0 + """Reply start -> first content token.""" + gen_first_sentence_ms: float = 0.0 + """Reply start -> first sentence complete (TTS request sent).""" + tts_ttfa_ms: float = 0.0 + """TTS request sent -> first audio byte.""" + chunks: list[tuple[float, bytes]] = field(default_factory=list) + """(audio-clock ms arrived, PCM16) for each TTS chunk.""" + task: asyncio.Task | None = None + + @property + def progress(self) -> int: + """How far along: 2 audio arriving, 1 text done, 0 still generating.""" + return 2 if self.chunks else 1 if self.text else 0 + + +@dataclass +class Sample: + """One measured interruption. All *_at_ms are on the audio clock (0 = turn starts).""" + + strategy: str + index: int + stt_backend: str = "" + ok: bool = False + transcript: str = "" + choice: str = "" + p_interrupt: float = 0.0 + last_word_end_at_ms: float = 0.0 + """End of the last transcribed word in the assistant audio, from Cartesia timestamps.""" + trigger_at_ms: float = 0.0 + """Triggering transcript delta became readable.""" + opener_at_ms: float = 0.0 + """Verdict arrived; opener playout starts.""" + decision_ms: float = 0.0 + reply_started_at_ms: float = 0.0 + stale_words: int = 0 + """Words between starting_word and latest_word (see missed_words).""" + """Words the trigger transcript had that the chosen reply's transcript did not.""" + gen_first_token_ms: float = 0.0 + gen_first_sentence_ms: float = 0.0 + tts_ttfa_ms: float = 0.0 + content_first_audio_at_ms: float = 0.0 + raw_content: str = "" + content: str = "" + gaps_ms: dict[str, float] = field(default_factory=dict) + """Opener phrase -> audible gap in the replayed track.""" + replies_started: int = 0 + first_speculation_delta: int = 0 + """Delta whose transcript the first speculative reply used; 0 if none.""" + delta_p: list[tuple[int, float]] = field(default_factory=list) + latest_word: str = "" + """Last assistant word whose audio had fully played before the opener started.""" + starting_word: str = "" + """Last word of the transcript the chosen reply was written from.""" + missed_words: str = "" + """Words between starting and latest: heard, but not in the reply's context.""" + reply_transcript: str = "" + """Transcript the chosen reply was written from.""" + source_end_at_ms: float = 0.0 + """End of that transcript's last word in the assistant audio.""" + source_age_ms: float = 0.0 + """Shortest opener: when the reply becomes audible, how old its source speech is.""" + early_interrupt_votes: int = 0 + """Pre-trigger deltas where the model itself chose interrupt (overridden to keep listening).""" + deltas_listened: int = 0 + """threshold strategies: (delta, P(interrupt)) for every per-delta decision.""" + cost: dict[str, int] = field(default_factory=dict) + in_flight_at_trigger: dict[str, int] = field(default_factory=dict) + """OpenAI requests already open when the trigger decision was sent.""" + pool_before: int = 0 + pool_after: int = 0 + """OpenAI pool connections before/after the trigger decision; growth = handshakes in the clock.""" + error: str = "" + + @property + def stt_lag_ms(self) -> float: + """End of the last heard word -> the transcript containing it was readable.""" + return self.trigger_at_ms - self.last_word_end_at_ms + + @property + def reaction_ms(self) -> float: + """End of the last heard word -> opener audible. What a listener perceives.""" + return self.opener_at_ms - self.last_word_end_at_ms + + @property + def content_ready_ms(self) -> float: + """Opener start -> first content audio. Negative when it was ready before the verdict.""" + return self.content_first_audio_at_ms - self.opener_at_ms + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Audio helpers +# ══════════════════════════════════════════════════════════════════════════════════ + + +def pcm_duration_ms(num_bytes: int) -> float: + """Duration of raw PCM16 mono at the caller sample rate.""" + return num_bytes / BYTES_PER_SECOND * 1000.0 + + +def ms_to_bytes(ms: float) -> int: + """Byte offset of a time in PCM16 mono, aligned to a sample.""" + offset = int(ms / 1000.0 * BYTES_PER_SECOND) + return offset - offset % 2 + + +def loud_windows(pcm: bytes) -> list[int]: + """Byte offsets of the 10ms windows at or above SILENCE_RMS.""" + window = ms_to_bytes(WINDOW_MS) + return [i for i in range(0, len(pcm), window) if audioop.rms(pcm[i : i + window], 2) >= SILENCE_RMS] + + +def trim_silence(pcm: bytes) -> bytes: + """Drop leading and trailing quiet windows.""" + loud = loud_windows(pcm) + return pcm[loud[0] : loud[-1] + ms_to_bytes(WINDOW_MS)] if loud else pcm + + +def place(track: bytearray, pcm: bytes, at_ms: float) -> None: + """Overwrite pcm into a track at a time offset, growing the track if needed.""" + start = ms_to_bytes(at_ms) + if len(track) < start + len(pcm): + track.extend(b"\x00" * (start + len(pcm) - len(track))) + track[start : start + len(pcm)] = pcm + + +def write_stereo(path: Path, left: bytes, right: bytes) -> None: + """Interleave two PCM16 mono tracks into a stereo WAV, padding the shorter.""" + length = max(len(left), len(right)) + left = audioop.mul(left, 2, ASSISTANT_GAIN).ljust(length, b"\x00") + right = right.ljust(length, b"\x00") + stereo = audioop.add(audioop.tostereo(left, 2, 1, 0), audioop.tostereo(right, 2, 0, 1), 2) + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(2) + wav.setsampwidth(2) + wav.setframerate(CALLER_SAMPLE_RATE) + wav.writeframes(stereo) + + +def replay_playout(opener_at_ms: float, opener: Opener, chunks: list[tuple[float, bytes]]) -> tuple[bytes, float]: + """Rebuild the caller track as a real-time player would emit it; returns (track, gap). + + The opener starts when the verdict arrived. Each content chunk starts at the later of + the previous audio ending and the chunk arriving. The gap is then read off the track + itself: last audible opener sample to first audible content sample. + """ + track = bytearray() + place(track, opener.pcm, opener_at_ms) + opener_end = len(track) + cursor = opener_at_ms + opener.ms + leading = SKIP_CONTENT_LEADING_SILENCE + for arrived_at, chunk in chunks: + if leading: + # Causal: only samples that have already arrived are inspected. + loud = loud_windows(chunk) + if not loud: + continue + chunk, leading = chunk[loud[0] :], False + start = max(cursor, arrived_at) + place(track, chunk, start) + cursor = start + pcm_duration_ms(len(chunk)) + + opener_loud = loud_windows(bytes(track[:opener_end])) + content_loud = loud_windows(bytes(track[opener_end:])) + if not opener_loud or not content_loud: + return bytes(track), float("nan") + window = ms_to_bytes(WINDOW_MS) + gap_bytes = opener_end + content_loud[0] - (opener_loud[-1] + window) + return bytes(track), pcm_duration_ms(max(0, gap_bytes)) + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Hedging +# ══════════════════════════════════════════════════════════════════════════════════ + + +BACKGROUND: set[asyncio.Task] = set() +"""Hedge losers left to finish. Cancelling one mid-response makes httpx close its +connection, which drains the warm pool over a listening period; letting it finish +returns the connection to the pool.""" + + +def finish_in_background(awaitable: Awaitable) -> None: + """Run to completion off the critical path, keeping a reference so it is not collected.""" + task = asyncio.ensure_future(awaitable) + BACKGROUND.add(task) + task.add_done_callback(BACKGROUND.discard) + + +async def drain_stream(head: asyncio.Task, stream: AsyncIterator) -> None: + """Consume a losing hedged stream to its end, so its connection is reused.""" + with contextlib.suppress(Exception): + await head + async for _ in stream: + pass + + +async def first_result(make: Callable[[], Awaitable], fanout: int): + """Await `fanout` identical calls and return the first success; losers finish in the background.""" + if fanout <= 1: + return await make() + pending = {asyncio.ensure_future(make()) for _ in range(fanout)} + error: BaseException | None = None + try: + while pending: + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + for task in done: + if task.exception() is None: + return task.result() + error = task.exception() + raise error # type: ignore[misc] + finally: + for task in pending: + finish_in_background(task) + + +async def first_stream(make: Callable[[], AsyncIterator], fanout: int) -> AsyncIterator: + """Start `fanout` identical streams; follow whichever yields first, drain the rest.""" + if fanout <= 1: + async with contextlib.aclosing(make()) as stream: + async for item in stream: + yield item + return + streams = [make() for _ in range(fanout)] + heads = {asyncio.ensure_future(anext(stream)): stream for stream in streams} + winner = None + try: + while heads and winner is None: + done, _ = await asyncio.wait(heads, return_when=asyncio.FIRST_COMPLETED) + for task in done: + stream = heads.pop(task) + if task.exception() is None: + winner, head = stream, task.result() + break + with contextlib.suppress(Exception): + await stream.aclose() + if winner is None: + raise RuntimeError("every hedged stream failed") + finally: + for task, stream in heads.items(): + finish_in_background(drain_stream(task, stream)) + try: + yield head + async for item in winner: + yield item + finally: + with contextlib.suppress(Exception): + await winner.aclose() + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Service calls (on long-lived, pre-warmed connections) +# ══════════════════════════════════════════════════════════════════════════════════ + + +async def decide_once(openai: httpx.AsyncClient, transcript: str) -> tuple[str, float]: + """One Decisions API choice call; returns (choice, P(interrupt)).""" + COST["decision_calls"] += 1 + IN_FLIGHT["decisions"] += 1 + try: + return await _decide_request(openai, transcript) + finally: + IN_FLIGHT["decisions"] -= 1 + + +async def _decide_request(openai: httpx.AsyncClient, transcript: str) -> tuple[str, float]: + """The Decisions API request itself.""" + response = await openai.post( + "/decisions", + json={ + "model": DECISION_MODEL, + "input": f"\n{GOAL_BLOCK}\n\n\nAgent (speaking now): {transcript}", + "questions": [ + {"type": "choice", "name": "action", "instructions": DECISION_INSTRUCTIONS, "choices": DECISION_CHOICES} + ], + }, + ) + response.raise_for_status() + answer = response.json()["answers"][0] + if answer.get("type") == "refusal": + raise RuntimeError(f"Decisions API refused: {answer}") + probabilities = {p["value"]: float(p["probability"]) for p in answer.get("probabilities") or []} + return str(answer.get("choice", "")), probabilities.get("interrupt", 0.0) + + +async def decide(openai: httpx.AsyncClient, transcript: str, *, fanout: int = 1) -> tuple[str, float]: + """Decision, optionally hedged.""" + return await first_result(lambda: decide_once(openai, transcript), fanout) + + +async def stream_response_once(openai: httpx.AsyncClient, transcript: str, strategy: Strategy) -> AsyncIterator[str]: + """Yield content deltas of the caller's interruption sentence.""" + COST["llm_calls"] += 1 + body = { + "model": strategy.response_model, + "messages": [{"role": "user", "content": RESPONSE_PROMPT.format(goal=GOAL_BLOCK, transcript=transcript)}], + "max_completion_tokens": 60, + "service_tier": strategy.service_tier, + "stream": True, + } + if strategy.reasoning_effort is not None: + body["reasoning_effort"] = strategy.reasoning_effort + IN_FLIGHT["llm_streams"] += 1 + try: + async with openai.stream("POST", "/chat/completions", json=body) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + choices = json.loads(line[6:]).get("choices") or [] + text = (choices[0].get("delta") or {}).get("content") if choices else None + if text: + yield text + finally: + IN_FLIGHT["llm_streams"] -= 1 + + +def tts_body(text: str, *, voice_id: str) -> dict: + """Cartesia request body for PCM16 mono at the caller sample rate (assistant turn only).""" + return { + "model_id": CARTESIA_MODEL, + "transcript": text, + "voice": {"mode": "id", "id": voice_id}, + "language": LANGUAGE, + "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": CALLER_SAMPLE_RATE}, + } + + +async def stream_tts_once(cartesia: CartesiaStreamingTTS, text: str) -> AsyncIterator[bytes]: + """Yield sample-aligned PCM16 chunks of caller speech from the simulator's TTS.""" + COST["tts_calls"] += 1 + COST["tts_chars"] += len(text) + carry = b"" + async for chunk in cartesia.stream(text, voice_id=VOICE_ID): + # Chunk boundaries need not fall on samples; an odd split would shear PCM16. + data = carry + chunk + cut = len(data) - len(data) % 2 + carry = data[cut:] + if cut: + yield data[:cut] + + +async def synthesize(cartesia: CartesiaStreamingTTS, text: str) -> bytes: + """Render caller speech in full.""" + return b"".join([chunk async for chunk in stream_tts_once(cartesia, text)]) + + +async def render_assistant_turn(cartesia: httpx.AsyncClient) -> tuple[bytes, list[float]]: + """Render the assistant turn with word timestamps; returns (pcm, word end times in ms).""" + body = tts_body(ASSISTANT_TURN_TEXT, voice_id=ASSISTANT_VOICE_ID) | {"add_timestamps": True} + pcm = bytearray() + word_ends: list[float] = [] + async with cartesia.stream("POST", CARTESIA_SSE_URL, json=body) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + event = json.loads(line[5:]) + if event.get("type") == "chunk" and event.get("data"): + pcm.extend(base64.b64decode(event["data"])) + elif event.get("type") == "timestamps": + word_ends.extend(end * 1000.0 for end in event["word_timestamps"]["end"]) + elif event.get("type") == "error": + raise RuntimeError(f"Cartesia error: {event}") + return bytes(pcm), word_ends + + +async def render_opener(cartesia: CartesiaStreamingTTS, phrase: str) -> Opener: + """Pre-render an opener and trim its dead air.""" + raw = await synthesize(cartesia, phrase) + return Opener(phrase=phrase, pcm=trim_silence(raw), raw_ms=pcm_duration_ms(len(raw))) + + +async def warm_up( + openai: httpx.AsyncClient, + decisions_client: httpx.AsyncClient, + cartesia: CartesiaStreamingTTS, + strategy: Strategy, +) -> None: + """Open, concurrently, as many pooled connections as the strategy can have in flight at once. + + Decisions and replies share one pool. Warming fewer than the peak would make the + decision open a fresh connection (a TLS handshake, ~100ms) inside the measurement + whenever speculative streams already hold the warm ones. + """ + warm = "Thanks for holding, I've pulled up your account now and" + decisions = HEDGE_FANOUT if strategy.hedge_decision else 1 + reply_fanout = HEDGE_FANOUT if strategy.hedge_reply else 1 + speculative = strategy.start in ("rolling", "threshold", "trigger") + streams = (ROLLING_MAX_IN_FLIGHT + 1 if speculative else 1) * reply_fanout + probes = decisions # a delta's decision can overlap the next one's + tts = (ROLLING_MAX_IN_FLIGHT + 1 if speculative and strategy.speculate_tts else 1) * reply_fanout + + async def drain_response() -> None: + async for _ in stream_response_once(openai, warm, strategy): + pass + + # Concurrent, so each request needs its own connection and the pool reaches the peak. + await asyncio.gather( + *(decide_once(decisions_client, warm) for _ in range(decisions + probes)), + *(drain_response() for _ in range(streams)), + ) + await asyncio.gather(*(synthesize(cartesia, "Okay.") for _ in range(tts))) + + +class CallerEar: + """The simulator's ScribeStreamingSTT, fed what the bridge delivers. + + The rendered turn is converted to 8kHz mu-law, the assistant's wire format, with resampler + state carried across chunks; ScribeStreamingSTT.send() does the rest exactly as in a call. + """ + + def __init__(self) -> None: + self.stt = ScribeStreamingSTT(STT_MODEL, language=LANGUAGE) + self._ratecv_state = None + + async def start(self) -> None: + await self.stt.start() + + async def feed(self, pcm: bytes) -> None: + resampled, self._ratecv_state = audioop.ratecv( + pcm, 2, 1, CALLER_SAMPLE_RATE, INPUT_SAMPLE_RATE, self._ratecv_state + ) + await self.stt.send(audioop.lin2ulaw(resampled, 2)) + + def heard_text(self) -> str: + return self.stt.heard_text() + + async def stop(self) -> None: + await self.stt.stop() + + +class ScribePcm16Ear(CallerEar): + """Same SDK, model and callbacks as the simulator, but sent 16kHz PCM16 with no conversion.""" + + async def start(self) -> None: + stt = self.stt + stt._connection = await ScribeRealtime(api_key=stt._api_key).connect( + { + "model_id": stt.model, + "audio_format": AudioFormat.PCM_16000, + "sample_rate": CALLER_SAMPLE_RATE, + "commit_strategy": CommitStrategy.MANUAL, + "language_code": stt.language, + } + ) + stt._connection.on(RealtimeEvents.PARTIAL_TRANSCRIPT, stt._on_partial) + stt._connection.on(RealtimeEvents.COMMITTED_TRANSCRIPT, stt._on_committed) + stt._connection.on(RealtimeEvents.ERROR, stt._on_error) + + async def feed(self, pcm: bytes) -> None: + # Bypass send(): it decodes 8kHz mu-law, and this path is already 16kHz PCM16. + await self.stt._connection.send({"audio_base_64": base64.b64encode(pcm).decode("ascii")}) + + +EARS = {"production": CallerEar, "scribe_pcm16": ScribePcm16Ear} + + +async def open_stt(backend: str) -> CallerEar: + """Start STT and stream silence until the session is live, so the clock starts warm.""" + stt = EARS[backend]() + await stt.start() + silence = b"\x00" * ms_to_bytes(FEED_CHUNK_MS) + for _ in range(STT_WARMUP_MS // FEED_CHUNK_MS): + await stt.feed(silence) + await asyncio.sleep(FEED_CHUNK_MS / 1000) + return stt + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Measurement +# ══════════════════════════════════════════════════════════════════════════════════ + + +def pool_size(client: httpx.AsyncClient) -> int: + """Open connections in an httpx client's pool (private API: diagnostics only).""" + try: + return len(client._transport._pool.connections) + except AttributeError: + return -1 + + +def active_strategies() -> list[str]: + """STRATEGIES filtered by RUN_ONLY.""" + return [name for name in STRATEGIES if not RUN_ONLY or name in RUN_ONLY] + + +def strip_leading_filler(text: str) -> str: + """Remove a repeated opener, or any leading interjection, from the content.""" + return LEADING_FILLER.sub("", text.strip()).strip() + + +async def run_text(reply: Reply, openai: httpx.AsyncClient, clock: Callable[[], float], strategy: Strategy) -> None: + """Generate the reply's first sentence.""" + fanout = HEDGE_FANOUT if strategy.hedge_reply else 1 + text = "" + # aclosing: breaking at the first sentence must release the HTTP stream now, not at GC. + async with contextlib.aclosing( + first_stream(lambda: stream_response_once(openai, reply.transcript, strategy), fanout) + ) as llm: + async for delta in llm: + if not text: + reply.gen_first_token_ms = clock() - reply.started_at_ms + text += delta + reply.partial = text + reply.updated.set() + if text.rstrip().endswith(SENTENCE_END): + break + reply.gen_first_sentence_ms = clock() - reply.started_at_ms + reply.raw_text = text.strip() + reply.text = strip_leading_filler(text) + reply.updated.set() + + +async def run_audio( + reply: Reply, cartesia: CartesiaStreamingTTS, clock: Callable[[], float], strategy: Strategy +) -> None: + """Stream the reply text's audio into reply.chunks.""" + fanout = HEDGE_FANOUT if strategy.hedge_reply else 1 + tts_sent = clock() + async for chunk in first_stream(lambda: stream_tts_once(cartesia, reply.text), fanout): + arrived = clock() + if not reply.chunks: + reply.tts_ttfa_ms = arrived - tts_sent + reply.chunks.append((arrived, chunk)) + + +def first_clause(text: str) -> str: + """The text up to the first clause break after CLAUSE_MIN_WORDS words, or '' if none yet.""" + for index, char in enumerate(text): + if char in CLAUSE_BREAKS and len(text[:index].split()) >= CLAUSE_MIN_WORDS: + return text[: index + 1] + return "" + + +async def speak_by_clause( + reply: Reply, cartesia: CartesiaStreamingTTS, clock: Callable[[], float], strategy: Strategy +) -> None: + """Synthesize the first clause as soon as it exists, then the rest of the sentence. + + Segments are requested concurrently but kept in order, so playout is clause 1 then the + remainder; a remainder that arrives late is an underrun the replay counts as gap. + """ + fanout = HEDGE_FANOUT if strategy.hedge_reply else 1 + while True: + head = reply.text or first_clause(strip_leading_filler(reply.partial)) + if head: + break + if reply.task and reply.task.done(): + reply.task.result() # surface the generation error + raise RuntimeError("reply finished without text") + reply.updated.clear() + await asyncio.wait( + {asyncio.ensure_future(reply.updated.wait()), reply.task}, return_when=asyncio.FIRST_COMPLETED + ) + + segments: list[list[tuple[float, bytes]]] = [] + + async def synth(text: str, first: bool) -> None: + out: list[tuple[float, bytes]] = [] + segments.append(out) + sent = clock() + async for chunk in first_stream(lambda: stream_tts_once(cartesia, text), fanout): + arrived = clock() + if first and not out: + reply.tts_ttfa_ms = arrived - sent + out.append((arrived, chunk)) + + head_task = asyncio.create_task(synth(head, True)) + if reply.task and not reply.task.done(): + await reply.task + rest = reply.text[len(head) :].strip() if reply.text.startswith(head) else "" + tail_task = asyncio.create_task(synth(rest, False)) if rest else None + await head_task + if tail_task: + await tail_task + reply.chunks = [chunk for segment in segments for chunk in segment] + + +async def run_reply( + reply: Reply, + openai: httpx.AsyncClient, + cartesia: CartesiaStreamingTTS, + clock: Callable[[], float], + strategy: Strategy, + *, + with_audio: bool, +) -> None: + """Text, then (optionally) audio.""" + await run_text(reply, openai, clock, strategy) + if with_audio: + await run_audio(reply, cartesia, clock, strategy) + + +async def run_iteration( + sample: Sample, + turn_pcm: bytes, + word_ends: list[float], + openai: httpx.AsyncClient, + decisions_client: httpx.AsyncClient, + cartesia: CartesiaStreamingTTS, +) -> list[tuple[float, bytes]]: + """Stream the turn, interrupt on the trigger delta, return the chosen reply's chunks.""" + strategy = STRATEGIES[sample.strategy] + decision_fanout = HEDGE_FANOUT if strategy.hedge_decision else 1 + stt = await open_stt(sample.stt_backend) # setup: outside the clock + replies: list[Reply] = [] + side_decisions: list[asyncio.Task] = [] + try: + await warm_up(openai, decisions_client, cartesia, strategy) # re-warm right before t0 + for key in COST: + COST[key] = 0 + t0 = time.perf_counter() + + def clock() -> float: + return (time.perf_counter() - t0) * 1000.0 + + def start_reply(transcript: str, delta: int, *, speculative: bool) -> Reply | None: + in_flight = [r for r in replies if r.task and not r.task.done()] + if speculative and len(in_flight) >= ROLLING_MAX_IN_FLIGHT: + if not strategy.keep_newest: + return None + in_flight[0].task.cancel() # make room for the freshest transcript + reply = Reply(transcript=transcript, started_at_ms=clock(), delta=delta) + with_audio = strategy.speculate_tts or not speculative + reply.task = asyncio.create_task(run_reply(reply, openai, cartesia, clock, strategy, with_audio=with_audio)) + replies.append(reply) + sample.replies_started += 1 + if speculative and not sample.first_speculation_delta: + sample.first_speculation_delta = delta + return reply + + async def listen_decision(transcript: str, delta: int, paired: Reply | None) -> None: + """A pre-trigger delta's decision: recorded, then treated as keep listening. + + Hedged like the committing decision, since live any delta could be the one. + """ + choice, p = await decide(decisions_client, transcript, fanout=decision_fanout) + sample.delta_p.append((delta, p)) + if choice == "interrupt": + sample.early_interrupt_votes += 1 + if paired is not None and paired.task: + paired.task.cancel() # its decision said keep listening + if strategy.start == "threshold" and p >= SPECULATE_P and decision is None: + start_reply(transcript, delta, speculative=True) + + step = ms_to_bytes(FEED_CHUNK_MS) + deltas, last_text = 0, "" + decision: asyncio.Task | None = None + for offset in range(0, len(turn_pcm), step): + # Pace against the audio clock, so drift does not accumulate. + delay = t0 + offset / BYTES_PER_SECOND - time.perf_counter() + if delay > 0: + await asyncio.sleep(delay) + await stt.feed(turn_pcm[offset : offset + step]) + if decision is not None: + if decision.done(): + break + continue + text = stt.heard_text() + if not text.strip() or text == last_text: + continue + deltas, last_text = deltas + 1, text + if deltas >= TRIGGER_ON_DELTA: + sample.trigger_at_ms = clock() + sample.transcript = text + heard_words = min(len(text.split()), len(word_ends)) + sample.last_word_end_at_ms = word_ends[heard_words - 1] if heard_words else 0.0 + # Decision goes on the wire first: it gates the opener, the reply does not. + sample.in_flight_at_trigger = dict(IN_FLIGHT) + sample.pool_before = pool_size(decisions_client) + decision = asyncio.create_task(decide(decisions_client, text, fanout=decision_fanout)) + await asyncio.sleep(0) + if strategy.start == "trigger" or (strategy.start == "threshold" and not replies): + start_reply(text, deltas, speculative=False) + elif strategy.start == "rolling": + start_reply(text, deltas, speculative=True) + else: + paired = None + if strategy.start == "rolling": + start_reply(text, deltas, speculative=True) + elif strategy.start == "trigger": + paired = start_reply(text, deltas, speculative=False) + side_decisions.append(asyncio.create_task(listen_decision(text, deltas, paired))) + sample.deltas_listened = deltas + if decision is None: + sample.error = f"only {deltas} transcript deltas, never reached TRIGGER_ON_DELTA={TRIGGER_ON_DELTA}" + return [] + + sample.choice, sample.p_interrupt = await decision + sample.pool_after = pool_size(decisions_client) + sample.opener_at_ms = clock() + sample.decision_ms = sample.opener_at_ms - sample.trigger_at_ms + if sample.choice != "interrupt" and not FORCE_INTERRUPT: + return [] + if not replies: + start_reply(sample.transcript, deltas, speculative=False) # after_decision: only now + + # Freshest reply that is furthest along; cancel the rest. + live = [r for r in replies if not (r.task and r.task.cancelled())] + fresh = [ + r for r in live if strategy.max_stale_deltas is not None and r.delta >= deltas - strategy.max_stale_deltas + ] + if fresh: + # Within the freshness window, the one furthest along (most text written) wins. + # Tie (e.g. neither has a token yet): the older one has the head start. + chosen = max(fresh, key=lambda r: (r.progress, len(r.partial), -r.started_at_ms)) + else: + chosen = max(live, key=lambda r: (r.progress, r.started_at_ms)) + for reply in replies: + if reply is not chosen and reply.task: + reply.task.cancel() + if strategy.tts_on_clause and not chosen.chunks: + await speak_by_clause(chosen, cartesia, clock, strategy) + else: + await chosen.task + if not chosen.chunks: + # Text-only speculation: synthesize the chosen text now. + await run_audio(chosen, cartesia, clock, strategy) + + sample.reply_started_at_ms = chosen.started_at_ms + # Staleness, on the audio clock: the last word fully spoken before the opener + # started (latest) vs the last word of the transcript the reply was written from + # (starting). Both are word indexes into the rendered turn's timestamps. + turn_words = ASSISTANT_TURN_TEXT.split() + latest = sum(1 for end in word_ends if end <= sample.opener_at_ms) + starting = min(len(chosen.transcript.split()), len(word_ends)) + sample.latest_word = turn_words[latest - 1] if latest else "" + sample.starting_word = turn_words[starting - 1] if starting else "" + sample.stale_words = max(0, latest - starting) + sample.missed_words = " ".join(turn_words[starting:latest]) + sample.reply_transcript = chosen.transcript + source_words = min(len(chosen.transcript.split()), len(word_ends)) + sample.source_end_at_ms = word_ends[source_words - 1] if source_words else 0.0 + sample.gen_first_token_ms = chosen.gen_first_token_ms + sample.gen_first_sentence_ms = chosen.gen_first_sentence_ms + sample.tts_ttfa_ms = chosen.tts_ttfa_ms + sample.content_first_audio_at_ms = chosen.chunks[0][0] if chosen.chunks else 0.0 + sample.raw_content, sample.content = chosen.raw_text, chosen.text + sample.ok = bool(chosen.chunks) + if not sample.ok: + sample.error = "chosen reply produced no audio" + return chosen.chunks + except Exception as exc: # noqa: BLE001 - a failed iteration is a data point, not a crash + sample.error = f"{type(exc).__name__}: {exc}" + return [] + finally: + pending = [r.task for r in replies if r.task] + side_decisions + for task in pending: + if not task.done(): + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + # Outside the clock: let hedge losers finish so their cost is counted and their + # connections return to the pool for the next iteration. + await asyncio.gather(*list(BACKGROUND), return_exceptions=True) + sample.cost = dict(COST) + await stt.stop() # teardown: outside the clock + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Reporting +# ══════════════════════════════════════════════════════════════════════════════════ + + +def row(label: str, values: list[float]) -> str: + """p50/min/max line. No p90: at a handful of samples it is just the max.""" + values = [v for v in values if v == v] # drop NaN + if not values: + return f" {label:<34} no data" + return ( + f" {label:<34} p50 {statistics.median(values):>6.0f}ms min {min(values):>6.0f}ms " + f"max {max(values):>6.0f}ms (n={len(values)})" + ) + + +def summarize_stt(samples: list[Sample]) -> None: + """The A/B that matters: STT lag per backend, and where each one put the trigger.""" + print("\n" + "=" * 100 + "\n STT BACKEND COMPARISON (same session, interleaved)\n" + "=" * 100) + for backend in STT_BACKENDS: + good = [s for s in samples if s.stt_backend == backend and s.ok] + print(row(f"{backend} STT lag", [s.stt_lag_ms for s in good])) + print(row(f"{backend} reaction", [s.reaction_ms for s in good])) + triggers = sorted({s.transcript[-40:] for s in good}) + print(f" {backend} trigger transcript(s): {triggers}") + failed = [s for s in samples if s.stt_backend == backend and not s.ok] + if failed: + print(f" {backend} failed {len(failed)}: {failed[0].error[:100]}") + print(" NOTE: lag is timed from the end of the last transcribed word; compare backends only") + print(" when they triggered on the same transcript (see above).") + + +def summarize(samples: list[Sample], openers: list[Opener]) -> None: + """Per strategy: reaction, reply pipeline, then the gap against every opener.""" + print("\n" + "=" * 100) + print(f" decision {DECISION_MODEL} speculate P >= {SPECULATE_P} max in flight {ROLLING_MAX_IN_FLIGHT}") + print( + f" stt {STT_MODEL} tts {CARTESIA_MODEL} @ {CALLER_SPEED}x hedge fanout {HEDGE_FANOUT} target gap <= {GAP_TARGET_MS:.0f}ms" + ) + print(" openers: " + ", ".join(f"{o.phrase!r} {o.ms:.0f}ms" for o in openers)) + for strategy in active_strategies(): + config = STRATEGIES[strategy] + runs = [s for s in samples if s.strategy == strategy] + good = [s for s in runs if s.ok] + print("\n" + "=" * 100) + print(f" {strategy.upper()} {len(good)} ok / {len(runs) - len(good)} failed") + print(f" {config}") + print("=" * 100) + print(row("STT lag (word end -> transcript)", [s.stt_lag_ms for s in good])) + print(row("decision", [s.decision_ms for s in good])) + print(f" per-run decision ms: {[round(s.decision_ms) for s in good]}") + print(f" in flight at trigger: {[s.in_flight_at_trigger for s in good]}") + print(f" decision pool before->after trigger decision: {[(s.pool_before, s.pool_after) for s in good]}") + print(row("REACTION (word end -> opener out)", [s.reaction_ms for s in good])) + print(row("gen first sentence (from reply start)", [s.gen_first_sentence_ms for s in good])) + print(row("TTS first audio", [s.tts_ttfa_ms for s in good])) + print(row("content ready (from opener start)", [s.content_ready_ms for s in good])) + if config.start in ("rolling", "threshold"): + print( + f" stale words (latest word before opener - reply's starting word): {[s.stale_words for s in good]}" + ) + print( + row("STALE ms (opener start - starting word end)", [s.opener_at_ms - s.source_end_at_ms for s in good]) + ) + for sample in good: + print( + f" #{sample.index} starting {sample.starting_word!r} -> latest {sample.latest_word!r}" + f" missed: {sample.missed_words!r}" + ) + print(row("SOURCE AGE when reply audible", [s.source_age_ms for s in good])) + print(f" first speculation at delta: {[s.first_speculation_delta for s in good]}") + if good: + print( + f" P(interrupt) by delta (#0): {[(d, round(p, 2)) for d, p in sorted(good[0].delta_p)]}" + f" early interrupt votes: {[s.early_interrupt_votes for s in good]}" + ) + costs = [s.cost for s in runs if s.cost] + if costs: + mean = {k: statistics.mean(c[k] for c in costs) for k in costs[0]} + print( + f" COST to listen {statistics.mean(s.deltas_listened for s in runs):.0f} deltas, then interrupt (mean): decisions {mean['decision_calls']:.1f} " + f"LLM calls {mean['llm_calls']:.1f} TTS calls {mean['tts_calls']:.1f} " + f"TTS chars {mean['tts_chars']:.0f}" + ) + for opener in openers: + gaps = [s.gaps_ms[opener.phrase] for s in good] + seamless = sum(1 for g in gaps if g <= GAP_TARGET_MS) + print(row(f"GAP {opener.phrase!r} ({seamless}/{len(gaps)} seamless)", gaps)) + for sample in runs: + if not sample.ok: + print(f" #{sample.index} failed: {sample.error[:120]}") + + good = [s for s in samples if s.ok] + if not good: + return + print("\n READ") + for strategy in active_strategies(): + runs = [s for s in good if s.strategy == strategy] + best = [o.phrase for o in openers if runs and all(s.gaps_ms[o.phrase] <= GAP_TARGET_MS for s in runs)] + print(f" {strategy:<20} seamless in every run with: {best or 'none'}") + reaction = statistics.median(s.reaction_ms for s in good) + print(f" Reaction p50 {reaction:.0f}ms overall vs {BUDGET_MS:.0f}ms human-reaction reference.") + stripped = sum(1 for s in good if s.raw_content != s.content) + print(f" Content opened with a filler/repeat in {stripped}/{len(good)} runs (stripped before TTS).") + print(f" Decisions: P(interrupt) p50 {statistics.median(s.p_interrupt for s in good):.2f}") + print(f" Transcript at trigger: {good[0].transcript!r}") + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Entry point +# ══════════════════════════════════════════════════════════════════════════════════ + + +async def main() -> None: + """Warm everything, run every strategy, save recordings, print the summary.""" + load_dotenv(override=True) + openai_key = os.environ.get("OPENAI_API_KEY") + cartesia_key = os.environ.get("CARTESIA_API_KEY") + if not openai_key or not cartesia_key: + raise SystemExit("OPENAI_API_KEY and CARTESIA_API_KEY must both be set.") + out_dir = Path(OUTPUT_DIR) / "subset" if RUN_ONLY else Path(OUTPUT_DIR) + out_dir.mkdir(parents=True, exist_ok=True) + + async with ( + httpx.AsyncClient( + base_url=OPENAI_URL, + headers={"Authorization": f"Bearer {openai_key}"}, + timeout=30.0, + limits=KEEPALIVE, + ) as openai, + httpx.AsyncClient( + base_url=OPENAI_URL, + headers={"Authorization": f"Bearer {openai_key}"}, + timeout=30.0, + limits=KEEPALIVE, + ) as decisions_client, + httpx.AsyncClient( + headers={"X-API-Key": cartesia_key, "Cartesia-Version": CARTESIA_VERSION}, + timeout=30.0, + ) as assistant_tts, + ): + cartesia = CartesiaStreamingTTS(CARTESIA_MODEL, language=LANGUAGE) + print("Rendering the assistant turn and openers...") + turn_pcm, word_ends = await render_assistant_turn(assistant_tts) + openers = [await render_opener(cartesia, phrase) for phrase in OPENERS] + print(f" turn {pcm_duration_ms(len(turn_pcm)) / 1000:.1f}s, {len(word_ends)} words timestamped") + for opener in openers: + print(f" opener {opener.phrase!r}: {opener.ms:.0f}ms (rendered {opener.raw_ms:.0f}ms)") + + samples: list[Sample] = [] + for strategy in active_strategies(): + for index in range(ITERATIONS_PER_STRATEGY): + for backend in STT_BACKENDS: + sample = Sample(strategy=strategy, index=index, stt_backend=backend) + chunks = await run_iteration(sample, turn_pcm, word_ends, openai, decisions_client, cartesia) + samples.append(sample) + if sample.ok: + for opener_index, opener in enumerate(openers): + track, gap = replay_playout(sample.opener_at_ms, opener, chunks) + sample.gaps_ms[opener.phrase] = gap + if opener_index == 0: + sample.source_age_ms = sample.opener_at_ms + opener.ms + gap - sample.source_end_at_ms + wav = out_dir / backend / strategy / f"opener{opener_index}_run{index}.wav" + write_stereo(wav, turn_pcm, track) + gaps = " ".join(f"{g:4.0f}" for g in sample.gaps_ms.values()) + print( + f" [{strategy:<19} {backend:<13} #{index}] stt {sample.stt_lag_ms:4.0f}ms " + f"reaction {sample.reaction_ms:5.0f}ms gaps [{gaps}]ms {sample.error or sample.content!r}" + ) + await cartesia.close() + + for backend in STT_BACKENDS: + print("\n" + "#" * 100 + f"\n STT BACKEND: {backend}\n" + "#" * 100) + summarize([s for s in samples if s.stt_backend == backend], openers) + summarize_stt(samples) + payload = { + "config": { + "strategies": {name: asdict(config) for name, config in STRATEGIES.items()}, + "openers": [{"phrase": o.phrase, "ms": o.ms, "raw_ms": o.raw_ms} for o in openers], + "stt_model": STT_MODEL, + "stt_backends": STT_BACKENDS, + "tts_model": CARTESIA_MODEL, + "tts_speed": CALLER_SPEED, + "hedge_fanout": HEDGE_FANOUT, + "rolling_max_in_flight": ROLLING_MAX_IN_FLIGHT, + "decision_model": DECISION_MODEL, + "speculate_p": SPECULATE_P, + "trigger_on_delta": TRIGGER_ON_DELTA, + }, + "samples": [ + asdict(s) + | {"reaction_ms": s.reaction_ms, "stt_lag_ms": s.stt_lag_ms, "content_ready_ms": s.content_ready_ms} + for s in samples + ], + } + (out_dir / "samples.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + print( + f"\nRecordings //opener_run.wav (L=assistant at {ASSISTANT_GAIN:.0%}, R=caller) -> {out_dir}/" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/run_text_only.py b/scripts/run_text_only.py index 2435457f4..221fb4a3d 100644 --- a/scripts/run_text_only.py +++ b/scripts/run_text_only.py @@ -511,7 +511,7 @@ async def run_record( audit_log = AuditLog() agent_llm_client = LiteLLMClient(model=llm_model) - user_sim_llm_client = LiteLLMClient(model="gpt-5.1") + user_sim_llm_client = LiteLLMClient(model="user-llm") agentic_system = AgenticSystem( current_date_time=record.current_date_time, diff --git a/src/eva/assistant/services/llm.py b/src/eva/assistant/services/llm.py index f35fb5962..5127e5a3a 100644 --- a/src/eva/assistant/services/llm.py +++ b/src/eva/assistant/services/llm.py @@ -93,7 +93,7 @@ async def complete( for attempt in range(max_retries + 1): try: start_time = time.time() - response = await router.get().acompletion(**kwargs) + response = await router.acompletion(**kwargs) elapsed_time = time.time() - start_time message = response.choices[0].message @@ -305,16 +305,17 @@ async def complete_stream( first_token = False try: start_time = time.time() - stream = await router.get().acompletion(**kwargs) - async for chunk in stream: - chunks.append(chunk) - choices = getattr(chunk, "choices", None) or [] - if choices: - delta = getattr(choices[0], "delta", None) - text = getattr(delta, "content", None) if delta else None - if text: - first_token = True - yield ("delta", text) + async with router.limit(self.model): + stream = await router.get().acompletion(**kwargs) + async for chunk in stream: + chunks.append(chunk) + choices = getattr(chunk, "choices", None) or [] + if choices: + delta = getattr(choices[0], "delta", None) + text = getattr(delta, "content", None) if delta else None + if text: + first_token = True + yield ("delta", text) elapsed_time = time.time() - start_time full = litellm.stream_chunk_builder(chunks, messages=messages) @@ -414,7 +415,8 @@ async def _complete_via_responses_api( for attempt in range(max_retries + 1): try: start_time = time.time() - response = await litellm.aresponses(**kwargs) + async with router.limit(self.model): + response = await litellm.aresponses(**kwargs) elapsed = time.time() - start_time text_content = "" diff --git a/src/eva/models/config.py b/src/eva/models/config.py index e13089b20..51b6aca51 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -491,8 +491,35 @@ class OpenAIRealtimeSimulatorConfig(BaseModel): male_voice: str = Field("cedar", description="Voice used for male caller personas.") +class CascadeSimulatorConfig(BaseModel): + """Caller built from independently chosen streaming STT, LLM, and TTS models.""" + + provider: Literal["cascade"] = "cascade" + stt: Literal["elevenlabs"] = Field("elevenlabs", description="Streaming STT provider for the assistant's audio.") + stt_model: str = Field("scribe_v2_realtime", description="Streaming STT model.") + llm: str = Field( + "user-llm", + description=( + "Caller LLM deployment, resolved via EVA_MODEL_LIST like the assistant's. A dedicated 'user-llm' " + "deployment lets the caller's model and params differ from the assistant under test." + ), + ) + tts: Literal["cartesia"] = Field("cartesia", description="TTS provider for the caller's speech.") + tts_model: str = Field("sonic-3.5", description="TTS model.") + female_voice: str = Field("f786b574-daa5-4673-aa0c-cbe3e8534c02", description="TTS voice for female personas.") + male_voice: str = Field("a0e99841-438c-4a64-b679-ae501e7d6091", description="TTS voice for male personas.") + + @field_validator("stt_model") + @classmethod + def _require_streaming_stt(cls, value: str) -> str: + # The caller must hear the assistant as it speaks; batch models can't support interruptions. + if not value.endswith("_realtime"): + raise ValueError(f"Cascade caller requires a streaming STT model, got {value!r}") + return value + + UserSimulatorConfig = Annotated[ - ElevenLabsSimulatorConfig | OpenAIRealtimeSimulatorConfig, + ElevenLabsSimulatorConfig | OpenAIRealtimeSimulatorConfig | CascadeSimulatorConfig, Field(discriminator="provider"), ] @@ -775,13 +802,13 @@ def _check_companion_services(self) -> "RunConfig": config is unused and conflicting env vars are harmless. """ if ( - isinstance(self.user_simulator, OpenAIRealtimeSimulatorConfig) + not isinstance(self.user_simulator, ElevenLabsSimulatorConfig) and self.perturbation is not None and self.perturbation.accent is not None ): raise ValueError( "Accent perturbations require the ElevenLabs user simulator; " - "OpenAI Realtime supports behavior, noise, and connection perturbations." + "other providers support behavior, noise, and connection perturbations." ) if self.max_rerun_attempts == 0 or self.aggregate_only: @@ -890,6 +917,16 @@ def _check_openai_realtime_simulator(self) -> "RunConfig": raise ValueError("EVA_USER_SIMULATOR__PROVIDER=openai_realtime requires OPENAI_API_KEY to be set.") return self + @model_validator(mode="after") + def _check_cascade_simulator(self) -> "RunConfig": + """When the cascade user simulator is selected, its STT and TTS keys must be present.""" + if not isinstance(self.user_simulator, CascadeSimulatorConfig): + return self + missing = [key for key in ("ELEVENLABS_API_KEY", "CARTESIA_API_KEY") if not os.environ.get(key)] + if missing: + raise ValueError(f"EVA_USER_SIMULATOR__PROVIDER=cascade requires {', '.join(missing)} to be set.") + return self + @model_validator(mode="before") @classmethod def _handle_all_keyword(cls, data: Any): diff --git a/src/eva/orchestrator/preflight.py b/src/eva/orchestrator/preflight.py index aedcb903d..319b9670f 100644 --- a/src/eva/orchestrator/preflight.py +++ b/src/eva/orchestrator/preflight.py @@ -160,6 +160,9 @@ async def _run_preflight(config: RunConfig) -> list[ProbeResult]: S2S runs are skipped (no live probe yet) — only a log line is emitted. """ + # TODO: probe the user simulator's own services when EVA_USER_SIMULATOR__PROVIDER=cascade (its LLM + # deployment, Scribe realtime STT, Cartesia TTS). Today a missing `user-llm` deployment only surfaces as + # every record failing after the assistant's greeting. timeout = config.preflight_timeout_seconds model = config.model probes: list[tuple[str, str, Awaitable[tuple[bool, str]]]] = [] diff --git a/src/eva/user_simulator/cascade/__init__.py b/src/eva/user_simulator/cascade/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eva/user_simulator/cascade/simulator.py b/src/eva/user_simulator/cascade/simulator.py new file mode 100644 index 000000000..3c2821c8c --- /dev/null +++ b/src/eva/user_simulator/cascade/simulator.py @@ -0,0 +1,393 @@ +"""Cascade (streaming STT -> LLM -> TTS) implementation of the EVA simulated caller.""" + +from __future__ import annotations + +import asyncio +import json +import re +from contextlib import suppress +from pathlib import Path +from typing import Any + +from eva.assistant.services.llm import LiteLLMClient +from eva.models.config import CascadeSimulatorConfig, PerturbationConfig +from eva.user_simulator.audio_bridge import BotToBotAudioBridge +from eva.user_simulator.base import AbstractUserSimulator +from eva.user_simulator.cascade.stt import ScribeStreamingSTT +from eva.user_simulator.cascade.tts import TTS_SAMPLE_RATE, CartesiaStreamingTTS +from eva.utils.logging import get_logger + +logger = get_logger(__name__) + +TURN_SETTLE_SECONDS = 1.0 +"""Assistant silence required before the caller replies.""" +TRANSCRIPT_WAIT_SECONDS = 1.5 +"""How long after a commit to wait for the final transcript before falling back to the partial.""" +INACTIVITY_TIMEOUT_SECONDS = 120.0 +"""Assistant silence that ends the call, matching ElevenLabsUserSimulator's 12 keep-alives.""" +PLAYBACK_DRAIN_SECONDS = 15.0 +FINAL_FLUSH_SECONDS = 4.0 +POLL_SECONDS = 0.05 + +TTS_WARM_CONNECTIONS = 1 +"""Pooled TTS connections opened before the first turn: one per TTS request that can render at once. +Barge-ins that split the first clause off (see ``next_segment``) will want 2.""" + +CLAUSE_MIN_WORDS = 3 +"""The first clause is spoken early only once it has this many words, so TTS has enough to phrase.""" +CLAUSE_BREAKS = ",;:\u2014\u2013" +SENTENCE_BREAKS = ".?!" + +_PERSONA_GENDER = {1: "F", 2: "M"} +_FENCE = re.compile(r"^```[a-z]*\s*|\s*```$", re.MULTILINE) + +END_CALL_DESCRIPTION = """Use this to end the phone call and hang up. + +Call this function when it is time to end the call and one of the following is true: +1. The agent has confirmed your request is resolved and all steps are completed. +2. The agent has initiated a transfer to a live agent. +3. The agent has been unable to make progress for at least 5 consecutive turns. +4. The agent says goodbye or indicates the conversation is over. +5. The agent indicates that the remainder of your request cannot be fulfilled. +6. The assistant reports an unrecoverable processing error. + +Only once one of these is true, say a brief goodbye and call this tool in that same response. +Your goodbye is spoken in full before the call hangs up, so do not wait for another turn to call it. + +That same-response rule is only for goodbyes. Never call this tool in a response that gives the +agent data, an identifier, an approval to proceed, a transfer request, or any other information, +or that asks the agent anything: that response needs the agent's reply first. A response that +calls this tool contains only your goodbye.""" +"""Written for the cascade, not shared with OpenAI Realtime: the caller hangs up in the goodbye turn +itself, since the cascade speaks a response's text before acting on its end_call, and waiting for a +later turn stalls when the agent doesn't answer the goodbye.""" + +END_CALL_TOOL = { + "type": "function", + "function": { + "name": "end_call", + "description": END_CALL_DESCRIPTION, + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} + + +def next_segment(buffer: str, *, first: bool, final: bool) -> tuple[str, str]: + """Split the next speakable segment off streamed text; returns (segment, rest). + + Not used on regular turns, which speak the whole line in one request (see ``_speak``). + Kept for barge-ins, where speaking the first clause early beats waiting for the full line. + + The first segment may end at a clause break, so audio starts as soon as a few words exist; + later ones end at sentences, so TTS gets whole sentences to phrase. A break only counts once + the following character is whitespace, so "4.5" or "MRC-07" never split. ``final`` flushes. + """ + breaks = SENTENCE_BREAKS + (CLAUSE_BREAKS if first else "") + for index, char in enumerate(buffer[:-1]): + if char not in breaks or not buffer[index + 1].isspace(): + continue + if char in CLAUSE_BREAKS and len(buffer[:index].split()) < CLAUSE_MIN_WORDS: + continue + return buffer[: index + 1].strip(), buffer[index + 1 :] + if final: + return buffer.strip(), "" + return "", buffer + + +def extract_turn(message: Any) -> tuple[str, bool]: + """Read (utterance, end_call) from LiteLLMClient output: a bare str, or a message with tool calls.""" + if isinstance(message, str): + return _FENCE.sub("", message).strip(), False + content = getattr(message, "content", None) or "" + calls = getattr(message, "tool_calls", None) or [] + end_call = any(getattr(call.function, "name", "") == "end_call" for call in calls) + return _FENCE.sub("", content).strip(), end_call + + +class CascadeUserSimulator(AbstractUserSimulator): + """Simulated caller built from independently chosen streaming STT, LLM, and TTS models.""" + + def __init__( + self, + current_date_time: str, + persona_config: dict, + goal: dict, + server_url: str, + output_dir: Path, + agent_id: str, + timeout: int = 600, + perturbation_config: PerturbationConfig | None = None, + language: str = "en", + *, + simulator_config: CascadeSimulatorConfig, + ) -> None: + super().__init__( + current_date_time=current_date_time, + persona_config=persona_config, + goal=goal, + server_url=server_url, + output_dir=output_dir, + agent_id=agent_id, + timeout=timeout, + perturbation_config=perturbation_config, + language=language, + provider="cascade", + ) + if perturbation_config and perturbation_config.accent is not None: + raise ValueError("Cascade caller does not support ElevenLabs-specific accent variants") + self.simulator_config = simulator_config + self._stt = ScribeStreamingSTT(simulator_config.stt_model, language=language) + self._tts = CartesiaStreamingTTS(simulator_config.tts_model, language=language) + self._llm = LiteLLMClient(model=simulator_config.llm) + # None is a commit marker: commit the STT segment once the audio queued ahead of it is sent. + self._assistant_audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._commit_after_next_chunk = False + self._history: list[dict[str, str]] = [] + + @property + def caller_voice(self) -> str: + gender = _PERSONA_GENDER.get(self.persona_config.get("user_persona_id")) + if gender == "M": + return self.simulator_config.male_voice + return self.simulator_config.female_voice + + async def run_conversation(self) -> str: + try: + await self._run_cascade_conversation() + except Exception as exc: + logger.error(f"Cascade caller simulation error: {exc}", exc_info=True) + self._end_reason = "error" + self.event_logger.log_error(str(exc)) + self.signal_conversation_ending("error") + finally: + # Logged last, after any error, so the final event carries the true end reason. + self.event_logger.log_connection_state("session_ended", {"reason": self._end_reason}) + self.event_logger.save() + return self._end_reason + + async def _run_cascade_conversation(self) -> None: + self._audio_interface = BotToBotAudioBridge( + websocket_uri=self.server_url, + conversation_id=self.output_dir.name, + record_callback=self._record_audio, + event_logger=self.event_logger, + conversation_done_callback=self._on_conversation_end, + perturbator=self._perturbator, + disconnect_reason="assistant_disconnect", + ) + tasks: list[asyncio.Task] = [] + try: + await self._stt.start() + await self._tts.warm_up(self.caller_voice, connections=TTS_WARM_CONNECTIONS) + await self._audio_interface.start_async() + self._audio_interface.start(self._on_assistant_audio) + self.event_logger.log_connection_state( + "connected", + { + "server_url": self.server_url, + "caller_provider": self.provider, + "caller_stt": f"{self.simulator_config.stt}/{self.simulator_config.stt_model}", + "caller_llm": self.simulator_config.llm, + "caller_tts": f"{self.simulator_config.tts}/{self.simulator_config.tts_model}", + "caller_voice": self.caller_voice, + }, + ) + completion_task = asyncio.create_task(self._wait_for_conversation_end()) + tasks = [completion_task, asyncio.create_task(self._forward_assistant_audio())] + tasks.append(asyncio.create_task(self._run_turns())) + + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if completion_task not in done and not self._conversation_done.is_set(): + finished = next(iter(done)) + raise finished.exception() or RuntimeError("Cascade caller task stopped unexpectedly") + + # Allow final goodbye audio and transcripts to flush before closing. + await asyncio.sleep(FINAL_FLUSH_SECONDS) + except Exception: + # Tell the assistant before teardown, as the base contract asks of every terminal path. + self.signal_conversation_ending("error") + raise + finally: + for task in tasks: + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + await self._stt.stop() + await self._tts.close() + await self._audio_interface.stop_async() + self._save_clean_user_audio(TTS_SAMPLE_RATE) + + async def _wait_for_conversation_end(self) -> None: + try: + await asyncio.wait_for(self._conversation_done.wait(), timeout=self.timeout) + except TimeoutError: + self.event_logger.log_event("timeout", {"duration": self.timeout}) + self._on_conversation_end("timeout") + + def _on_assistant_audio(self, mulaw_audio: bytes) -> None: + # Forwarded unconditionally, silence included, so the caller keeps hearing while it speaks. + if mulaw_audio: + self._assistant_audio_queue.put_nowait(mulaw_audio) + if self._commit_after_next_chunk: + self._commit_after_next_chunk = False + self._assistant_audio_queue.put_nowait(None) + + def _request_commit(self) -> None: + """Commit once the next bridge chunk is forwarded. + + The bridge flags the assistant's audio as ended before it hands over the tick that holds + the tail, so committing right away would cut the last word into the next segment. + """ + self._commit_after_next_chunk = True + # Until the commit is sent, an earlier turn's set event must not count as this segment's final. + self._stt.committed_event.clear() + + async def _forward_assistant_audio(self) -> None: + while True: + item = await self._assistant_audio_queue.get() + if item is None: + await self._stt.commit() + else: + await self._stt.send(item) + + async def _run_turns(self) -> None: + """Reply once the assistant's turn has settled; end the call if it goes quiet for too long.""" + loop = asyncio.get_running_loop() + bridge = self._audio_interface + assistant_spoke = False + committed_at: float | None = None + last_activity = loop.time() + + while not self._conversation_done.is_set(): + await asyncio.sleep(POLL_SECONDS) + now = loop.time() + if bridge.is_assistant_playing(): + assistant_spoke, committed_at, last_activity = True, None, now + continue + if bridge.is_caller_playing(): + last_activity = now + continue + if not assistant_spoke: + if now - last_activity > INACTIVITY_TIMEOUT_SECONDS: + self._on_conversation_end("inactivity_timeout") + return + continue + + # Commit each pause, so a mid-turn pause closes a segment and the rest accumulates. + if committed_at is None: + self._request_commit() + committed_at = now + ended_at = bridge.assistant_audio_ended_at + if ended_at is None or now - ended_at < TURN_SETTLE_SECONDS: + continue + + heard = await self._collect_heard_text(committed_at) + assistant_spoke = False + if not heard: + self.event_logger.log_event("assistant_audio_untranscribed", {}) + continue + if await self._take_turn(heard): + return + last_activity = loop.time() + + async def _collect_heard_text(self, committed_at: float) -> str: + """Return what the assistant said, falling back to the partial if the final is late.""" + remaining = TRANSCRIPT_WAIT_SECONDS - (asyncio.get_running_loop().time() - committed_at) + try: + await asyncio.wait_for(self._stt.committed_event.wait(), timeout=max(remaining, 0)) + except TimeoutError: + text = self._stt.take(include_partial=True) + logger.warning(f"No final transcript after {TRANSCRIPT_WAIT_SECONDS}s; using partial: {text[:120]!r}") + self.event_logger.log_event("transcript_partial_fallback", {"text": text}) + return text + return self._stt.take() + + async def _take_turn(self, heard: str) -> bool: + """Generate and speak one caller turn. Returns True when the caller hangs up.""" + self._history.append({"role": "assistant", "content": heard}) + self._on_assistant_speaks(heard) + + messages = self._messages() + message, stats, spoke = await self._speak(messages) + utterance, end_call = extract_turn(message) + self._log_llm_response(messages, message, stats) + + if utterance: + self._history.append({"role": "user", "content": utterance}) + self._on_user_speaks(utterance) + if spoke: + # A trailing partial chunk is what lets the bridge detect the end of the utterance. + self._audio_interface.output(b"\x00\x00") + self._audio_interface.notify_user_utterance_complete() + + if end_call: + self.event_logger.log_event("tool_call", {"name": "end_call", "arguments": "{}"}) + with suppress(TimeoutError): + await asyncio.wait_for(self._wait_for_caller_playback(), timeout=PLAYBACK_DRAIN_SECONDS) + self._on_conversation_end("goodbye") + return True + return False + + async def _speak(self, messages: list[dict[str, Any]]) -> tuple[Any, dict[str, Any], bool]: + """Generate the whole caller line, then speak it in one TTS request with the audio streamed back. + + One request per line keeps the audio gap-free (Cartesia renders faster than real time) and + the prosody continuous, the way an ElevenLabs caller sounds. Splitting the text at + ``next_segment`` breaks would start audio before the line is written, but a stall between + segments can let the bridge end the utterance mid-sentence. Splitting is reserved for + barge-ins, where the first clause must start before the rest of the line exists. + + Returns the assembled final message, its stats, and whether any audio was sent. + """ + message: Any = "" + stats: dict[str, Any] = {} + # Streamed even though only the final is used: barge-ins will consume the deltas. + async for kind, payload in self._llm.complete_stream(messages=messages, tools=[END_CALL_TOOL]): + if kind == "final": + message, stats = payload + utterance, _ = extract_turn(message) + spoke = False + if utterance: + async for chunk in self._tts.stream(utterance, voice_id=self.caller_voice): + self._audio_interface.output(chunk) + spoke = True + return message, stats, spoke + + def _log_llm_response(self, messages: list[dict[str, str]], message: Any, stats: dict[str, Any]) -> None: + """Record exactly what the caller LLM was sent and returned, for diagnosing turn decisions.""" + calls = getattr(message, "tool_calls", None) or [] + self.event_logger.log_event( + "caller_llm_response", + { + "response_type": type(message).__name__, + "content": message if isinstance(message, str) else getattr(message, "content", None), + "tool_calls": [ + { + "name": getattr(call.function, "name", None), + "arguments": getattr(call.function, "arguments", None), + } + for call in calls + ], + "finish_reason": stats.get("finish_reason"), + "model": stats.get("model"), + "parameters": json.loads(json.dumps(stats.get("parameters"), default=str)), + "message_roles": [m["role"] for m in messages], + "last_message": messages[-1]["content"] if len(messages) > 1 else None, + }, + ) + + async def _wait_for_caller_playback(self) -> None: + while self._audio_interface.is_caller_playing(): + await asyncio.sleep(POLL_SECONDS) + + def _messages(self) -> list[dict[str, str]]: + """The shared caller prompt plus history, with roles flipped into the caller LLM's own frame. + + ``_history`` keeps conversation-truth roles; the caller LLM is the "assistant" in its own + frame, so without the flip it reads the agent's lines as its own prior output. + """ + flipped = [ + {"role": "assistant" if turn["role"] == "user" else "user", "content": turn["content"]} + for turn in self._history + ] + return [{"role": "system", "content": self._build_prompt()}, *flipped] diff --git a/src/eva/user_simulator/cascade/stt.py b/src/eva/user_simulator/cascade/stt.py new file mode 100644 index 000000000..eaabfdbf5 --- /dev/null +++ b/src/eva/user_simulator/cascade/stt.py @@ -0,0 +1,140 @@ +"""Streaming transcription of the assistant's audio for the cascade caller.""" + +from __future__ import annotations + +import asyncio +import base64 +import os +from typing import Any + +try: + import audioop +except ImportError: # Python 3.13+ + import audioop_lts as audioop + +from elevenlabs.realtime import AudioFormat, CommitStrategy, RealtimeConnection, RealtimeEvents, ScribeRealtime + +from eva.utils.logging import get_logger + +logger = get_logger(__name__) + +INPUT_SAMPLE_RATE = 8000 +"""The bridge delivers the assistant's audio as 8kHz mu-law, which is what send() accepts.""" + +STT_AUDIO_FORMAT = AudioFormat.PCM_16000 +STT_SAMPLE_RATE = 16000 +"""What Scribe is sent. The same 8kHz audio decoded and upsampled to 16kHz PCM16 transcribed +~120ms faster (STT lag p50 422 vs 540ms) than sending Scribe the mu-law directly, with no +change in the information it carries (scripts/measure_barge_in_latency.py, STT_BACKENDS).""" + + +class ScribeStreamingSTT: + """Transcribes assistant audio continuously over ElevenLabs Scribe realtime. + + Partials are tracked as they arrive so a listener can act on what has been heard + mid-utterance. Commits are manual: the caller decides where a segment ends rather + than the provider's VAD, so turn boundaries have a single owner. + """ + + def __init__(self, model: str, *, language: str = "en", api_key: str | None = None) -> None: + self.model = model + self.language = language.split("-")[0] + self._api_key = api_key or os.environ.get("ELEVENLABS_API_KEY", "") + self._connection: RealtimeConnection | None = None + self._committed: list[str] = [] + self.partial = "" + self.error: str | None = None + self._closing = False + self.committed_event = asyncio.Event() + self._pending_commits = 0 + self._stale_commits = 0 + """Commits whose segment was already taken from the partial; their late finals are dropped.""" + self._resample_state = None + self.updated = asyncio.Event() + """Set on every partial or committed transcript, so a listener can react to each update + (e.g. an interruption decision) without polling. Clear it before waiting.""" + + async def start(self) -> None: + """Open the realtime session.""" + self._connection = await ScribeRealtime(api_key=self._api_key).connect( + { + "model_id": self.model, + "audio_format": STT_AUDIO_FORMAT, + "sample_rate": STT_SAMPLE_RATE, + "commit_strategy": CommitStrategy.MANUAL, + "language_code": self.language, + } + ) + self._connection.on(RealtimeEvents.PARTIAL_TRANSCRIPT, self._on_partial) + self._connection.on(RealtimeEvents.COMMITTED_TRANSCRIPT, self._on_committed) + self._connection.on(RealtimeEvents.ERROR, self._on_error) + + async def send(self, mulaw: bytes) -> None: + """Stream one chunk of the assistant's 8kHz mu-law audio, as 16kHz PCM16.""" + if self.error is not None: + raise RuntimeError(f"Caller STT failed: {self.error}") + if self._connection is not None and mulaw: + pcm, self._resample_state = audioop.ratecv( + audioop.ulaw2lin(mulaw, 2), 2, 1, INPUT_SAMPLE_RATE, STT_SAMPLE_RATE, self._resample_state + ) + await self._connection.send({"audio_base_64": base64.b64encode(pcm).decode("ascii")}) + + async def commit(self) -> None: + """Close the current segment. ``committed_event`` is set once every commit sent so far has its final text.""" + if self._connection is not None: + self._pending_commits += 1 + self.committed_event.clear() + await self._connection.commit() + + def heard_text(self) -> str: + """Everything heard since the last ``take``, including the in-flight partial.""" + return " ".join([*self._committed, self.partial]).strip() + + def take(self, *, include_partial: bool = False) -> str: + """Return and clear the committed text, optionally with the in-flight partial.""" + text = self.heard_text() if include_partial else " ".join(self._committed).strip() + self._committed.clear() + if include_partial: + self.partial = "" + # Finals still owed for this turn would otherwise land in the next one. + self._stale_commits += self._pending_commits + self._pending_commits = 0 + return text + + async def stop(self) -> None: + """Close the session. Safe to call twice.""" + connection, self._connection = self._connection, None + # Our own close makes the SDK's receive loop raise (Scribe sends no close frame back), + # which it reports as an ERROR event; anything after this point is teardown, not a failure. + self._closing = True + if connection is not None: + try: + await connection.close() + except Exception as exc: + logger.warning(f"Error closing caller STT: {exc}") + + def _on_partial(self, data: dict[str, Any]) -> None: + self.partial = data.get("text", "") + self.updated.set() + + def _on_committed(self, data: dict[str, Any]) -> None: + text = data.get("text", "").strip() + self.partial = "" + self.updated.set() + # Scribe answers commits in order, so the oldest outstanding one is the stale one. + if self._stale_commits: + self._stale_commits -= 1 + logger.debug(f"Dropping late final for an already-answered turn: {text[:120]!r}") + return + if text: + self._committed.append(text) + self._pending_commits = max(self._pending_commits - 1, 0) + if self._pending_commits == 0: + self.committed_event.set() + + def _on_error(self, data: dict[str, Any]) -> None: + if self._closing: + logger.debug(f"Caller STT closed: {data}") + return + self.error = str(data.get("error") or data.get("message_type") or data) + logger.error(f"Caller STT error: {self.error}") diff --git a/src/eva/user_simulator/cascade/tts.py b/src/eva/user_simulator/cascade/tts.py new file mode 100644 index 000000000..96f5e821e --- /dev/null +++ b/src/eva/user_simulator/cascade/tts.py @@ -0,0 +1,89 @@ +"""Streaming speech synthesis for the cascade caller.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +from collections.abc import AsyncIterator + +import httpx + +TTS_SAMPLE_RATE = 16000 +"""Matches what BotToBotAudioBridge.output() expects.""" + +CARTESIA_SSE_URL = "https://api.cartesia.ai/tts/sse" +CARTESIA_VERSION = "2025-11-04" + +CALLER_SPEED = 1.2 # TODO Will use this for the generated barge ins +"""Cartesia generation_config.speed for the caller. People speed up when they cut in, and it +shortens pre-rendered openers. Fixed for now; promote to config if it needs tuning.""" + +KEEPALIVE = httpx.Limits(max_keepalive_connections=8, keepalive_expiry=300.0) +"""httpx's 5s default would drop the warm connection during a long assistant turn, putting a +TLS handshake (~100ms) in front of the caller's next line.""" + + +class CartesiaStreamingTTS: + """Renders caller text to 16kHz PCM16, yielding chunks as they are generated. + + Calls Cartesia's SSE endpoint over a long-lived httpx client rather than the Cartesia SDK: + the SDK's ``generate_sse`` (3.0.2) buffers the whole response before yielding its first + event, so first audio waited for the entire line to render (~0.8-1.2s against ~250ms). + """ + + def __init__(self, model: str, *, language: str = "en", api_key: str | None = None) -> None: + self.model = model + self.language = language.split("-")[0] + self._client = httpx.AsyncClient( + headers={ + "X-API-Key": api_key or os.environ.get("CARTESIA_API_KEY", ""), + "Cartesia-Version": CARTESIA_VERSION, + }, + timeout=30.0, + limits=KEEPALIVE, + ) + + def _body(self, text: str, voice_id: str) -> dict: + body = { + "model_id": self.model, + "transcript": text, + "voice": {"mode": "id", "id": voice_id}, + "language": self.language, + "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": TTS_SAMPLE_RATE}, + #"generation_config": {"speed": CALLER_SPEED}, + } + return body + + async def stream(self, text: str, *, voice_id: str) -> AsyncIterator[bytes]: + """Yield sample-aligned PCM16 chunks so playout can start before the whole line is rendered.""" + carry = b"" + async with self._client.stream("POST", CARTESIA_SSE_URL, json=self._body(text, voice_id)) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + event = json.loads(line[5:]) + if event.get("type") == "error": + raise RuntimeError(f"Caller TTS failed: {event.get('error') or event}") + if event.get("type") != "chunk" or not event.get("data"): + continue + # Chunk boundaries need not fall on samples; an odd split would shear PCM16. + data = carry + base64.b64decode(event["data"]) + cut = len(data) - len(data) % 2 + carry = data[cut:] + if cut: + yield data[:cut] + + async def warm_up(self, voice_id: str, *, connections: int = 1) -> None: + """Open ``connections`` pooled connections now, so no handshake lands on a caller turn.""" + + async def render() -> None: + async for _ in self.stream("Okay.", voice_id=voice_id): + pass + + await asyncio.gather(*(render() for _ in range(connections))) + + async def close(self) -> None: + await self._client.aclose() diff --git a/src/eva/user_simulator/factory.py b/src/eva/user_simulator/factory.py index d2cef7e2e..0ad5da172 100644 --- a/src/eva/user_simulator/factory.py +++ b/src/eva/user_simulator/factory.py @@ -4,7 +4,12 @@ from typing import Any -from eva.models.config import ElevenLabsSimulatorConfig, OpenAIRealtimeSimulatorConfig, UserSimulatorConfig +from eva.models.config import ( + CascadeSimulatorConfig, + ElevenLabsSimulatorConfig, + OpenAIRealtimeSimulatorConfig, + UserSimulatorConfig, +) from eva.user_simulator.base import AbstractUserSimulator @@ -21,4 +26,8 @@ def create_user_simulator( from eva.user_simulator.openai_realtime import OpenAIRealtimeUserSimulator return OpenAIRealtimeUserSimulator(simulator_config=simulator_config, **kwargs) + if isinstance(simulator_config, CascadeSimulatorConfig): + from eva.user_simulator.cascade.simulator import CascadeUserSimulator + + return CascadeUserSimulator(simulator_config=simulator_config, **kwargs) raise ValueError(f"Unknown user simulator provider: {simulator_config.provider!r}") diff --git a/src/eva/utils/llm_client.py b/src/eva/utils/llm_client.py index 07751586b..72c0b8522 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -142,7 +142,7 @@ async def generate_text(self, messages: list[dict], response_format: dict | None # Make the API call logger.debug(f"{call_retry_id} started for {self.model}") - response = await router.get().acompletion(**kwargs) + response = await router.acompletion(**kwargs) (logger.info if attempt > 0 else logger.debug)(f"{call_retry_id} succeeded for {self.model}") diff --git a/src/eva/utils/router.py b/src/eva/utils/router.py index a6b73e1a8..5705d4e2b 100644 --- a/src/eva/utils/router.py +++ b/src/eva/utils/router.py @@ -1,28 +1,67 @@ """LiteLLM Router — configure-once module. Call `init(model_list)` once at startup, then `get()` anywhere to retrieve the shared Router instance. + +Concurrency: since LiteLLM 1.98 the Router rejects calls over a deployment's ``max_parallel_requests`` with an +immediate 429 instead of queueing them. EVA relies on queueing, so it bounds concurrency itself: every call goes +through `limit(model_group)` (or `acompletion`), which waits on a per-model-group semaphore. """ +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + from litellm import Router +from litellm.utils import calculate_max_parallel_requests from eva.utils.logging import get_logger logger = get_logger(__name__) +_DEFAULT_MAX_PARALLEL_REQUESTS = 5 + _router: Router | None = None +_semaphores: dict[str, asyncio.Semaphore] = {} def init(model_list: list) -> None: """Create and store the shared Router. Call once at startup.""" - global _router + global _router, _semaphores logger.info(f"Initializing LiteLLM Router with {len(model_list)} deployment(s)") - _router = Router(model_list=model_list, num_retries=0, default_max_parallel_requests=5, timeout=60) + _router = Router( + model_list=model_list, + num_retries=0, + default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, + timeout=60, + ) + _semaphores = _build_semaphores(model_list) + + +def _build_semaphores(model_list: list) -> dict[str, asyncio.Semaphore]: + """One semaphore per model group, sized to the sum of its deployments' concurrency caps.""" + capacities: dict[str, int] = {} + for deployment in model_list: + # TODO: max_parallel_requests/rpm/tpm are read from litellm_params for backwards compatibility with + # existing EVA_MODEL_LIST configs. This is now an EVA-level setting (LiteLLM no longer queues), so it + # should move out of litellm_params to the top level of the deployment, next to use_responses_api. + params = deployment.get("litellm_params", {}) + cap = calculate_max_parallel_requests( + max_parallel_requests=params.get("max_parallel_requests"), + rpm=params.get("rpm"), + tpm=params.get("tpm"), + default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, + ) + name = deployment["model_name"] + capacities[name] = capacities.get(name, 0) + cap + return {name: asyncio.Semaphore(cap) for name, cap in capacities.items()} def reset() -> None: """Reset the shared Router to None.""" - global _router + global _router, _semaphores _router = None + _semaphores = {} def get() -> Router: @@ -30,3 +69,23 @@ def get() -> Router: if _router is None: raise RuntimeError("Router not initialized — call eva.utils.router.init() first") return _router + + +@asynccontextmanager +async def limit(model_group: str) -> AsyncIterator[None]: + """Hold a concurrency slot for `model_group`, waiting if all slots are in use. + + Hold it for the whole call, including consuming a stream. Groups with no configured deployment are unbounded. + """ + semaphore = _semaphores.get(model_group) + if semaphore is None: + yield + return + async with semaphore: + yield + + +async def acompletion(**kwargs: Any) -> Any: + """`Router.acompletion` bounded by the model group's concurrency limit (non-streaming calls).""" + async with limit(kwargs["model"]): + return await get().acompletion(**kwargs) diff --git a/tests/unit/user_simulator/test_cascade.py b/tests/unit/user_simulator/test_cascade.py new file mode 100644 index 000000000..24ad10198 --- /dev/null +++ b/tests/unit/user_simulator/test_cascade.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from pydantic import ValidationError + +from eva.models.config import CascadeSimulatorConfig, PerturbationConfig +from eva.user_simulator.cascade import simulator as cascade_simulator +from eva.user_simulator.cascade.simulator import CascadeUserSimulator, extract_turn, next_segment +from eva.user_simulator.cascade.stt import ScribeStreamingSTT +from eva.user_simulator.cascade.tts import CartesiaStreamingTTS + +GOAL = { + "high_level_user_goal": "Reset my password.", + "decision_tree": { + "must_have_criteria": ["Reset the password."], + "escalation_behavior": "Escalate if blocked.", + "nice_to_have_criteria": [], + "negotiation_behavior": "Accept a valid resolution.", + "resolution_condition": "The password is reset.", + "failure_condition": "The password cannot be reset.", + "edge_cases": [], + }, + "information_required": ["employee ID"], + "starting_utterance": "I need to reset my password.", +} + + +@pytest.fixture(autouse=True) +def _no_router(monkeypatch): + monkeypatch.setattr(cascade_simulator, "LiteLLMClient", MagicMock()) + + +def _simulator(tmp_path: Path, *, persona_id: int = 1, **kwargs) -> CascadeUserSimulator: + return CascadeUserSimulator( + current_date_time="2026-06-05T12:00:00", + persona_config={"user_persona_id": persona_id}, + goal=GOAL, + server_url="ws://localhost:9999/ws", + output_dir=tmp_path, + agent_id="agent_itsm", + simulator_config=CascadeSimulatorConfig(), + **kwargs, + ) + + +def _tool_message(content: str, *tool_names: str) -> SimpleNamespace: + calls = [SimpleNamespace(function=SimpleNamespace(name=name)) for name in tool_names] + return SimpleNamespace(content=content, tool_calls=calls) + + +async def _chunks(*chunks: bytes): + for chunk in chunks: + yield chunk + + +def test_config_rejects_non_streaming_stt(): + with pytest.raises(ValidationError, match="streaming STT"): + CascadeSimulatorConfig(stt_model="scribe_v2") + + +def test_accent_variants_are_rejected(tmp_path): + with pytest.raises(ValueError, match="ElevenLabs-specific"): + _simulator(tmp_path, perturbation_config=PerturbationConfig(accent="french")) + + +def test_persona_selects_voice(tmp_path): + config = CascadeSimulatorConfig() + + assert _simulator(tmp_path / "f").caller_voice == config.female_voice + assert _simulator(tmp_path / "m", persona_id=2).caller_voice == config.male_voice + + +def test_regional_language_uses_base_code(tmp_path): + simulator = _simulator(tmp_path, language="fr-CA") + + assert simulator._stt.language == "fr" + assert simulator._tts.language == "fr" + + +def test_extract_turn_handles_text_and_tool_calls(): + assert extract_turn("```\nHi there.\n```") == ("Hi there.", False) + assert extract_turn(_tool_message("Bye.", "end_call")) == ("Bye.", True) + assert extract_turn(_tool_message("", "other")) == ("", False) + + +def test_messages_flip_roles_into_caller_frame(tmp_path): + simulator = _simulator(tmp_path) + simulator._history = [ + {"role": "assistant", "content": "How can I help?"}, + {"role": "user", "content": "Reset my password."}, + ] + + messages = simulator._messages() + + assert messages[0]["role"] == "system" + assert [m["role"] for m in messages[1:]] == ["user", "assistant"] + + +def test_stt_accumulates_committed_segments_and_partials(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + + stt._on_committed({"text": "Hello,"}) + stt._on_partial({"text": "how can"}) + + assert stt.heard_text() == "Hello, how can" + assert stt.take() == "Hello," + assert stt.partial == "how can" + assert stt.take(include_partial=True) == "how can" + assert stt.heard_text() == "" + + +@pytest.mark.asyncio +async def test_stt_send_raises_after_provider_error(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._on_error({"error": "auth failed"}) + + with pytest.raises(RuntimeError, match="auth failed"): + await stt.send(b"\xff") + + +@pytest.mark.asyncio +async def test_stt_waits_for_every_pending_commit(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._connection = MagicMock(commit=AsyncMock()) + await stt.commit() + await stt.commit() + + stt._on_committed({"text": "One moment."}) + assert not stt.committed_event.is_set() + stt._on_committed({"text": "I found it."}) + + assert stt.committed_event.is_set() + assert stt.take() == "One moment. I found it." + + +@pytest.mark.asyncio +async def test_stt_drops_late_final_after_partial_fallback(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._connection = MagicMock(commit=AsyncMock()) + await stt.commit() + stt._on_partial({"text": "your ticket is"}) + assert stt.take(include_partial=True) == "your ticket is" + + stt._on_committed({"text": "your ticket is open."}) + await stt.commit() + stt._on_committed({"text": "Anything else?"}) + + assert stt.take() == "Anything else?" + + +@pytest.mark.asyncio +async def test_commit_is_queued_behind_the_next_bridge_chunk(tmp_path): + simulator = _simulator(tmp_path) + simulator._stt = MagicMock(send=AsyncMock(), commit=AsyncMock(), committed_event=asyncio.Event()) + order: list[str] = [] + simulator._stt.send.side_effect = lambda chunk: order.append(f"send:{chunk.decode()}") + simulator._stt.commit.side_effect = lambda: order.append("commit") + + simulator._on_assistant_audio(b"speech") + simulator._request_commit() + simulator._on_assistant_audio(b"tail") + simulator._on_assistant_audio(b"silence") + forwarder = asyncio.create_task(simulator._forward_assistant_audio()) + while simulator._assistant_audio_queue.qsize(): + await asyncio.sleep(0) + forwarder.cancel() + + assert order == ["send:speech", "send:tail", "commit", "send:silence"] + + +@pytest.mark.asyncio +async def test_error_signals_assistant_and_ends_with_session_ended(tmp_path): + simulator = _simulator(tmp_path) + simulator._stt.start = AsyncMock(side_effect=RuntimeError("boom")) + simulator._stt.stop = AsyncMock() + simulator._tts.close = AsyncMock() + simulator._save_clean_user_audio = MagicMock() + signaled: list[str | None] = [] + simulator.on_conversation_ending = signaled.append + + assert await simulator.run_conversation() == "error" + + assert signaled == ["error"] + events = [json.loads(line) for line in (tmp_path / "user_simulator_events.jsonl").read_text().splitlines()] + assert events[-1]["type"] == "connection_state" + assert events[-1]["data"]["details"]["reason"] == "error" + + +@pytest.mark.asyncio +async def test_collect_heard_text_falls_back_to_partial(tmp_path, monkeypatch): + monkeypatch.setattr(cascade_simulator, "TRANSCRIPT_WAIT_SECONDS", 0.01) + simulator = _simulator(tmp_path) + simulator._stt._on_partial({"text": "your ticket is"}) + + heard = await simulator._collect_heard_text(asyncio.get_running_loop().time()) + + assert heard == "your ticket is" + + +def _llm_stream(*deltas: str, message=None): + """A complete_stream stand-in: text deltas, then the assembled final message.""" + + async def stream(**_): + for delta in deltas: + yield ("delta", delta) + yield ("final", (message if message is not None else "".join(deltas), {})) + + return stream + + +def test_next_segment_speaks_first_clause_then_sentences(): + assert next_segment("I have my ID, and", first=True, final=False) == ("I have my ID,", " and") + # Too few words for an early clause; later segments only end at sentences. + assert next_segment("Yes, my ID", first=True, final=False) == ("", "Yes, my ID") + assert next_segment("It is 42, thanks. More", first=False, final=False) == ("It is 42, thanks.", " More") + # A break needs following whitespace, so codes and decimals never split. + assert next_segment("Code MRC-07 or 4.5 now", first=True, final=False) == ("", "Code MRC-07 or 4.5 now") + assert next_segment("trailing words", first=False, final=True) == ("trailing words", "") + + +@pytest.mark.asyncio +async def test_take_turn_speaks_whole_line_in_one_request(tmp_path): + simulator = _simulator(tmp_path) + simulator._llm.complete_stream = _llm_stream("I have it here, ", "my ID is 42. ", "Thanks.") + rendered: list[str] = [] + + def stream(text, voice_id): + rendered.append(text) + return _chunks(b"\x01\x02", b"\x03\x04") + + simulator._tts.stream = stream + simulator._audio_interface = MagicMock() + + hung_up = await simulator._take_turn("What is your employee ID?") + + assert hung_up is False + assert rendered == ["I have it here, my ID is 42. Thanks."] + outputs = [call.args[0] for call in simulator._audio_interface.output.call_args_list] + assert outputs == [b"\x01\x02", b"\x03\x04", b"\x00\x00"] + simulator._audio_interface.notify_user_utterance_complete.assert_called_once() + assert simulator._history == [ + {"role": "assistant", "content": "What is your employee ID?"}, + {"role": "user", "content": "I have it here, my ID is 42. Thanks."}, + ] + + +@pytest.mark.asyncio +async def test_end_call_without_speech_skips_tts(tmp_path): + simulator = _simulator(tmp_path) + simulator._llm.complete_stream = _llm_stream(message=_tool_message("", "end_call")) + simulator._tts.stream = MagicMock() + simulator._audio_interface = MagicMock() + simulator._audio_interface.is_caller_playing.return_value = False + + assert await simulator._take_turn("Goodbye.") is True + simulator._tts.stream.assert_not_called() + simulator._audio_interface.output.assert_not_called() + + +@pytest.mark.asyncio +async def test_end_call_hangs_up_after_playback(tmp_path): + simulator = _simulator(tmp_path) + simulator._llm.complete_stream = _llm_stream("Thanks, bye.", message=_tool_message("Thanks, bye.", "end_call")) + simulator._tts.stream = MagicMock(return_value=_chunks(b"\x01\x02")) + simulator._audio_interface = MagicMock() + simulator._audio_interface.is_caller_playing.return_value = False + + hung_up = await simulator._take_turn("Your password is reset.") + + assert hung_up is True + assert simulator._end_reason == "goodbye" + + +def _sse(*events: dict) -> httpx.MockTransport: + body = "".join(f"data: {json.dumps(event)}\n\n" for event in events) + return httpx.MockTransport(lambda request: httpx.Response(200, text=body)) + + +@pytest.mark.asyncio +async def test_tts_streams_sample_aligned_chunks_and_raises_on_error(): + tts = CartesiaStreamingTTS("sonic-3.5", api_key="x") + odd = base64.b64encode(b"\x01\x02\x03").decode() + tail = base64.b64encode(b"\x04").decode() + tts._client = httpx.AsyncClient(transport=_sse({"type": "chunk", "data": odd}, {"type": "chunk", "data": tail})) + assert [chunk async for chunk in tts.stream("Hi", voice_id="v")] == [b"\x01\x02", b"\x03\x04"] + # Normal turns speak at the voice's natural speed; CALLER_SPEED is reserved for barge-ins. + assert "generation_config" not in tts._body("Hi", "v") + + tts._client = httpx.AsyncClient(transport=_sse({"type": "error", "error": "bad voice"})) + with pytest.raises(RuntimeError, match="bad voice"): + [chunk async for chunk in tts.stream("Hi", voice_id="v")] + + +@pytest.mark.asyncio +async def test_stt_ignores_errors_from_its_own_close(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._connection = MagicMock(close=AsyncMock(side_effect=lambda: stt._on_error({"error": "sent 1000 (OK)"}))) + await stt.stop() + assert stt.error is None + + +@pytest.mark.asyncio +async def test_stt_sends_bridge_mulaw_as_16k_pcm16(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._connection = MagicMock(send=AsyncMock()) + for _ in range(50): + await stt.send(b"\xff" * 160) # 20ms of 8kHz mu-law silence each, 1s total + sent = sum(len(base64.b64decode(c.args[0]["audio_base_64"])) for c in stt._connection.send.call_args_list) + # 1s of 16kHz PCM16 is 32000 bytes; the resampler carries a sample of state between chunks. + assert abs(sent - 32000) <= 4 diff --git a/tests/unit/user_simulator/test_factory.py b/tests/unit/user_simulator/test_factory.py index d47f1abea..7ae1f6f7a 100644 --- a/tests/unit/user_simulator/test_factory.py +++ b/tests/unit/user_simulator/test_factory.py @@ -1,6 +1,9 @@ from pathlib import Path +from unittest.mock import MagicMock -from eva.models.config import ElevenLabsSimulatorConfig, OpenAIRealtimeSimulatorConfig +from eva.models.config import CascadeSimulatorConfig, ElevenLabsSimulatorConfig, OpenAIRealtimeSimulatorConfig +from eva.user_simulator.cascade import simulator as cascade_simulator +from eva.user_simulator.cascade.simulator import CascadeUserSimulator from eva.user_simulator.elevenlabs import ElevenLabsUserSimulator from eva.user_simulator.factory import create_user_simulator from eva.user_simulator.openai_realtime import OpenAIRealtimeUserSimulator @@ -43,3 +46,11 @@ def test_factory_selects_openai_realtime(tmp_path): assert isinstance(simulator, OpenAIRealtimeUserSimulator) assert simulator.caller_model == "gpt-realtime-1.5" + + +def test_factory_selects_cascade(tmp_path, monkeypatch): + monkeypatch.setattr(cascade_simulator, "LiteLLMClient", MagicMock()) + simulator = create_user_simulator(CascadeSimulatorConfig(), **_kwargs(tmp_path)) + + assert isinstance(simulator, CascadeUserSimulator) + assert simulator.provider == "cascade" diff --git a/tests/unit/utils/test_llm_client.py b/tests/unit/utils/test_llm_client.py index 0ebecf6ab..16e5de9b0 100644 --- a/tests/unit/utils/test_llm_client.py +++ b/tests/unit/utils/test_llm_client.py @@ -216,8 +216,8 @@ class TestRouterConcurrency: @pytest.mark.asyncio async def test_max_parallel_requests_bounds_concurrency(self): """Peak concurrent calls never exceeds max_parallel_requests.""" - test_router = Router( - model_list=[ + router.init( + [ { "model_name": "test-model", "litellm_params": { @@ -226,8 +226,7 @@ async def test_max_parallel_requests_bounds_concurrency(self): "max_parallel_requests": 2, }, } - ], - num_retries=0, + ] ) peak_concurrent = 0 @@ -246,7 +245,7 @@ async def mock_acompletion(*args, **kwargs): with patch("litellm.acompletion", new=mock_acompletion): tasks = [ - test_router.acompletion( + router.acompletion( model="test-model", messages=[{"role": "user", "content": f"msg-{i}"}], ) diff --git a/uv.lock b/uv.lock index 27ff23590..3e491489c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -7,42 +7,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[[package]] -name = "aioboto3" -version = "15.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiobotocore", extra = ["boto3"] }, - { name = "aiofiles" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, -] - -[[package]] -name = "aiobotocore" -version = "2.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aioitertools" }, - { name = "botocore" }, - { name = "jmespath" }, - { name = "multidict" }, - { name = "python-dateutil" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, -] - -[package.optional-dependencies] -boto3 = [ - { name = "boto3" }, -] - [[package]] name = "aiofiles" version = "24.1.0" @@ -63,7 +27,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -72,70 +36,71 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, -] - -[[package]] -name = "aioitertools" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] [[package]] @@ -334,30 +299,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.40.61" +version = "1.43.114" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/8c/f6f884dc947789317e73ed6fce85e18580d22e9f90e48d67c2367b02667e/boto3-1.43.114.tar.gz", hash = "sha256:be704857751564a5cf69c5bbaadbfa01c22806409815c73563db42fbffe583a2", size = 112653, upload-time = "2026-10-14T19:24:22.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f8/0799a101e6f65c8b687f50c218654cef1e44658e946c7d33d362e2572621/boto3-1.43.114-py3-none-any.whl", hash = "sha256:d9cac2eb921ce674970cef1c9ad750f85ee3a846aedcf188d18368fb9eb6da23", size = 140043, upload-time = "2026-10-14T19:24:21.038Z" }, ] [[package]] name = "botocore" -version = "1.40.61" +version = "1.43.114" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/c8/b508359d1f3846a918c06807a9ae27eee063f904559269e42ccde9de09ea/botocore-1.43.114.tar.gz", hash = "sha256:f366fa4db518775632ad1eb128cd8203ca46396cecf37209d904f0bbc049ce90", size = 16369844, upload-time = "2026-10-14T19:24:17.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/41/7c6fa7ac5fcfd5ea3c6f32aab001942da32b184a210f39042778cb1ad8ed/botocore-1.43.114-py3-none-any.whl", hash = "sha256:d1c441a22e93e158de5b1e026205f5d6d67a4545d10540c5090c62dccb3a9eca", size = 16067885, upload-time = "2026-10-14T19:24:14.629Z" }, ] [[package]] @@ -737,7 +702,6 @@ wheels = [ name = "eva" source = { editable = "." } dependencies = [ - { name = "aioboto3" }, { name = "aiofiles" }, { name = "anthropic" }, { name = "assemblyai" }, @@ -794,7 +758,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aioboto3", specifier = ">=12.0.0" }, { name = "aiofiles", specifier = ">=23.0" }, { name = "anthropic", specifier = ">=0.83.0" }, { name = "assemblyai", specifier = ">=0.17.0" }, @@ -811,7 +774,7 @@ requires-dist = [ { name = "jaconv", specifier = ">=0.3.0" }, { name = "jiwer", specifier = ">=3.0.0" }, { name = "librosa", marker = "extra == 'apps'", specifier = ">=0.11" }, - { name = "litellm", specifier = "==1.85.0" }, + { name = "litellm", specifier = "==1.105.1" }, { name = "more-itertools", specifier = ">=10.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.5" }, { name = "numpy", specifier = ">=1.24" }, @@ -1193,6 +1156,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + [[package]] name = "hf-xet" version = "1.3.1" @@ -1217,6 +1193,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/46/1ba8d36f8290a4b98f78898bdce2b0e8fe6d9a59df34a1399eb61a8d877f/hf_xet-1.3.1-cp37-abi3-win_arm64.whl", hash = "sha256:851b1be6597a87036fe7258ce7578d5df3c08176283b989c3b165f94125c5097", size = 3500490, upload-time = "2026-02-25T00:58:00.667Z" }, ] +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1245,6 +1230,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "huggingface-hub" version = "0.36.2" @@ -1291,6 +1281,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261, upload-time = "2026-02-26T15:35:31.1Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.17" @@ -1556,25 +1555,38 @@ wheels = [ [[package]] name = "litellm" -version = "1.85.0" +version = "1.105.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, + { name = "boto3" }, { name = "click" }, { name = "fastuuid" }, - { name = "httpx" }, + { name = "filelock" }, + { name = "httpx", extra = ["http2"] }, + { name = "huggingface-hub", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "huggingface-hub", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, { name = "openai" }, + { name = "packaging" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/d5/3c9b560db2ffa9e498655d0dfd74f408bc5b32ede858b5731c2a5fa4c752/litellm-1.85.0.tar.gz", hash = "sha256:babdd569809af913d08a08a7eb55df1ed3e6a3960ee365c6cef4ad031c9bc72a", size = 15344387, upload-time = "2026-05-17T01:59:15.97Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ce/083565ef5a988c27ac4e9f63f36b4289397a59a41be83931619eed9d76e3/litellm-1.105.1.tar.gz", hash = "sha256:f1a274db0b2bcc78c34ed34c06df2c1dcd0dc038469193684927f6bb2cab2d97", size = 20321773, upload-time = "2026-10-14T04:28:23.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/38/e6a4abb062e039d18d59538cc4e6fc370c2c10cd2bff4a2e546acb69dcb9/litellm-1.85.0-py3-none-any.whl", hash = "sha256:2bb449153610691faffd76f5b94a8c29e4b66fc5394156ebf54fd4fe92759b1a", size = 16978229, upload-time = "2026-05-17T01:59:11.902Z" }, + { url = "https://files.pythonhosted.org/packages/d7/71/72b0e4c54a8fb0e10a35adb611f2f03febc27ac4fdf9b372c4f4a7d53442/litellm-1.105.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633886584ffb6421485349376f2c1666d4854fecc3ab8b1cdb24537817d4e1cc", size = 38069260, upload-time = "2026-10-14T04:27:50.741Z" }, + { url = "https://files.pythonhosted.org/packages/40/3b/a060486a4cb637d2aff7bedd2d9bdd807793cf8820153bca74fd14d7cbd4/litellm-1.105.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3253da7d4967e367fb7e4e404b598a26037c45ba6d67a79356a81d3f8b9f9f3a", size = 36430187, upload-time = "2026-10-14T04:27:55.416Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/52960a61798299aaf333d0010d1010a55099aa22bdedea3958672a4ce571/litellm-1.105.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:beb5044645144308b39560049c0f68875ad7de07e681ad0b73e2bc6c3705ba61", size = 37752535, upload-time = "2026-10-14T04:27:59.691Z" }, + { url = "https://files.pythonhosted.org/packages/07/1d/44e5c54df750ccb610285c778f92651794534a480dcce730eaf9d0aa8b31/litellm-1.105.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f3d743c2d274c6537f7341213607c927c486676012a9d94412a8a8ccbe2f51f1", size = 38775911, upload-time = "2026-10-14T04:28:03.87Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/ee5ce0ac611f595da18e01b613dbac6af241c5142c0e08765e5b29737605/litellm-1.105.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d0392f5e773ab4bdb15daf2856d89afce74dc8b7e6d802f52060a23778ef650b", size = 37873313, upload-time = "2026-10-14T04:28:08.943Z" }, + { url = "https://files.pythonhosted.org/packages/19/5d/6b44374c4ec2a297072fd331694c98dd7463407f17129b173d472c89c080/litellm-1.105.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d422f4fa44b424c541276bf55355b3650e2db01c08922f250baaf61eeaf788cf", size = 38907646, upload-time = "2026-10-14T04:28:14.409Z" }, + { url = "https://files.pythonhosted.org/packages/36/10/05865e564be95fa2e5d14bf94667a0d00684341f193645dde9ebaf5495cd/litellm-1.105.1-cp310-abi3-win_amd64.whl", hash = "sha256:e2b46bc884b1aa7299a238158f7ff3f3dad49821a0dcfa4a77a54cf38e78bac2", size = 39775121, upload-time = "2026-10-14T04:28:19.755Z" }, ] [[package]] @@ -2548,16 +2560,17 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" }, ] [[package]] @@ -2661,11 +2674,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] @@ -3004,14 +3017,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, ] [[package]] @@ -3651,121 +3664,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - [[package]] name = "yarl" -version = "1.22.0" +version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] [[package]] From 15aecc6b092340a286ca08f61475f7155a8d11a2 Mon Sep 17 00:00:00 2001 From: raghavm243512 <44511569+raghavm243512@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:40:16 +0000 Subject: [PATCH 02/12] Apply pre-commit --- src/eva/user_simulator/cascade/tts.py | 4 ++-- uv.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/eva/user_simulator/cascade/tts.py b/src/eva/user_simulator/cascade/tts.py index 96f5e821e..7424a5632 100644 --- a/src/eva/user_simulator/cascade/tts.py +++ b/src/eva/user_simulator/cascade/tts.py @@ -16,7 +16,7 @@ CARTESIA_SSE_URL = "https://api.cartesia.ai/tts/sse" CARTESIA_VERSION = "2025-11-04" -CALLER_SPEED = 1.2 # TODO Will use this for the generated barge ins +CALLER_SPEED = 1.2 # TODO Will use this for the generated barge ins """Cartesia generation_config.speed for the caller. People speed up when they cut in, and it shortens pre-rendered openers. Fixed for now; promote to config if it needs tuning.""" @@ -52,7 +52,7 @@ def _body(self, text: str, voice_id: str) -> dict: "voice": {"mode": "id", "id": voice_id}, "language": self.language, "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": TTS_SAMPLE_RATE}, - #"generation_config": {"speed": CALLER_SPEED}, + # "generation_config": {"speed": CALLER_SPEED}, } return body diff --git a/uv.lock b/uv.lock index 3e491489c..fb3754b16 100644 --- a/uv.lock +++ b/uv.lock @@ -763,7 +763,7 @@ requires-dist = [ { name = "assemblyai", specifier = ">=0.17.0" }, { name = "audioread", marker = "extra == 'apps'", specifier = ">=3.1" }, { name = "azure-cognitiveservices-speech", specifier = ">=1.31.0" }, - { name = "cartesia", specifier = ">=1.0.0" }, + { name = "cartesia", specifier = ">=3.0.0" }, { name = "deepgram-sdk", specifier = ">=7" }, { name = "elevenlabs", specifier = ">=2.53.0" }, { name = "fastapi", specifier = ">=0.100.0" }, From 31db1e5ed664fbc670df32cf8fbdc6aa15825aac Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Sun, 18 Oct 2026 17:52:56 -0700 Subject: [PATCH 03/12] voice control for cascade --- .env.example | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f5b3d2732..cb4322d25 100644 --- a/.env.example +++ b/.env.example @@ -320,14 +320,14 @@ EVA_USER_SIMULATOR__PROVIDER=elevenlabs #x EVA_USER_SIMULATOR__PROVIDER=openai_realtime #v EVA_USER_SIMULATOR__MODEL=gpt-realtime-1.5 -#i OpenAI voice for the female caller persona. +#i Voice for the female caller persona. Voice name or ID depending on provider. #d string -#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime +#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime,cascade #v EVA_USER_SIMULATOR__FEMALE_VOICE=marin -#i OpenAI voice for the male caller persona. +#i Voice for the male caller persona. Voice name or ID depending on provider. #d string -#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime +#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime,cascade #v EVA_USER_SIMULATOR__MALE_VOICE=cedar #i Cascade caller streaming STT model (ElevenLabs Scribe realtime). Requires ELEVENLABS_API_KEY. From 844384ea82118e16ca93e74c272656e9244f1b5b Mon Sep 17 00:00:00 2001 From: tara-servicenow Date: Wed, 16 Sep 2026 12:46:12 -0700 Subject: [PATCH 04/12] Remove unused backend and role scaffolding --- src/eva/backend/__init__.py | 22 --- src/eva/backend/base.py | 279 -------------------------------- src/eva/backend/capabilities.py | 48 ------ src/eva/backend/factory.py | 55 ------- src/eva/role/__init__.py | 17 -- src/eva/role/assistant.py | 132 --------------- src/eva/role/base.py | 159 ------------------ src/eva/role/user.py | 83 ---------- 8 files changed, 795 deletions(-) delete mode 100644 src/eva/backend/__init__.py delete mode 100644 src/eva/backend/base.py delete mode 100644 src/eva/backend/capabilities.py delete mode 100644 src/eva/backend/factory.py delete mode 100644 src/eva/role/__init__.py delete mode 100644 src/eva/role/assistant.py delete mode 100644 src/eva/role/base.py delete mode 100644 src/eva/role/user.py diff --git a/src/eva/backend/__init__.py b/src/eva/backend/__init__.py deleted file mode 100644 index b95139153..000000000 --- a/src/eva/backend/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Provider-agnostic ``Backend`` abstraction (design-only, Step 1 of the refactor). - -This package defines the contracts described in ``docs/refactor-step1.md``: -pure API/session objects (``Backend``) that know nothing about role -(assistant vs. user), plus a factory to construct them. Nothing in this -package is wired into the existing ``eva.assistant`` / ``eva.user_simulator`` -code yet -- these are new, additive, currently-unused types. -""" - -from eva.backend.base import Backend, BackendEvent, BackendEventType, ToolCallRequest, ToolCallResult -from eva.backend.capabilities import BackendCapabilities -from eva.backend.factory import BackendFactory - -__all__ = [ - "Backend", - "BackendCapabilities", - "BackendEvent", - "BackendEventType", - "BackendFactory", - "ToolCallRequest", - "ToolCallResult", -] diff --git a/src/eva/backend/base.py b/src/eva/backend/base.py deleted file mode 100644 index 63bbd278d..000000000 --- a/src/eva/backend/base.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Abstract ``Backend`` contract: pure API/session exchange, no role knowledge. - -DESIGN ONLY (Step 1 of the refactor, see docs/refactor-step1.md). This module -defines shapes, not behavior -- every method body is a stub. Nothing in -``eva.assistant`` or ``eva.user_simulator`` depends on this yet. - -A ``Backend`` wraps exactly one provider integration (OpenAI Realtime, Gemini -Live, ElevenLabs Agents, a cascade STT->LLM->TTS pipeline, ...) and exposes a -uniform send/receive surface for exchanging audio, text, and tool-call -traffic with that provider. It has **no opinion about role** -- it does not -know whether it is being driven by an ``AssistantRole`` or a ``UserRole``, -and it does not decide *what* prompt or tools to use (the ``Role`` supplies -those at open-time and owns tool execution). - -Symmetry note (per the design doc): a ``Backend`` must not assume it is "the -network server side" or "the client side" of a connection. Today, -assistant-side backends happen to be reached by an inbound WebSocket -connection (Twilio-framed) and user-side backends happen to dial out to a -provider or to the assistant's socket. Both are just implementation details -of a concrete subclass's ``open()``/``send()``/``receive()`` -- the abstract -contract itself is direction-agnostic so that a later mediator can sit -between two ``Backend`` instances (Backend <-> mediator <-> Backend) without -requiring either side to be "the server." -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from enum import StrEnum -from typing import Any - -from eva.backend.capabilities import BackendCapabilities - - -class BackendEventType(StrEnum): - """Kinds of events a ``Backend`` can surface via ``receive()``. - - Not every ``Backend`` implementation will emit every event type -- a thin, - end-to-end backend (e.g. ElevenLabs Agents) may only ever emit - ``AUDIO_OUTPUT``, ``TRANSCRIPT``, ``TURN_END``, and ``ERROR``, because it - has no separable tool-calling seam of its own that the caller can observe - (tool calls, if any, happen inside the provider and are not surfaced). - Consumers must treat unhandled event types as ignorable, not as errors. - """ - - AUDIO_OUTPUT = "audio_output" - """A chunk of output audio from the backend (assistant speech, or the - simulated user's speech, depending on which role's Backend this is).""" - - TRANSCRIPT = "transcript" - """A (possibly partial) transcript of something spoken -- either the - backend's own output or, for backends that provide it, the other party's - input as heard by this backend's ASR.""" - - TOOL_CALL_REQUEST = "tool_call_request" - """The backend's model wants to invoke a tool. Only emitted by backends - that expose a separable tool-calling seam (native S2S realtime APIs, - cascade LLM backends). The owning ``Role`` is responsible for executing - the tool and returning the result via ``send(tool_result=...)`` -- the - ``Backend`` never executes tools itself (see docs/refactor-step1.md, - "tool execution stays role-side").""" - - TURN_END = "turn_end" - """The backend's model has finished its current turn (end-of-utterance / - end-of-response signal).""" - - ERROR = "error" - """A provider-level error occurred (connection drop, API error, etc.).""" - - -@dataclass -class ToolCallRequest: - """A tool invocation requested by a backend's underlying model. - - Surfaced via a ``BackendEvent`` of type ``TOOL_CALL_REQUEST``. The owning - ``Role`` executes the tool (via its own ``ToolExecutor``) and reports the - outcome back to the backend with ``Backend.send(tool_result=...)`` so the - provider's tool-calling loop can continue. - """ - - call_id: str - """Provider-assigned identifier correlating the request to its result.""" - - name: str - """Tool name as requested by the model.""" - - arguments: dict[str, Any] - """Parsed tool call arguments.""" - - -@dataclass -class ToolCallResult: - """The outcome of executing a ``ToolCallRequest``. - - To be sent back to the backend so its underlying model can continue the - tool-calling loop. - """ - - call_id: str - """Must match the ``call_id`` of the originating ``ToolCallRequest``.""" - - result: Any - """JSON-serializable tool result payload.""" - - -@dataclass -class BackendEvent: - """A single event surfaced by ``Backend.receive()``. - - Exactly one of the optional payload fields is populated, matching - ``event_type``. This is intentionally a loose envelope (rather than a - tagged union of dataclasses) so that thin backends can populate only the - fields they support without needing empty placeholder subclasses. - """ - - event_type: BackendEventType - audio: bytes | None = None - transcript: str | None = None - tool_call_request: ToolCallRequest | None = None - error: str | None = None - metadata: dict[str, Any] = field(default_factory=dict) - """Provider-specific extras (e.g. raw event name, timestamps) that don't - warrant a first-class field. Consumers should not rely on specific keys - being present across providers. - - Convention (not enforced by this contract): a backend that proactively - re-engages after a dropped user turn (the turn-end fallback; see - ``AssistantRole``'s ``turn_end_fallback_seconds`` and the shipped - ``eva.assistant.pipeline.fallback``) tags the ``AUDIO_OUTPUT``/ - ``TRANSCRIPT`` event it emits for that turn so callers can distinguish a - fallback nudge from an ordinary model turn (e.g. for audit logging and so - downstream metrics can zero it). The shipped feature records the transcript - marker with ``message_type="turn_fallback"``; a backend surfacing the same - turn here should carry an equivalent flag in ``metadata`` (e.g. - ``metadata["turn_fallback"] = True``). This is *not* a new event type -- a - nudge is just an ordinary turn from the backend's model, triggered by the - backend noticing that a user turn was never detected within the fallback - window rather than by new input; it flows through the same ``receive()`` - surface as anything else.""" - - -class Backend(ABC): - """Pure API/session exchange with one provider. No role knowledge. - - Lifecycle: ``open()`` establishes the session, ``send()`` pushes audio / - text / tool results to the provider, ``receive()`` yields events back, - and ``close()`` tears the session down. A ``Role`` (see - ``eva.role.base``) owns one ``Backend`` instance and drives it. - - Implementations are expected to fall along a spectrum: - - - **Native speech-to-speech** (OpenAI Realtime, Gemini Live): a single - persistent duplex session; ``send(audio=...)`` streams mic audio in, - ``receive()`` yields interleaved ``AUDIO_OUTPUT``/``TRANSCRIPT``/ - ``TOOL_CALL_REQUEST``/``TURN_END`` events as the provider produces them. - - **Cascade** (STT -> LLM -> TTS, e.g. a Pipecat pipeline): internally - composed of separate provider calls, but from the caller's perspective - still just one ``Backend`` -- it decides internally when to run STT, - call the LLM, and synthesize TTS, and surfaces the same event shape. - - **End-to-end / thin** (ElevenLabs Agents): the provider handles - everything (ASR, dialogue policy, TTS) opaquely. Such a ``Backend`` - may only ever emit ``AUDIO_OUTPUT``/``TRANSCRIPT``/``TURN_END``/ - ``ERROR`` and may treat ``send(tool_result=...)`` as a no-op or raise - ``NotImplementedError`` -- callers must consult ``capabilities`` and - not assume every method does something on every backend. - - Symmetry: this contract says nothing about which side dials out and - which side is dialed into -- see the module docstring. - """ - - @property - @abstractmethod - def capabilities(self) -> BackendCapabilities: - """Static capability flags for this backend (see ``BackendCapabilities``). - - Must be available even before ``open()`` is called (i.e. it describes - the provider integration, not live session state). - """ - ... - - @abstractmethod - async def open(self, *, system_prompt: str, tools: list[dict[str, Any]] | None, config: dict[str, Any]) -> None: - """Establish the provider session. - - Args: - system_prompt: Fully-built system prompt for this session, as - assembled by the owning ``Role`` (``Role.build_prompt()``). - A thin end-to-end backend still receives this even if it - maps it onto a different provider concept (e.g. ElevenLabs - agent overrides). - tools: Tool schemas to expose to the provider's model, in - whatever wire format the concrete backend needs to translate - from the agent's tool definitions. ``None`` or ``[]`` for - backends/roles that don't expose tool calling (e.g. a - ``UserRole`` that only needs an ``end_call`` tool would still - pass that single tool here; a backend with no tool-calling - seam at all may simply ignore this argument). - config: Provider-specific configuration blob (model name, voice, - sample rate, turn-detection parameters, etc.). Deliberately - untyped here -- each concrete ``Backend`` defines and - validates its own config shape; the abstract contract does - not prescribe one, since a native S2S config and a cascade - config share little structure. An ``AssistantRole`` backend - configured for the turn-end fallback (see - ``AssistantRole.turn_end_fallback_seconds``) reads its - threshold from this blob (e.g. a - ``config["turn_end_fallback_seconds"]`` key) the same way -- - the fallback needs no dedicated typed parameter or new - ``Backend`` method, since the resulting nudge is just an - ordinary outbound turn (see ``BackendEvent.metadata``). - - Must be safe to call exactly once per ``Backend`` instance. Must not - block on the other party being ready to exchange data -- readiness to - *accept* traffic is enough (mirrors today's - ``AbstractAssistantServer.start()`` contract: non-blocking, returns - once ready). - """ - ... - - @abstractmethod - async def send( - self, - *, - audio: bytes | None = None, - text: str | None = None, - tool_result: ToolCallResult | None = None, - ) -> None: - """Push data to the provider. Exactly one of the keyword args is set. - - Args: - audio: Raw input audio chunk (format/sample-rate is whatever this - backend's ``open(config=...)`` declared; format conversion is - the caller's responsibility via the shared audio utilities, - not this method's). - text: A text turn to inject directly (e.g. a starting utterance, - or a cascade backend's synthesized user/assistant text before - TTS). Backends that are audio-only end-to-end (no text - injection seam) may raise ``NotImplementedError``. - tool_result: The result of executing a previously-surfaced - ``ToolCallRequest``, to be relayed back into the provider's - tool-calling loop so it can continue. Backends with no - tool-calling seam (see ``capabilities``) may raise - ``NotImplementedError``. - - This is intentionally the single, symmetric outbound method for both - "network-server-like" and "client-like" backends -- see the module - docstring on symmetry. A future mediator sitting between two - ``Backend`` instances would call this same method on each side. - """ - ... - - @abstractmethod - def receive(self) -> AsyncIterator[BackendEvent]: - """Yield events from the provider as they arrive. - - The single, symmetric inbound stream for both "network-server-like" - and "client-like" backends. Must be an async generator (or return an - object implementing ``__aiter__``/``__anext__``) that yields until - the session ends (``close()`` is called, the provider disconnects, - or a terminal ``ERROR``/``TURN_END``-with-hangup event occurs -- - exact termination semantics are provider-specific and left to each - concrete backend). - """ - ... - - @abstractmethod - async def close(self) -> None: - """Tear down the provider session. - - Must be safe to call even if ``open()`` was never called or the - session already ended on its own (idempotent). Concrete backends are - responsible for their own provider-specific teardown (closing - websockets, cancelling tasks, flushing buffers); this method does not - itself define audio/output persistence -- that remains a ``Role`` - concern (see ``eva.role.base``). - """ - ... diff --git a/src/eva/backend/capabilities.py b/src/eva/backend/capabilities.py deleted file mode 100644 index 7561872ba..000000000 --- a/src/eva/backend/capabilities.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Capability flags describing what a ``Backend`` implementation can do. - -These flags exist so that a future mediator/turn-taking layer can branch on -backend shape without every ``Backend`` implementation needing to expose the -same granular seams. Per docs/refactor-step1.md, they are declared now but -must remain UNUSED in this step -- no turn-taking logic should read them yet. -""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class BackendCapabilities: - """Static, provider-declared capability flags for a ``Backend``. - - Each concrete ``Backend`` subclass sets these once (typically as a class - attribute or constructed in ``__init__``) to describe its streaming shape. - They are informational only in this step -- nothing consumes them yet. - - Attributes: - emits_continuous_audio: True if the backend produces a continuous - audio stream once speaking starts (native speech-to-speech models - such as OpenAI Realtime or Gemini Live, and end-to-end providers - such as ElevenLabs Agents). False for cascade backends - (STT -> LLM -> TTS) that emit discrete, chunked utterances with - gaps between TTS renders. This is the flag a future mediator uses - to decide whether "audio is still arriving" is a meaningful - signal on its own, or whether it must also track discrete - utterance boundaries. - supports_streaming_interruption: True if the underlying provider API - supports being told "stop talking now" mid-utterance and reacting - immediately (e.g. OpenAI/Gemini Realtime `response.cancel`-style - semantics). False if interruption can only be approximated by the - caller (e.g. stop forwarding audio, drop the rest of a queued TTS - buffer) rather than being a first-class provider feature. Declared - for later interruption-policy work; not consumed in this step. - owns_playout_clock: True if the backend itself is responsible for - audio playout pacing (e.g. a cascade backend streaming TTS chunks - at wall-clock rate), which is required for the barge-in work - planned for a later phase. False if playout pacing is delegated - to the caller/mediator, or if the backend has no continuous - playout concept at all (e.g. a fully end-to-end provider that - hands back a finished audio blob). - """ - - emits_continuous_audio: bool - supports_streaming_interruption: bool - owns_playout_clock: bool diff --git a/src/eva/backend/factory.py b/src/eva/backend/factory.py deleted file mode 100644 index d0fa61b21..000000000 --- a/src/eva/backend/factory.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Factory interface for constructing ``Backend`` instances by name. - -DESIGN ONLY (Step 1 of the refactor). Mirrors the shape of today's -``eva.user_simulator.factory.create_user_simulator`` (lazy per-provider -imports keyed off config type) but is provider-and-role-agnostic: the same -factory is meant to be usable to build a backend for either an -``AssistantRole`` or a ``UserRole``, since a ``Backend`` has no role -knowledge (that's the whole point of the split -- see docs/refactor-step1.md, -"lets any backend act as either role"). -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any - -from eva.backend.base import Backend - - -class BackendFactory(ABC): - """Constructs a ``Backend`` for a named provider from a config blob. - - A concrete implementation is expected to hold (or look up) a registry - mapping provider name -> ``Backend`` subclass, analogous to today's - ``create_user_simulator`` / assistant-server construction in - ``orchestrator/runner.py``, and to import each provider module lazily so - that unused providers' SDKs need not be installed/imported. - """ - - @abstractmethod - def create(self, name: str, config: dict[str, Any]) -> Backend: - """Construct and return a not-yet-opened ``Backend``. - - Args: - name: Provider identifier (e.g. ``"openai_realtime"``, - ``"gemini_live"``, ``"elevenlabs"``, ``"cascade"``). The set - of valid names is defined by the concrete factory's registry, - not by this interface. - config: Provider-specific configuration understood by that - backend's ``open()`` (see ``Backend.open``). This factory - does not validate the shape of ``config`` beyond dispatching - on ``name`` -- each ``Backend`` subclass is responsible for - validating its own config. - - Returns: - A constructed ``Backend`` instance. The returned backend has not - had ``open()`` called on it yet -- construction and session - establishment are separate steps so a ``Role`` can construct its - backend early (e.g. at record setup) and open the session later - (e.g. once the other party is ready). - - Raises: - ValueError: if ``name`` does not match a known provider. - """ - ... diff --git a/src/eva/role/__init__.py b/src/eva/role/__init__.py deleted file mode 100644 index c005c0a7d..000000000 --- a/src/eva/role/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Provider-agnostic ``Role`` abstraction (design-only, Step 1 of the refactor). - -A ``Role`` owns everything that today is duplicated across the assistant and -user-simulator stacks per-provider: prompt construction, tool ownership, and -goal/persona/agent-config data. Each ``Role`` holds exactly one -``eva.backend.Backend`` instance, created at runtime via a -``eva.backend.BackendFactory``. - -Nothing in this package is wired into the existing ``eva.assistant`` / -``eva.user_simulator`` code yet. -""" - -from eva.role.assistant import AssistantRole -from eva.role.base import Role -from eva.role.user import UserRole - -__all__ = ["AssistantRole", "Role", "UserRole"] diff --git a/src/eva/role/assistant.py b/src/eva/role/assistant.py deleted file mode 100644 index 897dba14d..000000000 --- a/src/eva/role/assistant.py +++ /dev/null @@ -1,132 +0,0 @@ -"""``AssistantRole`` contract: the business-side answering role. - -DESIGN ONLY (Step 1 of the refactor, see docs/refactor-step1.md). Method -bodies are stubs; nothing here is wired into the existing code path yet. - -Plug-in point (where this will eventually replace existing code): - Today the assistant side is a concrete ``AbstractAssistantServer`` - subclass selected by ``eva.orchestrator.worker._get_server_class(framework)`` - (worker.py) and constructed + started inside - ``ConversationWorker._start_assistant()`` (worker.py), which calls - ``server_cls(...).start()``. Its outputs are flushed by - ``ConversationWorker._cleanup()`` via ``server.stop()`` (which internally - calls ``save_outputs()``), and ``get_conversation_stats()`` / - ``get_final_scenario_db()`` are read back in ``ConversationWorker.run()``. - - In a later phase, ``_start_assistant()`` becomes the construction site for - an ``AssistantRole`` (framework string -> ``backend_name`` passed to the - ``BackendFactory``), and the worker drives ``role.run()`` / - ``role.save_outputs()`` / ``role.get_final_scenario_db()`` instead of the - server's own lifecycle methods. The provider-specific server subclasses - collapse into ``Backend`` implementations behind the factory; the - role-agnostic orchestration in ``ConversationWorker`` stays put. This - module is deliberately separate from ``eva.role.user`` so that migration - can land assistant-side first without touching the user-side diff. -""" - -from __future__ import annotations - -from abc import abstractmethod -from typing import Any - -from eva.backend.factory import BackendFactory -from eva.role.base import Role - - -class AssistantRole(Role): - """Role that answers on behalf of the business (today's "assistant server"). - - Carries agent configuration and tool catalog; owns a ``ToolExecutor`` - (constructed by subclasses, not by this contract) to fulfill - ``handle_tool_call_request``. - - Turn-end fallback (self-nudge): the assistant's backstop for a *dropped - user turn*. When VAD / turn detection silently fails to fire for a real - user utterance, the call would otherwise hang until the provider's - inactivity timeout ends it. After the assistant stops speaking, if no user - turn is detected within ``turn_end_fallback_seconds``, the assistant - proactively re-engages with a nudge (acknowledge-and-answer if partial - user speech/audio was captured, otherwise ask the caller to repeat). This - is the seam already shipped as the pipeline-side ``TurnEndFallbackTimer`` - (see ``eva.assistant.pipeline.fallback`` and ``EVA_TURN_END_FALLBACK_TIME``); - it works for both cascade and audio-LLM pipelines. - - Two policies the backend owns, carried over from the shipped feature: - - Give up after a small number of *consecutive* nudges without a real user - turn resetting the count (``MAX_CONSECUTIVE_FALLBACK_NUDGES``), then let - the provider's inactivity backstop end the call. - - Never nudge once the call is ending (a nudge during teardown produces a - phantom assistant turn after the conversation is logically closed). - - Unlike the tool-call/idle-detection seams elsewhere in this contract, the - fallback needs no new ``Role`` method and no new ``Backend`` event type: - the nudge is just an ordinary outbound turn that this role's backend - produces on its own after the timeout, using the same - ``system_prompt``/instructions already established at ``open()`` time (see - ``Backend.open``'s ``config`` docstring). It is surfaced through the normal - ``receive()`` stream and tagged so downstream metrics can identify and zero - it (the shipped feature records the transcript marker with - ``message_type="turn_fallback"``; see ``BackendEvent.metadata``). Whether - the *other* side (a ``UserRole``) needs to do anything special upon - receiving it, versus just treating it as an ordinary assistant turn through - its existing ``run()`` loop, is left open -- see docs/refactor-step1.md - discussion; nothing here requires ``UserRole`` changes to handle it today. - """ - - def __init__( - self, - *, - backend_factory: BackendFactory, - backend_name: str, - backend_config: dict[str, Any], - agent_config_path: str, - scenario_db_path: str, - current_date_time: str, - turn_end_fallback_seconds: float | None = None, - ) -> None: - """Initialize the assistant role. - - Args: - backend_factory: Factory used to construct the backend. - backend_name: Key passed to the factory to select a backend. - backend_config: Provider-specific configuration for the backend. - agent_config_path: Path to the agent YAML (role, instructions, - tool schemas) -- mirrors ``AbstractAssistantServer.agent`` / - ``agent_config_path``. - scenario_db_path: Path to the per-record scenario database JSON - consumed by tool execution -- mirrors - ``AbstractAssistantServer.scenario_db_path``. - current_date_time: Current date/time string threaded into both - prompt construction and tool execution (mirrors existing - ``current_date_time`` plumbing throughout the assistant - stack). - turn_end_fallback_seconds: How long after the assistant stops - speaking to wait for a user turn before firing a turn-end - fallback nudge, or ``None`` to disable the fallback entirely - (preserving the old behavior of waiting for the provider's - inactivity timeout). Mirrors the shipped - ``EVA_TURN_END_FALLBACK_TIME`` knob. This is an - ``AssistantRole``-level tuning value, not a - ``BackendCapabilities`` flag (capabilities describe what a - backend *can* do, statically). Wiring it into the constructed - ``self.backend``'s own config (via ``backend_config`` / - ``Backend.open(config=...)``) is left to the concrete - subclass's constructor, same as elsewhere in this contract -- - a ``Role`` does not otherwise reach into backend config after - construction. A backend with no notion of idle timing (e.g. a - thin end-to-end backend that relies on its own provider - backstop) may simply ignore this value. - """ - super().__init__(backend_factory=backend_factory, backend_name=backend_name, backend_config=backend_config) - self.agent_config_path = agent_config_path - self.scenario_db_path = scenario_db_path - self.current_date_time = current_date_time - self.turn_end_fallback_seconds = turn_end_fallback_seconds - - @abstractmethod - def get_final_scenario_db(self) -> dict[str, Any]: - """Return the (possibly mutated) scenario database state, for metrics. - - Mirrors ``AbstractAssistantServer.get_final_scenario_db()``. - """ - ... diff --git a/src/eva/role/base.py b/src/eva/role/base.py deleted file mode 100644 index a75b4fa14..000000000 --- a/src/eva/role/base.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Abstract ``Role`` base contract: prompt/tools/goal ownership over a ``Backend``. - -DESIGN ONLY (Step 1 of the refactor, see docs/refactor-step1.md). Every -method body here is a stub -- this module defines shapes, not behavior, and -is not imported by any existing code path. - -This module holds only the shared ``Role`` base. The two concrete roles live -in sibling modules -- ``AssistantRole`` in ``eva.role.assistant`` and -``UserRole`` in ``eva.role.user`` -- since each carries a meaningfully -different data payload and plug-in point (see those modules' docstrings) and -is expected to grow its own implementation in later phases; keeping them in -separate files keeps each phase's diff scoped to one role. - -Design choice -- one ``Role`` base with ``AssistantRole``/``UserRole`` -subclasses, rather than two unrelated ABCs: - Both roles share an identical *control loop* shape: construct a backend - via ``BackendFactory``, ``build_prompt()`` before opening it, drive - ``backend.receive()`` and dispatch tool-call requests to - ``handle_tool_call_request()``, and record recorded audio/transcript for - output. What differs between them is only the *data* they carry (agent - config + tool catalog for the assistant; goal + persona + starting - utterance for the user) and how they decide the conversation is over. - That's a difference in constructor args and a couple of abstract methods, - not in control flow -- so one shared base with two thin subclasses avoids - duplicating the event loop, while still keeping tool-ownership and - prompt-building role-specific via abstract methods. If the two roles' - control loops diverge significantly in a later phase, splitting them - apart is a mechanical extraction of ``Role`` into two ABCs -- nothing - here should make that harder. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Any - -from eva.backend.base import Backend, ToolCallRequest, ToolCallResult -from eva.backend.factory import BackendFactory - - -class Role(ABC): - """Owns prompt, tools/goal, and a runtime-created ``Backend``. - - A ``Role`` is the thing that used to be split across - ``AbstractAssistantServer`` (assistant side) and ``AbstractUserSimulator`` - (user side): everything that is *not* pure provider API exchange lives - here instead of in ``Backend``. In particular: - - - Tool execution stays role-side (per docs/refactor-step1.md): a ``Role`` - is responsible for turning a ``ToolCallRequest`` surfaced by its - backend into a ``ToolCallResult``, using whatever execution engine is - appropriate for that role (``ToolExecutor`` for ``AssistantRole``; a - trivial/no-op handler for ``UserRole``, which today only exposes a - synthetic ``end_call`` tool). ``Backend`` implementations never - execute tools themselves. - - Prompt construction stays role-side: ``build_prompt()`` replaces both - ``AbstractAssistantServer._build_system_prompt()`` / - ``AgenticSystem``'s prompt building and - ``AbstractUserSimulator._build_prompt()``. - - Audio recording / output persistence is a role-side concern shared by - both subclasses (see ``docs/refactor-step1.md`` point 5, "consolidate - audio recording / output-saving into one shared helper") -- this base - class declares the seam (``record_audio`` / ``save_outputs``) but does - not implement the shared helper itself; that helper is later work. - """ - - def __init__(self, *, backend_factory: BackendFactory, backend_name: str, backend_config: dict[str, Any]) -> None: - """Construct the role's backend (but do not open its session yet). - - Args: - backend_factory: Factory used to construct ``self.backend``. - backend_name: Provider name passed through to - ``BackendFactory.create``. - backend_config: Provider-specific config passed through to - ``BackendFactory.create`` (not to be confused with the - ``config`` argument of ``Backend.open``, which is also - provider-specific but may be augmented by the role at - open-time, e.g. with a resolved sample rate). - """ - self.backend: Backend = backend_factory.create(backend_name, backend_config) - - @abstractmethod - def build_prompt(self) -> str: - """Build the full system prompt / instructions for this role. - - For ``AssistantRole`` this replaces - ``AbstractAssistantServer._build_system_prompt()`` and - ``AgenticSystem``'s inline prompt construction. For ``UserRole`` this - replaces ``AbstractUserSimulator._build_prompt()``. Called before - ``Backend.open()`` so the result can be passed as its - ``system_prompt`` argument. - """ - ... - - @abstractmethod - async def handle_tool_call_request(self, request: ToolCallRequest) -> ToolCallResult: - """Execute a tool call surfaced by this role's backend and return the result. - - This is the single place tool execution happens for this role -- - ``Backend`` implementations must never execute tools directly (see - class docstring). Implementations should log the call/result (e.g. - to an audit log) as part of executing it. - """ - ... - - @abstractmethod - async def run(self) -> str: - """Drive the conversation for this role until it reaches a terminal state. - - Expected shape (left to subclasses to implement, not prescribed in - detail here since the exact loop depends on the backend's - capabilities -- see ``BackendCapabilities``): - 1. ``await self.backend.open(system_prompt=self.build_prompt(), ...)`` - 2. Iterate ``self.backend.receive()``, dispatching - ``TOOL_CALL_REQUEST`` events to ``handle_tool_call_request`` and - feeding the ``ToolCallResult`` back via - ``self.backend.send(tool_result=...)``. - 3. Record audio/transcript events as they arrive (see - ``record_audio``). - 4. On a terminal event (hangup, timeout, transfer, error), call - ``await self.backend.close()`` and return an end-reason string. - - Returns: - A short end-reason string (e.g. ``"goodbye"``, ``"transfer"``, - ``"timeout"``, ``"error"``) -- mirrors the return contract of - today's ``AbstractUserSimulator.run_conversation()``. - """ - ... - - @abstractmethod - def record_audio(self, source: str, audio_data: bytes) -> None: - """Accumulate a chunk of audio for later persistence. - - Args: - source: Role-defined stream label (e.g. ``"user"``, - ``"assistant"``, or a cleaned/pre-perturbation variant). - Mirrors ``AbstractUserSimulator._record_audio`` / - ``AbstractAssistantServer``'s audio-buffer fields; the exact - set of valid labels is left to subclasses/shared helper, not - fixed by this contract. - audio_data: Raw PCM16 bytes at this role's recording sample rate. - """ - ... - - @abstractmethod - async def save_outputs(self, output_dir: Path) -> None: - """Persist this role's output artifacts to ``output_dir``. - - For ``AssistantRole`` this covers ``audit_log.json``, - ``transcript.jsonl``, scenario DB snapshots (mirrors - ``AbstractAssistantServer.save_outputs``). For ``UserRole`` this - covers ``user_simulator_events.jsonl`` (mirrors the event logger in - ``AbstractUserSimulator``). Audio WAV files are expected to be - written by the shared audio-recording helper referenced in - ``record_audio``, not necessarily by this method -- exact division of - labor is left to the later implementation phase. - """ - ... diff --git a/src/eva/role/user.py b/src/eva/role/user.py deleted file mode 100644 index 415d8b7af..000000000 --- a/src/eva/role/user.py +++ /dev/null @@ -1,83 +0,0 @@ -"""``UserRole`` contract: the simulated-caller role. - -DESIGN ONLY (Step 1 of the refactor, see docs/refactor-step1.md). Method -bodies are stubs; nothing here is wired into the existing code path yet. - -Plug-in point (where this will eventually replace existing code): - Today the user side is a concrete ``AbstractUserSimulator`` subclass - selected by ``eva.user_simulator.factory.create_user_simulator(config, ...)`` - and constructed inside ``ConversationWorker._start_user_simulator()`` - (worker.py), which passes it ``server_url=f"ws://localhost:{port}/ws"`` - to reach the assistant server. The conversation is driven by - ``ConversationWorker._run_conversation()`` calling - ``user_simulator.run_conversation()``, whose returned end-reason string - becomes the conversation result. - - In a later phase, ``_start_user_simulator()`` becomes the construction - site for a ``UserRole`` (simulator config -> ``backend_name`` + - ``backend_config`` for the ``BackendFactory``), and the worker drives - ``role.run()`` (returning the same end-reason string via - ``get_end_reason()``) instead of ``run_conversation()``. Note the - ``server_url`` handoff is a *transport* detail that today's user side owns - directly; per docs/refactor-step1.md the ``Backend`` contract is kept - direction-agnostic precisely so this WS-connect concern can move into a - ``Backend`` implementation (or, later, a mediator) without the role - caring. This module is deliberately separate from ``eva.role.assistant`` - so the user-side migration can land as its own scoped diff. -""" - -from __future__ import annotations - -from abc import abstractmethod -from typing import Any - -from eva.backend.factory import BackendFactory -from eva.role.base import Role - - -class UserRole(Role): - """Role that simulates the human caller (today's "user simulator"). - - Carries goal/persona instead of agent config/tools -- its tool surface, - if any, is limited to caller-side affordances like ``end_call`` (see - ``END_CALL_DESCRIPTION`` in today's ``eva.user_simulator.base``), not a - business tool catalog. - """ - - def __init__( - self, - *, - backend_factory: BackendFactory, - backend_name: str, - backend_config: dict[str, Any], - goal: dict[str, Any], - persona_config: dict[str, Any], - current_date_time: str, - ) -> None: - """Initialize the user role. - - Args: - backend_factory: Factory used to construct the backend. - backend_name: Key passed to the factory to select a backend. - backend_config: Provider-specific configuration for the backend. - goal: User goal / decision-tree data -- mirrors - ``AbstractUserSimulator.goal``. - persona_config: Persona/voice/behavior configuration -- mirrors - ``AbstractUserSimulator.persona_config``. - current_date_time: Threaded into prompt construction, mirroring - existing plumbing. - """ - super().__init__(backend_factory=backend_factory, backend_name=backend_name, backend_config=backend_config) - self.goal = goal - self.persona_config = persona_config - self.current_date_time = current_date_time - - @abstractmethod - def get_end_reason(self) -> str: - """Return the terminal end-reason for this conversation. - - Mirrors the return value of today's - ``AbstractUserSimulator.run_conversation()`` (``"goodbye"``, - ``"transfer"``, ``"timeout"``, ``"error"``, ...). - """ - ... From 2c5c68e4ab4ebf63476101b8b5c85c9ac5024ba9 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Tue, 20 Oct 2026 13:00:49 -0700 Subject: [PATCH 05/12] address comments --- .env.example | 10 +-- src/eva/assistant/services/llm.py | 61 ++++++---------- src/eva/models/config.py | 19 ++--- src/eva/user_simulator/cascade/simulator.py | 45 ++++++++---- src/eva/user_simulator/cascade/stt.py | 37 ++++++++-- src/eva/user_simulator/cascade/tts.py | 2 +- src/eva/utils/llm_client.py | 3 +- src/eva/utils/router.py | 81 ++++++++++++++------- tests/unit/user_simulator/test_cascade.py | 53 ++++++++++++-- tests/unit/user_simulator/test_factory.py | 2 + tests/unit/utils/test_llm_client.py | 43 +++++++++-- 11 files changed, 238 insertions(+), 118 deletions(-) diff --git a/.env.example b/.env.example index cb4322d25..2ef36acba 100644 --- a/.env.example +++ b/.env.example @@ -315,9 +315,10 @@ EVA_MODEL_LIST='[ #e elevenlabs,openai_realtime,cascade EVA_USER_SIMULATOR__PROVIDER=elevenlabs -#i OpenAI Realtime caller model. Used only when provider=openai_realtime. +#i Caller model. For openai_realtime, the Realtime model (default gpt-realtime-1.5). For cascade, the caller LLM +#i deployment name from EVA_MODEL_LIST (default user-llm). #d string -#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime +#x EVA_USER_SIMULATOR__PROVIDER=openai_realtime,cascade #v EVA_USER_SIMULATOR__MODEL=gpt-realtime-1.5 #i Voice for the female caller persona. Voice name or ID depending on provider. @@ -335,11 +336,6 @@ EVA_USER_SIMULATOR__PROVIDER=elevenlabs #x EVA_USER_SIMULATOR__PROVIDER=cascade #v EVA_USER_SIMULATOR__STT_MODEL=scribe_v2_realtime -#i Cascade caller LLM deployment name from EVA_MODEL_LIST. -#d string -#x EVA_USER_SIMULATOR__PROVIDER=cascade -#v EVA_USER_SIMULATOR__LLM=user-llm - #i Cascade caller Cartesia TTS model. Requires CARTESIA_API_KEY. #d string #x EVA_USER_SIMULATOR__PROVIDER=cascade diff --git a/src/eva/assistant/services/llm.py b/src/eva/assistant/services/llm.py index 5127e5a3a..df4100487 100644 --- a/src/eva/assistant/services/llm.py +++ b/src/eva/assistant/services/llm.py @@ -93,7 +93,8 @@ async def complete( for attempt in range(max_retries + 1): try: start_time = time.time() - response = await router.acompletion(**kwargs) + async with router.limit(self.model) as deployment: + response = await router.get().acompletion(**{**kwargs, "model": deployment.id}) elapsed_time = time.time() - start_time message = response.choices[0].message @@ -267,17 +268,24 @@ def _convert_messages_for_responses_api( i += 1 return instructions, input_items - def _get_router_litellm_params(self) -> dict[str, Any]: - """Look up the litellm_params for self.model from the router config. + def _responses_deployment_kwargs(self, deployment: router.Deployment) -> dict[str, Any]: + """Model, credentials and reasoning effort for a Responses API call to `deployment`.""" + litellm_params = deployment.litellm_params + kwargs: dict[str, Any] = {"model": litellm_params.get("model", self.model)} - Assumes the router is initialized (only called from _complete_via_responses_api, - which requires use_responses_api=True read from the router at init time). - """ - r = router.get() - for deployment in getattr(r, "model_list", []): - if deployment.get("model_name") == self.model: - return deployment.get("litellm_params", {}) - return {} + # Resolve api_key (may be "os.environ/VAR_NAME") + raw_key = litellm_params.get("api_key", "") + if raw_key.startswith("os.environ/"): + raw_key = os.environ.get(raw_key.split("/", 1)[1], "") + if raw_key: + kwargs["api_key"] = raw_key + if api_base := litellm_params.get("api_base"): + kwargs["api_base"] = api_base + + # reasoning_effort from litellm_params (set in EVA_MODEL_LIST deployment config) + if reasoning_effort := litellm_params.get("reasoning_effort"): + kwargs["reasoning"] = {"effort": reasoning_effort} + return kwargs async def complete_stream( self, @@ -305,8 +313,8 @@ async def complete_stream( first_token = False try: start_time = time.time() - async with router.limit(self.model): - stream = await router.get().acompletion(**kwargs) + async with router.limit(self.model) as deployment: + stream = await router.get().acompletion(**{**kwargs, "model": deployment.id}) async for chunk in stream: chunks.append(chunk) choices = getattr(chunk, "choices", None) or [] @@ -376,47 +384,26 @@ async def _complete_via_responses_api( The caller (system.py) stores ``responses_output_items`` from stats on the assistant message so the next iteration injects them back into the input. """ - # Resolve model credentials from router config (handles Azure, custom api_base, etc.) - litellm_params = self._get_router_litellm_params() - litellm_model = litellm_params.get("model", self.model) - - # Resolve api_key (may be "os.environ/VAR_NAME") - raw_key = litellm_params.get("api_key", "") - if raw_key.startswith("os.environ/"): - raw_key = os.environ.get(raw_key.split("/", 1)[1], "") - api_key = raw_key or None - - api_base = litellm_params.get("api_base") or None - - # reasoning_effort from litellm_params (set in EVA_MODEL_LIST deployment config) - reasoning_effort = litellm_params.get("reasoning_effort") - instructions, input_items = self._convert_messages_for_responses_api(messages) kwargs: dict[str, Any] = { - "model": litellm_model, "input": input_items, "store": False, "include": ["reasoning.encrypted_content"], } - if reasoning_effort: - kwargs["reasoning"] = {"effort": reasoning_effort} if instructions: kwargs["instructions"] = instructions if tools: kwargs["tools"] = self._convert_tools_for_responses_api(tools) kwargs["tool_choice"] = "auto" - if api_key: - kwargs["api_key"] = api_key - if api_base: - kwargs["api_base"] = api_base last_exception = None for attempt in range(max_retries + 1): try: start_time = time.time() - async with router.limit(self.model): - response = await litellm.aresponses(**kwargs) + async with router.limit(self.model) as deployment: + # Resolve credentials from the deployment holding the slot (handles Azure, custom api_base, etc.) + response = await litellm.aresponses(**kwargs, **self._responses_deployment_kwargs(deployment)) elapsed = time.time() - start_time text_content = "" diff --git a/src/eva/models/config.py b/src/eva/models/config.py index 51b6aca51..890c42e51 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -497,7 +497,7 @@ class CascadeSimulatorConfig(BaseModel): provider: Literal["cascade"] = "cascade" stt: Literal["elevenlabs"] = Field("elevenlabs", description="Streaming STT provider for the assistant's audio.") stt_model: str = Field("scribe_v2_realtime", description="Streaming STT model.") - llm: str = Field( + model: str = Field( "user-llm", description=( "Caller LLM deployment, resolved via EVA_MODEL_LIST like the assistant's. A dedicated 'user-llm' " @@ -517,6 +517,13 @@ def _require_streaming_stt(cls, value: str) -> str: raise ValueError(f"Cascade caller requires a streaming STT model, got {value!r}") return value + @model_validator(mode="after") + def _require_provider_keys(self) -> "CascadeSimulatorConfig": + missing = [key for key in ("ELEVENLABS_API_KEY", "CARTESIA_API_KEY") if not os.environ.get(key)] + if missing: + raise ValueError(f"The cascade user simulator requires {', '.join(missing)} to be set.") + return self + UserSimulatorConfig = Annotated[ ElevenLabsSimulatorConfig | OpenAIRealtimeSimulatorConfig | CascadeSimulatorConfig, @@ -917,16 +924,6 @@ def _check_openai_realtime_simulator(self) -> "RunConfig": raise ValueError("EVA_USER_SIMULATOR__PROVIDER=openai_realtime requires OPENAI_API_KEY to be set.") return self - @model_validator(mode="after") - def _check_cascade_simulator(self) -> "RunConfig": - """When the cascade user simulator is selected, its STT and TTS keys must be present.""" - if not isinstance(self.user_simulator, CascadeSimulatorConfig): - return self - missing = [key for key in ("ELEVENLABS_API_KEY", "CARTESIA_API_KEY") if not os.environ.get(key)] - if missing: - raise ValueError(f"EVA_USER_SIMULATOR__PROVIDER=cascade requires {', '.join(missing)} to be set.") - return self - @model_validator(mode="before") @classmethod def _handle_all_keyword(cls, data: Any): diff --git a/src/eva/user_simulator/cascade/simulator.py b/src/eva/user_simulator/cascade/simulator.py index 3c2821c8c..7a0b9de15 100644 --- a/src/eva/user_simulator/cascade/simulator.py +++ b/src/eva/user_simulator/cascade/simulator.py @@ -21,14 +21,22 @@ TURN_SETTLE_SECONDS = 1.0 """Assistant silence required before the caller replies.""" -TRANSCRIPT_WAIT_SECONDS = 1.5 -"""How long after a commit to wait for the final transcript before falling back to the partial.""" +TRANSCRIPT_WAIT_SECONDS = 2.5 +"""How long after a commit to wait for the final transcript before falling back to the partial. + +The commit is sent at the start of the pause, so this leaves about 1.5s past ``TURN_SETTLE_SECONDS`` +for a slow final. Only a late final costs this wait; a final dropped after the fallback is lost for good.""" INACTIVITY_TIMEOUT_SECONDS = 120.0 """Assistant silence that ends the call, matching ElevenLabsUserSimulator's 12 keep-alives.""" PLAYBACK_DRAIN_SECONDS = 15.0 FINAL_FLUSH_SECONDS = 4.0 POLL_SECONDS = 0.05 +INAUDIBLE = "[inaudible]" +"""Stands in for an agent line the caller heard but could not transcribe.""" +INAUDIBLE_NOTE = f"""The agent just said something you could not make out ({INAUDIBLE} above). \ +Ask them briefly to repeat it, the way a caller on a bad line would.""" + TTS_WARM_CONNECTIONS = 1 """Pooled TTS connections opened before the first turn: one per TTS request that can render at once. Barge-ins that split the first clause off (see ``next_segment``) will want 2.""" @@ -136,9 +144,11 @@ def __init__( if perturbation_config and perturbation_config.accent is not None: raise ValueError("Cascade caller does not support ElevenLabs-specific accent variants") self.simulator_config = simulator_config - self._stt = ScribeStreamingSTT(simulator_config.stt_model, language=language) + self._stt = ScribeStreamingSTT( + simulator_config.stt_model, language=language, on_dropped_final=self._on_dropped_final + ) self._tts = CartesiaStreamingTTS(simulator_config.tts_model, language=language) - self._llm = LiteLLMClient(model=simulator_config.llm) + self._llm = LiteLLMClient(model=simulator_config.model) # None is a commit marker: commit the STT segment once the audio queued ahead of it is sent. self._assistant_audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._commit_after_next_chunk = False @@ -187,7 +197,7 @@ async def _run_cascade_conversation(self) -> None: "server_url": self.server_url, "caller_provider": self.provider, "caller_stt": f"{self.simulator_config.stt}/{self.simulator_config.stt_model}", - "caller_llm": self.simulator_config.llm, + "caller_llm": self.simulator_config.model, "caller_tts": f"{self.simulator_config.tts}/{self.simulator_config.tts_model}", "caller_voice": self.caller_voice, }, @@ -239,8 +249,11 @@ def _request_commit(self) -> None: the tail, so committing right away would cut the last word into the next segment. """ self._commit_after_next_chunk = True - # Until the commit is sent, an earlier turn's set event must not count as this segment's final. - self._stt.committed_event.clear() + self._stt.expect_commit() + + def _on_dropped_final(self, text: str) -> None: + # Pairs with the turn's transcript_partial_fallback: a final longer than that partial was lost. + self.event_logger.log_event("transcript_late_final_dropped", {"text": text}) async def _forward_assistant_audio(self) -> None: while True: @@ -284,9 +297,9 @@ async def _run_turns(self) -> None: heard = await self._collect_heard_text(committed_at) assistant_spoke = False if not heard: + # Staying silent would stall the call until the inactivity timeout; a caller asks again. self.event_logger.log_event("assistant_audio_untranscribed", {}) - continue - if await self._take_turn(heard): + if await self._take_turn(heard or None): return last_activity = loop.time() @@ -302,12 +315,18 @@ async def _collect_heard_text(self, committed_at: float) -> str: return text return self._stt.take() - async def _take_turn(self, heard: str) -> bool: - """Generate and speak one caller turn. Returns True when the caller hangs up.""" - self._history.append({"role": "assistant", "content": heard}) - self._on_assistant_speaks(heard) + async def _take_turn(self, heard: str | None) -> bool: + """Generate and speak one caller turn. Returns True when the caller hangs up. + + ``heard`` is None when the agent spoke but nothing was transcribed: the caller asks them to repeat. + """ + self._history.append({"role": "assistant", "content": heard or INAUDIBLE}) + if heard: + self._on_assistant_speaks(heard) messages = self._messages() + if not heard: + messages.append({"role": "system", "content": INAUDIBLE_NOTE}) message, stats, spoke = await self._speak(messages) utterance, end_call = extract_turn(message) self._log_llm_response(messages, message, stats) diff --git a/src/eva/user_simulator/cascade/stt.py b/src/eva/user_simulator/cascade/stt.py index eaabfdbf5..9d62d2714 100644 --- a/src/eva/user_simulator/cascade/stt.py +++ b/src/eva/user_simulator/cascade/stt.py @@ -5,6 +5,7 @@ import asyncio import base64 import os +from collections.abc import Callable from typing import Any try: @@ -36,7 +37,14 @@ class ScribeStreamingSTT: than the provider's VAD, so turn boundaries have a single owner. """ - def __init__(self, model: str, *, language: str = "en", api_key: str | None = None) -> None: + def __init__( + self, + model: str, + *, + language: str = "en", + api_key: str | None = None, + on_dropped_final: Callable[[str], None] | None = None, + ) -> None: self.model = model self.language = language.split("-")[0] self._api_key = api_key or os.environ.get("ELEVENLABS_API_KEY", "") @@ -49,6 +57,8 @@ def __init__(self, model: str, *, language: str = "en", api_key: str | None = No self._pending_commits = 0 self._stale_commits = 0 """Commits whose segment was already taken from the partial; their late finals are dropped.""" + self._on_dropped_final = on_dropped_final + """Called with each dropped final, so the caller can record what it acted on without.""" self._resample_state = None self.updated = asyncio.Event() """Set on every partial or committed transcript, so a listener can react to each update @@ -79,11 +89,18 @@ async def send(self, mulaw: bytes) -> None: ) await self._connection.send({"audio_base_64": base64.b64encode(pcm).decode("ascii")}) + def expect_commit(self) -> None: + """Count a commit as pending from the moment it is requested, before ``commit`` sends it. + + ``committed_event`` is set once every expected commit has its final text. Counting at request + time means a partial fallback taken before the commit is sent still marks its final as stale. + """ + self._pending_commits += 1 + self.committed_event.clear() + async def commit(self) -> None: - """Close the current segment. ``committed_event`` is set once every commit sent so far has its final text.""" + """Close the current segment, answering the oldest ``expect_commit``.""" if self._connection is not None: - self._pending_commits += 1 - self.committed_event.clear() await self._connection.commit() def heard_text(self) -> str: @@ -124,11 +141,19 @@ def _on_committed(self, data: dict[str, Any]) -> None: # Scribe answers commits in order, so the oldest outstanding one is the stale one. if self._stale_commits: self._stale_commits -= 1 - logger.debug(f"Dropping late final for an already-answered turn: {text[:120]!r}") + logger.warning(f"Dropping late final for an already-answered turn: {text[:120]!r}") + if self._on_dropped_final is not None: + self._on_dropped_final(text) + return + if not self._pending_commits: + # Finals are matched to commits by order; one with no commit owed means that matching is off, + # and later text could be dropped or heard twice. Fail like a provider error rather than guess. + self.error = f"final transcript with no commit outstanding: {text[:120]!r}" + logger.error(f"Caller STT error: {self.error}") return if text: self._committed.append(text) - self._pending_commits = max(self._pending_commits - 1, 0) + self._pending_commits -= 1 if self._pending_commits == 0: self.committed_event.set() diff --git a/src/eva/user_simulator/cascade/tts.py b/src/eva/user_simulator/cascade/tts.py index 7424a5632..d4f4e2ae8 100644 --- a/src/eva/user_simulator/cascade/tts.py +++ b/src/eva/user_simulator/cascade/tts.py @@ -71,7 +71,7 @@ async def stream(self, text: str, *, voice_id: str) -> AsyncIterator[bytes]: continue # Chunk boundaries need not fall on samples; an odd split would shear PCM16. data = carry + base64.b64decode(event["data"]) - cut = len(data) - len(data) % 2 + cut = len(data) // 2 * 2 carry = data[cut:] if cut: yield data[:cut] diff --git a/src/eva/utils/llm_client.py b/src/eva/utils/llm_client.py index 72c0b8522..61756f0c8 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -142,7 +142,8 @@ async def generate_text(self, messages: list[dict], response_format: dict | None # Make the API call logger.debug(f"{call_retry_id} started for {self.model}") - response = await router.acompletion(**kwargs) + async with router.limit(self.model) as deployment: + response = await router.get().acompletion(**{**kwargs, "model": deployment.id}) (logger.info if attempt > 0 else logger.debug)(f"{call_retry_id} succeeded for {self.model}") diff --git a/src/eva/utils/router.py b/src/eva/utils/router.py index 5705d4e2b..00a0a9761 100644 --- a/src/eva/utils/router.py +++ b/src/eva/utils/router.py @@ -4,12 +4,15 @@ Concurrency: since LiteLLM 1.98 the Router rejects calls over a deployment's ``max_parallel_requests`` with an immediate 429 instead of queueing them. EVA relies on queueing, so it bounds concurrency itself: every call goes -through `limit(model_group)` (or `acompletion`), which waits on a per-model-group semaphore. +through `limit(model_group)`, which waits for a free slot and yields the specific deployment to call. Callers pass +that deployment's id as ``model`` so the Router sends the call there, rather than picking a deployment on its own +that may already be at its cap. """ import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from dataclasses import dataclass, field from typing import Any from litellm import Router @@ -21,13 +24,30 @@ _DEFAULT_MAX_PARALLEL_REQUESTS = 5 + +@dataclass +class Deployment: + """One entry of EVA_MODEL_LIST. Pass `id` as the Router's ``model`` to call exactly this deployment.""" + + id: str + litellm_params: dict[str, Any] = field(default_factory=dict) + capacity: int = _DEFAULT_MAX_PARALLEL_REQUESTS + in_use: int = 0 + + +@dataclass +class _ModelGroup: + deployments: list[Deployment] + semaphore: asyncio.Semaphore + + _router: Router | None = None -_semaphores: dict[str, asyncio.Semaphore] = {} +_groups: dict[str, _ModelGroup] = {} def init(model_list: list) -> None: """Create and store the shared Router. Call once at startup.""" - global _router, _semaphores + global _router, _groups logger.info(f"Initializing LiteLLM Router with {len(model_list)} deployment(s)") _router = Router( model_list=model_list, @@ -35,16 +55,19 @@ def init(model_list: list) -> None: default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, timeout=60, ) - _semaphores = _build_semaphores(model_list) + # The Router assigns each deployment a model_info.id, so read the deployments back from it. + _groups = _build_groups(_router.model_list) -def _build_semaphores(model_list: list) -> dict[str, asyncio.Semaphore]: - """One semaphore per model group, sized to the sum of its deployments' concurrency caps.""" - capacities: dict[str, int] = {} +def _build_groups(model_list: list) -> dict[str, _ModelGroup]: + """Group deployments by model_name, each with its own concurrency cap and a semaphore for the group total.""" + deployments: dict[str, list[Deployment]] = {} for deployment in model_list: # TODO: max_parallel_requests/rpm/tpm are read from litellm_params for backwards compatibility with # existing EVA_MODEL_LIST configs. This is now an EVA-level setting (LiteLLM no longer queues), so it # should move out of litellm_params to the top level of the deployment, next to use_responses_api. + # TODO: evaluate whether per-deployment caps are needed at all, since max_concurrent_conversations + # already bounds concurrency. params = deployment.get("litellm_params", {}) cap = calculate_max_parallel_requests( max_parallel_requests=params.get("max_parallel_requests"), @@ -52,16 +75,20 @@ def _build_semaphores(model_list: list) -> dict[str, asyncio.Semaphore]: tpm=params.get("tpm"), default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, ) - name = deployment["model_name"] - capacities[name] = capacities.get(name, 0) + cap - return {name: asyncio.Semaphore(cap) for name, cap in capacities.items()} + deployments.setdefault(deployment["model_name"], []).append( + Deployment(id=deployment["model_info"]["id"], litellm_params=params, capacity=cap) + ) + return { + name: _ModelGroup(group, asyncio.Semaphore(sum(d.capacity for d in group))) + for name, group in deployments.items() + } def reset() -> None: """Reset the shared Router to None.""" - global _router, _semaphores + global _router, _groups _router = None - _semaphores = {} + _groups = {} def get() -> Router: @@ -72,20 +99,22 @@ def get() -> Router: @asynccontextmanager -async def limit(model_group: str) -> AsyncIterator[None]: - """Hold a concurrency slot for `model_group`, waiting if all slots are in use. +async def limit(model_group: str) -> AsyncIterator[Deployment]: + """Hold a concurrency slot on one of `model_group`'s deployments, waiting if all slots are in use. - Hold it for the whole call, including consuming a stream. Groups with no configured deployment are unbounded. + Yields the deployment holding the slot; call the Router with ``model=deployment.id``. Hold the slot for the + whole call, including consuming a stream. Groups with no configured deployment are unbounded and yield a + placeholder whose id is the group name itself. """ - semaphore = _semaphores.get(model_group) - if semaphore is None: - yield + group = _groups.get(model_group) + if group is None: + yield Deployment(id=model_group) return - async with semaphore: - yield - - -async def acompletion(**kwargs: Any) -> Any: - """`Router.acompletion` bounded by the model group's concurrency limit (non-streaming calls).""" - async with limit(kwargs["model"]): - return await get().acompletion(**kwargs) + async with group.semaphore: + # The group semaphore is the sum of the caps, so some deployment always has a free slot here. + deployment = min(group.deployments, key=lambda d: d.in_use / d.capacity) + deployment.in_use += 1 + try: + yield deployment + finally: + deployment.in_use -= 1 diff --git a/tests/unit/user_simulator/test_cascade.py b/tests/unit/user_simulator/test_cascade.py index 24ad10198..8287c8ade 100644 --- a/tests/unit/user_simulator/test_cascade.py +++ b/tests/unit/user_simulator/test_cascade.py @@ -36,6 +36,8 @@ @pytest.fixture(autouse=True) def _no_router(monkeypatch): monkeypatch.setattr(cascade_simulator, "LiteLLMClient", MagicMock()) + monkeypatch.setenv("ELEVENLABS_API_KEY", "test-key") + monkeypatch.setenv("CARTESIA_API_KEY", "test-key") def _simulator(tmp_path: Path, *, persona_id: int = 1, **kwargs) -> CascadeUserSimulator: @@ -106,6 +108,7 @@ def test_messages_flip_roles_into_caller_frame(tmp_path): def test_stt_accumulates_committed_segments_and_partials(): stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt.expect_commit() stt._on_committed({"text": "Hello,"}) stt._on_partial({"text": "how can"}) @@ -129,9 +132,8 @@ async def test_stt_send_raises_after_provider_error(): @pytest.mark.asyncio async def test_stt_waits_for_every_pending_commit(): stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") - stt._connection = MagicMock(commit=AsyncMock()) - await stt.commit() - await stt.commit() + stt.expect_commit() + stt.expect_commit() stt._on_committed({"text": "One moment."}) assert not stt.committed_event.is_set() @@ -143,23 +145,34 @@ async def test_stt_waits_for_every_pending_commit(): @pytest.mark.asyncio async def test_stt_drops_late_final_after_partial_fallback(): - stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") - stt._connection = MagicMock(commit=AsyncMock()) - await stt.commit() + dropped: list[str] = [] + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x", on_dropped_final=dropped.append) + stt.expect_commit() stt._on_partial({"text": "your ticket is"}) assert stt.take(include_partial=True) == "your ticket is" stt._on_committed({"text": "your ticket is open."}) - await stt.commit() + stt.expect_commit() stt._on_committed({"text": "Anything else?"}) assert stt.take() == "Anything else?" + assert dropped == ["your ticket is open."] + + +@pytest.mark.asyncio +async def test_stt_fails_on_final_with_no_commit_outstanding(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._on_committed({"text": "Where did this come from?"}) + + assert stt.take() == "" + with pytest.raises(RuntimeError, match="no commit outstanding"): + await stt.send(b"\xff") @pytest.mark.asyncio async def test_commit_is_queued_behind_the_next_bridge_chunk(tmp_path): simulator = _simulator(tmp_path) - simulator._stt = MagicMock(send=AsyncMock(), commit=AsyncMock(), committed_event=asyncio.Event()) + simulator._stt = MagicMock(send=AsyncMock(), commit=AsyncMock()) order: list[str] = [] simulator._stt.send.side_effect = lambda chunk: order.append(f"send:{chunk.decode()}") simulator._stt.commit.side_effect = lambda: order.append("commit") @@ -252,6 +265,30 @@ def stream(text, voice_id): ] +@pytest.mark.asyncio +async def test_take_turn_asks_to_repeat_untranscribed_speech(tmp_path): + simulator = _simulator(tmp_path) + sent: list[list[dict]] = [] + + async def stream(messages, **_): + sent.append(messages) + yield ("final", ("Sorry, could you repeat that?", {})) + + simulator._llm.complete_stream = stream + simulator._tts.stream = lambda text, voice_id: _chunks(b"\x01\x02") + simulator._audio_interface = MagicMock() + simulator._on_assistant_speaks = MagicMock() + + assert await simulator._take_turn(None) is False + + assert sent[0][-1] == {"role": "system", "content": cascade_simulator.INAUDIBLE_NOTE} + simulator._on_assistant_speaks.assert_not_called() # nothing made up lands in the transcript + assert simulator._history == [ + {"role": "assistant", "content": cascade_simulator.INAUDIBLE}, + {"role": "user", "content": "Sorry, could you repeat that?"}, + ] + + @pytest.mark.asyncio async def test_end_call_without_speech_skips_tts(tmp_path): simulator = _simulator(tmp_path) diff --git a/tests/unit/user_simulator/test_factory.py b/tests/unit/user_simulator/test_factory.py index 7ae1f6f7a..40033fa89 100644 --- a/tests/unit/user_simulator/test_factory.py +++ b/tests/unit/user_simulator/test_factory.py @@ -50,6 +50,8 @@ def test_factory_selects_openai_realtime(tmp_path): def test_factory_selects_cascade(tmp_path, monkeypatch): monkeypatch.setattr(cascade_simulator, "LiteLLMClient", MagicMock()) + monkeypatch.setenv("ELEVENLABS_API_KEY", "test-key") + monkeypatch.setenv("CARTESIA_API_KEY", "test-key") simulator = create_user_simulator(CascadeSimulatorConfig(), **_kwargs(tmp_path)) assert isinstance(simulator, CascadeUserSimulator) diff --git a/tests/unit/utils/test_llm_client.py b/tests/unit/utils/test_llm_client.py index 16e5de9b0..87f9ed682 100644 --- a/tests/unit/utils/test_llm_client.py +++ b/tests/unit/utils/test_llm_client.py @@ -244,17 +244,44 @@ async def mock_acompletion(*args, **kwargs): return _mock_response("ok") with patch("litellm.acompletion", new=mock_acompletion): - tasks = [ - router.acompletion( - model="test-model", - messages=[{"role": "user", "content": f"msg-{i}"}], - ) - for i in range(8) - ] - await asyncio.gather(*tasks) + await asyncio.gather(*(_limited_call("test-model", i) for i in range(8))) assert peak_concurrent == 2 + @pytest.mark.asyncio + async def test_multi_deployment_group_never_exceeds_a_deployment_cap(self): + """Calls are pinned to a deployment with a free slot, so LiteLLM never rejects one with a 429.""" + router.init( + [ + { + "model_name": "test-model", + "litellm_params": {"model": f"openai/{name}", "api_key": "test-key", "max_parallel_requests": cap}, + } + for name, cap in (("small", 1), ("large", 3)) + ] + ) + + peak: dict[str, int] = {} + current: dict[str, int] = {} + + async def mock_acompletion(*args, **kwargs): + name = kwargs["model"] + current[name] = current.get(name, 0) + 1 + peak[name] = max(peak.get(name, 0), current[name]) + await asyncio.sleep(0.01) + current[name] -= 1 + return _mock_response("ok") + + with patch("litellm.acompletion", new=mock_acompletion): + await asyncio.gather(*(_limited_call("test-model", i) for i in range(20))) + + assert peak == {"openai/small": 1, "openai/large": 3} + + +async def _limited_call(model_group: str, i: int): + async with router.limit(model_group) as deployment: + return await router.get().acompletion(model=deployment.id, messages=[{"role": "user", "content": f"msg-{i}"}]) + class TestLitellmParamsForwarding: """Verify that litellm_params from the model_list reach the underlying API call.""" From 91162691878bfc5aff59d87628a7b727964b61e9 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Thu, 22 Oct 2026 14:18:11 -0700 Subject: [PATCH 06/12] bug fixes and optional silence --- src/eva/user_simulator/audio_bridge.py | 3 + src/eva/user_simulator/cascade/simulator.py | 90 ++++++++++--- src/eva/user_simulator/cascade/stt.py | 47 +++---- tests/unit/user_simulator/test_cascade.py | 141 ++++++++++++++++---- 4 files changed, 211 insertions(+), 70 deletions(-) diff --git a/src/eva/user_simulator/audio_bridge.py b/src/eva/user_simulator/audio_bridge.py index bf588a700..79ff569c5 100644 --- a/src/eva/user_simulator/audio_bridge.py +++ b/src/eva/user_simulator/audio_bridge.py @@ -153,6 +153,8 @@ def __init__( self._assistant_audio_active = False # assistant speaking self._user_audio_ended_time = None # Track when user audio ended for silence sending self._assistant_audio_ended_time = None # Track when assistant audio ended for silence sending + # Assistant utterances started so far; lets a caller tell whether the assistant spoke while it was busy. + self.assistant_audio_starts = 0 # Loop time of the last real user-audio chunk sent — used to stamp the # user audio_end at the actual end (detection lags it by ~600ms). self._last_user_audio_send_time: float | None = None @@ -567,6 +569,7 @@ def _on_assistant_audio_start(self) -> None: if self._assistant_audio_ended_time is not None: self._assistant_audio_ended_time = None self._assistant_audio_active = True + self.assistant_audio_starts += 1 # The trailing white-noise block exists only to nudge the assistant's VAD # into detecting end-of-speech. Once the assistant actually responds it # has done its job, so cancel any remaining frames — otherwise noise would diff --git a/src/eva/user_simulator/cascade/simulator.py b/src/eva/user_simulator/cascade/simulator.py index 7a0b9de15..4abb6463f 100644 --- a/src/eva/user_simulator/cascade/simulator.py +++ b/src/eva/user_simulator/cascade/simulator.py @@ -22,16 +22,20 @@ TURN_SETTLE_SECONDS = 1.0 """Assistant silence required before the caller replies.""" TRANSCRIPT_WAIT_SECONDS = 2.5 -"""How long after a commit to wait for the final transcript before falling back to the partial. +"""How long after a commit to wait for the final transcript before failing the call. The commit is sent at the start of the pause, so this leaves about 1.5s past ``TURN_SETTLE_SECONDS`` -for a slow final. Only a late final costs this wait; a final dropped after the fallback is lost for good.""" +for a slow final. Scribe usually answers within a few hundred ms; only a stalled final costs this wait.""" INACTIVITY_TIMEOUT_SECONDS = 120.0 """Assistant silence that ends the call, matching ElevenLabsUserSimulator's 12 keep-alives.""" PLAYBACK_DRAIN_SECONDS = 15.0 FINAL_FLUSH_SECONDS = 4.0 POLL_SECONDS = 0.05 +MAX_UNTRANSCRIBED_IN_A_ROW = 2 +"""Agent lines in a row the caller could not transcribe before the call fails: one is a bad line, +repeated ones mean the caller cannot hear the agent, and the conversation would not be a fair test.""" + INAUDIBLE = "[inaudible]" """Stands in for an agent line the caller heard but could not transcribe.""" INAUDIBLE_NOTE = f"""The agent just said something you could not make out ({INAUDIBLE} above). \ @@ -80,6 +84,30 @@ } +KEEP_LISTENING_DESCRIPTION = """Use this instead of replying when the agent has not finished its turn. + +Agents pause mid-turn while they look something up, so what you heard may be only a first part: a +filler like "Let me check that", "One moment", or "I'll look that up", or an announcement of what +they are about to do. A caller waits through those in silence. Call this tool, with no text, to keep +listening; the rest of the agent's turn will follow. + +Never use it when the agent asked you a question, gave you a result, or said goodbye: those need +your reply.""" + +KEEP_LISTENING_TOOL = { + "type": "function", + "function": { + "name": "keep_listening", + "description": KEEP_LISTENING_DESCRIPTION, + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} + +MAX_KEEP_LISTENING_IN_A_ROW = 2 +"""After this many silent waits the tool is withheld, so a caller that keeps waiting still answers +rather than stalling the call until the inactivity timeout.""" + + def next_segment(buffer: str, *, first: bool, final: bool) -> tuple[str, str]: """Split the next speakable segment off streamed text; returns (segment, rest). @@ -112,6 +140,11 @@ def extract_turn(message: Any) -> tuple[str, bool]: return _FENCE.sub("", content).strip(), end_call +def calls_tool(message: Any, name: str) -> bool: + """Whether a LiteLLMClient message called the tool ``name``.""" + return any(getattr(call.function, "name", "") == name for call in getattr(message, "tool_calls", None) or []) + + class CascadeUserSimulator(AbstractUserSimulator): """Simulated caller built from independently chosen streaming STT, LLM, and TTS models.""" @@ -144,15 +177,14 @@ def __init__( if perturbation_config and perturbation_config.accent is not None: raise ValueError("Cascade caller does not support ElevenLabs-specific accent variants") self.simulator_config = simulator_config - self._stt = ScribeStreamingSTT( - simulator_config.stt_model, language=language, on_dropped_final=self._on_dropped_final - ) + self._stt = ScribeStreamingSTT(simulator_config.stt_model, language=language) self._tts = CartesiaStreamingTTS(simulator_config.tts_model, language=language) self._llm = LiteLLMClient(model=simulator_config.model) # None is a commit marker: commit the STT segment once the audio queued ahead of it is sent. self._assistant_audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._commit_after_next_chunk = False self._history: list[dict[str, str]] = [] + self._kept_listening = 0 @property def caller_voice(self) -> str: @@ -211,6 +243,10 @@ async def _run_cascade_conversation(self) -> None: finished = next(iter(done)) raise finished.exception() or RuntimeError("Cascade caller task stopped unexpectedly") + # Mark the bridge stopped, as ElevenLabs' end_session() does: an assistant that hangs up on + # signal_conversation_ending (e.g. Cartesia) drops the socket during the flush below, and a + # still-running bridge would report that as a disconnect mid-conversation. + self._audio_interface.stop() # Allow final goodbye audio and transcripts to flush before closing. await asyncio.sleep(FINAL_FLUSH_SECONDS) except Exception: @@ -251,10 +287,6 @@ def _request_commit(self) -> None: self._commit_after_next_chunk = True self._stt.expect_commit() - def _on_dropped_final(self, text: str) -> None: - # Pairs with the turn's transcript_partial_fallback: a final longer than that partial was lost. - self.event_logger.log_event("transcript_late_final_dropped", {"text": text}) - async def _forward_assistant_audio(self) -> None: while True: item = await self._assistant_audio_queue.get() @@ -270,6 +302,7 @@ async def _run_turns(self) -> None: assistant_spoke = False committed_at: float | None = None last_activity = loop.time() + untranscribed = 0 while not self._conversation_done.is_set(): await asyncio.sleep(POLL_SECONDS) @@ -290,29 +323,41 @@ async def _run_turns(self) -> None: if committed_at is None: self._request_commit() committed_at = now - ended_at = bridge.assistant_audio_ended_at - if ended_at is None or now - ended_at < TURN_SETTLE_SECONDS: + # The caller starting to speak clears the bridge's end time, so a line the assistant finished + # during the caller's turn settles from when this pause was first seen instead. + ended_at = bridge.assistant_audio_ended_at or committed_at + if now - ended_at < TURN_SETTLE_SECONDS: continue heard = await self._collect_heard_text(committed_at) assistant_spoke = False - if not heard: + if heard: + untranscribed = 0 + else: # Staying silent would stall the call until the inactivity timeout; a caller asks again. self.event_logger.log_event("assistant_audio_untranscribed", {}) + untranscribed += 1 + if untranscribed >= MAX_UNTRANSCRIBED_IN_A_ROW: + raise RuntimeError(f"Caller could not transcribe {untranscribed} agent lines in a row") + starts = bridge.assistant_audio_starts if await self._take_turn(heard or None): return last_activity = loop.time() + if bridge.assistant_audio_starts != starts: + # The assistant spoke while the caller was generating (e.g. "One moment."). The polls above + # never saw it play, so without this it would go uncommitted, merge into the next turn, or + # leave the caller silent until the inactivity timeout if the assistant waits for an answer. + self.event_logger.log_event("assistant_spoke_during_caller_turn", {}) + assistant_spoke, committed_at = True, None async def _collect_heard_text(self, committed_at: float) -> str: - """Return what the assistant said, falling back to the partial if the final is late.""" + """Return what the assistant said; a final that never arrives fails the call.""" remaining = TRANSCRIPT_WAIT_SECONDS - (asyncio.get_running_loop().time() - committed_at) try: await asyncio.wait_for(self._stt.committed_event.wait(), timeout=max(remaining, 0)) except TimeoutError: - text = self._stt.take(include_partial=True) - logger.warning(f"No final transcript after {TRANSCRIPT_WAIT_SECONDS}s; using partial: {text[:120]!r}") - self.event_logger.log_event("transcript_partial_fallback", {"text": text}) - return text + # Acting on the partial would answer a cut-off line; failing lets the call be rerun. + raise RuntimeError(f"No final transcript within {TRANSCRIPT_WAIT_SECONDS}s of the commit") from None return self._stt.take() async def _take_turn(self, heard: str | None) -> bool: @@ -331,6 +376,10 @@ async def _take_turn(self, heard: str | None) -> bool: utterance, end_call = extract_turn(message) self._log_llm_response(messages, message, stats) + if calls_tool(message, "keep_listening") and not end_call: + self._kept_listening += 1 + return False + self._kept_listening = 0 if utterance: self._history.append({"role": "user", "content": utterance}) self._on_user_speaks(utterance) @@ -360,13 +409,16 @@ async def _speak(self, messages: list[dict[str, Any]]) -> tuple[Any, dict[str, A """ message: Any = "" stats: dict[str, Any] = {} + tools = [END_CALL_TOOL] + if self._kept_listening < MAX_KEEP_LISTENING_IN_A_ROW: + tools.append(KEEP_LISTENING_TOOL) # Streamed even though only the final is used: barge-ins will consume the deltas. - async for kind, payload in self._llm.complete_stream(messages=messages, tools=[END_CALL_TOOL]): + async for kind, payload in self._llm.complete_stream(messages=messages, tools=tools): if kind == "final": message, stats = payload utterance, _ = extract_turn(message) spoke = False - if utterance: + if utterance and not calls_tool(message, "keep_listening"): async for chunk in self._tts.stream(utterance, voice_id=self.caller_voice): self._audio_interface.output(chunk) spoke = True diff --git a/src/eva/user_simulator/cascade/stt.py b/src/eva/user_simulator/cascade/stt.py index 9d62d2714..29c1870ca 100644 --- a/src/eva/user_simulator/cascade/stt.py +++ b/src/eva/user_simulator/cascade/stt.py @@ -5,7 +5,6 @@ import asyncio import base64 import os -from collections.abc import Callable from typing import Any try: @@ -28,6 +27,9 @@ ~120ms faster (STT lag p50 422 vs 540ms) than sending Scribe the mu-law directly, with no change in the information it carries (scripts/measure_barge_in_latency.py, STT_BACKENDS).""" +MIN_COMMIT_SECONDS = 0.3 +"""Scribe ignores a commit with less uncommitted audio than this, and sends no final for it.""" + class ScribeStreamingSTT: """Transcribes assistant audio continuously over ElevenLabs Scribe realtime. @@ -43,7 +45,6 @@ def __init__( *, language: str = "en", api_key: str | None = None, - on_dropped_final: Callable[[str], None] | None = None, ) -> None: self.model = model self.language = language.split("-")[0] @@ -55,10 +56,7 @@ def __init__( self._closing = False self.committed_event = asyncio.Event() self._pending_commits = 0 - self._stale_commits = 0 - """Commits whose segment was already taken from the partial; their late finals are dropped.""" - self._on_dropped_final = on_dropped_final - """Called with each dropped final, so the caller can record what it acted on without.""" + self._uncommitted_bytes = 0 self._resample_state = None self.updated = asyncio.Event() """Set on every partial or committed transcript, so a listener can react to each update @@ -87,13 +85,13 @@ async def send(self, mulaw: bytes) -> None: pcm, self._resample_state = audioop.ratecv( audioop.ulaw2lin(mulaw, 2), 2, 1, INPUT_SAMPLE_RATE, STT_SAMPLE_RATE, self._resample_state ) + self._uncommitted_bytes += len(pcm) await self._connection.send({"audio_base_64": base64.b64encode(pcm).decode("ascii")}) def expect_commit(self) -> None: """Count a commit as pending from the moment it is requested, before ``commit`` sends it. - ``committed_event`` is set once every expected commit has its final text. Counting at request - time means a partial fallback taken before the commit is sent still marks its final as stale. + ``committed_event`` is set once every expected commit has its final text. """ self._pending_commits += 1 self.committed_event.clear() @@ -101,21 +99,22 @@ def expect_commit(self) -> None: async def commit(self) -> None: """Close the current segment, answering the oldest ``expect_commit``.""" if self._connection is not None: + # A pause commit can follow an earlier one by a few ms of audio; pad the tail with silence so + # Scribe accepts it, rather than never answering a commit that was counted as pending. + missing = int(MIN_COMMIT_SECONDS * STT_SAMPLE_RATE * 2) - self._uncommitted_bytes + if missing > 0: + await self._connection.send({"audio_base_64": base64.b64encode(bytes(missing)).decode("ascii")}) + self._uncommitted_bytes = 0 await self._connection.commit() def heard_text(self) -> str: """Everything heard since the last ``take``, including the in-flight partial.""" return " ".join([*self._committed, self.partial]).strip() - def take(self, *, include_partial: bool = False) -> str: - """Return and clear the committed text, optionally with the in-flight partial.""" - text = self.heard_text() if include_partial else " ".join(self._committed).strip() + def take(self) -> str: + """Return and clear the committed text.""" + text = " ".join(self._committed).strip() self._committed.clear() - if include_partial: - self.partial = "" - # Finals still owed for this turn would otherwise land in the next one. - self._stale_commits += self._pending_commits - self._pending_commits = 0 return text async def stop(self) -> None: @@ -138,18 +137,12 @@ def _on_committed(self, data: dict[str, Any]) -> None: text = data.get("text", "").strip() self.partial = "" self.updated.set() - # Scribe answers commits in order, so the oldest outstanding one is the stale one. - if self._stale_commits: - self._stale_commits -= 1 - logger.warning(f"Dropping late final for an already-answered turn: {text[:120]!r}") - if self._on_dropped_final is not None: - self._on_dropped_final(text) - return if not self._pending_commits: - # Finals are matched to commits by order; one with no commit owed means that matching is off, - # and later text could be dropped or heard twice. Fail like a provider error rather than guess. - self.error = f"final transcript with no commit outstanding: {text[:120]!r}" - logger.error(f"Caller STT error: {self.error}") + # Scribe also commits on its own (e.g. a long unbroken stretch of speech). That text is real + # and owes nobody a commit, so keep it rather than lose it or fail the call. + logger.warning(f"Final transcript with no commit outstanding, keeping it: {text[:120]!r}") + if text: + self._committed.append(text) return if text: self._committed.append(text) diff --git a/tests/unit/user_simulator/test_cascade.py b/tests/unit/user_simulator/test_cascade.py index 8287c8ade..18674dec1 100644 --- a/tests/unit/user_simulator/test_cascade.py +++ b/tests/unit/user_simulator/test_cascade.py @@ -116,8 +116,7 @@ def test_stt_accumulates_committed_segments_and_partials(): assert stt.heard_text() == "Hello, how can" assert stt.take() == "Hello," assert stt.partial == "how can" - assert stt.take(include_partial=True) == "how can" - assert stt.heard_text() == "" + assert stt.heard_text() == "how can" @pytest.mark.asyncio @@ -143,30 +142,25 @@ async def test_stt_waits_for_every_pending_commit(): assert stt.take() == "One moment. I found it." -@pytest.mark.asyncio -async def test_stt_drops_late_final_after_partial_fallback(): - dropped: list[str] = [] - stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x", on_dropped_final=dropped.append) - stt.expect_commit() - stt._on_partial({"text": "your ticket is"}) - assert stt.take(include_partial=True) == "your ticket is" - - stt._on_committed({"text": "your ticket is open."}) - stt.expect_commit() - stt._on_committed({"text": "Anything else?"}) +def test_stt_keeps_final_with_no_commit_outstanding(): + stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") + stt._on_committed({"text": "Scribe committed this itself."}) - assert stt.take() == "Anything else?" - assert dropped == ["your ticket is open."] + assert stt.error is None + assert stt.take() == "Scribe committed this itself." @pytest.mark.asyncio -async def test_stt_fails_on_final_with_no_commit_outstanding(): +async def test_stt_pads_a_commit_that_is_too_short_for_scribe(): stt = ScribeStreamingSTT("scribe_v2_realtime", api_key="x") - stt._on_committed({"text": "Where did this come from?"}) + stt._connection = AsyncMock() + await stt.send(b"\xff" * 80) # 10ms of audio - assert stt.take() == "" - with pytest.raises(RuntimeError, match="no commit outstanding"): - await stt.send(b"\xff") + await stt.commit() + + padded = sum(len(base64.b64decode(c.args[0]["audio_base_64"])) for c in stt._connection.send.await_args_list) + assert padded >= 0.3 * 16000 * 2 + stt._connection.commit.assert_awaited_once() @pytest.mark.asyncio @@ -208,14 +202,14 @@ async def test_error_signals_assistant_and_ends_with_session_ended(tmp_path): @pytest.mark.asyncio -async def test_collect_heard_text_falls_back_to_partial(tmp_path, monkeypatch): +async def test_collect_heard_text_fails_when_the_final_never_arrives(tmp_path, monkeypatch): monkeypatch.setattr(cascade_simulator, "TRANSCRIPT_WAIT_SECONDS", 0.01) simulator = _simulator(tmp_path) + simulator._stt.expect_commit() simulator._stt._on_partial({"text": "your ticket is"}) - heard = await simulator._collect_heard_text(asyncio.get_running_loop().time()) - - assert heard == "your ticket is" + with pytest.raises(RuntimeError, match="No final transcript"): + await simulator._collect_heard_text(asyncio.get_running_loop().time()) def _llm_stream(*deltas: str, message=None): @@ -289,6 +283,40 @@ async def stream(messages, **_): ] +@pytest.mark.asyncio +async def test_keep_listening_stays_silent_until_the_agent_finishes(tmp_path): + simulator = _simulator(tmp_path) + simulator._llm.complete_stream = _llm_stream(message=_tool_message("", "keep_listening")) + simulator._tts.stream = MagicMock() + simulator._audio_interface = MagicMock() + + assert await simulator._take_turn("Let me check that.") is False + + simulator._tts.stream.assert_not_called() + simulator._audio_interface.output.assert_not_called() + assert simulator._history == [{"role": "assistant", "content": "Let me check that."}] + + +@pytest.mark.asyncio +async def test_keep_listening_is_withheld_after_repeated_waits(tmp_path): + simulator = _simulator(tmp_path) + simulator._kept_listening = cascade_simulator.MAX_KEEP_LISTENING_IN_A_ROW + offered: list[str] = [] + + async def stream(*, tools, **_): + offered.extend(t["function"]["name"] for t in tools) + yield ("final", ("Okay.", {})) + + simulator._llm.complete_stream = stream + simulator._tts.stream = MagicMock(return_value=_chunks()) + simulator._audio_interface = MagicMock() + + await simulator._take_turn("Anything else?") + + assert offered == ["end_call"] + assert simulator._kept_listening == 0 + + @pytest.mark.asyncio async def test_end_call_without_speech_skips_tts(tmp_path): simulator = _simulator(tmp_path) @@ -353,3 +381,68 @@ async def test_stt_sends_bridge_mulaw_as_16k_pcm16(): sent = sum(len(base64.b64decode(c.args[0]["audio_base_64"])) for c in stt._connection.send.call_args_list) # 1s of 16kHz PCM16 is 32000 bytes; the resampler carries a sample of state between chunks. assert abs(sent - 32000) <= 4 + + +@pytest.mark.asyncio +async def test_assistant_line_spoken_during_caller_turn_gets_its_own_turn(tmp_path, monkeypatch): + monkeypatch.setattr(cascade_simulator, "TURN_SETTLE_SECONDS", 0) + monkeypatch.setattr(cascade_simulator, "POLL_SECONDS", 0) + simulator = _simulator(tmp_path) + # The caller's own speech clears the end time, as the real bridge does on user audio start. + bridge = MagicMock(assistant_audio_starts=0, assistant_audio_ended_at=None) + bridge.is_assistant_playing.return_value = False + bridge.is_caller_playing.return_value = False + simulator._audio_interface = bridge + simulator._request_commit = MagicMock() + simulator._collect_heard_text = AsyncMock(side_effect=["What is your ID?", "One moment."]) + simulator.event_logger = MagicMock() + heard: list[str] = [] + + async def take_turn(text): + heard.append(text) + if len(heard) == 1: + bridge.assistant_audio_starts += 1 # "One moment." starts and ends while the caller replies + return False + return True + + simulator._take_turn = take_turn + + async def first_pause(): + # Seed the loop as if the assistant just finished its first line. + bridge.is_assistant_playing.return_value = True + await asyncio.sleep(0) + bridge.is_assistant_playing.return_value = False + + seeder = asyncio.create_task(first_pause()) + await asyncio.wait_for(simulator._run_turns(), timeout=1) + await seeder + + assert heard == ["What is your ID?", "One moment."] + simulator.event_logger.log_event.assert_called_with("assistant_spoke_during_caller_turn", {}) + + +@pytest.mark.asyncio +async def test_repeated_untranscribed_agent_lines_fail_the_call(tmp_path, monkeypatch): + monkeypatch.setattr(cascade_simulator, "TURN_SETTLE_SECONDS", 0) + monkeypatch.setattr(cascade_simulator, "POLL_SECONDS", 0) + simulator = _simulator(tmp_path) + bridge = MagicMock(assistant_audio_starts=0, assistant_audio_ended_at=None) + bridge.is_caller_playing.return_value = False + simulator._audio_interface = bridge + simulator._request_commit = MagicMock() + simulator.event_logger = MagicMock() + simulator._collect_heard_text = AsyncMock(return_value="") + turns: list[str | None] = [] + + async def take_turn(text): + turns.append(text) + bridge.assistant_audio_starts += 1 # the agent repeats itself, still untranscribed + return False + + simulator._take_turn = take_turn + bridge.is_assistant_playing.side_effect = [True, *[False] * 1000] + + with pytest.raises(RuntimeError, match="2 agent lines in a row"): + await asyncio.wait_for(simulator._run_turns(), timeout=1) + + assert turns == [None] # asked to repeat once, then gave up From 85b2cf2b8a3c7c079e05f9153b5f14201c7da0cc Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Thu, 22 Oct 2026 18:41:04 -0700 Subject: [PATCH 07/12] concurrency fix --- .env.example | 15 ++--- docs/llm_configuration.md | 29 +++------ src/eva/assistant/services/llm.py | 77 +++++++++++++---------- src/eva/metrics/runner.py | 7 +-- src/eva/models/config.py | 6 ++ src/eva/run_benchmark.py | 2 + src/eva/utils/llm_client.py | 15 ++++- src/eva/utils/router.py | 96 ++--------------------------- tests/unit/utils/test_llm_client.py | 77 +++++------------------ 9 files changed, 107 insertions(+), 217 deletions(-) diff --git a/.env.example b/.env.example index 2ef36acba..bf12f3f89 100644 --- a/.env.example +++ b/.env.example @@ -175,8 +175,7 @@ EVA_MODEL_LIST='[ "model_name": "gpt-5.2", "litellm_params": { "model": "openai/gpt-5.2", - "api_key": "os.environ/OPENAI_API_KEY", - "max_parallel_requests": 5 + "api_key": "os.environ/OPENAI_API_KEY" }, "model_info": {"base_model": "gpt-5.2"} }, @@ -186,8 +185,7 @@ EVA_MODEL_LIST='[ "model": "vertex_ai/gemini-3.1-pro-preview", "vertex_project": "your-gcp-project-id", "vertex_location": "global", - "vertex_credentials": "os.environ/GOOGLE_APPLICATION_CREDENTIALS", - "max_parallel_requests": 5 + "vertex_credentials": "os.environ/GOOGLE_APPLICATION_CREDENTIALS" } }, { @@ -195,8 +193,7 @@ EVA_MODEL_LIST='[ "litellm_params": { "model": "bedrock/us.anthropic.claude-opus-4-6-v1", "aws_access_key_id": "os.environ/AWS_ACCESS_KEY_ID", - "aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY", - "max_parallel_requests": 5 + "aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY" } }, { @@ -204,7 +201,6 @@ EVA_MODEL_LIST='[ "litellm_params": { "model": "openai/gpt-6-luna", "api_key": "os.environ/OPENAI_API_KEY", - "max_parallel_requests": 5, "reasoning_effort": "none" } } @@ -224,6 +220,11 @@ EVA_MODEL_LIST='[ #r 1,100,1 #v EVA_MAX_CONCURRENT_CONVERSATIONS=1 +#i Maximum number of concurrent judge LLM calls (metrics and validation), across all judge models. +#d int +#r 1,100,1 +#v EVA_MAX_CONCURRENT_JUDGES=3 + #i Conversation time limit in seconds. #d int #r 30,10000,10 diff --git a/docs/llm_configuration.md b/docs/llm_configuration.md index 35381fca3..44f7688c4 100644 --- a/docs/llm_configuration.md +++ b/docs/llm_configuration.md @@ -154,8 +154,7 @@ EVA_MODEL_LIST='[ "litellm_params": { "model": "azure/gpt-5.2", "api_key": "os.environ/AZURE_KEY_A", - "api_base": "https://endpoint-a.openai.azure.com", - "max_parallel_requests": 5 + "api_base": "https://endpoint-a.openai.azure.com" } }, { @@ -163,8 +162,7 @@ EVA_MODEL_LIST='[ "litellm_params": { "model": "azure/gpt-5.2", "api_key": "os.environ/AZURE_KEY_B", - "api_base": "https://endpoint-b.openai.azure.com", - "max_parallel_requests": 5 + "api_base": "https://endpoint-b.openai.azure.com" } } ]' @@ -194,23 +192,14 @@ EVA_MODEL_LIST='[ ## Concurrency Control -Set `max_parallel_requests` per deployment to limit concurrent API calls: +EVA bounds load with two settings rather than per-deployment limits: -```bash -EVA_MODEL_LIST='[ - { - "model_name": "gpt-5.2", - "litellm_params": { - "model": "azure/gpt-5.2", - "api_key": "os.environ/AZURE_API_KEY", - "api_base": "https://your-resource.openai.azure.com", - "max_parallel_requests": 5 - } - } -]' -``` +- `EVA_MAX_CONCURRENT_CONVERSATIONS`: conversations running at once (assistant and user simulator calls). +- `EVA_MAX_CONCURRENT_JUDGES`: judge LLM calls in flight at once, across all metrics, validation and judge models. -You can also set `rpm` (requests per minute) and `tpm` (tokens per minute) limits. +Their sum is the upper bound on concurrent LLM calls. Do not set `max_parallel_requests` on deployments: LiteLLM +rejects calls over that cap with an immediate 429 instead of queueing them. `rpm`/`tpm` are also turned into a +per-deployment parallel cap by LiteLLM, so keep them well above the limits above if you set them. ## Advanced: Model Parameters @@ -228,7 +217,7 @@ Configure model parameters (temperature, max_tokens, reasoning_effort, top_p, fr - See LiteLLM docs: https://docs.litellm.ai/docs/providers ### "Rate Limit Exceeded" -- Adjust `max_parallel_requests` per deployment in `EVA_MODEL_LIST` +- Lower `EVA_MAX_CONCURRENT_JUDGES` (metrics) or `EVA_MAX_CONCURRENT_CONVERSATIONS` (conversations) ## Retry Logic and Rate Limits diff --git a/src/eva/assistant/services/llm.py b/src/eva/assistant/services/llm.py index df4100487..f35fb5962 100644 --- a/src/eva/assistant/services/llm.py +++ b/src/eva/assistant/services/llm.py @@ -93,8 +93,7 @@ async def complete( for attempt in range(max_retries + 1): try: start_time = time.time() - async with router.limit(self.model) as deployment: - response = await router.get().acompletion(**{**kwargs, "model": deployment.id}) + response = await router.get().acompletion(**kwargs) elapsed_time = time.time() - start_time message = response.choices[0].message @@ -268,24 +267,17 @@ def _convert_messages_for_responses_api( i += 1 return instructions, input_items - def _responses_deployment_kwargs(self, deployment: router.Deployment) -> dict[str, Any]: - """Model, credentials and reasoning effort for a Responses API call to `deployment`.""" - litellm_params = deployment.litellm_params - kwargs: dict[str, Any] = {"model": litellm_params.get("model", self.model)} + def _get_router_litellm_params(self) -> dict[str, Any]: + """Look up the litellm_params for self.model from the router config. - # Resolve api_key (may be "os.environ/VAR_NAME") - raw_key = litellm_params.get("api_key", "") - if raw_key.startswith("os.environ/"): - raw_key = os.environ.get(raw_key.split("/", 1)[1], "") - if raw_key: - kwargs["api_key"] = raw_key - if api_base := litellm_params.get("api_base"): - kwargs["api_base"] = api_base - - # reasoning_effort from litellm_params (set in EVA_MODEL_LIST deployment config) - if reasoning_effort := litellm_params.get("reasoning_effort"): - kwargs["reasoning"] = {"effort": reasoning_effort} - return kwargs + Assumes the router is initialized (only called from _complete_via_responses_api, + which requires use_responses_api=True read from the router at init time). + """ + r = router.get() + for deployment in getattr(r, "model_list", []): + if deployment.get("model_name") == self.model: + return deployment.get("litellm_params", {}) + return {} async def complete_stream( self, @@ -313,17 +305,16 @@ async def complete_stream( first_token = False try: start_time = time.time() - async with router.limit(self.model) as deployment: - stream = await router.get().acompletion(**{**kwargs, "model": deployment.id}) - async for chunk in stream: - chunks.append(chunk) - choices = getattr(chunk, "choices", None) or [] - if choices: - delta = getattr(choices[0], "delta", None) - text = getattr(delta, "content", None) if delta else None - if text: - first_token = True - yield ("delta", text) + stream = await router.get().acompletion(**kwargs) + async for chunk in stream: + chunks.append(chunk) + choices = getattr(chunk, "choices", None) or [] + if choices: + delta = getattr(choices[0], "delta", None) + text = getattr(delta, "content", None) if delta else None + if text: + first_token = True + yield ("delta", text) elapsed_time = time.time() - start_time full = litellm.stream_chunk_builder(chunks, messages=messages) @@ -384,26 +375,46 @@ async def _complete_via_responses_api( The caller (system.py) stores ``responses_output_items`` from stats on the assistant message so the next iteration injects them back into the input. """ + # Resolve model credentials from router config (handles Azure, custom api_base, etc.) + litellm_params = self._get_router_litellm_params() + litellm_model = litellm_params.get("model", self.model) + + # Resolve api_key (may be "os.environ/VAR_NAME") + raw_key = litellm_params.get("api_key", "") + if raw_key.startswith("os.environ/"): + raw_key = os.environ.get(raw_key.split("/", 1)[1], "") + api_key = raw_key or None + + api_base = litellm_params.get("api_base") or None + + # reasoning_effort from litellm_params (set in EVA_MODEL_LIST deployment config) + reasoning_effort = litellm_params.get("reasoning_effort") + instructions, input_items = self._convert_messages_for_responses_api(messages) kwargs: dict[str, Any] = { + "model": litellm_model, "input": input_items, "store": False, "include": ["reasoning.encrypted_content"], } + if reasoning_effort: + kwargs["reasoning"] = {"effort": reasoning_effort} if instructions: kwargs["instructions"] = instructions if tools: kwargs["tools"] = self._convert_tools_for_responses_api(tools) kwargs["tool_choice"] = "auto" + if api_key: + kwargs["api_key"] = api_key + if api_base: + kwargs["api_base"] = api_base last_exception = None for attempt in range(max_retries + 1): try: start_time = time.time() - async with router.limit(self.model) as deployment: - # Resolve credentials from the deployment holding the slot (handles Azure, custom api_base, etc.) - response = await litellm.aresponses(**kwargs, **self._responses_deployment_kwargs(deployment)) + response = await litellm.aresponses(**kwargs) elapsed = time.time() - start_time text_content = "" diff --git a/src/eva/metrics/runner.py b/src/eva/metrics/runner.py index 576df4ca9..65febab7d 100644 --- a/src/eva/metrics/runner.py +++ b/src/eva/metrics/runner.py @@ -263,9 +263,8 @@ def process_records(self) -> dict[str, Any]: async def run(self, contexts: dict[str, Any] | None = None) -> MetricsRunResult: """Run all metrics on all records. - All records and metrics run concurrently. The LiteLLM Router - manages concurrency per deployment via max_parallel_requests - and rpm/tpm limits configured in EVA_MODEL_LIST. + All records and metrics run concurrently; in-flight judge calls + are capped by ``max_concurrent_judges`` inside LLMClient. After computing per-record metrics, if multi-attempt records are detected, computes pass@k and pass^k aggregation across attempts. @@ -292,7 +291,7 @@ async def run(self, contexts: dict[str, Any] | None = None) -> MetricsRunResult: targeted = all_record_dirs targeted_ids = {rid for rid, _ in targeted} - # Run targeted records concurrently; LiteLLM limits concurrent API calls. + # Run targeted records concurrently; LLMClient caps concurrent judge calls. tasks = [self.run_and_save_record(rid, rdir) for rid, rdir in targeted] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/src/eva/models/config.py b/src/eva/models/config.py index 890c42e51..fcfecace3 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -729,6 +729,12 @@ class ModelDeployment(DeploymentTypedDict): le=100, description="Maximum number of concurrent conversations", ) + max_concurrent_judges: int = Field( + 3, + ge=1, + le=100, + description="Maximum number of concurrent judge LLM calls across all metrics and validation", + ) conversation_time_limit_seconds: int = Field( 600, ge=30, diff --git a/src/eva/run_benchmark.py b/src/eva/run_benchmark.py index 10701c4c3..24fe53be3 100644 --- a/src/eva/run_benchmark.py +++ b/src/eva/run_benchmark.py @@ -10,6 +10,7 @@ from eva.models.record import EvaluationRecord from eva.orchestrator.runner import BenchmarkRunner from eva.utils import router +from eva.utils.llm_client import LLMClient from eva.utils.logging import get_logger, setup_logging load_dotenv() @@ -23,6 +24,7 @@ async def run_benchmark(config: RunConfig) -> int: setup_logging(level=config.log_level) logger = get_logger(__name__) router.init(config.model_list) + LLMClient.set_max_concurrency(config.max_concurrent_judges) logger.info(f"Run ID: {config.run_id}") diff --git a/src/eva/utils/llm_client.py b/src/eva/utils/llm_client.py index 61756f0c8..76719b100 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -1,6 +1,7 @@ """Unified LLM client using LiteLLM for all model calls.""" import asyncio +import contextlib import itertools import random from typing import ClassVar @@ -18,10 +19,17 @@ class LLMClient: """Unified LLM client using LiteLLM. - Concurrency is managed by the LiteLLM Router per-deployment `max_parallel_requests` and `rpm`/`tpm` limits configured in `EVA_MODEL_LIST`. + Used by metric judges. In-flight calls across all instances are capped by `set_max_concurrency` + (``max_concurrent_judges``); unbounded until it is called. """ _call_counter: ClassVar[itertools.count] = itertools.count(1) # Used as a unique identifier for logs. + _slots: ClassVar[asyncio.Semaphore | None] = None + + @classmethod + def set_max_concurrency(cls, limit: int) -> None: + """Cap concurrent calls across all LLMClient instances. Call once at startup.""" + cls._slots = asyncio.Semaphore(limit) def __init__( self, @@ -142,8 +150,9 @@ async def generate_text(self, messages: list[dict], response_format: dict | None # Make the API call logger.debug(f"{call_retry_id} started for {self.model}") - async with router.limit(self.model) as deployment: - response = await router.get().acompletion(**{**kwargs, "model": deployment.id}) + # Hold a slot only for the call itself, not while backing off between retries. + async with self._slots or contextlib.nullcontext(): + response = await router.get().acompletion(**kwargs) (logger.info if attempt > 0 else logger.debug)(f"{call_retry_id} succeeded for {self.model}") diff --git a/src/eva/utils/router.py b/src/eva/utils/router.py index 00a0a9761..ad509a5b2 100644 --- a/src/eva/utils/router.py +++ b/src/eva/utils/router.py @@ -2,93 +2,31 @@ Call `init(model_list)` once at startup, then `get()` anywhere to retrieve the shared Router instance. -Concurrency: since LiteLLM 1.98 the Router rejects calls over a deployment's ``max_parallel_requests`` with an -immediate 429 instead of queueing them. EVA relies on queueing, so it bounds concurrency itself: every call goes -through `limit(model_group)`, which waits for a free slot and yields the specific deployment to call. Callers pass -that deployment's id as ``model`` so the Router sends the call there, rather than picking a deployment on its own -that may already be at its cap. +Concurrency is not limited here: since LiteLLM 1.98 the Router rejects calls over a deployment's +``max_parallel_requests`` with an immediate 429 instead of queueing them, so no default cap is set. +EVA bounds load upstream with ``max_concurrent_conversations`` and ``max_concurrent_judges``. """ -import asyncio -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from typing import Any - from litellm import Router -from litellm.utils import calculate_max_parallel_requests from eva.utils.logging import get_logger logger = get_logger(__name__) -_DEFAULT_MAX_PARALLEL_REQUESTS = 5 - - -@dataclass -class Deployment: - """One entry of EVA_MODEL_LIST. Pass `id` as the Router's ``model`` to call exactly this deployment.""" - - id: str - litellm_params: dict[str, Any] = field(default_factory=dict) - capacity: int = _DEFAULT_MAX_PARALLEL_REQUESTS - in_use: int = 0 - - -@dataclass -class _ModelGroup: - deployments: list[Deployment] - semaphore: asyncio.Semaphore - - _router: Router | None = None -_groups: dict[str, _ModelGroup] = {} def init(model_list: list) -> None: """Create and store the shared Router. Call once at startup.""" - global _router, _groups + global _router logger.info(f"Initializing LiteLLM Router with {len(model_list)} deployment(s)") - _router = Router( - model_list=model_list, - num_retries=0, - default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, - timeout=60, - ) - # The Router assigns each deployment a model_info.id, so read the deployments back from it. - _groups = _build_groups(_router.model_list) - - -def _build_groups(model_list: list) -> dict[str, _ModelGroup]: - """Group deployments by model_name, each with its own concurrency cap and a semaphore for the group total.""" - deployments: dict[str, list[Deployment]] = {} - for deployment in model_list: - # TODO: max_parallel_requests/rpm/tpm are read from litellm_params for backwards compatibility with - # existing EVA_MODEL_LIST configs. This is now an EVA-level setting (LiteLLM no longer queues), so it - # should move out of litellm_params to the top level of the deployment, next to use_responses_api. - # TODO: evaluate whether per-deployment caps are needed at all, since max_concurrent_conversations - # already bounds concurrency. - params = deployment.get("litellm_params", {}) - cap = calculate_max_parallel_requests( - max_parallel_requests=params.get("max_parallel_requests"), - rpm=params.get("rpm"), - tpm=params.get("tpm"), - default_max_parallel_requests=_DEFAULT_MAX_PARALLEL_REQUESTS, - ) - deployments.setdefault(deployment["model_name"], []).append( - Deployment(id=deployment["model_info"]["id"], litellm_params=params, capacity=cap) - ) - return { - name: _ModelGroup(group, asyncio.Semaphore(sum(d.capacity for d in group))) - for name, group in deployments.items() - } + _router = Router(model_list=model_list, num_retries=0, timeout=60) def reset() -> None: """Reset the shared Router to None.""" - global _router, _groups + global _router _router = None - _groups = {} def get() -> Router: @@ -96,25 +34,3 @@ def get() -> Router: if _router is None: raise RuntimeError("Router not initialized — call eva.utils.router.init() first") return _router - - -@asynccontextmanager -async def limit(model_group: str) -> AsyncIterator[Deployment]: - """Hold a concurrency slot on one of `model_group`'s deployments, waiting if all slots are in use. - - Yields the deployment holding the slot; call the Router with ``model=deployment.id``. Hold the slot for the - whole call, including consuming a stream. Groups with no configured deployment are unbounded and yield a - placeholder whose id is the group name itself. - """ - group = _groups.get(model_group) - if group is None: - yield Deployment(id=model_group) - return - async with group.semaphore: - # The group semaphore is the sum of the caps, so some deployment always has a free slot here. - deployment = min(group.deployments, key=lambda d: d.in_use / d.capacity) - deployment.in_use += 1 - try: - yield deployment - finally: - deployment.in_use -= 1 diff --git a/tests/unit/utils/test_llm_client.py b/tests/unit/utils/test_llm_client.py index 87f9ed682..1b72a5f15 100644 --- a/tests/unit/utils/test_llm_client.py +++ b/tests/unit/utils/test_llm_client.py @@ -1,4 +1,4 @@ -"""Tests for LLMClient: generate_text, retry logic, and Router concurrency.""" +"""Tests for LLMClient: generate_text, retry logic, and judge concurrency.""" import asyncio import logging @@ -210,78 +210,35 @@ async def test_gives_up_after_max_retries(self, caplog: pytest.LogCaptureFixture ) -class TestRouterConcurrency: - """Verify that the LiteLLM Router enforces per-deployment concurrency limits.""" +class TestJudgeConcurrency: + """Verify that set_max_concurrency caps in-flight calls across all LLMClient instances.""" @pytest.mark.asyncio - async def test_max_parallel_requests_bounds_concurrency(self): - """Peak concurrent calls never exceeds max_parallel_requests.""" - router.init( - [ - { - "model_name": "test-model", - "litellm_params": { - "model": "openai/test-model", - "api_key": "test-key", - "max_parallel_requests": 2, - }, - } - ] - ) - + async def test_max_concurrency_bounds_calls_across_clients(self): + """Peak concurrent calls never exceeds the limit, even when spread over several clients.""" peak_concurrent = 0 current_concurrent = 0 - lock = asyncio.Lock() async def mock_acompletion(*args, **kwargs): nonlocal peak_concurrent, current_concurrent - async with lock: - current_concurrent += 1 - peak_concurrent = max(peak_concurrent, current_concurrent) + current_concurrent += 1 + peak_concurrent = max(peak_concurrent, current_concurrent) await asyncio.sleep(0.01) - async with lock: - current_concurrent -= 1 + current_concurrent -= 1 return _mock_response("ok") - with patch("litellm.acompletion", new=mock_acompletion): - await asyncio.gather(*(_limited_call("test-model", i) for i in range(8))) + LLMClient.set_max_concurrency(2) + try: + clients = [_make_client(model=f"model-{i}")[0] for i in range(4)] + router._router.acompletion = mock_acompletion + await asyncio.gather( + *(c.generate_text([{"role": "user", "content": f"msg-{i}"}]) for c in clients for i in range(2)) + ) + finally: + LLMClient._slots = None assert peak_concurrent == 2 - @pytest.mark.asyncio - async def test_multi_deployment_group_never_exceeds_a_deployment_cap(self): - """Calls are pinned to a deployment with a free slot, so LiteLLM never rejects one with a 429.""" - router.init( - [ - { - "model_name": "test-model", - "litellm_params": {"model": f"openai/{name}", "api_key": "test-key", "max_parallel_requests": cap}, - } - for name, cap in (("small", 1), ("large", 3)) - ] - ) - - peak: dict[str, int] = {} - current: dict[str, int] = {} - - async def mock_acompletion(*args, **kwargs): - name = kwargs["model"] - current[name] = current.get(name, 0) + 1 - peak[name] = max(peak.get(name, 0), current[name]) - await asyncio.sleep(0.01) - current[name] -= 1 - return _mock_response("ok") - - with patch("litellm.acompletion", new=mock_acompletion): - await asyncio.gather(*(_limited_call("test-model", i) for i in range(20))) - - assert peak == {"openai/small": 1, "openai/large": 3} - - -async def _limited_call(model_group: str, i: int): - async with router.limit(model_group) as deployment: - return await router.get().acompletion(model=deployment.id, messages=[{"role": "user", "content": f"msg-{i}"}]) - class TestLitellmParamsForwarding: """Verify that litellm_params from the model_list reach the underlying API call.""" From 1d2230b61a119266f899d28c7eef3debaee8e5e6 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Thu, 22 Oct 2026 19:05:21 -0700 Subject: [PATCH 08/12] error case --- src/eva/user_simulator/cascade/simulator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/eva/user_simulator/cascade/simulator.py b/src/eva/user_simulator/cascade/simulator.py index 4abb6463f..642840b1e 100644 --- a/src/eva/user_simulator/cascade/simulator.py +++ b/src/eva/user_simulator/cascade/simulator.py @@ -241,7 +241,9 @@ async def _run_cascade_conversation(self) -> None: done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) if completion_task not in done and not self._conversation_done.is_set(): finished = next(iter(done)) - raise finished.exception() or RuntimeError("Cascade caller task stopped unexpectedly") + # exception() raises CancelledError on a cancelled task, which would skip the error path below. + error = None if finished.cancelled() else finished.exception() + raise error or RuntimeError("Cascade caller task stopped unexpectedly") # Mark the bridge stopped, as ElevenLabs' end_session() does: an assistant that hangs up on # signal_conversation_ending (e.g. Cartesia) drops the socket during the flush below, and a From 5f2e8e703b4a4d80d57514151dc5a84f74f204e5 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Fri, 23 Oct 2026 12:12:44 -0700 Subject: [PATCH 09/12] remove user spec and fix preflight --- .env.example | 10 +-- scripts/run_text_only.py | 2 +- src/eva/models/config.py | 5 +- src/eva/orchestrator/preflight.py | 74 +++++++++++++++++++---- tests/unit/user_simulator/test_cascade.py | 6 +- tests/unit/user_simulator/test_factory.py | 2 +- 6 files changed, 70 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index bf12f3f89..b20e62be4 100644 --- a/.env.example +++ b/.env.example @@ -195,14 +195,6 @@ EVA_MODEL_LIST='[ "aws_access_key_id": "os.environ/AWS_ACCESS_KEY_ID", "aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY" } - }, - { - "model_name": "user-llm", - "litellm_params": { - "model": "openai/gpt-6-luna", - "api_key": "os.environ/OPENAI_API_KEY", - "reasoning_effort": "none" - } } ]' @@ -317,7 +309,7 @@ EVA_MODEL_LIST='[ EVA_USER_SIMULATOR__PROVIDER=elevenlabs #i Caller model. For openai_realtime, the Realtime model (default gpt-realtime-1.5). For cascade, the caller LLM -#i deployment name from EVA_MODEL_LIST (default user-llm). +#i deployment name from EVA_MODEL_LIST (required). #d string #x EVA_USER_SIMULATOR__PROVIDER=openai_realtime,cascade #v EVA_USER_SIMULATOR__MODEL=gpt-realtime-1.5 diff --git a/scripts/run_text_only.py b/scripts/run_text_only.py index 221fb4a3d..dbcb191cf 100644 --- a/scripts/run_text_only.py +++ b/scripts/run_text_only.py @@ -511,7 +511,7 @@ async def run_record( audit_log = AuditLog() agent_llm_client = LiteLLMClient(model=llm_model) - user_sim_llm_client = LiteLLMClient(model="user-llm") + user_sim_llm_client = LiteLLMClient(model=os.environ.get("EVA_USER_SIMULATOR__MODEL", llm_model)) agentic_system = AgenticSystem( current_date_time=record.current_date_time, diff --git a/src/eva/models/config.py b/src/eva/models/config.py index fcfecace3..9f77b0666 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -498,10 +498,9 @@ class CascadeSimulatorConfig(BaseModel): stt: Literal["elevenlabs"] = Field("elevenlabs", description="Streaming STT provider for the assistant's audio.") stt_model: str = Field("scribe_v2_realtime", description="Streaming STT model.") model: str = Field( - "user-llm", description=( - "Caller LLM deployment, resolved via EVA_MODEL_LIST like the assistant's. A dedicated 'user-llm' " - "deployment lets the caller's model and params differ from the assistant under test." + "Caller LLM deployment, resolved via EVA_MODEL_LIST like the assistant's. Configure a separate " + "deployment if the caller's params need to differ from the assistant under test or the judges." ), ) tts: Literal["cartesia"] = Field("cartesia", description="TTS provider for the caller's speech.") diff --git a/src/eva/orchestrator/preflight.py b/src/eva/orchestrator/preflight.py index 319b9670f..4de793f9d 100644 --- a/src/eva/orchestrator/preflight.py +++ b/src/eva/orchestrator/preflight.py @@ -26,7 +26,9 @@ ErrorFrame, Frame, InputAudioRawFrame, + TTSAudioRawFrame, TTSSpeakFrame, + TTSStoppedFrame, ) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.worker import PipelineWorker @@ -39,7 +41,9 @@ create_tts_service, ) from eva.assistant.services.llm import LiteLLMClient -from eva.models.config import PipelineType, RunConfig, get_model_alias_from_params +from eva.models.config import CascadeSimulatorConfig, PipelineType, RunConfig, get_model_alias_from_params +from eva.user_simulator.cascade.stt import ScribeStreamingSTT +from eva.user_simulator.cascade.tts import CartesiaStreamingTTS from eva.utils.logging import get_logger logger = get_logger(__name__) @@ -58,7 +62,7 @@ class PreflightError(RuntimeError): class ProbeResult: """Outcome of probing a single model/provider.""" - model_type: str # "LLM", "STT", "TTS", "AUDIO_LLM" + model_type: str # "LLM", "STT", "TTS", "AUDIO_LLM", "USER_SIM_LLM", "USER_SIM_STT", "USER_SIM_TTS" model_alias: str ok: bool detail: str = "" @@ -67,17 +71,23 @@ class ProbeResult: class _FrameCapture(FrameProcessor): """Pass-through processor that records every frame it sees.""" - def __init__(self) -> None: + def __init__(self, done: asyncio.Event, done_types: tuple[type[Frame], ...] = ()) -> None: super().__init__() self.frames: list[Frame] = [] + self._done_types = (ErrorFrame, *done_types) if done_types else () + self.done = done async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: await super().process_frame(frame, direction) self.frames.append(frame) + if self._done_types and isinstance(frame, self._done_types): + self.done.set() await self.push_frame(frame, direction) -async def _drive_pipecat_service(service: FrameProcessor, input_frames: list[Frame]) -> tuple[bool, str]: +async def _drive_pipecat_service( + service: FrameProcessor, input_frames: list[Frame], done_types: tuple[type[Frame], ...] = () +) -> tuple[bool, str]: """Run a pipecat STT/TTS service through a minimal pipeline and report success. The service is wrapped between two capture processors: errors travel upstream and @@ -85,14 +95,24 @@ async def _drive_pipecat_service(service: FrameProcessor, input_frames: list[Fra auto-sends a ``StartFrame`` (which is when streaming services connect and surface auth failures as an ``ErrorFrame``); the trailing ``EndFrame`` shuts it down cleanly. + When ``done_types`` is given, no ``EndFrame`` is sent: the probe waits for one of those + frames (or an ``ErrorFrame``) and then cancels. TTS needs this because it holds frames until + a ``BotStoppedSpeakingFrame`` from an output transport, which this pipeline lacks, so an + ``EndFrame`` would never drain — and the worker's ``CancelFrame`` queues behind it, so the + pipeline could not even be cancelled. + Returns ``(ok, detail)`` — ``ok=False`` only when an ``ErrorFrame`` is seen. """ - head, tail = _FrameCapture(), _FrameCapture() + done = asyncio.Event() + head, tail = _FrameCapture(done, done_types), _FrameCapture(done, done_types) task = PipelineWorker(Pipeline([head, service, tail])) runner = WorkerRunner(handle_sigint=False, force_gc=True) async def feed() -> None: - await task.queue_frames([*input_frames, EndFrame()]) + await task.queue_frames(input_frames if done_types else [*input_frames, EndFrame()]) + if done_types: + await done.wait() + await task.cancel() try: await runner.add_workers(task) @@ -138,7 +158,31 @@ async def _probe_tts(config: RunConfig) -> tuple[bool, str]: service = create_tts_service(config.model.tts, config.model.tts_params, language_code=config.language.value) if service is None: return True, "" - return await _drive_pipecat_service(service, [TTSSpeakFrame("ok")]) + return await _drive_pipecat_service(service, [TTSSpeakFrame("ok")], done_types=(TTSAudioRawFrame, TTSStoppedFrame)) + + +async def _probe_user_sim_llm(sim: CascadeSimulatorConfig) -> tuple[bool, str]: + client = LiteLLMClient(model=sim.model) + await client.complete([{"role": "user", "content": "ping"}], max_retries=0) + return True, "" + + +async def _probe_user_sim_stt(sim: CascadeSimulatorConfig, language: str) -> tuple[bool, str]: + stt = ScribeStreamingSTT(sim.stt_model, language=language) + try: + await stt.start() + finally: + await stt.stop() + return (False, stt.error) if stt.error else (True, "") + + +async def _probe_user_sim_tts(sim: CascadeSimulatorConfig, language: str) -> tuple[bool, str]: + tts = CartesiaStreamingTTS(sim.tts_model, language=language) + try: + await tts.warm_up(sim.female_voice) + finally: + await tts.close() + return True, "" async def _guard( @@ -158,11 +202,9 @@ async def _guard( async def _run_preflight(config: RunConfig) -> list[ProbeResult]: """Probe every model the run will use; return one ``ProbeResult`` per component. - S2S runs are skipped (no live probe yet) — only a log line is emitted. + S2S assistants are skipped (no live probe yet) — only a log line is emitted. A cascade user + simulator's LLM, STT, and TTS are probed regardless of the assistant's pipeline. """ - # TODO: probe the user simulator's own services when EVA_USER_SIMULATOR__PROVIDER=cascade (its LLM - # deployment, Scribe realtime STT, Cartesia TTS). Today a missing `user-llm` deployment only surfaces as - # every record failing after the assistant's greeting. timeout = config.preflight_timeout_seconds model = config.model probes: list[tuple[str, str, Awaitable[tuple[bool, str]]]] = [] @@ -177,7 +219,15 @@ async def _run_preflight(config: RunConfig) -> list[ProbeResult]: probes.append(("TTS", get_model_alias_from_params(model.tts_params), _probe_tts(config))) case PipelineType.S2S: logger.info("Pre-flight: S2S live probe not yet supported") - return [] + + if isinstance(sim := config.user_simulator, CascadeSimulatorConfig): + language = config.language.value + probes.append(("USER_SIM_LLM", sim.model, _probe_user_sim_llm(sim))) + probes.append(("USER_SIM_STT", sim.stt_model, _probe_user_sim_stt(sim, language))) + probes.append(("USER_SIM_TTS", sim.tts_model, _probe_user_sim_tts(sim, language))) + + if not probes: + return [] logger.info(f"Pre-flight: checking {len(probes)} model(s) before the run starts...") return await asyncio.gather(*(_guard(model_type, alias, probe, timeout) for model_type, alias, probe in probes)) diff --git a/tests/unit/user_simulator/test_cascade.py b/tests/unit/user_simulator/test_cascade.py index 18674dec1..56235af3b 100644 --- a/tests/unit/user_simulator/test_cascade.py +++ b/tests/unit/user_simulator/test_cascade.py @@ -48,7 +48,7 @@ def _simulator(tmp_path: Path, *, persona_id: int = 1, **kwargs) -> CascadeUserS server_url="ws://localhost:9999/ws", output_dir=tmp_path, agent_id="agent_itsm", - simulator_config=CascadeSimulatorConfig(), + simulator_config=CascadeSimulatorConfig(model="gpt-test"), **kwargs, ) @@ -65,7 +65,7 @@ async def _chunks(*chunks: bytes): def test_config_rejects_non_streaming_stt(): with pytest.raises(ValidationError, match="streaming STT"): - CascadeSimulatorConfig(stt_model="scribe_v2") + CascadeSimulatorConfig(model="gpt-test", stt_model="scribe_v2") def test_accent_variants_are_rejected(tmp_path): @@ -74,7 +74,7 @@ def test_accent_variants_are_rejected(tmp_path): def test_persona_selects_voice(tmp_path): - config = CascadeSimulatorConfig() + config = CascadeSimulatorConfig(model="gpt-test") assert _simulator(tmp_path / "f").caller_voice == config.female_voice assert _simulator(tmp_path / "m", persona_id=2).caller_voice == config.male_voice diff --git a/tests/unit/user_simulator/test_factory.py b/tests/unit/user_simulator/test_factory.py index 40033fa89..4b19dd23f 100644 --- a/tests/unit/user_simulator/test_factory.py +++ b/tests/unit/user_simulator/test_factory.py @@ -52,7 +52,7 @@ def test_factory_selects_cascade(tmp_path, monkeypatch): monkeypatch.setattr(cascade_simulator, "LiteLLMClient", MagicMock()) monkeypatch.setenv("ELEVENLABS_API_KEY", "test-key") monkeypatch.setenv("CARTESIA_API_KEY", "test-key") - simulator = create_user_simulator(CascadeSimulatorConfig(), **_kwargs(tmp_path)) + simulator = create_user_simulator(CascadeSimulatorConfig(model="gpt-test"), **_kwargs(tmp_path)) assert isinstance(simulator, CascadeUserSimulator) assert simulator.provider == "cascade" From 1ba4131fb237dd4d802de0a510ba9f48fe0fd67d Mon Sep 17 00:00:00 2001 From: "joseph.marinier" Date: Fri, 23 Oct 2026 16:10:29 -0400 Subject: [PATCH 10/12] Fix comment --- src/eva/utils/llm_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eva/utils/llm_client.py b/src/eva/utils/llm_client.py index 76719b100..3b66028f7 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -1,4 +1,4 @@ -"""Unified LLM client using LiteLLM for all model calls.""" +"""Unified LLM client using LiteLLM for metric judges.""" import asyncio import contextlib From d3c301c79ce35778c26e9f1c3c6f1bb6ef89d137 Mon Sep 17 00:00:00 2001 From: "joseph.marinier" Date: Fri, 23 Oct 2026 16:32:47 -0400 Subject: [PATCH 11/12] Set one semaphore per model so judges on different models (Claude, GPT, Gemini) don't wait on each other. --- .env.example | 2 +- docs/llm_configuration.md | 5 +++-- src/eva/metrics/runner.py | 4 ++-- src/eva/models/config.py | 2 +- src/eva/utils/llm_client.py | 25 ++++++++++++++++------- tests/unit/utils/test_llm_client.py | 31 +++++++++++++++++------------ 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index b20e62be4..bd4a40fee 100644 --- a/.env.example +++ b/.env.example @@ -212,7 +212,7 @@ EVA_MODEL_LIST='[ #r 1,100,1 #v EVA_MAX_CONCURRENT_CONVERSATIONS=1 -#i Maximum number of concurrent judge LLM calls (metrics and validation), across all judge models. +#i Maximum number of concurrent judge LLM calls (metrics and validation) to each judge model. #d int #r 1,100,1 #v EVA_MAX_CONCURRENT_JUDGES=3 diff --git a/docs/llm_configuration.md b/docs/llm_configuration.md index 44f7688c4..f43249b7e 100644 --- a/docs/llm_configuration.md +++ b/docs/llm_configuration.md @@ -195,9 +195,10 @@ EVA_MODEL_LIST='[ EVA bounds load with two settings rather than per-deployment limits: - `EVA_MAX_CONCURRENT_CONVERSATIONS`: conversations running at once (assistant and user simulator calls). -- `EVA_MAX_CONCURRENT_JUDGES`: judge LLM calls in flight at once, across all metrics, validation and judge models. +- `EVA_MAX_CONCURRENT_JUDGES`: judge LLM calls in flight at once to each judge model, across all metrics and + validation. Each model has its own limit, so judges on different models don't wait on each other. -Their sum is the upper bound on concurrent LLM calls. Do not set `max_parallel_requests` on deployments: LiteLLM +At most `EVA_MAX_CONCURRENT_JUDGES` × the number of judge models are in flight at once. Do not set `max_parallel_requests` on deployments: LiteLLM rejects calls over that cap with an immediate 429 instead of queueing them. `rpm`/`tpm` are also turned into a per-deployment parallel cap by LiteLLM, so keep them well above the limits above if you set them. diff --git a/src/eva/metrics/runner.py b/src/eva/metrics/runner.py index 65febab7d..4945df843 100644 --- a/src/eva/metrics/runner.py +++ b/src/eva/metrics/runner.py @@ -264,7 +264,7 @@ async def run(self, contexts: dict[str, Any] | None = None) -> MetricsRunResult: """Run all metrics on all records. All records and metrics run concurrently; in-flight judge calls - are capped by ``max_concurrent_judges`` inside LLMClient. + to each model are capped by ``max_concurrent_judges`` inside LLMClient. After computing per-record metrics, if multi-attempt records are detected, computes pass@k and pass^k aggregation across attempts. @@ -291,7 +291,7 @@ async def run(self, contexts: dict[str, Any] | None = None) -> MetricsRunResult: targeted = all_record_dirs targeted_ids = {rid for rid, _ in targeted} - # Run targeted records concurrently; LLMClient caps concurrent judge calls. + # Run targeted records concurrently; LLMClient caps concurrent judge calls per model. tasks = [self.run_and_save_record(rid, rdir) for rid, rdir in targeted] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/src/eva/models/config.py b/src/eva/models/config.py index 9f77b0666..19987fcb0 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -732,7 +732,7 @@ class ModelDeployment(DeploymentTypedDict): 3, ge=1, le=100, - description="Maximum number of concurrent judge LLM calls across all metrics and validation", + description="Maximum number of concurrent judge LLM calls to each model, across all metrics and validation", ) conversation_time_limit_seconds: int = Field( 600, diff --git a/src/eva/utils/llm_client.py b/src/eva/utils/llm_client.py index 3b66028f7..8374e37e9 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -19,17 +19,28 @@ class LLMClient: """Unified LLM client using LiteLLM. - Used by metric judges. In-flight calls across all instances are capped by `set_max_concurrency` - (``max_concurrent_judges``); unbounded until it is called. + Used by metric judges. In-flight calls to each model, across all instances, are capped by + `set_max_concurrency` (``max_concurrent_judges``); unbounded until it is called. Each model gets its own + slots, so judges on different models don't wait on each other. """ _call_counter: ClassVar[itertools.count] = itertools.count(1) # Used as a unique identifier for logs. - _slots: ClassVar[asyncio.Semaphore | None] = None + _max_concurrency: ClassVar[int | None] = None + _slots: ClassVar[dict[str, asyncio.Semaphore]] = {} # One per model, created on its first call. @classmethod - def set_max_concurrency(cls, limit: int) -> None: - """Cap concurrent calls across all LLMClient instances. Call once at startup.""" - cls._slots = asyncio.Semaphore(limit) + def set_max_concurrency(cls, limit: int | None) -> None: + """Cap concurrent calls per model across all LLMClient instances; None removes the cap. Call at startup.""" + cls._max_concurrency = limit + cls._slots = {} + + def _slot(self) -> contextlib.AbstractAsyncContextManager: + """The concurrency slot shared by every LLMClient calling this model.""" + if self._max_concurrency is None: + return contextlib.nullcontext() + if self.model not in self._slots: + self._slots[self.model] = asyncio.Semaphore(self._max_concurrency) + return self._slots[self.model] def __init__( self, @@ -151,7 +162,7 @@ async def generate_text(self, messages: list[dict], response_format: dict | None # Make the API call logger.debug(f"{call_retry_id} started for {self.model}") # Hold a slot only for the call itself, not while backing off between retries. - async with self._slots or contextlib.nullcontext(): + async with self._slot(): response = await router.get().acompletion(**kwargs) (logger.info if attempt > 0 else logger.debug)(f"{call_retry_id} succeeded for {self.model}") diff --git a/tests/unit/utils/test_llm_client.py b/tests/unit/utils/test_llm_client.py index 1b72a5f15..fbaa0679b 100644 --- a/tests/unit/utils/test_llm_client.py +++ b/tests/unit/utils/test_llm_client.py @@ -211,33 +211,38 @@ async def test_gives_up_after_max_retries(self, caplog: pytest.LogCaptureFixture class TestJudgeConcurrency: - """Verify that set_max_concurrency caps in-flight calls across all LLMClient instances.""" + """Verify that set_max_concurrency caps in-flight calls to each model across all LLMClient instances.""" @pytest.mark.asyncio - async def test_max_concurrency_bounds_calls_across_clients(self): - """Peak concurrent calls never exceeds the limit, even when spread over several clients.""" - peak_concurrent = 0 - current_concurrent = 0 + async def test_max_concurrency_is_per_model_across_clients(self): + """Clients on the same model share a limit; clients on different models don't wait on each other.""" + peak_concurrent: dict[str, int] = {} + current_concurrent: dict[str, int] = {} + peak_total = 0 async def mock_acompletion(*args, **kwargs): - nonlocal peak_concurrent, current_concurrent - current_concurrent += 1 - peak_concurrent = max(peak_concurrent, current_concurrent) + nonlocal peak_total + model = kwargs["model"] + current_concurrent[model] = current_concurrent.get(model, 0) + 1 + peak_concurrent[model] = max(peak_concurrent.get(model, 0), current_concurrent[model]) + peak_total = max(peak_total, sum(current_concurrent.values())) await asyncio.sleep(0.01) - current_concurrent -= 1 + current_concurrent[model] -= 1 return _mock_response("ok") LLMClient.set_max_concurrency(2) try: - clients = [_make_client(model=f"model-{i}")[0] for i in range(4)] + # Two clients per model, each making 3 calls: 6 calls per model competing for 2 slots. + clients = [_make_client(model=f"model-{i % 2}")[0] for i in range(4)] router._router.acompletion = mock_acompletion await asyncio.gather( - *(c.generate_text([{"role": "user", "content": f"msg-{i}"}]) for c in clients for i in range(2)) + *(c.generate_text([{"role": "user", "content": f"msg-{i}"}]) for c in clients for i in range(3)) ) finally: - LLMClient._slots = None + LLMClient.set_max_concurrency(None) - assert peak_concurrent == 2 + assert peak_concurrent == {"model-0": 2, "model-1": 2} + assert peak_total == 4 class TestLitellmParamsForwarding: From c6c04a2da3caba8c5af5c3f156f55d5fb1108cd7 Mon Sep 17 00:00:00 2001 From: "raghav.mehndiratta" Date: Sat, 24 Oct 2026 15:13:29 -0700 Subject: [PATCH 12/12] update config --- src/eva/models/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/eva/models/config.py b/src/eva/models/config.py index 19987fcb0..9087c65b9 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -725,13 +725,11 @@ class ModelDeployment(DeploymentTypedDict): max_concurrent_conversations: int = Field( 1, ge=1, - le=100, description="Maximum number of concurrent conversations", ) max_concurrent_judges: int = Field( - 3, + 1, ge=1, - le=100, description="Maximum number of concurrent judge LLM calls to each model, across all metrics and validation", ) conversation_time_limit_seconds: int = Field(