Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Breaking Changes

### `sarvam` plugin: drop `sarvam-30b` from supported LLM models (#636)

`sarvam.LLM` no longer accepts `sarvam-30b`. Use `sarvam-m` or `sarvam-105b` instead.

### `deepgram` plugin: TTS defaults to Flux (`/v2/speak`)

`deepgram.TTS` now streams Flux TTS on `wss://api.deepgram.com/v2/speak` and defaults to `flux-haley-en`. Aura model strings (`aura-*`) are rejected with `ValueError`. Call sites that passed an Aura voice must switch to a Flux model (`flux-{voice}-en`). See the [Flux voice catalog](https://developers.deepgram.com/docs/flux-tts/voices).
Expand Down
88 changes: 83 additions & 5 deletions plugins/elevenlabs/tests/test_elevenlabs_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,74 @@
load_dotenv()


class TestElevenLabsSTTCallbacks:
"""Unit coverage for translating provider callbacks into turn events."""

@pytest.fixture
def participant(self) -> Participant:
return Participant({}, user_id="test-user", id="test-user")

@pytest.fixture
def stt(self, participant: Participant) -> elevenlabs.STT:
stt = elevenlabs.STT(api_key="test-key")
stt._current_participant = participant
return stt
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_duplicate_partials_emit_one_turn_started(
self, stt: elevenlabs.STT
) -> None:
stt._on_partial_transcript({"text": "Hello"})
stt._on_partial_transcript({"text": "Hello world"})

turn_events = [
item for item in stt.output.peek() if isinstance(item, TurnStarted)
]
assert len(turn_events) == 1

def test_multiple_committed_utterances_emit_balanced_ordered_turns(
self, stt: elevenlabs.STT
) -> None:
stt._on_partial_transcript({"text": "First"})
stt._on_committed_transcript({"text": "First utterance"})
stt._on_partial_transcript({"text": "Second"})
stt._on_committed_transcript({"text": "Second utterance"})

turn_events = [
item
for item in stt.output.peek()
if isinstance(item, (TurnStarted, TurnEnded))
]
assert len(turn_events) == 4
assert all(
isinstance(turn_events[index], TurnStarted)
and isinstance(turn_events[index + 1], TurnEnded)
for index in range(0, len(turn_events), 2)
)

def test_empty_commit_ends_active_turn(self, stt: elevenlabs.STT) -> None:
stt._on_partial_transcript({"text": "Speech"})
stt._on_committed_transcript({"text": ""})

turn_events = [
item
for item in stt.output.peek()
if isinstance(item, (TurnStarted, TurnEnded))
]
assert len(turn_events) == 2
assert isinstance(turn_events[0], TurnStarted)
assert isinstance(turn_events[1], TurnEnded)

def test_keepalive_empty_commit_does_not_emit_turn_events(
self, stt: elevenlabs.STT
) -> None:
stt._on_committed_transcript({"text": ""})
stt._on_committed_transcript({"text": " "})

assert not any(
isinstance(item, (TurnStarted, TurnEnded)) for item in stt.output.peek()
)


class TestElevenLabsSTT:
"""Integration tests for ElevenLabs Scribe v2 STT"""

Expand Down Expand Up @@ -87,14 +155,24 @@ async def test_turn_detection_enabled(self, stt):

@pytest.mark.integration
async def test_turn_events_emitted(self, stt, mia_audio_16khz, participant):
"""One TurnStarted and exactly one TurnEnded per utterance."""
"""Every provider-detected utterance has ordered start and end events."""
await stt.process_audio(mia_audio_16khz, participant=participant)

items = await stt.output.collect(timeout=10.0)
turn_started = [i for i in items if isinstance(i, TurnStarted)]
turn_ended = [i for i in items if isinstance(i, TurnEnded)]
assert len(turn_started) == 1
assert len(turn_ended) == 1
turn_events = [
item for item in items if isinstance(item, (TurnStarted, TurnEnded))
]
turn_started = [item for item in turn_events if isinstance(item, TurnStarted)]
turn_ended = [item for item in turn_events if isinstance(item, TurnEnded)]

assert turn_started
assert len(turn_started) == len(turn_ended)
assert all(
isinstance(turn_events[index], TurnStarted)
and isinstance(turn_events[index + 1], TurnEnded)
for index in range(0, len(turn_events), 2)
)
assert all(event.participant == participant for event in turn_events)

@pytest.mark.integration
async def test_multiple_audio_segments(
Expand Down
96 changes: 77 additions & 19 deletions plugins/huggingface/tests/test_transformers_vlm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Tests for TransformersVLM - local vision-language model inference."""

import asyncio
import fractions
import os
import threading
import time
from unittest.mock import MagicMock

import numpy as np
Expand All @@ -11,7 +14,6 @@
from conftest import skip_blockbuster, skip_if_huggingface_model_unavailable
from vision_agents.testing import collect_simple_response
from vision_agents.core.agents.conversation import InMemoryConversation
from vision_agents.core.llm.llm import LLMResponseFinal
from vision_agents.plugins.huggingface.transformers_vlm import (
TransformersVLM,
VLMResources,
Expand Down Expand Up @@ -122,6 +124,40 @@ async def test_generation_error(self, vlm, conversation):
assert final.text == ""
assert deltas == []

async def test_interrupt_stops_in_flight_generation(self, vlm):
generation_started = threading.Event()
generated_token_counts: list[int] = []

def cancellable_generate(*args, **kwargs):
input_ids = kwargs["input_ids"]
output = input_ids.clone()
stopping_criteria = kwargs["stopping_criteria"]
generation_started.set()

for _ in range(500):
if stopping_criteria(output, None):
break
output = torch.cat(
(output, torch.ones((1, 1), dtype=output.dtype)), dim=1
)
time.sleep(0.001)

generated_token_counts.append(output.shape[-1] - input_ids.shape[-1])
return output

vlm._resources.model.generate.side_effect = cancellable_generate
response_task = asyncio.create_task(
collect_simple_response(vlm.simple_response(text="describe"))
)

started = await asyncio.to_thread(generation_started.wait, 1)
assert started
await vlm.interrupt()

_, final = await asyncio.wait_for(response_task, timeout=1)
assert final.text == "A cat on a couch"
assert generated_token_counts[0] < 20

async def test_processor_fallback(self, vlm):
"""When apply_chat_template fails, falls back to direct processor call."""
processor = vlm._resources.processor
Expand Down Expand Up @@ -297,22 +333,44 @@ async def test_interrupt_stops_generation(self):

vlm._frame_buffer.append(_random_video_frame())

deltas = []
final = None
async for item in vlm.simple_response(
text="Describe in extreme detail every single object you can see"
):
if isinstance(item, LLMResponseFinal):
final = item
else:
deltas.append(item)
if len(deltas) == 1:
await vlm.interrupt()

assert final is not None
# Without interrupt the response would run for hundreds of tokens.
# With interrupt fired after the first delta, the rest of the run
# produces only a handful more tokens before generation exits.
assert len(deltas) < 20
generation_started = threading.Event()
generated_token_counts: list[int] = []
original_generate = resources.model.generate

def signaling_generate(*args, **kwargs):
generation_started.set()
output = original_generate(*args, **kwargs)
generated_token_counts.append(
output.shape[-1] - kwargs["input_ids"].shape[-1]
)
return output

setattr(resources.model, "generate", signaling_generate)

response_task = asyncio.create_task(
collect_simple_response(
vlm.simple_response(
text="Describe in extreme detail every single object you can see"
)
)
)

vlm.unload()
try:
started = await asyncio.to_thread(generation_started.wait, 10)
assert started, "model.generate did not start within 10 seconds"

await vlm.interrupt()
_, final = await asyncio.wait_for(response_task, timeout=10)

assert final is not None
assert generated_token_counts
assert generated_token_counts[0] < 20
finally:
if not response_task.done():
await vlm.interrupt()
try:
await asyncio.wait_for(response_task, timeout=10)
except asyncio.TimeoutError:
response_task.cancel()
await asyncio.gather(response_task, return_exceptions=True)
vlm.unload()
6 changes: 3 additions & 3 deletions plugins/sarvam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ AI models built for Indian languages.
Activity Detection for turn events.
- **TTS**: WebSocket streaming text-to-speech (Bulbul) with configurable
speaker, pace, and language.
- **LLM**: OpenAI-compatible chat completions (Sarvam-30B / Sarvam-105B /
Sarvam-M) via the existing `ChatCompletionsLLM` from the OpenAI plugin.
- **LLM**: OpenAI-compatible chat completions (Sarvam-105B / Sarvam-M) via
the existing `ChatCompletionsLLM` from the OpenAI plugin.

## Installation

Expand All @@ -28,7 +28,7 @@ agent = Agent(
edge=getstream.Edge(),
agent_user=User(name="Sarvam AI"),
instructions="Reply in Hindi or English, whichever the user speaks",
llm=sarvam.LLM(model="sarvam-30b"),
llm=sarvam.LLM(model="sarvam-105b"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
stt=sarvam.STT(language="hi-IN"),
tts=sarvam.TTS(speaker="shubh"),
turn_detection=smart_turn.TurnDetection(),
Expand Down
6 changes: 3 additions & 3 deletions plugins/sarvam/tests/test_sarvam_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ async def test_default_model(self):
assert llm.model == "sarvam-m"

async def test_custom_model(self):
llm = LLM(api_key="sk_test", model="sarvam-30b")
assert llm.model == "sarvam-30b"
llm = LLM(api_key="sk_test", model="sarvam-105b")
assert llm.model == "sarvam-105b"

async def test_base_url_points_to_sarvam(self):
llm = LLM(api_key="sk_test")
Expand All @@ -89,7 +89,7 @@ class TestSarvamLLMIntegration:

@pytest.fixture
async def llm(self):
llm = LLM(model="sarvam-30b")
llm = LLM(model="sarvam-105b")
llm.set_conversation(InMemoryConversation("be friendly", []))
return llm

Expand Down
6 changes: 3 additions & 3 deletions plugins/sarvam/vision_agents/plugins/sarvam/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

SARVAM_BASE_URL = "https://api.sarvam.ai/v1"
DEFAULT_MODEL = "sarvam-m"
SUPPORTED_MODELS = {"sarvam-m", "sarvam-30b", "sarvam-105b"}
SUPPORTED_MODELS = {"sarvam-m", "sarvam-105b"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 4 '\bSUPPORTED_MODELS\b|sarvam-30b' .

Repository: GetStream/Vision-Agents

Length of output: 5457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
fd -t f 'llm\.py$|test.*sarvam|sarvam.*test' . | head -80

printf '%s\n' '--- Sarvam LLM structure ---'
ast-grep outline plugins/sarvam/vision_agents/plugins/sarvam/llm.py

printf '%s\n' '--- Sarvam LLM implementation ---'
cat -n plugins/sarvam/vision_agents/plugins/sarvam/llm.py | sed -n '1,180p'

printf '%s\n' '--- LLM class and constructor usages ---'
rg -n -C 5 'class LLM|def __init__|model not in|SUPPORTED_MODELS|Sarvam LLM|sarvam-30b' \
  plugins/sarvam . -g '*.py' | head -300

Repository: GetStream/Vision-Agents

Length of output: 31989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- base ChatCompletionsLLM constructor ---'
rg -n -C 12 'class ChatCompletionsLLM|def __init__' \
  plugins/openai/vision_agents/plugins/openai agents-core/vision_agents/core/llm/llm.py -g '*.py' | head -240

printf '%s\n' '--- Sarvam LLM tests ---'
cat -n plugins/sarvam/tests/test_sarvam_llm.py | sed -n '1,150p'

printf '%s\n' '--- AST validation probe ---'
python3 - <<'PY'
import ast
from pathlib import Path

sarvam_path = Path("plugins/sarvam/vision_agents/plugins/sarvam/llm.py")
source = sarvam_path.read_text()
tree = ast.parse(source)

class InitVisitor(ast.NodeVisitor):
    def __init__(self):
        self.init = None
    def visit_FunctionDef(self, node):
        if node.name == "__init__":
            self.init = node
        self.generic_visit(node)

visitor = InitVisitor()
visitor.visit(tree)
init = visitor.init
print("SarvamLLM.__init__ contains SUPPORTED_MODELS:", any(
    isinstance(node, ast.Name) and node.id == "SUPPORTED_MODELS"
    for node in ast.walk(init)
))
print("SarvamLLM.__init__ model comparisons:", [
    ast.unparse(node) for node in ast.walk(init)
    if isinstance(node, ast.Compare)
])

base_candidates = list(Path("plugins/openai").rglob("*.py"))
for path in base_candidates:
    text = path.read_text()
    if "class ChatCompletionsLLM" not in text:
        continue
    base_tree = ast.parse(text)
    for node in ast.walk(base_tree):
        if isinstance(node, ast.ClassDef) and node.name == "ChatCompletionsLLM":
            inits = [
                item for item in node.body
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
                and item.name == "__init__"
            ]
            if inits:
                base_init = inits[0]
                print("Base constructor:", path)
                print("Base constructor model comparisons:", [
                    ast.unparse(item) for item in ast.walk(base_init)
                    if isinstance(item, ast.Compare)
                ])
                print("Base constructor model assignments:", [
                    ast.unparse(item) for item in ast.walk(base_init)
                    if isinstance(item, ast.Assign)
                    and "model" in ast.unparse(item)
                ])
PY

Repository: GetStream/Vision-Agents

Length of output: 27706


Reject unsupported Sarvam models at construction.

SarvamLLM.__init__ does not enforce SUPPORTED_MODELS; the base class only assigns self.model. Add a ValueError for sarvam-30b and a rejection test.


_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)

Expand Down Expand Up @@ -97,7 +97,7 @@ class SarvamLLM(ChatCompletionsLLM):
Examples:

from vision_agents.plugins import sarvam
llm = sarvam.LLM(model="sarvam-30b")
llm = sarvam.LLM(model="sarvam-105b")
"""

provider_name = "sarvam"
Expand All @@ -113,7 +113,7 @@ def __init__(

Args:
model: The Sarvam model id. Defaults to ``sarvam-m``. Supported:
``sarvam-m``, ``sarvam-30b``, ``sarvam-105b``.
``sarvam-m``, ``sarvam-105b``.
api_key: Sarvam API key. Defaults to ``SARVAM_API_KEY`` env var.
base_url: API base URL. Defaults to ``https://api.sarvam.ai/v1``.
client: Optional pre-configured ``AsyncOpenAI`` client. Takes
Expand Down