diff --git a/.env.example b/.env.example index e1034e84..4c2c4216 100644 --- a/.env.example +++ b/.env.example @@ -39,8 +39,12 @@ AWS_ACCESS_KEY_ID=your_aws_access_key_id_here AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here # --- Alternative providers (optional) --- -# If you only have an OpenAI key you can skip AWS and set JUDGE_MODEL=gpt-5.2 -# to override all text judges. Audio judge metrics still require Gemini. +# JUDGE_MODEL overrides all text judges (validation + scoring). Set it to a model_name in +# EVA_MODEL_LIST — e.g. a Bedrock deployment for an all-AWS run: +# JUDGE_MODEL=us.anthropic.claude-opus-4-6-v1 +# or an OpenAI model if that's all you have: JUDGE_MODEL=gpt-5.2 +# Audio judge metrics (user_speech_fidelity) need an audio-capable model (e.g. Gemini) — Bedrock has +# none, so that judge is automatically skipped when its model isn't a deployment in EVA_MODEL_LIST. #i Azure OpenAI key (alternative to direct OpenAI). #d secret @@ -69,7 +73,7 @@ EVA_MODEL__LLM=gpt-5.2 # --- LLM mode: STT --- #i STT provider for the voice pipeline. #d enum -#e assemblyai,cartesia,cartesia-multilingual,deepgram,deepgram-flux,elevenlabs,nvidia,nvidia-baseten,openai,smallest +#e assemblyai,aws_transcribe,cartesia,cartesia-multilingual,deepgram,deepgram-flux,elevenlabs,nvidia,nvidia-baseten,openai,smallest #x pipeline_mode=LLM EVA_MODEL__STT=cartesia @@ -87,7 +91,7 @@ EVA_MODEL__STT_PARAMS='{"api_key": "your_cartesia_api_key", "model": "ink-2"}' # --- TTS (LLM and AudioLLM modes) --- #i TTS provider for the voice pipeline. #d enum -#e cartesia,chatterbox,elevenlabs,gemini,kokoro,nvidia-baseten,openai,smallest,xtts +#e aws_polly,cartesia,chatterbox,elevenlabs,gemini,kokoro,nvidia-baseten,openai,smallest,xtts #x pipeline_mode=LLM,AudioLLM EVA_MODEL__TTS=cartesia @@ -97,6 +101,15 @@ EVA_MODEL__TTS=cartesia #x pipeline_mode=LLM,AudioLLM EVA_MODEL__TTS_PARAMS='{"api_key": "your_cartesia_api_key", "model": "sonic"}' +# --- All-AWS cascade agent (Amazon Transcribe STT + Bedrock LLM + Amazon Polly TTS) --- +# Runs the agent-under-test entirely on AWS. Credentials + region come from the standard AWS env +# chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY[/AWS_SESSION_TOKEN] + AWS_REGION), so *_PARAMS are +# optional (region/voice/engine tuning only). The LLM uses a bedrock/... entry in EVA_MODEL_LIST. +# EVA_MODEL__STT=aws_transcribe +# EVA_MODEL__TTS=aws_polly +# EVA_MODEL__TTS_PARAMS='{"voice_id": "Joanna", "engine": "neural"}' +# EVA_MODEL__LLM=us.anthropic.claude-opus-4-6 # a model_name whose litellm_params.model is bedrock/... + # --- LLM mode: cascade latency optimizations --- #i Prompt a model-generated lead-in before a tool call: 'off' or 'auto'. #d enum @@ -261,7 +274,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,bedrock_s2s EVA_USER_SIMULATOR__PROVIDER=elevenlabs #i OpenAI Realtime caller model. Used only when provider=openai_realtime. @@ -279,6 +292,31 @@ EVA_USER_SIMULATOR__PROVIDER=elevenlabs #x EVA_USER_SIMULATOR__PROVIDER=openai_realtime #v EVA_USER_SIMULATOR__MALE_VOICE=cedar +# --- Bedrock Nova Sonic (audio-native S2S) caller --- +# Requires the optional dependency on Python >=3.12: pip install 'eva[bedrock-s2s]' +# and AWS creds in the env (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY[/AWS_SESSION_TOKEN]). +#i Nova Sonic model id. Used only when provider=bedrock_s2s. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=bedrock_s2s +#v EVA_USER_SIMULATOR__MODEL_ID=amazon.nova-2-sonic-v1:0 + +#i AWS region for the Bedrock bidirectional stream. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=bedrock_s2s +#v EVA_USER_SIMULATOR__REGION=us-east-1 + +#i Nova Sonic voiceId for the female / male caller personas. +#d string +#x EVA_USER_SIMULATOR__PROVIDER=bedrock_s2s +#v EVA_USER_SIMULATOR__FEMALE_VOICE=tiffany +#v EVA_USER_SIMULATOR__MALE_VOICE=matthew + +#i Turn-detection endpointing sensitivity (HIGH|MEDIUM|LOW). +#d enum +#e HIGH,MEDIUM,LOW +#x EVA_USER_SIMULATOR__PROVIDER=bedrock_s2s +#v EVA_USER_SIMULATOR__ENDPOINTING_SENSITIVITY=HIGH + # --- 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 diff --git a/pyproject.toml b/pyproject.toml index f7d79a95..baffbf2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ license = "MIT" dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", + # AWS Transcribe STT / Polly TTS assistant services (pipecat.services.aws.*) run on `websockets` + # + `aiobotocore` (brought in by `aioboto3` below) — no extra needed. Not using pipecat's [aws] + # extra because it pins aiobotocore>=3, which conflicts with aioboto3's aiobotocore 2.x. "pipecat-ai>=1.4.0", "elevenlabs>=2.53.0", "openai>=2.36.0", @@ -56,6 +59,12 @@ dependencies = [ ] [project.optional-dependencies] +# Audio-native Nova Sonic (bedrock_s2s) caller. Isolated because the bidirectional +# streaming client is experimental (pre-1.0) and requires Python >=3.12, while EVA +# itself supports 3.11+. Install with: pip install 'eva[bedrock-s2s]' (Python >=3.12). +bedrock-s2s = [ + "aws-sdk-bedrock-runtime>=0.7.0; python_version >= '3.12'", +] dev = [ "pytest>=7.0", "pytest-asyncio>=0.21", @@ -104,3 +113,17 @@ ignore = ["D203", "D206", "D213", "D400", "D401", "D413", "D415", "E1", "E501"] [tool.mypy] python_version = "3.11" strict = true + +# The optional 'bedrock-s2s' extra pulls the experimental, untyped aws-sdk-bedrock-runtime +# and its smithy deps, which are Python >=3.12-only (they use `type X = ...` syntax mypy +# rejects under python_version 3.11). Skip following into them; the imports are lazy and +# guarded at the factory. Only relevant when the extra is installed. +[[tool.mypy.overrides]] +module = [ + "aws_sdk_bedrock_runtime.*", + "smithy_aws_core.*", + "smithy_core.*", + "smithy_http.*", +] +follow_imports = "skip" +ignore_missing_imports = true diff --git a/src/eva/__init__.py b/src/eva/__init__.py index eb2eaa6a..c821273b 100644 --- a/src/eva/__init__.py +++ b/src/eva/__init__.py @@ -7,7 +7,7 @@ # Bump simulation_version when changes affect benchmark outputs (agent code, # user simulator, orchestrator, simulation prompts, agent configs, tool mocks). -simulation_version = "2.0.1" +simulation_version = "2.0.5" # Bump metrics_version when changes affect metric computation (metrics code, # judge prompts, pricing tables, postprocessor). diff --git a/src/eva/assistant/pipeline/services.py b/src/eva/assistant/pipeline/services.py index 21fb0fd3..b0a0386c 100644 --- a/src/eva/assistant/pipeline/services.py +++ b/src/eva/assistant/pipeline/services.py @@ -5,6 +5,7 @@ import dataclasses import datetime +import os from collections.abc import AsyncGenerator from typing import Any @@ -110,6 +111,20 @@ def _to_language_enum(tag: str) -> Language: _audio_llm_url_counter: int = 0 +def _aws_credentials(params: dict[str, Any]) -> dict[str, Any]: + """Resolve AWS creds/region for the pipecat AWS services from params, then the env chain. + + pipecat's AWS services take the secret access key as ``api_key``. Any value left as None + lets the underlying SDK fall back to its own default resolution. + """ + return { + "api_key": params.get("aws_secret_access_key") or os.environ.get("AWS_SECRET_ACCESS_KEY"), + "aws_access_key_id": params.get("aws_access_key_id") or os.environ.get("AWS_ACCESS_KEY_ID"), + "aws_session_token": params.get("aws_session_token") or os.environ.get("AWS_SESSION_TOKEN"), + "region": params.get("region") or os.environ.get("AWS_REGION", "us-east-1"), + } + + def create_stt_service( model: str | None, params: dict[str, Any] | None = None, @@ -313,9 +328,19 @@ def create_stt_service( ), ) + elif model_lower in {"aws", "aws_transcribe", "amazon_transcribe", "transcribe"}: + from pipecat.services.aws.stt import AWSTranscribeSTTService, AWSTranscribeSTTSettings + + logger.info("Using AWS Transcribe STT") + return AWSTranscribeSTTService( + sample_rate=params.get("sample_rate", SAMPLE_RATE), + settings=AWSTranscribeSTTSettings(language=_to_language_enum(language_code)), + **_aws_credentials(params), + ) + else: raise ValueError( - f"Unknown STT model: {model}. Available: assemblyai, cartesia, cartesia-multilingual, cohere, deepgram, deepgram-flux, elevenlabs, nvidia, nvidia-baseten, openai, smallest, xai" + f"Unknown STT model: {model}. Available: assemblyai, aws_transcribe, cartesia, cartesia-multilingual, cohere, deepgram, deepgram-flux, elevenlabs, nvidia, nvidia-baseten, openai, smallest, xai" ) @@ -591,9 +616,31 @@ def create_tts_service( xtts_tts._settings.language = language_code return xtts_tts + elif model_lower in {"aws", "aws_polly", "amazon_polly", "polly"}: + from pipecat.services.aws.tts import AWSPollyTTSService, AWSPollyTTSSettings + + logger.info("Using AWS Polly TTS") + # Forward optional Polly tuning (engine, pitch, rate, volume, lexicon_names); voice and + # language are passed explicitly below. + polly_settings_kwargs = { + k: params[k] + for f in dataclasses.fields(AWSPollyTTSSettings) + if (k := f.name) in params and k not in {"voice", "language", "model"} + } + polly_settings_kwargs.setdefault("engine", "neural") + return AWSPollyTTSService( + sample_rate=SAMPLE_RATE, + settings=AWSPollyTTSSettings( + voice=params.get("voice_id", "Joanna"), + language=_to_language_enum(language_code), + **polly_settings_kwargs, + ), + **_aws_credentials(params), + ) + else: raise ValueError( - f"Unknown TTS model: {model}. Available: cartesia, chatterbox, deepgram, elevenlabs, gemini, kokoro, nvidia-baseten, openai, smallest, voxtral, xai, xtts" + f"Unknown TTS model: {model}. Available: aws_polly, cartesia, chatterbox, deepgram, elevenlabs, gemini, kokoro, nvidia-baseten, openai, smallest, voxtral, xai, xtts" ) diff --git a/src/eva/models/config.py b/src/eva/models/config.py index ea797e8a..dfcd0e3d 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -51,9 +51,14 @@ def _get_all_metrics() -> list[str]: return [m for m in get_global_registry().list_metrics() if m not in _VALIDATION_METRIC_NAMES] -def _param_alias(params: dict[str, Any]) -> str: - """Return the display alias from a params dict.""" - return params.get("alias") or params["model"] +def _param_alias(params: dict[str, Any] | None, fallback: str = "") -> str: + """Return the display alias from a params dict (``alias`` > ``model``), else ``fallback``. + + AWS Transcribe/Polly have no ``model``/params, so callers pass the provider name as fallback. + """ + if not params: + return fallback + return params.get("alias") or params.get("model") or fallback _elevenlabs_agent_cache: dict[str, dict[str, str]] = {} @@ -101,6 +106,13 @@ def _fetch_elevenlabs_agent_models(s2s_params: dict[str, Any]) -> dict[str, str] return {"stt": "unknown", "llm": "unknown", "tts": "unknown"} +# STT/TTS providers that authenticate via the standard AWS credential chain (AWS_ACCESS_KEY_ID / +# AWS_SECRET_ACCESS_KEY[/AWS_SESSION_TOKEN] + AWS_REGION) instead of an api_key + model in *_PARAMS. +_AWS_ENV_CREDENTIALED_STT_TTS: frozenset[str] = frozenset( + {"aws", "aws_transcribe", "amazon_transcribe", "transcribe", "aws_polly", "amazon_polly", "polly"} +) + + class ModelConfig(BaseModel): """Flat model configuration covering all pipeline modes. @@ -217,8 +229,8 @@ def pipeline_parts(self) -> dict[str, str]: match self.pipeline_type: case PipelineType.AUDIO_LLM: return { - "audio_llm": _param_alias(self.audio_llm_params), - "tts": _param_alias(self.tts_params), + "audio_llm": _param_alias(self.audio_llm_params, self.audio_llm or ""), + "tts": _param_alias(self.tts_params, self.tts or ""), } case PipelineType.S2S: if self.s2s == "elevenlabs": @@ -230,9 +242,9 @@ def pipeline_parts(self) -> dict[str, str]: return {"s2s": _param_alias(self.s2s_params)} case PipelineType.CASCADE: return { - "stt": _param_alias(self.stt_params), + "stt": _param_alias(self.stt_params, self.stt or ""), "llm": self.llm, - "tts": _param_alias(self.tts_params), + "tts": _param_alias(self.tts_params, self.tts or ""), } @model_validator(mode="before") @@ -430,8 +442,53 @@ class OpenAIRealtimeSimulatorConfig(BaseModel): male_voice: str = Field("cedar", description="Voice used for male caller personas.") +class BedrockS2SSimulatorConfig(BaseModel): + """Settings for the native Amazon Bedrock speech-to-speech user simulator. + + EVA plays the caller with **Amazon Nova Sonic**, an audio-native S2S model, over + Bedrock's bidirectional streaming API. The agent's audio is streamed straight to + Nova Sonic and the caller's spoken reply streams back — no separate STT/LLM/TTS + cascade. Nova Sonic also emits both transcripts itself (agent ASR + caller text). + + Requires the optional ``eva[bedrock-s2s]`` dependency (the experimental + ``aws-sdk-bedrock-runtime`` client, Python >=3.12) and AWS credentials in the + environment (``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` / + ``AWS_SESSION_TOKEN``) plus ``AWS_REGION``. + """ + + provider: Literal["bedrock_s2s"] = "bedrock_s2s" + model_id: str = Field( + "amazon.nova-2-sonic-v1:0", + description="Nova Sonic model id (e.g. 'amazon.nova-2-sonic-v1:0' or 'amazon.nova-sonic-v1:0').", + ) + region: str = Field("us-east-1", description="AWS region for the Bedrock bidirectional stream.") + female_voice: str = Field("tiffany", description="Nova Sonic voiceId used for female caller personas.") + male_voice: str = Field("matthew", description="Nova Sonic voiceId used for male caller personas.") + temperature: float = Field(0.7, description="Sampling temperature for the caller model.") + top_p: float = Field(0.9, description="Nucleus sampling top-p for the caller model.") + max_tokens: int = Field(1024, description="Max tokens per caller response (sessionStart inferenceConfiguration).") + endpointing_sensitivity: Literal["HIGH", "MEDIUM", "LOW"] = Field( + "HIGH", + description=( + "Nova Sonic turn-detection endpointing sensitivity. Higher = quicker to decide the agent " + "finished its turn (more responsive, but may cut in on a pausey agent). Lower = waits longer." + ), + ) + output_sample_rate: Literal[8000, 16000, 24000] = Field( + 16000, + description=( + "Nova Sonic audio output rate (Hz). 16000 matches the audio bridge input rate so no " + "resampling is needed on the caller-audio hop." + ), + ) + playback_drain_seconds: float = Field( + 15.0, + description="Max seconds to wait for the caller's audio to finish playing before ending its turn.", + ) + + UserSimulatorConfig = Annotated[ - ElevenLabsSimulatorConfig | OpenAIRealtimeSimulatorConfig, + ElevenLabsSimulatorConfig | OpenAIRealtimeSimulatorConfig | BedrockS2SSimulatorConfig, Field(discriminator="provider"), ] @@ -730,6 +787,11 @@ def _validate_service_params( loc = ("model", service.lower()) yield InitErrorDetails(type=PydanticCustomError("missing_service", message), loc=loc, input=provider) + # AWS Transcribe/Polly authenticate via the standard AWS credential chain (env vars), not an + # api_key, and have no "model" param — so their *_PARAMS are optional (region/voice/engine only). + if provider and provider.lower() in _AWS_ENV_CREDENTIALED_STT_TTS: + return + required_keys = ["api_key", "model"] missing = [key for key in required_keys if key not in params] if params else required_keys if missing: diff --git a/src/eva/orchestrator/runner.py b/src/eva/orchestrator/runner.py index a04fc8e2..07f22015 100644 --- a/src/eva/orchestrator/runner.py +++ b/src/eva/orchestrator/runner.py @@ -159,14 +159,15 @@ async def run(self, records: list[EvaluationRecord]) -> RunResult: # Resolve exact models used (captures defaults from services.py + any alias labels) if self.config.model.pipeline_type == PipelineType.CASCADE: - stt_params = self.config.model.stt_params - tts_params = self.config.model.tts_params + # AWS Transcribe/Polly have no model/params; fall back to the provider name. + stt_params = self.config.model.stt_params or {} + tts_params = self.config.model.tts_params or {} self.config.resolved_models = { "stt_provider": self.config.model.stt, - "stt_model": stt_params["model"], + "stt_model": stt_params.get("model", self.config.model.stt), "stt_alias": stt_params.get("alias"), "tts_provider": self.config.model.tts, - "tts_model": tts_params["model"], + "tts_model": tts_params.get("model", self.config.model.tts), "tts_alias": tts_params.get("alias"), "llm": self.config.model.llm, } diff --git a/src/eva/orchestrator/validation_runner.py b/src/eva/orchestrator/validation_runner.py index d1fd693f..745a825f 100644 --- a/src/eva/orchestrator/validation_runner.py +++ b/src/eva/orchestrator/validation_runner.py @@ -14,6 +14,9 @@ GATE_METRIC = "conversation_valid_end" LLM_METRICS = ["user_behavioral_fidelity", "user_speech_fidelity"] +# Needs an audio-capable judge (e.g. Gemini); no Bedrock model accepts audio input, so on an +# all-AWS (Bedrock) setup this judge has no runnable model and is skipped (see _audio_judge_available). +AUDIO_JUDGE_METRIC = "user_speech_fidelity" @dataclass @@ -50,16 +53,45 @@ def __init__( self.metric_configs = metric_configs or {} self.output_ids = output_ids + # Drop the audio judge when its model isn't a router deployment (it needs an audio-capable + # model such as Gemini; Bedrock has none). Skipping beats erroring on a model that can't run. + self._llm_metrics = list(LLM_METRICS) + if AUDIO_JUDGE_METRIC in self._llm_metrics and not self._audio_judge_available(): + self._llm_metrics = [m for m in LLM_METRICS if m != AUDIO_JUDGE_METRIC] + logger.warning( + f"Validation: skipping audio judge '{AUDIO_JUDGE_METRIC}' — its judge model is not a " + "deployment in EVA_MODEL_LIST (audio judging needs an audio-capable model, e.g. Gemini; " + "Bedrock has none)." + ) + # Shared MetricsRunners for validate_one() — lazily initialized on first call. # Safe for concurrent calls on different output_ids (asyncio single-threaded). self._shared_gate_runner: MetricsRunner | None = None self._shared_llm_runner: MetricsRunner | None = None self._runner_init_lock = asyncio.Lock() + def _audio_judge_available(self) -> bool: + """True if the audio judge's model is a deployment in the LiteLLM router. + + The audio judge resolves its model from ``audio_judge_model`` config or the metric's + default; if that model isn't in ``EVA_MODEL_LIST`` it can never run (no healthy deployment), + so validation skips it instead of erroring. + """ + from eva.metrics.validation.user_speech_fidelity import UserSpeechFidelityMetric + from eva.utils import router + + cfg = self.metric_configs.get(AUDIO_JUDGE_METRIC, {}) + model = cfg.get("audio_judge_model") or UserSpeechFidelityMetric.default_model + try: + deployments = getattr(router.get(), "model_list", []) or [] + except Exception: # noqa: BLE001 — router not initialized (e.g. some unit tests) + return False + return model in {d.get("model_name") for d in deployments} + async def run_validation(self) -> dict[str, ValidationResult]: validation_results: dict[str, ValidationResult] = {} check_ids = self.output_ids if self.output_ids is not None else [r.id for r in self.dataset] - logger.info(f"Validation: processing {len(check_ids)} records, metrics={self.VALIDATION_METRICS}") + logger.info(f"Validation: processing {len(check_ids)} records, metrics={[GATE_METRIC, *self._llm_metrics]}") logger.info(f"Thresholds: {self.thresholds}") gate_runner = MetricsRunner( @@ -104,7 +136,7 @@ async def run_validation(self) -> dict[str, ValidationResult]: metrics_runner = MetricsRunner( run_dir=self.run_dir, dataset=self.dataset, - metric_names=LLM_METRICS, + metric_names=self._llm_metrics, metric_configs=self.metric_configs, record_ids=ids_to_judge, ) @@ -113,7 +145,7 @@ async def run_validation(self) -> dict[str, ValidationResult]: gate_passed_set = set(gate_passed) for record_id, record_metrics in metrics_run.all_metrics.items(): - vr = self._evaluate_record(record_id, record_metrics, LLM_METRICS) + vr = self._evaluate_record(record_id, record_metrics, self._llm_metrics) if record_id in gate_passed_set: vr.scores[GATE_METRIC] = 1.0 validation_results[record_id] = vr @@ -161,7 +193,7 @@ async def validate_one(self, output_id: str, *, skip_gate: bool = False) -> Vali self._shared_llm_runner = MetricsRunner( run_dir=self.run_dir, dataset=self.dataset, - metric_names=LLM_METRICS, + metric_names=self._llm_metrics, metric_configs=self.metric_configs, ) @@ -181,9 +213,9 @@ async def validate_one(self, output_id: str, *, skip_gate: bool = False) -> Vali # Phase 2: LLM metrics (gate passed or skipped) llm_metrics = await self._shared_llm_runner.run_and_save_record(output_id, record_dir) if llm_metrics is None: - return ValidationResult(passed=False, failed_metrics=list(LLM_METRICS)) + return ValidationResult(passed=False, failed_metrics=list(self._llm_metrics)) - vr = self._evaluate_record(output_id, llm_metrics, LLM_METRICS) + vr = self._evaluate_record(output_id, llm_metrics, self._llm_metrics) if not skip_gate: vr.scores[GATE_METRIC] = 1.0 return vr diff --git a/src/eva/user_simulator/bedrock_s2s.py b/src/eva/user_simulator/bedrock_s2s.py new file mode 100644 index 00000000..e82f3bd6 --- /dev/null +++ b/src/eva/user_simulator/bedrock_s2s.py @@ -0,0 +1,597 @@ +"""Amazon Bedrock (Nova Sonic) speech-to-speech implementation of the EVA caller. + +This is an audio-native S2S caller, the AWS analog of ``OpenAIRealtimeUserSimulator``: +EVA streams the agent's audio straight to **Amazon Nova Sonic** over Bedrock's +bidirectional streaming API and streams the caller's spoken reply back — there is no +separate STT/LLM/TTS cascade. Nova Sonic also emits both transcripts itself +(agent ASR as ``role: USER``; the caller's own speech as ``role: ASSISTANT``). + +Audio path (the bridge speaks μ-law 8 kHz; Nova speaks LPCM): +- **agent → Nova:** μ-law 8 kHz → ``audioop.ulaw2lin`` → PCM16 8 kHz → resample to + 16 kHz → base64 ``audioInput``. Fed only while the caller isn't speaking (half-duplex). +- **Nova → bridge:** ``audioOutput`` is LPCM at ``output_sample_rate`` (pinned to + 16 kHz = the bridge's ``output()`` rate, so no resample) → played over the bridge. + +Turn-taking differs from the OpenAI caller: Nova Sonic has no "don't auto-respond" +switch, so we rely on its native endpointing (``endpointingSensitivity``) plus +half-duplex suppression (agent audio is dropped while the caller is speaking) rather +than manual transcript-gated response creation. + +The experimental ``aws-sdk-bedrock-runtime`` client (Python >=3.12) is imported lazily +so this module and its unit tests load without the optional dependency installed. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import uuid +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Any + +try: + import audioop +except ImportError: # Python 3.13 removed stdlib audioop + import audioop_lts as audioop # type: ignore[import-not-found, no-redef] + +from eva.user_simulator.audio_bridge import BotToBotAudioBridge +from eva.user_simulator.base import AbstractUserSimulator +from eva.utils.audio_utils import save_pcm_as_wav +from eva.utils.logging import get_logger + +if TYPE_CHECKING: + from eva.models.config import BedrockS2SSimulatorConfig, PerturbationConfig + +logger = get_logger(__name__) + +MULAW_SAMPLE_RATE = 8000 # agent audio arrives from the bridge as μ-law 8 kHz +NOVA_INPUT_RATE = 16000 # we send LPCM 16 kHz to Nova Sonic +BRIDGE_OUTPUT_RATE = 16000 # BotToBotAudioBridge.output() expects PCM16 16 kHz +_PERSONA_GENDER = {1: "F", 2: "M"} +_FINAL_DRAIN_SECONDS = 4.0 +# Nova Sonic drops the session if it receives no audio/interactive content for ~55s. Send a +# small silence frame if the input stream has been idle this long (e.g. agent gone quiet +# mid-call or at end-of-call) so a live-but-quiet conversation isn't killed. +_KEEPALIVE_CHECK_SECONDS = 5.0 +_KEEPALIVE_IDLE_SECONDS = 15.0 +_KEEPALIVE_SILENCE = base64.b64encode(b"\x00" * (NOVA_INPUT_RATE // 50 * 2)).decode("ascii") # 20 ms PCM16 16 kHz + +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, all steps are completed, and you have said goodbye. +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. + +Never call this tool in the same turn that you provide the agent with data, an identifier, +an approval to proceed, a transfer request, or any other information. Say a brief goodbye first.""" + + +class BedrockS2SUserSimulator(AbstractUserSimulator): + """Play EVA's simulated caller with Amazon Nova Sonic (Bedrock bidirectional streaming).""" + + 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: BedrockS2SSimulatorConfig, + ) -> 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="bedrock_s2s", + ) + if perturbation_config and perturbation_config.accent is not None: + raise ValueError("Bedrock S2S caller does not support ElevenLabs-specific accent variants") + self.simulator_config = simulator_config + + # Nova Sonic event identifiers (a promptName ties the session together; each + # content block gets its own contentName). + self._prompt_name = uuid.uuid4().hex + self._audio_content_name = uuid.uuid4().hex + + self._stream: Any = None + self._is_active = False + self._agent_audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._input_resampler_state: Any = None + # Loop time of the last audioInput frame sent to Nova (drives the keepalive). + self._last_input_sent = 0.0 + + # Caller-turn state: True from the moment Nova starts emitting the caller's audio + # until that audio has drained, so agent audio is suppressed while the caller speaks. + self._caller_response_active = False + self._end_call_pending = False + + # Text-output tracking: role + generation stage come on the TEXT contentStart and + # apply to the following textOutput (Nova emits agent ASR as USER, caller text as + # ASSISTANT with generationStage SPECULATIVE preview then FINAL). The caller's text is + # buffered and flushed once per turn. + self._current_text_role: str | None = None + self._current_text_stage: str | None = None + self._pending_caller_text: str | None = None + + # ── config-derived helpers ─────────────────────────────────────────────── + @property + def caller_model(self) -> str: + return self.simulator_config.model_id + + @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 + + # ── lifecycle ──────────────────────────────────────────────────────────── + async def run_conversation(self) -> str: + try: + await self._run_nova_conversation() + except Exception as exc: # noqa: BLE001 + logger.error(f"Bedrock S2S caller simulation error: {exc}", exc_info=True) + self._end_reason = "error" + self.event_logger.log_error(str(exc)) + if self._audio_interface is not None: + with suppress(Exception): + await self._audio_interface.stop_async() + self.event_logger.log_connection_state("session_ended", {"reason": self._end_reason}) + finally: + self.event_logger.save() + return self._end_reason + + async def _run_nova_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", + ) + await self._audio_interface.start_async() + self._audio_interface.start(self._on_agent_audio) + self.event_logger.log_connection_state( + "connected", + { + "server_url": self.server_url, + "caller_provider": self.provider, + "caller_model": self.caller_model, + "caller_voice": self.caller_voice, + "caller_input_transport": "audio/lpcm_16000hz", + "caller_output_transport": f"audio/lpcm_{self.simulator_config.output_sample_rate}hz", + "assistant_input_transport": "audio/pcmu_8000hz", + "caller_response_sequencing": "native_endpointing_halfduplex", + "caller_endpointing_sensitivity": self.simulator_config.endpointing_sensitivity, + }, + ) + + client = self._create_client() + forward_task: asyncio.Task | None = None + listener_task: asyncio.Task | None = None + completion_task: asyncio.Task | None = None + keepalive_task: asyncio.Task | None = None + try: + self._stream = await client.invoke_model_with_bidirectional_stream( + self._stream_input(client, self.caller_model) + ) + self._is_active = True + self._last_input_sent = asyncio.get_running_loop().time() + await self._start_session() + self.event_logger.log_connection_state("session_started") + + forward_task = asyncio.create_task(self._forward_agent_audio()) + listener_task = asyncio.create_task(self._process_responses()) + completion_task = asyncio.create_task(self._wait_for_conversation_end()) + keepalive_task = asyncio.create_task(self._keepalive_audio()) + + await self._wait_for_session_completion(completion_task, forward_task, listener_task) + + # Allow the final goodbye audio and transcript to flush before closing. + await asyncio.sleep(_FINAL_DRAIN_SECONDS) + finally: + self._is_active = False + with suppress(Exception): + await self._end_session() + for task in (completion_task, forward_task, listener_task, keepalive_task): + if task is not None: + await self._cancel_background_task(task) + await self._audio_interface.stop_async() + self._save_user_audio() + self.event_logger.log_connection_state("session_ended", {"reason": self._end_reason}) + + @staticmethod + async def _cancel_background_task(task: asyncio.Task) -> None: + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + + 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") + + async def _wait_for_session_completion( + self, + completion_task: asyncio.Task, + forward_task: asyncio.Task, + listener_task: asyncio.Task, + ) -> None: + done, _ = await asyncio.wait( + {completion_task, forward_task, listener_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if completion_task in done: + return + finished_task = next(iter(done)) + if self._conversation_done.is_set(): + await completion_task + return + exception = finished_task.exception() + if exception is not None: + raise exception + task_name = "listener" if finished_task is listener_task else "audio forwarder" + raise RuntimeError(f"Nova Sonic {task_name} stopped unexpectedly") + + # ── Bedrock client + event encoding (experimental SDK, imported lazily) ─── + def _create_client(self) -> Any: + from aws_sdk_bedrock_runtime.client import BedrockRuntimeClient + from aws_sdk_bedrock_runtime.config import Config, HTTPAuthSchemeResolver, SigV4AuthScheme + from smithy_aws_core.identity import EnvironmentCredentialsResolver + + region = self.simulator_config.region + config = Config( + endpoint_uri=f"https://bedrock-runtime.{region}.amazonaws.com", + region=region, + aws_credentials_identity_resolver=EnvironmentCredentialsResolver(), + auth_scheme_resolver=HTTPAuthSchemeResolver(), + auth_schemes={"aws.auth#sigv4": SigV4AuthScheme(service="bedrock")}, + ) + return BedrockRuntimeClient(config=config) + + @staticmethod + def _stream_input(client: Any, model_id: str) -> Any: # noqa: ARG004 + from aws_sdk_bedrock_runtime.client import InvokeModelWithBidirectionalStreamOperationInput + + return InvokeModelWithBidirectionalStreamOperationInput(model_id=model_id) + + def _encode_chunk(self, payload: bytes) -> Any: + from aws_sdk_bedrock_runtime.models import ( + BidirectionalInputPayloadPart, + InvokeModelWithBidirectionalStreamInputChunk, + ) + + return InvokeModelWithBidirectionalStreamInputChunk(value=BidirectionalInputPayloadPart(bytes_=payload)) + + async def _send_event(self, event: dict[str, Any]) -> None: + if self._stream is None: + return + payload = json.dumps(event).encode("utf-8") + await self._stream.input_stream.send(self._encode_chunk(payload)) + + # ── session setup / teardown (Nova Sonic event sequence) ────────────────── + async def _start_session(self) -> None: + cfg = self.simulator_config + await self._send_event( + { + "event": { + "sessionStart": { + "inferenceConfiguration": { + "maxTokens": cfg.max_tokens, + "topP": cfg.top_p, + "temperature": cfg.temperature, + }, + "turnDetectionConfiguration": {"endpointingSensitivity": cfg.endpointing_sensitivity}, + } + } + } + ) + await self._send_event( + { + "event": { + "promptStart": { + "promptName": self._prompt_name, + "textOutputConfiguration": {"mediaType": "text/plain"}, + "audioOutputConfiguration": { + "mediaType": "audio/lpcm", + "sampleRateHertz": cfg.output_sample_rate, + "sampleSizeBits": 16, + "channelCount": 1, + "voiceId": self.caller_voice, + "encoding": "base64", + "audioType": "SPEECH", + }, + "toolUseOutputConfiguration": {"mediaType": "application/json"}, + "toolConfiguration": { + "tools": [ + { + "toolSpec": { + "name": "end_call", + "description": END_CALL_DESCRIPTION, + "inputSchema": {"json": json.dumps({"type": "object", "properties": {}})}, + } + } + ] + }, + } + } + } + ) + # System prompt (persona + goal template) as a one-shot SYSTEM text block. + system_content_name = uuid.uuid4().hex + await self._send_event( + { + "event": { + "contentStart": { + "promptName": self._prompt_name, + "contentName": system_content_name, + "type": "TEXT", + "interactive": True, + "role": "SYSTEM", + "textInputConfiguration": {"mediaType": "text/plain"}, + } + } + } + ) + await self._send_event( + { + "event": { + "textInput": { + "promptName": self._prompt_name, + "contentName": system_content_name, + "content": self._build_prompt(), + } + } + } + ) + await self._send_event( + {"event": {"contentEnd": {"promptName": self._prompt_name, "contentName": system_content_name}}} + ) + # Open the single, long-lived audio input block; all agent audio streams into it. + await self._send_event( + { + "event": { + "contentStart": { + "promptName": self._prompt_name, + "contentName": self._audio_content_name, + "type": "AUDIO", + "interactive": True, + "role": "USER", + "audioInputConfiguration": { + "mediaType": "audio/lpcm", + "sampleRateHertz": NOVA_INPUT_RATE, + "sampleSizeBits": 16, + "channelCount": 1, + "audioType": "SPEECH", + "encoding": "base64", + }, + } + } + } + ) + + async def _end_session(self) -> None: + await self._send_event( + {"event": {"contentEnd": {"promptName": self._prompt_name, "contentName": self._audio_content_name}}} + ) + await self._send_event({"event": {"promptEnd": {"promptName": self._prompt_name}}}) + await self._send_event({"event": {"sessionEnd": {}}}) + if self._stream is not None: + with suppress(Exception): + await self._stream.input_stream.close() + + # ── agent audio in (bridge → Nova) ─────────────────────────────────────── + def _on_agent_audio(self, mulaw_audio: bytes) -> None: + """Bridge callback: queue agent audio (μ-law 8 kHz) for Nova as LPCM 16 kHz. + + Half-duplex: drop agent audio while the caller's own audio is still playing, so the + caller's line isn't fed back to Nova as the agent's turn. Gated solely on the bridge's + ``is_caller_playing()`` (not the ``_caller_response_active`` flag) so a missed Nova + turn-end event can't wedge suppression on and starve the input stream. + """ + if not mulaw_audio or self._caller_audio_is_playing(): + return + try: + pcm8k = audioop.ulaw2lin(mulaw_audio, 2) + pcm16k, self._input_resampler_state = audioop.ratecv( + pcm8k, 2, 1, MULAW_SAMPLE_RATE, NOVA_INPUT_RATE, self._input_resampler_state + ) + except Exception: # noqa: BLE001 + return + self._agent_audio_queue.put_nowait(pcm16k) + + def _caller_audio_is_playing(self) -> bool: + if self._audio_interface is None: + return False + return self._audio_interface.is_caller_playing() + + async def _forward_agent_audio(self) -> None: + while True: + pcm16k = await self._agent_audio_queue.get() + if not pcm16k: + continue + await self._send_audio_input(base64.b64encode(pcm16k).decode("ascii")) + + async def _send_audio_input(self, content_b64: str) -> None: + await self._send_event( + { + "event": { + "audioInput": { + "promptName": self._prompt_name, + "contentName": self._audio_content_name, + "content": content_b64, + } + } + } + ) + self._last_input_sent = asyncio.get_running_loop().time() + + async def _keepalive_audio(self) -> None: + """Send a silence frame if the input stream has been idle, so Nova doesn't drop us. + + Nova Sonic ends the session on a >~55s gap with no audio/interactive content. When the + agent is quiet (thinking, or the call has wound down) neither real agent audio nor the + bridge's silence may be flowing, so top up the stream with a brief silence frame. + """ + while self._is_active: + await asyncio.sleep(_KEEPALIVE_CHECK_SECONDS) + if self._conversation_done.is_set() or self._stream is None: + continue + idle = asyncio.get_running_loop().time() - self._last_input_sent + if idle >= _KEEPALIVE_IDLE_SECONDS and not self._caller_audio_is_playing(): + with suppress(Exception): + await self._send_audio_input(_KEEPALIVE_SILENCE) + + # ── Nova responses out (Nova → bridge) ─────────────────────────────────── + async def _process_responses(self) -> None: + try: + while self._is_active: + output = await self._stream.await_output() + result = await output[1].receive() + value = getattr(result, "value", None) + raw = getattr(value, "bytes_", None) if value is not None else None + if not raw: + continue + await self._handle_event(json.loads(raw.decode("utf-8"))) + except asyncio.CancelledError: + raise + except StopAsyncIteration: + return + except Exception as exc: # noqa: BLE001 + logger.error(f"Nova Sonic event loop error: {exc}", exc_info=True) + self.event_logger.log_error(str(exc)) + self._on_conversation_end("error") + + async def _handle_event(self, data: dict[str, Any]) -> None: + event = data.get("event") + if not event: + return + if "contentStart" in event: + self._on_content_start(event["contentStart"]) + elif "textOutput" in event: + self._on_text_output(event["textOutput"]) + elif "audioOutput" in event: + self._on_audio_output(event["audioOutput"]) + elif "toolUse" in event: + self._on_tool_use(event["toolUse"]) + # Nova may invoke end_call as a pure tool with no trailing audio END_TURN, so + # hang up here (after any in-flight caller audio drains) rather than waiting for + # a turn-end event that never comes. + if self._end_call_pending: + await self._finish_caller_response() + elif "contentEnd" in event: + await self._on_content_end(event["contentEnd"]) + elif "completionEnd" in event: + await self._finish_caller_response() + + def _on_content_start(self, content_start: dict[str, Any]) -> None: + content_type = content_start.get("type") + role = content_start.get("role") + if content_type == "TEXT": + self._current_text_role = role + stage = None + fields = content_start.get("additionalModelFields") + if fields: + with suppress(Exception): + stage = json.loads(fields).get("generationStage") + self._current_text_stage = stage + elif content_type == "AUDIO" and role == "ASSISTANT": + # The caller has started speaking — suppress agent audio until it drains. + self._caller_response_active = True + + def _on_text_output(self, text_output: dict[str, Any]) -> None: + text = (text_output.get("content") or "").strip() + if not text: + return + role = text_output.get("role") or self._current_text_role + if role == "USER": + # Nova's ASR of the agent-under-test's speech. + self._on_assistant_speaks(text) + elif role == "ASSISTANT": + # The caller's own words. Buffer and emit once at turn end, preferring the FINAL + # post-audio transcript but falling back to the SPECULATIVE preview if that's all + # Nova sends for a short turn (a FINAL always overwrites a buffered SPECULATIVE). + if self._current_text_stage == "FINAL" or self._pending_caller_text is None: + self._pending_caller_text = text + + def _on_audio_output(self, audio_output: dict[str, Any]) -> None: + content = audio_output.get("content") + if not content or self._audio_interface is None: + return + pcm = base64.b64decode(content) + if pcm: + self._audio_interface.output(pcm) + self._caller_response_active = True + + def _on_tool_use(self, tool_use: dict[str, Any]) -> None: + if tool_use.get("toolName") == "end_call": + self.event_logger.log_event("tool_call", {"name": "end_call", "arguments": tool_use.get("content", "")}) + self._end_call_pending = True + + async def _on_content_end(self, content_end: dict[str, Any]) -> None: + stop_reason = content_end.get("stopReason") + if content_end.get("type") == "AUDIO" and stop_reason in {"END_TURN", "INTERRUPTED"}: + await self._finish_caller_response() + + async def _finish_caller_response(self) -> None: + """The caller's turn ended: emit its transcript, drain its audio, release / hang up.""" + if self._conversation_done.is_set(): + return # idempotent: a trailing turn-end event after end_call must not re-run this + if self._pending_caller_text: + self._on_user_speaks(self._pending_caller_text) + self._pending_caller_text = None + # Tell the bridge no more caller audio is coming so it finalizes the turn even when the + # audio ends on an exact chunk boundary; otherwise is_caller_playing() stays True, which + # would wedge half-duplex suppression on and starve Nova's input stream (a hard freeze). + if self._audio_interface is not None: + self._audio_interface.notify_user_utterance_complete() + with suppress(TimeoutError): + await asyncio.wait_for( + self._wait_for_caller_playback_complete(), + timeout=self.simulator_config.playback_drain_seconds, + ) + self._caller_response_active = False + if self._end_call_pending: + self._end_call_pending = False + self._on_conversation_end("goodbye") + + async def _wait_for_caller_playback_complete(self) -> None: + if self._audio_interface is None: + return + while True: + if not self._audio_interface.is_caller_playing(): + await asyncio.sleep(0.7) + if not self._audio_interface.is_caller_playing(): + return + await asyncio.sleep(0.05) + + def _save_user_audio(self) -> None: + if not self._user_clean_audio_chunks: + return + save_pcm_as_wav( + b"".join(self._user_clean_audio_chunks), + self.output_dir / "audio_user_clean.wav", + sample_rate=BRIDGE_OUTPUT_RATE, + num_channels=1, + ) diff --git a/src/eva/user_simulator/factory.py b/src/eva/user_simulator/factory.py index d2cef7e2..45d22f8a 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 ( + BedrockS2SSimulatorConfig, + ElevenLabsSimulatorConfig, + OpenAIRealtimeSimulatorConfig, + UserSimulatorConfig, +) from eva.user_simulator.base import AbstractUserSimulator @@ -21,4 +26,14 @@ def create_user_simulator( from eva.user_simulator.openai_realtime import OpenAIRealtimeUserSimulator return OpenAIRealtimeUserSimulator(simulator_config=simulator_config, **kwargs) + if isinstance(simulator_config, BedrockS2SSimulatorConfig): + try: + from eva.user_simulator.bedrock_s2s import BedrockS2SUserSimulator + except ImportError as exc: # experimental SDK requires Python >=3.12 + raise RuntimeError( + "The 'bedrock_s2s' caller requires the optional dependency 'aws-sdk-bedrock-runtime' " + "(Python >=3.12). Install it with: pip install 'eva[bedrock-s2s]'." + ) from exc + + return BedrockS2SUserSimulator(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 07751586..38b80475 100644 --- a/src/eva/utils/llm_client.py +++ b/src/eva/utils/llm_client.py @@ -49,12 +49,33 @@ def __init__( self.params = params or {} self.timeout = timeout + # Cache the resolved OpenAI-ness for param filtering (service_tier). Resolved lazily so the + # router is initialized by first call. + self._openai_resolved: bool | None = None + # Retry configuration self.max_retries = max_retries self.retry_min_wait = retry_min_wait self.retry_max_wait = retry_max_wait self.retry_multiplier = retry_multiplier + def _resolves_to_openai(self) -> bool: + """True if this model routes to an OpenAI/Azure deployment (which accept service_tier).""" + if self._openai_resolved is None: + from eva.utils import router + + target = self.model + try: + for d in getattr(router.get(), "model_list", []) or []: + if d.get("model_name") == self.model: + target = d.get("litellm_params", {}).get("model", self.model) + break + except Exception: # noqa: BLE001 — router not initialized (e.g. unit tests): assume OpenAI + return True + m = target.lower() + self._openai_resolved = m.startswith(("openai/", "azure/")) or "/" not in m + return self._openai_resolved + def _is_retryable_error(self, error: Exception) -> bool: """Determine if an error is retryable using centralized error handling. @@ -116,6 +137,11 @@ async def generate_text(self, messages: list[dict], response_format: dict | None } # Merge model params (temperature, max_tokens, top_p, etc.) kwargs.update(self.params) + # service_tier (e.g. "flex", set by the LLM judges) is an OpenAI/Azure-only param; + # Bedrock and others reject it, and litellm's drop_params doesn't strip it. Drop it for + # non-OpenAI models so the same judge can route to a Bedrock deployment. + if "service_tier" in kwargs and not self._resolves_to_openai(): + kwargs.pop("service_tier") # Add response format if specified if response_format: diff --git a/tests/unit/assistant/test_services.py b/tests/unit/assistant/test_services.py index ef8a3a3d..8549c6e0 100644 --- a/tests/unit/assistant/test_services.py +++ b/tests/unit/assistant/test_services.py @@ -179,6 +179,19 @@ def test_unknown_stt_error_lists_cartesia_multilingual_and_xai(self): assert "cartesia-multilingual" in msg assert "xai" in msg + @pytest.mark.parametrize("alias", ["aws_transcribe", "aws", "transcribe", "amazon_transcribe"]) + def test_aws_transcribe_created_without_api_key_or_model(self, alias, monkeypatch): + # AWS creds come from the env chain; no api_key/model params required. + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA_TEST") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") + monkeypatch.setenv("AWS_REGION", "us-east-1") + svc = create_stt_service(alias) + assert "AWSTranscribe" in type(svc).__name__ + + def test_aws_transcribe_listed_in_unknown_error(self): + with pytest.raises(ValueError, match="aws_transcribe"): + create_stt_service("definitely_not_a_provider", params={"api_key": "k"}) + _CARTESIA_STT_PROVIDERS = [ ("cartesia", {"api_key": "k", "model": "ink-2"}), @@ -298,6 +311,23 @@ def test_openai_azure_url_uses_azure_client(self): # Azure URL should trigger AsyncAzureOpenAI client assert "Azure" in type(svc._client).__name__ + def test_aws_polly_created_with_voice_and_engine(self, monkeypatch): + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA_TEST") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") + monkeypatch.setenv("AWS_REGION", "us-east-1") + svc = create_tts_service("aws_polly", params={"voice_id": "Matthew", "engine": "generative"}) + assert "AWSPolly" in type(svc).__name__ + assert svc._settings.voice == "Matthew" + assert svc._settings.engine == "generative" + + def test_aws_polly_defaults_voice_and_engine(self, monkeypatch): + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA_TEST") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") + # 'aws' alias, no params — creds from env, defaults applied. + svc = create_tts_service("aws") + assert svc._settings.voice == "Joanna" + assert svc._settings.engine == "neural" + class TestCreateRealtimeLlmService: def test_unknown_model_raises(self): diff --git a/tests/unit/models/test_config_models.py b/tests/unit/models/test_config_models.py index 4d96402d..879b6d92 100644 --- a/tests/unit/models/test_config_models.py +++ b/tests/unit/models/test_config_models.py @@ -476,6 +476,23 @@ def test_missing_stt_tts_params(self): } ) + def test_aws_stt_tts_need_no_api_key_or_model_params(self): + """AWS Transcribe/Polly authenticate via the AWS env chain, so their *_PARAMS are optional. + + A full all-AWS cascade (Bedrock LLM from MODEL_LIST + aws_transcribe + aws_polly) with no + STT/TTS params must validate. + """ + config = _config( + env_vars=_EVA_MODEL_LIST_ENV + | { + "EVA_MODEL__LLM": "us.anthropic.claude-opus-4-6", # bedrock/... deployment in MODEL_LIST + "EVA_MODEL__STT": "aws_transcribe", + "EVA_MODEL__TTS": "aws_polly", + } + ) + assert config.model.stt == "aws_transcribe" + assert config.model.tts == "aws_polly" + class TestMaxRerunAttemptsZeroSkipsConflictCheck: """When max_rerun_attempts=0, multiple pipeline modes don't raise.""" diff --git a/tests/unit/orchestrator/test_validation_runner.py b/tests/unit/orchestrator/test_validation_runner.py index 4bbe8ac7..3c0ddc23 100644 --- a/tests/unit/orchestrator/test_validation_runner.py +++ b/tests/unit/orchestrator/test_validation_runner.py @@ -466,3 +466,45 @@ async def test_output_ids_passed_to_metrics_runner(self, temp_dir): await runner.run_validation() assert calls[0]["record_ids"] == output_ids + + +class TestAudioJudgeSkip: + """The audio judge (user_speech_fidelity) is skipped unless its model is a router deployment. + + Bedrock has no audio-input model, so an all-AWS EVA_MODEL_LIST omits the audio judge's model + and validation should run only the Bedrock-capable judges instead of erroring. + """ + + @staticmethod + def _runner() -> ValidationRunner: + return ValidationRunner(run_dir=Path("/tmp/fake"), dataset=[], thresholds={}) + + def test_skipped_when_audio_model_absent(self): + from eva.utils import router + + router.reset() + router.init([{"model_name": "us.anthropic.claude-opus-4-6-v1", "litellm_params": {"model": "bedrock/x"}}]) + try: + vr = self._runner() + assert "user_speech_fidelity" not in vr._llm_metrics + assert "user_behavioral_fidelity" in vr._llm_metrics # text judge still runs (on Bedrock) + finally: + router.reset() + + def test_kept_when_audio_model_present(self): + from eva.utils import router + + router.reset() + router.init( + [{"model_name": "gemini-3-flash-preview", "litellm_params": {"model": "gemini/gemini-3-flash-preview"}}] + ) + try: + assert "user_speech_fidelity" in self._runner()._llm_metrics + finally: + router.reset() + + def test_skipped_when_router_uninitialized(self): + from eva.utils import router + + router.reset() + assert "user_speech_fidelity" not in self._runner()._llm_metrics diff --git a/tests/unit/user_simulator/test_bedrock_s2s_simulator.py b/tests/unit/user_simulator/test_bedrock_s2s_simulator.py new file mode 100644 index 00000000..4d7e5310 --- /dev/null +++ b/tests/unit/user_simulator/test_bedrock_s2s_simulator.py @@ -0,0 +1,204 @@ +"""Tests for the Nova Sonic speech-to-speech user simulator. + +Fully mocked — no AWS credentials and no experimental SDK required. The simulator +imports ``aws-sdk-bedrock-runtime`` lazily, so these exercise the event protocol +(``_start_session``) and the response-handling logic (``_handle_event``) directly. +""" + +import base64 +import json + +from eva.models.config import BedrockS2SSimulatorConfig +from eva.user_simulator.bedrock_s2s import BedrockS2SUserSimulator +from eva.user_simulator.factory import create_user_simulator + + +async def _noop_sleep(_seconds): + return None + + +class _FakeBridge: + def __init__(self, caller_playing: bool = False): + self.outputs: list[bytes] = [] + self._caller_playing = caller_playing + self.utterance_complete_calls = 0 + + def output(self, pcm: bytes) -> None: + self.outputs.append(pcm) + + def notify_user_utterance_complete(self) -> None: + self.utterance_complete_calls += 1 + + def is_caller_playing(self) -> bool: + return self._caller_playing + + +class _FakeInputStream: + def __init__(self): + self.sent: list[bytes] = [] + + async def send(self, chunk) -> None: + self.sent.append(chunk) + + async def close(self) -> None: + pass + + +class _FakeStream: + def __init__(self): + self.input_stream = _FakeInputStream() + + +_GOAL = { + "high_level_user_goal": "Reset my password.", + "decision_tree": { + "must_have_criteria": ["Reset the password."], + "nice_to_have_criteria": [], + "negotiation_behavior": ["Accept a valid resolution."], + "resolution_condition": "The password is reset.", + "failure_condition": "The password cannot be reset.", + "escalation_behavior": "Escalate if blocked.", + "edge_cases": [], + }, + "information_required": {"employee_id": "12345"}, + "starting_utterance": "I need to reset my password.", +} + + +def _make_sim(tmp_path, persona_id: int = 1): + cfg = BedrockS2SSimulatorConfig() + return create_user_simulator( + cfg, + current_date_time="now", + persona_config={"user_persona": "frustrated user", "user_persona_id": persona_id, "name": "Ruth Callahan"}, + goal=_GOAL, + server_url="ws://localhost:1/ws", + output_dir=tmp_path, + agent_id="agent_itsm", + timeout=10, + perturbation_config=None, + language="en", + ) + + +def _sent_events(stream: _FakeStream) -> list[dict]: + """Decode the JSON `event` payloads pushed to the input stream.""" + return [json.loads(raw.decode("utf-8"))["event"] for raw in stream.input_stream.sent] + + +def test_config_and_factory_dispatch(tmp_path): + cfg = BedrockS2SSimulatorConfig(model_id="amazon.nova-sonic-v1:0") + assert cfg.provider == "bedrock_s2s" + assert cfg.model_id == "amazon.nova-sonic-v1:0" + sim = _make_sim(tmp_path) + assert isinstance(sim, BedrockS2SUserSimulator) + assert sim.provider == "bedrock_s2s" + + +def test_caller_voice_follows_persona_gender(tmp_path): + assert _make_sim(tmp_path, persona_id=1).caller_voice == "tiffany" # female + assert _make_sim(tmp_path, persona_id=2).caller_voice == "matthew" # male + + +async def test_start_session_sends_system_prompt_and_tool(tmp_path): + sim = _make_sim(tmp_path) + sim._stream = _FakeStream() + sim._encode_chunk = lambda payload: payload # identity: keep raw JSON bytes + + await sim._start_session() + events = _sent_events(sim._stream) + + # Ordered init sequence. + assert "sessionStart" in events[0] + assert "promptStart" in events[1] + assert [k for e in events for k in e] == [ + "sessionStart", + "promptStart", + "contentStart", # SYSTEM text + "textInput", + "contentEnd", + "contentStart", # AUDIO input block + ] + + prompt_start = events[1]["promptStart"] + assert prompt_start["audioOutputConfiguration"]["voiceId"] == "tiffany" + assert prompt_start["audioOutputConfiguration"]["sampleRateHertz"] == 16000 + tools = prompt_start["toolConfiguration"]["tools"] + assert tools[0]["toolSpec"]["name"] == "end_call" + + # System prompt is delivered as a SYSTEM text block carrying the persona+goal template. + sys_start = events[2]["contentStart"] + assert sys_start["type"] == "TEXT" and sys_start["role"] == "SYSTEM" + assert "password" in events[3]["textInput"]["content"].lower() + + audio_start = events[5]["contentStart"] + assert audio_start["type"] == "AUDIO" and audio_start["role"] == "USER" + assert audio_start["audioInputConfiguration"]["sampleRateHertz"] == 16000 + + +async def test_role_mapping_audio_and_end_call(tmp_path, monkeypatch): + monkeypatch.setattr("eva.user_simulator.bedrock_s2s.asyncio.sleep", _noop_sleep) + sim = _make_sim(tmp_path) + sim._audio_interface = _FakeBridge() + + spoken = {"user": [], "assistant": []} + sim._on_user_speaks = lambda t: spoken["user"].append(t) + sim._on_assistant_speaks = lambda t: spoken["assistant"].append(t) + + caller_pcm = b"\x01\x02" * 80 + + async def feed(event: dict) -> None: + await sim._handle_event({"event": event}) + + # 1. Nova's ASR of the agent's speech (role USER) -> assistant_speech. + await feed({"contentStart": {"type": "TEXT", "role": "USER"}}) + await feed({"textOutput": {"role": "USER", "content": "Hi, this is IT support, how can I help?"}}) + # 2. Caller text: SPECULATIVE preview is ignored, only FINAL is recorded as user_speech. + await feed( + { + "contentStart": { + "type": "TEXT", + "role": "ASSISTANT", + "additionalModelFields": '{"generationStage":"SPECULATIVE"}', + } + } + ) + await feed({"textOutput": {"role": "ASSISTANT", "content": "please reset my (preview)"}}) + await feed( + {"contentStart": {"type": "TEXT", "role": "ASSISTANT", "additionalModelFields": '{"generationStage":"FINAL"}'}} + ) + await feed({"textOutput": {"role": "ASSISTANT", "content": "Please reset my password, employee ID 12345."}}) + # 3. Caller audio streams back and plays over the bridge. + await feed({"contentStart": {"type": "AUDIO", "role": "ASSISTANT"}}) + await feed({"audioOutput": {"content": base64.b64encode(caller_pcm).decode("ascii")}}) + assert sim._caller_response_active is True + # 4. end_call tool, then the caller-audio turn ends. + await feed({"toolUse": {"toolName": "end_call", "content": "{}"}}) + await feed({"contentEnd": {"type": "AUDIO", "stopReason": "END_TURN"}}) + + assert spoken["assistant"] == ["Hi, this is IT support, how can I help?"] + # Caller text buffered across SPECULATIVE->FINAL and flushed once at turn end. + assert spoken["user"] == ["Please reset my password, employee ID 12345."] + assert sim._audio_interface.outputs == [caller_pcm] + # Turn end must signal the bridge, or is_caller_playing() wedges and starves Nova. + assert sim._audio_interface.utterance_complete_calls == 1 + assert sim._end_reason == "goodbye" + assert sim._conversation_done.is_set() + assert sim._caller_response_active is False # released after the turn drained + + +def test_half_duplex_suppresses_agent_audio_while_caller_speaks(tmp_path): + mulaw = b"\xff" * 160 # μ-law silence, valid frame + + # Caller is speaking -> agent audio is dropped (not queued to Nova). + sim = _make_sim(tmp_path) + sim._audio_interface = _FakeBridge(caller_playing=True) + sim._on_agent_audio(mulaw) + assert sim._agent_audio_queue.empty() + + # Caller idle -> agent audio is decoded, resampled, and queued. + sim2 = _make_sim(tmp_path) + sim2._audio_interface = _FakeBridge(caller_playing=False) + sim2._on_agent_audio(mulaw) + assert sim2._agent_audio_queue.qsize() == 1 + assert isinstance(sim2._agent_audio_queue.get_nowait(), bytes) diff --git a/tests/unit/user_simulator/test_factory.py b/tests/unit/user_simulator/test_factory.py index d47f1abe..aed03a00 100644 --- a/tests/unit/user_simulator/test_factory.py +++ b/tests/unit/user_simulator/test_factory.py @@ -1,6 +1,11 @@ from pathlib import Path -from eva.models.config import ElevenLabsSimulatorConfig, OpenAIRealtimeSimulatorConfig +from eva.models.config import ( + BedrockS2SSimulatorConfig, + ElevenLabsSimulatorConfig, + OpenAIRealtimeSimulatorConfig, +) +from eva.user_simulator.bedrock_s2s import BedrockS2SUserSimulator 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 +48,13 @@ def test_factory_selects_openai_realtime(tmp_path): assert isinstance(simulator, OpenAIRealtimeUserSimulator) assert simulator.caller_model == "gpt-realtime-1.5" + + +def test_factory_selects_bedrock_s2s(tmp_path): + # The simulator module imports the experimental SDK lazily (inside client-creation + # methods), so construction works without aws-sdk-bedrock-runtime installed. + config = BedrockS2SSimulatorConfig() + simulator = create_user_simulator(config, **_kwargs(tmp_path)) + + assert isinstance(simulator, BedrockS2SUserSimulator) + assert simulator.provider == "bedrock_s2s" diff --git a/tests/unit/utils/test_llm_client.py b/tests/unit/utils/test_llm_client.py index 0ebecf6a..6051af9e 100644 --- a/tests/unit/utils/test_llm_client.py +++ b/tests/unit/utils/test_llm_client.py @@ -318,3 +318,36 @@ async def test_conftest_litellm_params_reach_api_call(self): ) router.reset() + + +class TestServiceTierProviderResolution: + """service_tier is an OpenAI/Azure-only param; it must be dropped for other providers (Bedrock).""" + + @staticmethod + def _client(model: str, deployments: list[dict]) -> LLMClient: + mock_router = MagicMock(spec=Router) + mock_router.model_list = deployments + router._router = mock_router + return LLMClient(model=model) + + def test_bedrock_is_not_openai(self): + try: + client = self._client( + "claude", [{"model_name": "claude", "litellm_params": {"model": "bedrock/us.anthropic.x"}}] + ) + assert client._resolves_to_openai() is False + finally: + router.reset() + + def test_openai_bare_and_azure_are_openai(self): + try: + bare = self._client("gpt", [{"model_name": "gpt", "litellm_params": {"model": "gpt-5.2"}}]) + azure = self._client("az", [{"model_name": "az", "litellm_params": {"model": "azure/gpt-5.2"}}]) + assert bare._resolves_to_openai() is True + assert azure._resolves_to_openai() is True + finally: + router.reset() + + def test_uninitialized_router_assumes_openai(self): + router.reset() + assert LLMClient(model="anything")._resolves_to_openai() is True diff --git a/uv.lock b/uv.lock index 4eae4cdb..8b3c69f7 100644 --- a/uv.lock +++ b/uv.lock @@ -294,6 +294,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" }, ] +[[package]] +name = "aws-sdk-bedrock-runtime" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smithy-aws-core", extra = ["eventstream", "json"] }, + { name = "smithy-core" }, + { name = "smithy-http", extra = ["awscrt"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/8a/ed3fd98775273b0b7f6006b4970aa876d506668b7fe29145f54fcb941c3b/aws_sdk_bedrock_runtime-0.7.0.tar.gz", hash = "sha256:0cb172cbc03ff060e5c1d6f9cfa9a8ac5e71d9e0d58d3117006ebf614cbb4677", size = 170304, upload-time = "2026-06-23T04:04:52.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/e1/f86d50f0ad9c8200645f315c524d285e86b30b94bb65118e1108597714e6/aws_sdk_bedrock_runtime-0.7.0-py3-none-any.whl", hash = "sha256:de67ede6f441bbb77ef61c237945d559513843fc827abe1af12535c2519650c5", size = 94948, upload-time = "2026-06-23T04:04:51.281Z" }, +] + +[[package]] +name = "aws-sdk-signers" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/62/30685f9a096ad834409771a567065cd25094d3a5124874e1d7e373f17dd7/aws_sdk_signers-0.3.0.tar.gz", hash = "sha256:6fd654ea3dafe3ae3cf172fb3bd1e81877fe6e18156e0b638ccefb045ffb420c", size = 18590, upload-time = "2026-05-05T18:04:09.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/91/25405e647de8dea8419812e85bcb341323500905f4f55fc23cf488b7a2a4/aws_sdk_signers-0.3.0-py3-none-any.whl", hash = "sha256:098c7784064931d2da968400fc0466e092a7c271a79fac9d55daa4a7c35fd6f1", size = 21999, upload-time = "2026-05-05T18:04:08.971Z" }, +] + +[[package]] +name = "awscrt" +version = "0.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/cb/980fe60c4209af71d036276217f8b9f372f958e290c15d2849a3de4dcd23/awscrt-0.32.2.tar.gz", hash = "sha256:a4f48805e8a66237923f03b7b692d213994cff42d1ff08125d1d60c74fcaf872", size = 36862073, upload-time = "2026-04-24T22:59:55.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/a6c712a2f638c766b0879da0eacd9fe5695c64deb89c0357bcc4f1f4df9b/awscrt-0.32.2-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:32785f54d0786e07b6491b51f9c2f75ea9e17decd39bb6b66fdc60cd871a49ef", size = 3406247, upload-time = "2026-04-24T22:58:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/a7/03/665c81f3b9d3c56fcdfb8f353c22501bc538d192c431cfaaf307f867d404/awscrt-0.32.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f8a586c52f41ff14c2f1b8afeb764e231ad3d66acfd42a6b9fb6c8afd8da8fe2", size = 3906890, upload-time = "2026-04-24T22:58:47.96Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/5db0691a0c72d55a17c03c47149cf15d4602b83b470355c322c9a7f115e8/awscrt-0.32.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ef1664588d35dcad2115377120667e689cd7a517da52a481373c9536811ed96", size = 4196631, upload-time = "2026-04-24T22:58:49.244Z" }, + { url = "https://files.pythonhosted.org/packages/9a/66/63a4654b2996158f33e88d74d79178a5812d94a7e0d6c94ec475115dff18/awscrt-0.32.2-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7ed7c209136650fe25659436bb4150e5af6eb43d71a0bf294f0bf414428736ee", size = 3818090, upload-time = "2026-04-24T22:58:50.657Z" }, + { url = "https://files.pythonhosted.org/packages/cd/26/b8fee227918465830a0bbba48f54ebf0a5029c3a7d11d4fa973838a79262/awscrt-0.32.2-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cfd437122d5a2ec7eb9fffaf2cd8b96543d4a0d7e906b9515b79672005a1607a", size = 4056453, upload-time = "2026-04-24T22:58:52.373Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/f5b9e9677bf90d1840d4db1513ba92e73601ef82e61a16e747a83493d35c/awscrt-0.32.2-cp311-abi3-win32.whl", hash = "sha256:5197d1e4e780d755632f79f8d32a09a30a9101cccb51ca1694fe25c711b2e801", size = 4066027, upload-time = "2026-04-24T22:58:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/57/0b/fd1798551ecdc8a28d61ecdeda248f99faa3457f83aefa10ab797f466889/awscrt-0.32.2-cp311-abi3-win_amd64.whl", hash = "sha256:80420aa19c074a4c0335f2bd0e4aee3381fa452328d937795a1e0c779f0c052d", size = 4221984, upload-time = "2026-04-24T22:58:55.115Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/075e29b39945f398adfb5d5ee4d533e00d37c0f5c68d79b250a1bdb087ba/awscrt-0.32.2-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:d2f7aee3bce261ab1ceba1fac404de4d496aa866237161d4257cef92bff9d828", size = 3404901, upload-time = "2026-04-24T22:58:56.492Z" }, + { url = "https://files.pythonhosted.org/packages/e3/41/1c4783b32bf4ec7383156787570ea1221c95c037c2c0f11cbc9e9529ba48/awscrt-0.32.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff0fff9c2b613d7fabc298b0fd81f0d7056353f3d20271a852a719c5b2f7ccf4", size = 3897627, upload-time = "2026-04-24T22:58:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/af/2d/5736128847ff4a877d50720ea7a48d7e50a56b78741919e4b6ffabffb1ad/awscrt-0.32.2-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:79bb11d5d1dfdcfd867aac4a026bee11afbd2154279e12b66588442d8c14bdf7", size = 4189579, upload-time = "2026-04-24T22:58:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/90/f5/064b23d4c927e9b63725ee60c88edb75a1e8f5fd1e97d56d4d6a63fe954b/awscrt-0.32.2-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:47330f421948207a122092042f235cf82d48fa145c446ff4db12cc8cd3a418b6", size = 3809647, upload-time = "2026-04-24T22:59:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/f5/36/f14ea531cccb6ff85c4d50c9e79e61030675520a393b4af23685d24ea018/awscrt-0.32.2-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5ec4d8200eb0158b60e35fbb65faa96ef1eb7763f6ce1192827886ee24c6df99", size = 4051532, upload-time = "2026-04-24T22:59:02.593Z" }, + { url = "https://files.pythonhosted.org/packages/64/aa/b12e721c8148a1bbc58a22cbd204d88d0a6263331049d40c057f9dcd3e11/awscrt-0.32.2-cp313-abi3-win32.whl", hash = "sha256:a81f30a501d2eb6ba52c769cd6ecb3f7005512fba4a533211dc717e1115b0d94", size = 4062484, upload-time = "2026-04-24T22:59:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/9f5f23647465a4fe1b28afce334e54a4264e23b69365024c28955f0c4119/awscrt-0.32.2-cp313-abi3-win_amd64.whl", hash = "sha256:8d731edda20dce5afc15a04731d91136a31779a244672d1f0a292a8b04aa0fd3", size = 4220425, upload-time = "2026-04-24T22:59:05.627Z" }, + { url = "https://files.pythonhosted.org/packages/dd/76/18077410c1d7b524a7a7486550bc969218f6575288f66e285157dc78e143/awscrt-0.32.2-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:f2a085d0dd4eef974f2ae5467ae3717d2fc08dd8cd508c4fd7a5acb658c68616", size = 3414715, upload-time = "2026-04-24T22:59:07.01Z" }, + { url = "https://files.pythonhosted.org/packages/d3/12/465d72ea6afffc7829f7f48f408a707d4dab9e3f2b694f4e0e63e011478a/awscrt-0.32.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8a4e52f7312e5e435461119aa903f6424e9996d93a040101fb1eb7b9c4e58dc", size = 4028634, upload-time = "2026-04-24T22:59:08.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ee/2e9d3716cf6a24f30b85af24691d473c913c119dc996bcd8c9b2b3fe2c17/awscrt-0.32.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cb3c858e96ba023de691a3b6478bed9fb59085433042dff78c42e59eed19cf2", size = 4311846, upload-time = "2026-04-24T22:59:10.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ce/aa08aad92e48929cfe243ed7da5cf039fbb137c391e84af79e88c848a37a/awscrt-0.32.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:7f92b8f40a104a2a87ea5f428b3799220666ec1450b3a90665867d3715749e91", size = 3952242, upload-time = "2026-04-24T22:59:11.632Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/09ad98aa7dae9cd3ad578c7721b64e9da03f9a5ed444e518c63e82db05a9/awscrt-0.32.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:9fd2dbca02f4e22fb5c02d1505327e6e6e9320dbe8ca80fc033cbbb29ed8631a", size = 4187982, upload-time = "2026-04-24T22:59:14.477Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/897b1ad519d83a837d721f4e8a87d98e6f36e5b985fa697f3ffed512a8eb/awscrt-0.32.2-cp313-cp313t-win32.whl", hash = "sha256:023a2f4595804a0f1d61ab49b64dda5612be9bfbe9b13759331e8e31658dda3f", size = 4111958, upload-time = "2026-04-24T22:59:15.857Z" }, + { url = "https://files.pythonhosted.org/packages/71/54/6cca298e8acb6769c8078b39bedbc507f17a3f7fe6ea768d8256d584d4ed/awscrt-0.32.2-cp313-cp313t-win_amd64.whl", hash = "sha256:a2f513f0fb3aafdf7cdf29d7f6f0c46bf4cc7880380c86e88635b7818565e76d", size = 4271679, upload-time = "2026-04-24T22:59:17.425Z" }, +] + [[package]] name = "azure-cognitiveservices-speech" version = "1.48.2" @@ -768,6 +820,9 @@ apps = [ { name = "streamlit" }, { name = "streamlit-diff-viewer" }, ] +bedrock-s2s = [ + { name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12'" }, +] dev = [ { name = "mypy" }, { name = "pre-commit" }, @@ -784,6 +839,7 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "assemblyai", specifier = ">=0.17.0" }, { name = "audioread", marker = "extra == 'apps'", specifier = ">=3.1" }, + { name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12' and extra == 'bedrock-s2s'", specifier = ">=0.7.0" }, { name = "azure-cognitiveservices-speech", specifier = ">=1.31.0" }, { name = "cartesia", specifier = ">=1.0.0" }, { name = "deepgram-sdk", specifier = ">=7" }, @@ -827,7 +883,7 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.23.0" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["apps", "dev"] +provides-extras = ["apps", "bedrock-s2s", "dev"] [[package]] name = "fastapi" @@ -1239,14 +1295,14 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "filelock", marker = "python_full_version < '3.13'" }, - { name = "fsspec", marker = "python_full_version < '3.13'" }, - { name = "hf-xet", marker = "(python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'arm64') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pyyaml", marker = "python_full_version < '3.13'" }, - { name = "requests", marker = "python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ @@ -1261,15 +1317,15 @@ resolution-markers = [ "python_full_version >= '3.13'", ] dependencies = [ - { name = "filelock", marker = "python_full_version >= '3.13'" }, - { name = "fsspec", marker = "python_full_version >= '3.13'" }, - { name = "hf-xet", marker = "(python_full_version >= '3.13' and platform_machine == 'AMD64') or (python_full_version >= '3.13' and platform_machine == 'aarch64') or (python_full_version >= '3.13' and platform_machine == 'amd64') or (python_full_version >= '3.13' and platform_machine == 'arm64') or (python_full_version >= '3.13' and platform_machine == 'x86_64')" }, - { name = "httpx", marker = "python_full_version >= '3.13'" }, - { name = "packaging", marker = "python_full_version >= '3.13'" }, - { name = "pyyaml", marker = "python_full_version >= '3.13'" }, - { name = "tqdm", marker = "python_full_version >= '3.13'" }, - { name = "typer", marker = "python_full_version >= '3.13'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.13'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872, upload-time = "2026-02-26T15:35:32.745Z" } wheels = [ @@ -1294,6 +1350,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "ijson" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/b31f040a8764336a11152e474a7abcb3782fedb0d1cdf78f442b82878c56/ijson-3.5.1.tar.gz", hash = "sha256:af40bd1a85f55db0b8b30715c858761306bd92d5590148636f75c3309e6e76bd", size = 69913, upload-time = "2026-07-06T17:37:42.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/d3/16d1595d3ef4743fc55129211bc52f52d59c582d0b7be045d8c04be0ae0c/ijson-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2aa9d0cf21d4de89fb633e5ec27e9ad02c3f9a4ffa3940d120b23b8aed3acffc", size = 89069, upload-time = "2026-07-06T17:36:15.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/ddba126e2d46cf3b86ad762aeb5e0a02ce0ebc6e4529fe7d06eecb217844/ijson-3.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:05eba5268a38809ba1c3dbfa44ea67336e2c353fc11768acc9c6442fe0ccac50", size = 60697, upload-time = "2026-07-06T17:36:16.66Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/444d8d00a4506a79fc5544614106fa48d5f6f7049511148d8b6cddb8e9d7/ijson-3.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:40ddd236c80a667dd6a1f6b625d18ddac68b8719ff795761b7542f2e1f78e4a4", size = 60747, upload-time = "2026-07-06T17:36:17.927Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/bc07831e646aebcc91a7bad9c5a0bf7c3f3395f0b10599e021667a3777f1/ijson-3.5.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e6cf9e49902f28af7a2e2f8b35c201195c0f0d5c170a5786e0c0a1b8492a4e37", size = 132095, upload-time = "2026-07-06T17:36:19.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/b4547461d75db40744616e40c0a06cf2f46a14e60742f6d12510f4612985/ijson-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ee1e6d59c800aa819952f6cb5ff08707ecd576b29cc9c3d00e33c2b371a92ce", size = 138790, upload-time = "2026-07-06T17:36:20.22Z" }, + { url = "https://files.pythonhosted.org/packages/a7/30/7ecba8377509eaea2666db5b39a1a99e23f5e3e1e7ee371ec366cbfc4f7c/ijson-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:affb85eb75fa03a21d1f790bbf26a0e66e5701672062a30dc5c3c6a29c5c0a63", size = 135233, upload-time = "2026-07-06T17:36:21.252Z" }, + { url = "https://files.pythonhosted.org/packages/38/36/0679010904b24398336b3099b09ccb1daa41c534e7cb0931e89d5fcdbee4/ijson-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3060b141ef758be3742315d44476109460c265b88247e3a4e479949f8b134eac", size = 138832, upload-time = "2026-07-06T17:36:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/b0/90/a40f971e78191e423c7b3a23756f37c3a51c27aadd7769b3fb1816e0044d/ijson-3.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ffba9bce60be21b496afc67a05ab8e3f431f87f0282fd6ce3c62004c951a1428", size = 133313, upload-time = "2026-07-06T17:36:23.405Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/b012c347d3ab011c0c4f7988dc6e85b83eaab59df1aec089f5db0e7b29c5/ijson-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170cc4c209f57decc9b7ee5fd340f2a1602d54020fa222846482ff1c99e88fdc", size = 135706, upload-time = "2026-07-06T17:36:24.464Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/3eacb96124e78271f4e648c6ce36f9ce15ce2cef2afb6f8dc6e213e43979/ijson-3.5.1-cp311-cp311-win32.whl", hash = "sha256:6d581a071dae8dbee61f8d962e892787707bad6e641e2f6fb30dd89d3e896939", size = 52221, upload-time = "2026-07-06T17:36:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1a/19eff8576da0b46fa4a5c8751536ea27ab34c44b2609b2bcded9d7808d42/ijson-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:1356bca96d015948b601b013defb2d5631e4330e8f5880e4d7c933d472a90c34", size = 54641, upload-time = "2026-07-06T17:36:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/c7/80/86b28f28ebf190fffd4f46790e065311e2758b55d8e6bbd33d92e9a49448/ijson-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2b83b24be73f0c7a301807a4c3081939524421c7ae1556eb6eac7cff50ddfa7", size = 53954, upload-time = "2026-07-06T17:36:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6e/f3ded1ebb85ccc89a30f7b10a0076f30db70ae1d1e0b6423ff93c57b7539/ijson-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee60c7741012671867678eae71c51872cac938b76f3d4ca40a778e6c361774d2", size = 88643, upload-time = "2026-07-06T17:36:28.529Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f2/18f14a1d79ef4898e746b4f50dcdbe60abab317cc2bd8390f043b9553c4e/ijson-3.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:11c1d7d36a13054b5872ecd5d745dc4009d9abdbcba2312de69e66c2f92a46d2", size = 60611, upload-time = "2026-07-06T17:36:29.597Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/6e3e591324fd4c7a7a9e1bc23548bacbd84c0d91766b71f09f13e945e7e9/ijson-3.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9517efbe6604bce16f3e50d49b0cd1bdc58917f98cf2eab026599c5c0422991", size = 60447, upload-time = "2026-07-06T17:36:30.747Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a5/9af7be670381ddac26dd55107ed0110b50f5161673b053311db67f510dcc/ijson-3.5.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea4fd7bec203a600b1cc88a492dfe6b75ce4b1b87488a66adcd5406022213f64", size = 139092, upload-time = "2026-07-06T17:36:31.749Z" }, + { url = "https://files.pythonhosted.org/packages/41/fb/f9c1664d75467453e6bd4e5f9cd2211b730b09e049445ab64cbac68cc6a3/ijson-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350caea815e53151994b597abc80cf669454276b5ac6aadcec69ef6d48f7e90b", size = 149921, upload-time = "2026-07-06T17:36:32.912Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/d20b1c49c4aa7cc6644131e2e57192b45346ef4816566ed1cd9fd05bae38/ijson-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4fcebfe1685bb7ba06a8255a5d428ea6b4b895d7acf979cb637d8bbc9db2f47", size = 149848, upload-time = "2026-07-06T17:36:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fc/5baa710869f5ab939e6233583ced1546889b55c35f35b844c518ac10abc3/ijson-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d78f362f51c8691798758a9e6ac3c9d385ee1228cb82987c91562a2fae235cd3", size = 150810, upload-time = "2026-07-06T17:36:35.19Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/a12b3d987a5c1677b04557c6f9b9feb7e04b7d4171e9a344856cb9136e9b/ijson-3.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0b184180d45f85fd4479659582749b109e49f4a29c21ac700ccc9c2280fe015e", size = 142989, upload-time = "2026-07-06T17:36:36.23Z" }, + { url = "https://files.pythonhosted.org/packages/ed/63/1026c535671fc334fc85aeb78f0945c825e7a338575edc753c0f455459ae/ijson-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e353891d33a2e6aa5caf72c2a5fbadd7a46f5f9b32dcfd0c84113b2444c255b8", size = 151702, upload-time = "2026-07-06T17:36:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/cb/af/b58aa3a2bf4d31c388ea78b49826605f60932891ce97e404d196766b4ea3/ijson-3.5.1-cp312-cp312-win32.whl", hash = "sha256:936f28671f018f8ac4d3f003ae9fa01d0467ab4ef4cfd0c97f23beda485b61c6", size = 52613, upload-time = "2026-07-06T17:36:38.345Z" }, + { url = "https://files.pythonhosted.org/packages/04/66/ce70a92949c2a753dad91fdd5761dc14f3a44517e80cfc3c26612982ed61/ijson-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:322c783f3ee0c6b383bbd4db88370b10172168808cc2a0bf811f1253f7435602", size = 54729, upload-time = "2026-07-06T17:36:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/e17784240c9cf1d58de2f2853ebaf9cc54f6bce117a1f12a6150bbb4a5aa/ijson-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e2ac204b59f09e38e16d277f906240e9fd38780e42076599419265af183dc4b4", size = 53714, upload-time = "2026-07-06T17:36:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c0/5384ccf4fc497ae3dc79a5a28561b05518b503ade29daf3898168d640406/ijson-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3c0556d628443d3e871f414855313b2ae6cd9faa0104de3316bd8db03aab1589", size = 88652, upload-time = "2026-07-06T17:36:41.278Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/58769b8b6d614adb15c2c938c77bcdbfadfba8b1d21a98b5b09cb8961adc/ijson-3.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12aa7fcf46f0fdc8e9e7cf37541e1dc20ac3f9243a23f4d346ab5395f72b0fe2", size = 60607, upload-time = "2026-07-06T17:36:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/db/4a/8322c2824c24184880587bbca45531127a21a4b3bfc897f13427fea02424/ijson-3.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a96066d8c12a18ce2fa90579f2bbf991377cb71725874932e4a5d855226c162a", size = 60447, upload-time = "2026-07-06T17:36:43.791Z" }, + { url = "https://files.pythonhosted.org/packages/f4/43/7bdca8f733c45ce97f61a64fadd3e51d255c4c9b467345cbf71ccc7bb368/ijson-3.5.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a19413a092d458a57aaa574fec08e265851d3b5c6e018377f426cd5e70b91280", size = 138889, upload-time = "2026-07-06T17:36:45.081Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/e8a2e63700ab1d63aaf3fa38c454f8178eaa5b80a6d7c019d1d61b490a6c/ijson-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65974568748678165d7e90e3e7ce2f7c233cfe4de6c37fbb0760941c97e14632", size = 149933, upload-time = "2026-07-06T17:36:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/d9/56/640a4d980f7f2c11e399a7fd5ccb9e3d3c9e1dec3a1d5a10024570697c25/ijson-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bad5d55c99c89de8cd0a4cded51f86427ba3353c4dccca37ec2e32e06f26b437", size = 149857, upload-time = "2026-07-06T17:36:47.309Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a1/c953e22c83992b69ae538a83b3678d28768f1a48042fc7794733423a5ce7/ijson-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a38d503ce343952e88edfd9a27296a4ec96af7073a9db58b3df6233367f75fc", size = 151141, upload-time = "2026-07-06T17:36:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ab/8fe5b7269b140e6e5f8837a33ce980fd9b67c70d0f8114289ed1cea4dace/ijson-3.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2f41982c73896acab4a2a14faa14e152e444bd69f37c3139204429fd3fe65a10", size = 143112, upload-time = "2026-07-06T17:36:50.353Z" }, + { url = "https://files.pythonhosted.org/packages/78/f3/23d1284edcde50ba337ddfba5b5d59f8273084d98b28af94715e73dd2b64/ijson-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3321fede2b638d400de0036889a3a25c3bb689feb8df45e70a393346aad6194f", size = 152184, upload-time = "2026-07-06T17:36:51.536Z" }, + { url = "https://files.pythonhosted.org/packages/82/4e/df61be89dd295e4da722ec96ba03b1765bcb2becdaaaede9c96a7d2365b6/ijson-3.5.1-cp313-cp313-win32.whl", hash = "sha256:af6ddbd10ac9bce87a835f2de3ec61455ec435c54e7e0ba7b17c31c66de6f164", size = 52607, upload-time = "2026-07-06T17:36:52.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/03e5dbd3ef7e0cee06fbef0f87b91d7ce1c07fae9b5a1b0ca8b895de62c4/ijson-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:1de3de278b0ffb40338374ad2a730e1c56f933e0706b1815ebeb07b82239b1a3", size = 54730, upload-time = "2026-07-06T17:36:53.526Z" }, + { url = "https://files.pythonhosted.org/packages/38/30/4f37076c88a96a1a5e44df38b59fade4f59eaef87ef8b5162d55b2d426d5/ijson-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:c8a36a19b92cb7172c6448ab94f446033cfa3129dc4894aebe205f96b3fabf42", size = 53719, upload-time = "2026-07-06T17:36:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/49/ea/f42470cc773c8686dd0823da8aefc31a138cd9aea1ad476d43c8293068da/ijson-3.5.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:077b1b0bcb6a622d460c6674fe6647c7af5a3b06503e1996d1efcf9f78c94512", size = 57830, upload-time = "2026-07-06T17:37:37.005Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/64c61edab2c5ecf42a524146a70fa6171c8cf3960b947fb4c5f175660cb3/ijson-3.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e8dbf71b21e65cb7f0d4d387c07fe73be820168070c3be05a0763a80f424f1c7", size = 57325, upload-time = "2026-07-06T17:37:38.017Z" }, + { url = "https://files.pythonhosted.org/packages/9f/5b/553ea8f14dfc756d6b6c9be2e2231ab44877ce96408eb9da3bb3f11ddd13/ijson-3.5.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7c5025a820f36f3e0e64f4b0232b338c690664c12b497e205cf64dcc64fc12", size = 71344, upload-time = "2026-07-06T17:37:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3e/0248fd00746731074ca01365a25d8aa3c4d54642c8a14490d94f7550bda9/ijson-3.5.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa7a2c94e43c02e0482088e6ff997e2bd7b9a76e6f1d0fd70891b4b5ff51318f", size = 71335, upload-time = "2026-07-06T17:37:39.965Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b9/1f1259546cc875adad240c468515f428d3a79b3def3ced17be3cdfe29146/ijson-3.5.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b5eef70240e9734c5a2fb5cc3742cae411fc833a66b9a50722b9eedb1e27de", size = 68728, upload-time = "2026-07-06T17:37:40.928Z" }, + { url = "https://files.pythonhosted.org/packages/ea/02/aafbf0c3e1468c7c0f607065363b49c381de7e4bb43ae6674684a3fafe92/ijson-3.5.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b75b6bf4b0dbb0df24947db6722cd5723ce8d6e6b13fddbfc98db312ba82237", size = 54922, upload-time = "2026-07-06T17:37:41.879Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -1612,7 +1718,7 @@ name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.13'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -2856,8 +2962,8 @@ name = "rich" version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.13'" }, - { name = "pygments", marker = "python_full_version >= '3.13'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ @@ -3096,6 +3202,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smithy-aws-core" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aws-sdk-signers" }, + { name = "smithy-core" }, + { name = "smithy-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/a8/37bfde59519f45d2047d0033b791aca6574d867aaf57bb56a6de42ab5c26/smithy_aws_core-0.7.0.tar.gz", hash = "sha256:34e82d09fc808acd5ffc80f03828d0609c6a211f49f0884dc6ee7ca095a1b6af", size = 15670, upload-time = "2026-06-23T04:04:50.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/54/2d06dd9a3972a380d71bb8c3312e317aa8f1ea68dd28cffc06955ccf0220/smithy_aws_core-0.7.0-py3-none-any.whl", hash = "sha256:6c60c8fbb9431c60e80ea7f2d37e7ae48409cc1541f587fe073f202eca067e92", size = 24894, upload-time = "2026-06-23T04:04:49.349Z" }, +] + +[package.optional-dependencies] +eventstream = [ + { name = "smithy-aws-event-stream" }, +] +json = [ + { name = "smithy-json" }, +] + +[[package]] +name = "smithy-aws-event-stream" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smithy-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/0e/6efb3a4ed92c0f1ada6de060ac92e7115a1e34d0ab1fb99a6056734a88ea/smithy_aws_event_stream-0.3.0.tar.gz", hash = "sha256:a0e227367a973144e205a075d0a424f95c92f26656a1018d08900da2ae547c49", size = 12818, upload-time = "2026-05-05T18:04:14.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/b4/c4c4a9aa5a4cf3ee285f1bd71999441177c651d911a82a6e64c57b6e3e65/smithy_aws_event_stream-0.3.0-py3-none-any.whl", hash = "sha256:8b505cc28230e4fe9c5e025333209b44ca2db560451ac9ed9a7d74939edf4413", size = 15845, upload-time = "2026-05-05T18:04:13.351Z" }, +] + +[[package]] +name = "smithy-core" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/45/688d52c61cd4d843bb230694259e91d4c7d6954eeecbadf452a168001d45/smithy_core-0.6.0.tar.gz", hash = "sha256:ba2e5d860d716aff75004a23f53e09dfaca3e2b94f8a00c1f76dcb355b769ce0", size = 52095, upload-time = "2026-06-23T04:04:44.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/b6/06795faa9844b9667ae492e6293370393e19e7f0c2df8da1b4bf7e5f6ed9/smithy_core-0.6.0-py3-none-any.whl", hash = "sha256:51e347ed309d60ab9d36b783dbf88de614c460d51bec79d39cd403956b00f063", size = 66879, upload-time = "2026-06-23T04:04:43.596Z" }, +] + +[[package]] +name = "smithy-http" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smithy-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/58/5a772d212e066d6fc1398946c4aae19bcdaa75209879d776f641b6a06b5b/smithy_http-0.4.2.tar.gz", hash = "sha256:50d11b6a55e42448450a01e3d0f605ccee65a72abf52d02eed82862a15be5937", size = 29616, upload-time = "2026-06-23T04:04:45.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/3e/7b2464d40893bec0b5d1f479d25116d4aa09f9f66536b4c4b3126202215d/smithy_http-0.4.2-py3-none-any.whl", hash = "sha256:a158f107e9fab925289d20772c2e38b0bba94e55c05d0edc9290310f22a60454", size = 41025, upload-time = "2026-06-23T04:04:46.764Z" }, +] + +[package.optional-dependencies] +awscrt = [ + { name = "awscrt" }, +] + +[[package]] +name = "smithy-json" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ijson" }, + { name = "smithy-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/6c/418b5687d8933b7a135d5e1a98c61fe814b98f72517dbae0e666860cb876/smithy_json-0.2.3.tar.gz", hash = "sha256:686e9b55a36dacb08e472732b358573ef78009055e05e9fce2e806d61490b2b3", size = 7805, upload-time = "2026-06-23T04:04:47.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/14/eabb26b355415bcd9feef27fb5b18f1dad3fabd4208cfcbaf152025fa9ae/smithy_json-0.2.3-py3-none-any.whl", hash = "sha256:594e1bbe3d480963237f8fd0fc648dbd4e988b4503fea90157b5f07706796327", size = 10252, upload-time = "2026-06-23T04:04:48.46Z" }, +] + [[package]] name = "smmap" version = "5.0.2" @@ -3159,8 +3338,8 @@ name = "standard-aifc" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, + { name = "standard-chunk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } wheels = [ @@ -3181,7 +3360,7 @@ name = "standard-sunau" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } wheels = [ @@ -3462,10 +3641,10 @@ name = "typer" version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "python_full_version >= '3.13'" }, - { name = "click", marker = "python_full_version >= '3.13'" }, - { name = "rich", marker = "python_full_version >= '3.13'" }, - { name = "shellingham", marker = "python_full_version >= '3.13'" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [