Skip to content

Commit b4c0da8

Browse files
Merge pull request #117 from GetStream/openrouter
[AI-194] Openrouter
2 parents 3d06446 + a426bc2 commit b4c0da8

File tree

12 files changed

+383
-5
lines changed

12 files changed

+383
-5
lines changed

dev.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,14 @@ def mypy_plugins():
9595
"uv run mypy --install-types --non-interactive --exclude 'plugins/.*/tests/.*' plugins"
9696
)
9797

98-
9998
@cli.command()
10099
def check():
101100
"""Run full check: ruff, mypy, and unit tests."""
102101
click.echo("Running full development check...")
103102

104103
# Run ruff
105104
click.echo("\n=== 1. Ruff Linting ===")
106-
run("uv run ruff check .")
105+
run("uv run ruff check . --fix")
107106

108107
# Run mypy on main package
109108
click.echo("\n=== 2. MyPy Type Checking ===")

plugins/openai/vision_agents/plugins/openai/openai_llm.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ async def simple_response(
107107
instructions=instructions,
108108
)
109109

110+
async def create_conversation(self):
111+
if not self.openai_conversation:
112+
self.openai_conversation = await self.client.conversations.create()
113+
114+
def add_conversation_history(self, kwargs):
115+
if self.openai_conversation:
116+
kwargs["conversation"] = self.openai_conversation.id
117+
110118
async def create_response(
111119
self, *args: Any, **kwargs: Any
112120
) -> LLMResponseEvent[OpenAIResponse]:
@@ -119,9 +127,9 @@ async def create_response(
119127
if "stream" not in kwargs:
120128
kwargs["stream"] = True
121129

122-
#if not self.openai_conversation:
123-
# self.openai_conversation = await self.client.conversations.create()
124-
#kwargs["conversation"] = self.openai_conversation.id
130+
# create the conversation if needed and add the required args
131+
await self.create_conversation()
132+
self.add_conversation_history(kwargs)
125133

126134
# Add tools if available - convert to OpenAI format
127135
tools_spec = self._get_tools_for_provider()

plugins/openrouter/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# OpenRouter Plugin
2+
3+
OpenRouter plugin for vision agents. This plugin provides LLM capabilities using OpenRouter's API, which is compatible with the OpenAI API format.
4+
5+
## Note/ Issues
6+
7+
Instruction following doesn't always work with openrouter atm.
8+
9+
## Installation
10+
11+
```bash
12+
uv pip install vision-agents-plugins-openrouter
13+
```
14+
15+
## Usage
16+
17+
```python
18+
from vision_agents.plugins import openrouter, getstream, elevenlabs, cartesia, deepgram, smart_turn
19+
20+
21+
agent = Agent(
22+
edge=getstream.Edge(),
23+
agent_user=User(name="OpenRouter AI"),
24+
instructions="Be helpful and friendly to the user",
25+
llm=openrouter.LLM(
26+
model="anthropic/claude-haiku-4.5", # Can also use other models like anthropic/claude-3-opus
27+
),
28+
tts=elevenlabs.TTS(),
29+
stt=deepgram.STT(),
30+
turn_detection=smart_turn.TurnDetection(
31+
buffer_duration=2.0, confidence_threshold=0.5
32+
)
33+
)
34+
```

plugins/openrouter/example/__init__.py

Whitespace-only changes.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import asyncio
2+
import logging
3+
from uuid import uuid4
4+
5+
from dotenv import load_dotenv
6+
7+
from vision_agents.core import User
8+
from vision_agents.core.agents import Agent
9+
from vision_agents.plugins import openrouter, getstream, elevenlabs, deepgram, smart_turn
10+
11+
load_dotenv()
12+
13+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [call_id=%(call_id)s] %(name)s: %(message)s")
14+
logger = logging.getLogger(__name__)
15+
16+
17+
async def start_agent() -> None:
18+
"""Example agent using OpenRouter LLM.
19+
20+
This example demonstrates how to use the OpenRouter plugin with a Vision Agent.
21+
OpenRouter provides access to multiple LLM providers through a unified API.
22+
23+
Set OPENROUTER_API_KEY environment variable before running.
24+
"""
25+
agent = Agent(
26+
edge=getstream.Edge(),
27+
agent_user=User(name="OpenRouter AI"),
28+
instructions="Be helpful and friendly to the user",
29+
llm=openrouter.LLM(
30+
model="openai/gpt-4o", # Can also use other models like anthropic/claude-3-opus
31+
),
32+
tts=elevenlabs.TTS(),
33+
stt=deepgram.STT(),
34+
turn_detection=smart_turn.TurnDetection(
35+
buffer_duration=2.0, confidence_threshold=0.5
36+
)
37+
)
38+
await agent.create_user()
39+
40+
call = agent.edge.client.video.call("default", str(uuid4()))
41+
await agent.edge.open_demo(call)
42+
43+
with await agent.join(call):
44+
await asyncio.sleep(5)
45+
await agent.llm.simple_response(text="Hello! I'm powered by OpenRouter.")
46+
await agent.finish()
47+
48+
49+
if __name__ == "__main__":
50+
asyncio.run(start_agent())
51+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[project]
2+
name = "openrouter-example"
3+
version = "0.0.0"
4+
requires-python = ">=3.10"
5+
6+
dependencies = [
7+
"python-dotenv>=1.0",
8+
"vision-agents-plugins-openrouter",
9+
"vision-agents-plugins-getstream",
10+
"vision-agents-plugins-elevenlabs",
11+
"vision-agents-plugins-deepgram",
12+
"vision-agents-plugins-cartesia",
13+
"vision-agents-plugins-smart-turn",
14+
"vision-agents",
15+
]
16+
17+
[tool.uv.sources]
18+
"vision-agents-plugins-openrouter" = {path = "..", editable=true}
19+
"vision-agents-plugins-openai" = {path = "../../openai", editable=true}
20+
"vision-agents-plugins-getstream" = {path = "../../getstream", editable=true}
21+
"vision-agents-plugins-elevenlabs" = {path = "../../elevenlabs", editable=true}
22+
"vision-agents-plugins-deepgram" = {path = "../../deepgram", editable=true}
23+
"vision-agents-plugins-cartesia" = {path = "../../cartesia", editable=true}
24+
"vision-agents-plugins-smart-turn" = {path = "../../smart_turn", editable=true}
25+
"vision-agents" = {path = "../../../agents-core", editable=true}
26+

plugins/openrouter/py.typed

Whitespace-only changes.

plugins/openrouter/pyproject.toml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[build-system]
2+
requires = ["hatchling", "hatch-vcs"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "vision-agents-plugins-openrouter"
7+
dynamic = ["version"]
8+
description = "OpenRouter plugin for vision agents"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = "MIT"
12+
dependencies = [
13+
"vision-agents",
14+
"vision-agents-plugins-openai",
15+
]
16+
17+
[project.urls]
18+
Documentation = "https://visionagents.ai/"
19+
Website = "https://visionagents.ai/"
20+
Source = "https://github.com/GetStream/Vision-Agents"
21+
22+
[tool.hatch.version]
23+
source = "vcs"
24+
raw-options = { root = "..", search_parent_directories = true, fallback_version = "0.0.0" }
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["."]
28+
29+
[tool.uv.sources]
30+
vision-agents = { workspace = true }
31+
vision-agents-plugins-openai = { workspace = true }
32+
33+
[dependency-groups]
34+
dev = [
35+
"pytest>=8.4.1",
36+
"pytest-asyncio>=1.0.0",
37+
]
38+

plugins/openrouter/tests/__init__.py

Whitespace-only changes.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Tests for OpenRouter LLM plugin."""
2+
3+
import os
4+
5+
import pytest
6+
from dotenv import load_dotenv
7+
8+
from vision_agents.core.agents.conversation import Message, InMemoryConversation
9+
from vision_agents.core.llm.events import (
10+
LLMResponseChunkEvent,
11+
)
12+
from vision_agents.plugins.openrouter import LLM
13+
14+
load_dotenv()
15+
16+
17+
class TestOpenRouterLLM:
18+
"""Test suite for OpenRouter LLM class."""
19+
20+
def assert_response_successful(self, response):
21+
"""Utility method to verify a response is successful.
22+
23+
A successful response has:
24+
- response.text is set (not None and not empty)
25+
- response.exception is None
26+
27+
Args:
28+
response: LLMResponseEvent to check
29+
"""
30+
assert response.text is not None, "Response text should not be None"
31+
assert len(response.text) > 0, "Response text should not be empty"
32+
assert not hasattr(response, "exception") or response.exception is None, (
33+
f"Response should not have an exception, got: {getattr(response, 'exception', None)}"
34+
)
35+
36+
def test_message(self):
37+
"""Test basic message normalization."""
38+
messages = LLM._normalize_message("say hi")
39+
assert isinstance(messages[0], Message)
40+
message = messages[0]
41+
assert message.original is not None
42+
assert message.content == "say hi"
43+
44+
def test_advanced_message(self):
45+
"""Test advanced message format with image."""
46+
img_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/2023_06_08_Raccoon1.jpg/1599px-2023_06_08_Raccoon1.jpg"
47+
48+
advanced = [
49+
{
50+
"role": "user",
51+
"content": [
52+
{"type": "input_text", "text": "what do you see in this image?"},
53+
{"type": "input_image", "image_url": f"{img_url}"},
54+
],
55+
}
56+
]
57+
messages = LLM._normalize_message(advanced)
58+
assert messages[0].original is not None
59+
60+
@pytest.fixture
61+
async def llm(self) -> LLM:
62+
"""Fixture for OpenRouter LLM with z-ai/glm-4.6 model."""
63+
if not os.environ.get("OPENROUTER_API_KEY"):
64+
pytest.skip("OPENROUTER_API_KEY environment variable not set")
65+
66+
llm = LLM(model="anthropic/claude-haiku-4.5")
67+
llm._conversation = InMemoryConversation("be friendly", [])
68+
return llm
69+
70+
@pytest.mark.integration
71+
async def test_simple(self, llm: LLM):
72+
"""Test simple response generation."""
73+
response = await llm.simple_response(
74+
"Explain quantum computing in 1 paragraph",
75+
)
76+
77+
self.assert_response_successful(response)
78+
79+
@pytest.mark.integration
80+
async def test_native_api(self, llm: LLM):
81+
"""Test native OpenAI-compatible API."""
82+
response = await llm.create_response(
83+
input="say hi", instructions="You are a helpful assistant."
84+
)
85+
86+
self.assert_response_successful(response)
87+
assert hasattr(response.original, "id") # OpenAI-compatible response has id
88+
89+
@pytest.mark.integration
90+
async def test_streaming(self, llm: LLM):
91+
"""Test streaming response."""
92+
streamingWorks = False
93+
94+
@llm.events.subscribe
95+
async def passed(event: LLMResponseChunkEvent):
96+
nonlocal streamingWorks
97+
streamingWorks = True
98+
99+
response = await llm.simple_response(
100+
"Explain quantum computing in 1 paragraph",
101+
)
102+
103+
await llm.events.wait()
104+
105+
self.assert_response_successful(response)
106+
assert streamingWorks, "Streaming should have generated chunk events"
107+
108+
@pytest.mark.integration
109+
async def test_memory(self, llm: LLM):
110+
"""Test conversation memory using simple_response."""
111+
await llm.simple_response(
112+
text="There are 2 dogs in the room",
113+
)
114+
response = await llm.simple_response(
115+
text="How many paws are there in the room?",
116+
)
117+
118+
self.assert_response_successful(response)
119+
assert "8" in response.text or "eight" in response.text.lower(), (
120+
f"Expected '8' or 'eight' in response, got: {response.text}"
121+
)
122+
123+
@pytest.mark.integration
124+
async def test_native_memory(self, llm: LLM):
125+
"""Test conversation memory using native API."""
126+
await llm.create_response(
127+
input="There are 2 dogs in the room",
128+
)
129+
response = await llm.create_response(
130+
input="How many paws are there in the room?",
131+
)
132+
133+
self.assert_response_successful(response)
134+
assert "8" in response.text or "eight" in response.text.lower(), (
135+
f"Expected '8' or 'eight' in response, got: {response.text}"
136+
)
137+
138+
@pytest.mark.integration
139+
async def test_instruction_following(self):
140+
"""Test that the LLM follows system instructions."""
141+
if not os.environ.get("OPENROUTER_API_KEY"):
142+
pytest.skip("OPENROUTER_API_KEY environment variable not set")
143+
144+
pytest.skip("instruction following doesnt always work")
145+
llm = LLM(model="anthropic/claude-haiku-4.5")
146+
llm._set_instructions("Only reply in 2 letter country shortcuts")
147+
148+
response = await llm.simple_response(
149+
text="Which country is rainy, protected from water with dikes and below sea level?",
150+
)
151+
152+
self.assert_response_successful(response)
153+
assert "nl" in response.text.lower(), (
154+
f"Expected 'NL' in response, got: {response.text}"
155+
)
156+

0 commit comments

Comments
 (0)