Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions examples/voice_turn_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,41 @@

from __future__ import annotations

import asyncio
import os
import random
from datetime import datetime, timezone

from timbal import Agent
from timbal.core.tool import Tool
from timbal.voice import resolve_turn_detector

_MODE = os.environ.get("TIMBAL_VOICE_TURN_DETECTOR", "heuristic").strip().lower()


async def get_datetime() -> str:
"""Get the current UTC date and time (slow on purpose for voice tool-call testing)."""
await asyncio.sleep(random.uniform(3.0, 5.0))
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")


agent = Agent(
name="voice_turn_modes",
model=os.environ.get("TIMBAL_VOICE_DEMO_MODEL", "openai/gpt-4o-mini"),
model=os.environ.get("TIMBAL_VOICE_DEMO_MODEL", "groq/llama-3.1-8b-instant"),
max_tokens=1024, # required when switching to Anthropic from the playground
system_prompt=(
"You are a concise voice assistant. Keep replies to 1–2 short sentences. "
"When the user asks for the date and/or time, you MUST call get_datetime "
"and then speak the returned value clearly (include both date and time). "
"Never claim you cannot tell the time. "
f"(turn_detector mode: {_MODE})"
),
tools=[],
tools=[
Tool(
handler=get_datetime,
description="Return the current UTC date and time. Always use this for date/time questions.",
)
],
)

# Server reads this on startup (instance or mode name). Client WS JSON cannot override it.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ server = [
voice = [
"huggingface-hub>=0.28.0",
"onnxruntime>=1.20.0",
"transformers>=4.40.0",
]
all = [
"timbal[codegen]",
Expand Down
3 changes: 3 additions & 0 deletions python/tests/server/test_voice_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
class TestDefaultVoiceConfigFromEnv:
def test_defaults_when_unset(self) -> None:
cfg = voice_routes.default_voice_config_from_env()
assert cfg["stt_provider"] == "elevenlabs"
assert cfg["stt_model"] == "scribe_v2_realtime"
assert cfg["tts_model"] == "eleven_flash_v2_5"
assert cfg["voice"] == voice_routes._DEFAULT_VOICE_ID
Expand All @@ -33,10 +34,12 @@ def test_defaults_when_unset(self) -> None:
assert cfg["tts_extra"]["auto_mode"] is True

def test_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TIMBAL_STT_PROVIDER", "deepgram")
monkeypatch.setenv("TIMBAL_STT_MODEL", "custom_stt")
monkeypatch.setenv("TIMBAL_TTS_MODEL", "custom_tts")
monkeypatch.setenv("TIMBAL_VOICE_LANGUAGE", "en")
cfg = voice_routes.default_voice_config_from_env()
assert cfg["stt_provider"] == "deepgram"
assert cfg["stt_model"] == "custom_stt"
assert cfg["tts_model"] == "custom_tts"
assert cfg["language"] == "en"
Expand Down
1 change: 1 addition & 0 deletions python/tests/server/voice_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# Env vars that affect ``default_voice_config_from_env`` / ``merge_voice_config``.
VOICE_ENV_KEYS = (
"TIMBAL_STT_PROVIDER",
"TIMBAL_STT_MODEL",
"TIMBAL_TTS_MODEL",
"ELEVENLABS_VOICE_ID",
Expand Down
14 changes: 14 additions & 0 deletions python/tests/utils/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ def test_empty_string_returns_empty_dict(self):
assert coerce_to_dict("") == {}
assert coerce_to_dict(" ") == {}

def test_nullish_no_arg_tool_inputs(self):
# Groq / OpenAI-compat often emit null for tools with zero parameters.
assert coerce_to_dict(None) == {}
assert coerce_to_dict("null") == {}
assert coerce_to_dict("None") == {}
assert coerce_to_dict("NULL") == {}

def test_json_null_literal_returns_empty_dict(self):
assert coerce_to_dict("null") == {}

def test_literal_eval_fallback(self):
# Python dict literal that isn't valid JSON (single quotes)
assert coerce_to_dict("{'a': 1}") == {"a": 1}
Expand All @@ -177,6 +187,10 @@ def test_unparseable_string_raises(self):
with pytest.raises(ValueError, match="Cannot coerce value to dict"):
coerce_to_dict("not a dict at all!")

def test_non_dict_json_raises(self):
with pytest.raises(ValueError, match="Cannot coerce value to dict"):
coerce_to_dict("[1, 2, 3]")

def test_non_string_non_dict_raises(self):
with pytest.raises(ValueError, match="Cannot coerce value to dict"):
coerce_to_dict(42)
Expand Down
Empty file added python/tests/voice/__init__.py
Empty file.
Loading