Skip to content
Closed
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
19 changes: 19 additions & 0 deletions plugins/telnyx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ real-time bidirectional media streaming.
- **Audio Conversion**: PCMU, PCMA, and L16 RTP payload conversion
- **WebSocket Management**: Handle Telnyx WebSocket media events
- **Stream Bridge**: Attach a Telnyx phone participant to a Stream call
- **LLM**: Telnyx Inference via the OpenAI-compatible Chat Completions API

## Installation

Expand Down Expand Up @@ -46,6 +47,23 @@ call.telnyx_stream = stream
await stream.run()
```

## LLM

Telnyx Inference is OpenAI-compatible, so the LLM is a thin wrapper over
`ChatCompletionsLLM` pointed at `https://api.telnyx.com/v2/ai`. Streaming and
tool calling work the same as any other Chat Completions provider.

```python
from vision_agents.plugins import telnyx

llm = telnyx.LLM(model="openai/gpt-4o")
```

Requires `TELNYX_API_KEY` in the environment, or an `api_key` argument.

Model ids come from the Telnyx catalogue at `GET /v2/ai/models` and are not
validated locally. The default is `meta-llama/Llama-3.3-70B-Instruct`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Examples

See [examples/](examples/) for minimal inbound and outbound Telnyx phone
Expand Down Expand Up @@ -189,5 +207,6 @@ payload = pcm_to_pcmu(pcm)
## Dependencies

- vision-agents
- vision-agents-plugins-openai
- numpy
- fastapi
2 changes: 2 additions & 0 deletions plugins/telnyx/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ requires-python = ">=3.10"
license = "MIT"
dependencies = [
"vision-agents",
"vision-agents-plugins-openai",
"numpy>=1.24.0", # capped at <2.0 via workspace override in root pyproject.toml
"cryptography>=44.0.0",
"fastapi>=0.135.1",
Expand All @@ -33,6 +34,7 @@ include = ["/vision_agents"]

[tool.uv.sources]
vision-agents = { workspace = true }
vision-agents-plugins-openai = { workspace = true }

[dependency-groups]
dev = [
Expand Down
95 changes: 95 additions & 0 deletions plugins/telnyx/tests/test_telnyx_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for the Telnyx LLM plugin."""

import os

import pytest
from dotenv import load_dotenv
from vision_agents.core.agents.conversation import InMemoryConversation
from vision_agents.plugins.telnyx import LLM
from vision_agents.testing import collect_simple_response

load_dotenv()
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class TestTelnyxLLM:
"""Unit tests for Telnyx LLM configuration."""

def test_requires_api_key(self, monkeypatch):
monkeypatch.delenv("TELNYX_API_KEY", raising=False)
with pytest.raises(ValueError, match="TELNYX_API_KEY"):
LLM()

async def test_default_model(self):
llm = LLM(api_key="KEY_test")
assert llm.model == "meta-llama/Llama-3.3-70B-Instruct"

async def test_custom_model(self):
llm = LLM(api_key="KEY_test", model="openai/gpt-4o")
assert llm.model == "openai/gpt-4o"

async def test_base_url_points_to_telnyx_inference(self):
llm = LLM(api_key="KEY_test")
assert str(llm._client.base_url).startswith("https://api.telnyx.com/v2/ai")

async def test_bearer_token_used_for_auth(self):
llm = LLM(api_key="KEY_test")
assert llm._client.api_key == "KEY_test"

async def test_provider_name(self):
llm = LLM(api_key="KEY_test")
assert llm.provider_name == "telnyx"

async def test_explicit_client_skips_api_key_requirement(self, monkeypatch):
from openai import AsyncOpenAI

monkeypatch.delenv("TELNYX_API_KEY", raising=False)
client = AsyncOpenAI(api_key="KEY_injected", base_url="https://example.invalid")
llm = LLM(client=client)
assert llm._client is client


@pytest.mark.skipif(not os.getenv("TELNYX_API_KEY"), reason="TELNYX_API_KEY not set")
@pytest.mark.integration
class TestTelnyxLLMIntegration:
"""Integration tests hitting the real Telnyx Inference endpoint."""

@pytest.fixture
async def llm(self):
llm = LLM()
llm.set_conversation(InMemoryConversation("be friendly", []))
return llm

async def test_simple_response(self, llm):
deltas, final = await collect_simple_response(
llm.simple_response("Greet the user in English")
)
assert final.text
assert deltas

async def test_streaming_chunks(self, llm):
deltas, final = await collect_simple_response(
llm.simple_response("List the first 3 prime numbers, separated by commas.")
)
assert final.text
assert len(deltas) > 0, f"No chunks emitted. Response text: {final.text!r}"

async def test_function_calling(self, llm):
calls: list[str] = []

@llm.register_function(description="Probe tool that records invocation")
async def probe_tool(ping: str) -> str:
calls.append(ping)
return f"probe_ok:{ping}"

prompt = (
"Call the tool named 'probe_tool' with the parameter ping='pong' now. "
"After receiving the tool result, reply by returning ONLY the tool result string."
)
_, final = await collect_simple_response(llm.simple_response(prompt))

assert "pong" in calls, (
f"probe_tool was not invoked with ping='pong' (got calls={calls})"
)
assert "probe_ok:pong" in final.text, (
f"Expected 'probe_ok:pong', got: {final.text}"
)
4 changes: 4 additions & 0 deletions plugins/telnyx/vision_agents/plugins/telnyx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,22 @@
telnyx_payload_to_pcm,
)
from .call_registry import TelnyxCall, TelnyxCallRegistry
from .llm import TelnyxLLM
from .media_stream import TelnyxMediaFormat, TelnyxMediaStream, attach_phone_to_call

LLM = TelnyxLLM
CallRegistry = TelnyxCallRegistry
MediaStream = TelnyxMediaStream

__all__ = [
"CallRegistry",
"LLM",
"MediaStream",
"TELNYX_DEFAULT_SAMPLE_RATE",
"TELNYX_L16_SAMPLE_RATE",
"TelnyxCall",
"TelnyxCallRegistry",
"TelnyxLLM",
"TelnyxMediaFormat",
"TelnyxMediaStream",
"attach_phone_to_call",
Expand Down
71 changes: 71 additions & 0 deletions plugins/telnyx/vision_agents/plugins/telnyx/llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Telnyx Inference LLM using the OpenAI-compatible Chat Completions endpoint.

Telnyx serves ``/v2/ai/chat/completions`` with the same request and response
shape as OpenAI, so we point an ``AsyncOpenAI`` client at the Telnyx base URL
and authenticate with the standard bearer token. Streaming, tool calling, and
conversation history are all inherited from :class:`ChatCompletionsLLM`.

The catalogue of served models is fetched at runtime from ``/v2/ai/models``
and changes over time, so model ids are not validated locally.

Docs: https://developers.telnyx.com/api/inference/inference-embedding/post-chat-completions-public-chat-completions-post
"""

import logging
import os
from typing import Optional

from openai import AsyncOpenAI
from vision_agents.plugins.openai import ChatCompletionsLLM

logger = logging.getLogger(__name__)

TELNYX_BASE_URL = "https://api.telnyx.com/v2/ai"
DEFAULT_MODEL = "meta-llama/Llama-3.3-70B-Instruct"


class TelnyxLLM(ChatCompletionsLLM):
"""Telnyx Inference Chat Completions LLM.

Thin wrapper around :class:`ChatCompletionsLLM` that configures the OpenAI
client for Telnyx's OpenAI-compatible inference endpoint.

Examples:

from vision_agents.plugins import telnyx
llm = telnyx.LLM(model="openai/gpt-4o")
"""

provider_name = "telnyx"

def __init__(
self,
model: str = DEFAULT_MODEL,
api_key: Optional[str] = None,
base_url: str = TELNYX_BASE_URL,
client: Optional[AsyncOpenAI] = None,
tools_max_rounds: int = 3,
) -> None:
"""Initialize the Telnyx LLM.

Args:
model: The model id as served by Telnyx Inference, for example
``meta-llama/Llama-3.3-70B-Instruct`` or ``openai/gpt-4o``.
api_key: Telnyx API key. Defaults to the ``TELNYX_API_KEY`` env var.
base_url: API base URL. Defaults to ``https://api.telnyx.com/v2/ai``.
client: Optional pre-configured ``AsyncOpenAI`` client. Takes
precedence over ``api_key`` / ``base_url``.
tools_max_rounds: Max calling rounds for multi-hop tool calls.
"""
resolved_key = (
api_key if api_key is not None else os.environ.get("TELNYX_API_KEY")
)
if client is None and not resolved_key:
raise ValueError(
"TELNYX_API_KEY env var or api_key parameter required for Telnyx LLM"
)

if client is None:
client = AsyncOpenAI(api_key=resolved_key, base_url=base_url)

super().__init__(model=model, client=client, tools_max_rounds=tools_max_rounds)
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading