diff --git a/plugins/telnyx/README.md b/plugins/telnyx/README.md index dd4564e8b..2a8c0c19e 100644 --- a/plugins/telnyx/README.md +++ b/plugins/telnyx/README.md @@ -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 @@ -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`. + ## Examples See [examples/](examples/) for minimal inbound and outbound Telnyx phone @@ -189,5 +207,6 @@ payload = pcm_to_pcmu(pcm) ## Dependencies - vision-agents +- vision-agents-plugins-openai - numpy - fastapi diff --git a/plugins/telnyx/pyproject.toml b/plugins/telnyx/pyproject.toml index 961aedd0e..6a1957830 100644 --- a/plugins/telnyx/pyproject.toml +++ b/plugins/telnyx/pyproject.toml @@ -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", @@ -33,6 +34,7 @@ include = ["/vision_agents"] [tool.uv.sources] vision-agents = { workspace = true } +vision-agents-plugins-openai = { workspace = true } [dependency-groups] dev = [ diff --git a/plugins/telnyx/tests/test_telnyx_llm.py b/plugins/telnyx/tests/test_telnyx_llm.py new file mode 100644 index 000000000..2689012a5 --- /dev/null +++ b/plugins/telnyx/tests/test_telnyx_llm.py @@ -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() + + +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}" + ) diff --git a/plugins/telnyx/vision_agents/plugins/telnyx/__init__.py b/plugins/telnyx/vision_agents/plugins/telnyx/__init__.py index cf5b476c1..14df8c06d 100644 --- a/plugins/telnyx/vision_agents/plugins/telnyx/__init__.py +++ b/plugins/telnyx/vision_agents/plugins/telnyx/__init__.py @@ -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", diff --git a/plugins/telnyx/vision_agents/plugins/telnyx/llm.py b/plugins/telnyx/vision_agents/plugins/telnyx/llm.py new file mode 100644 index 000000000..efda03958 --- /dev/null +++ b/plugins/telnyx/vision_agents/plugins/telnyx/llm.py @@ -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) diff --git a/uv.lock b/uv.lock index d0db9afe3..957b77b5a 100644 --- a/uv.lock +++ b/uv.lock @@ -7539,6 +7539,7 @@ dependencies = [ { name = "fastapi" }, { name = "numpy" }, { name = "vision-agents" }, + { name = "vision-agents-plugins-openai" }, ] [package.dev-dependencies] @@ -7553,6 +7554,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "vision-agents", editable = "agents-core" }, + { name = "vision-agents-plugins-openai", editable = "plugins/openai" }, ] [package.metadata.requires-dev]