Skip to content

Commit 300b119

Browse files
committed
feat: Add openrouter integration
1 parent 7a4328e commit 300b119

11 files changed

Lines changed: 1273 additions & 2 deletions

File tree

py/noxfile.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def _pinned_python_version():
7070
"autoevals",
7171
"braintrust_core",
7272
"litellm",
73+
"openrouter",
7374
"opentelemetry-api",
7475
"opentelemetry-sdk",
7576
"opentelemetry-exporter-otlp-proto-http",
@@ -100,6 +101,7 @@ def _pinned_python_version():
100101
GENAI_VERSIONS = (LATEST,)
101102
DSPY_VERSIONS = (LATEST,)
102103
GOOGLE_ADK_VERSIONS = (LATEST, "1.14.1")
104+
OPENROUTER_VERSIONS = (LATEST,)
103105
# temporalio 1.19.0+ requires Python >= 3.10; skip Python 3.9 entirely
104106
TEMPORAL_VERSIONS = (LATEST, "1.20.0", "1.19.0")
105107
PYTEST_VERSIONS = (LATEST, "8.4.2")
@@ -224,11 +226,14 @@ def test_openai_http2_streaming(session):
224226

225227

226228
@nox.session()
227-
def test_openrouter(session):
228-
"""Test wrap_openai with OpenRouter. Requires OPENROUTER_API_KEY env var."""
229+
@nox.parametrize("version", OPENROUTER_VERSIONS, ids=OPENROUTER_VERSIONS)
230+
def test_openrouter(session, version):
231+
"""Test OpenRouter support across wrap_openai and the native OpenRouter SDK integration."""
229232
_install_test_deps(session)
230233
_install(session, "openai")
234+
_install(session, "openrouter", version)
231235
_run_tests(session, f"{WRAPPER_DIR}/test_openrouter.py")
236+
_run_tests(session, f"{INTEGRATION_DIR}/openrouter/test_openrouter.py")
232237

233238

234239
@nox.session()

py/requirements-optional.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ google-adk==1.14.1
77
dspy==3.1.3
88
langsmith==0.7.12
99
litellm==1.82.0
10+
openrouter==0.7.11

py/src/braintrust/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ def is_equal(expected, output):
7373
from .integrations.anthropic import (
7474
wrap_anthropic, # noqa: F401 # type: ignore[reportUnusedImport]
7575
)
76+
from .integrations.openrouter import (
77+
wrap_openrouter, # noqa: F401 # type: ignore[reportUnusedImport]
78+
)
7679
from .logger import *
7780
from .logger import (
7881
_internal_get_global_state, # noqa: F401 # type: ignore[reportUnusedImport]

py/src/braintrust/auto.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
AnthropicIntegration,
1414
ClaudeAgentSDKIntegration,
1515
GoogleGenAIIntegration,
16+
OpenRouterIntegration,
1617
)
1718

1819

@@ -39,6 +40,7 @@ def auto_instrument(
3940
litellm: bool = True,
4041
pydantic_ai: bool = True,
4142
google_genai: bool = True,
43+
openrouter: bool = True,
4244
agno: bool = True,
4345
claude_agent_sdk: bool = True,
4446
dspy: bool = True,
@@ -59,6 +61,7 @@ def auto_instrument(
5961
litellm: Enable LiteLLM instrumentation (default: True)
6062
pydantic_ai: Enable Pydantic AI instrumentation (default: True)
6163
google_genai: Enable Google GenAI instrumentation (default: True)
64+
openrouter: Enable OpenRouter instrumentation (default: True)
6265
agno: Enable Agno instrumentation (default: True)
6366
claude_agent_sdk: Enable Claude Agent SDK instrumentation (default: True)
6467
dspy: Enable DSPy instrumentation (default: True)
@@ -120,6 +123,8 @@ def auto_instrument(
120123
results["pydantic_ai"] = _instrument_pydantic_ai()
121124
if google_genai:
122125
results["google_genai"] = _instrument_integration(GoogleGenAIIntegration)
126+
if openrouter:
127+
results["openrouter"] = _instrument_integration(OpenRouterIntegration)
123128
if agno:
124129
results["agno"] = _instrument_integration(AgnoIntegration)
125130
if claude_agent_sdk:

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .anthropic import AnthropicIntegration
44
from .claude_agent_sdk import ClaudeAgentSDKIntegration
55
from .google_genai import GoogleGenAIIntegration
6+
from .openrouter import OpenRouterIntegration
67

78

89
__all__ = [
@@ -11,4 +12,5 @@
1112
"AnthropicIntegration",
1213
"ClaudeAgentSDKIntegration",
1314
"GoogleGenAIIntegration",
15+
"OpenRouterIntegration",
1416
]
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Test auto_instrument for OpenRouter."""
2+
3+
from braintrust import logger
4+
from braintrust.auto import auto_instrument
5+
from braintrust.test_helpers import init_test_logger
6+
from openrouter import OpenRouter, components
7+
from openrouter.chat import Chat
8+
9+
10+
def fake_send(self, **kwargs):
11+
return components.ChatResponse.model_validate(
12+
{
13+
"id": "chat_123",
14+
"choices": [
15+
{
16+
"index": 0,
17+
"message": {
18+
"role": "assistant",
19+
"content": "AUTO OK",
20+
},
21+
"finish_reason": "stop",
22+
}
23+
],
24+
"created": 1,
25+
"model": "openai/gpt-4o-mini",
26+
"object": "chat.completion",
27+
"usage": {
28+
"prompt_tokens": 5,
29+
"completion_tokens": 2,
30+
"total_tokens": 7,
31+
},
32+
}
33+
)
34+
35+
36+
Chat.send = fake_send
37+
38+
init_test_logger("test-auto-openrouter")
39+
40+
with logger._internal_with_memory_background_logger() as memory_logger:
41+
memory_logger.pop()
42+
43+
results = auto_instrument()
44+
assert results.get("openrouter") == True
45+
46+
results2 = auto_instrument()
47+
assert results2.get("openrouter") == True
48+
49+
client = OpenRouter(api_key="test-key")
50+
response = client.chat.send(
51+
model="openai/gpt-4o-mini",
52+
messages=[{"role": "user", "content": "Say hi"}],
53+
temperature=0,
54+
)
55+
assert response.choices[0].message.content == "AUTO OK"
56+
57+
spans = memory_logger.pop()
58+
assert len(spans) == 1, f"Expected 1 span, got {len(spans)}"
59+
span = spans[0]
60+
assert span["metadata"]["provider"] == "openai"
61+
assert span["metadata"]["model"] == "gpt-4o-mini"
62+
assert span["output"][0]["message"]["content"] == "AUTO OK"
63+
64+
print("SUCCESS")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Braintrust integration for the OpenRouter Python SDK."""
2+
3+
from .integration import OpenRouterIntegration
4+
from .tracing import wrap_openrouter
5+
6+
7+
__all__ = [
8+
"OpenRouterIntegration",
9+
"wrap_openrouter",
10+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""OpenRouter integration orchestration."""
2+
3+
from braintrust.integrations.base import BaseIntegration
4+
5+
from .patchers import ChatPatcher, EmbeddingsPatcher, ResponsesPatcher
6+
7+
8+
class OpenRouterIntegration(BaseIntegration):
9+
"""Braintrust instrumentation for the OpenRouter Python SDK."""
10+
11+
name = "openrouter"
12+
import_names = ("openrouter",)
13+
patchers = (
14+
ChatPatcher,
15+
EmbeddingsPatcher,
16+
ResponsesPatcher,
17+
)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""OpenRouter patchers."""
2+
3+
from braintrust.integrations.base import CompositeFunctionWrapperPatcher, FunctionWrapperPatcher
4+
5+
from .tracing import (
6+
_chat_send_async_wrapper,
7+
_chat_send_wrapper,
8+
_embeddings_generate_async_wrapper,
9+
_embeddings_generate_wrapper,
10+
_responses_send_async_wrapper,
11+
_responses_send_wrapper,
12+
)
13+
14+
15+
class ChatSendPatcher(FunctionWrapperPatcher):
16+
name = "openrouter.chat.send"
17+
target_module = "openrouter.chat"
18+
target_path = "Chat.send"
19+
wrapper = _chat_send_wrapper
20+
21+
22+
class ChatSendAsyncPatcher(FunctionWrapperPatcher):
23+
name = "openrouter.chat.send_async"
24+
target_module = "openrouter.chat"
25+
target_path = "Chat.send_async"
26+
wrapper = _chat_send_async_wrapper
27+
28+
29+
class ChatPatcher(CompositeFunctionWrapperPatcher):
30+
name = "openrouter.chat"
31+
sub_patchers = (ChatSendPatcher, ChatSendAsyncPatcher)
32+
33+
34+
class EmbeddingsGeneratePatcher(FunctionWrapperPatcher):
35+
name = "openrouter.embeddings.generate"
36+
target_module = "openrouter.embeddings"
37+
target_path = "Embeddings.generate"
38+
wrapper = _embeddings_generate_wrapper
39+
40+
41+
class EmbeddingsGenerateAsyncPatcher(FunctionWrapperPatcher):
42+
name = "openrouter.embeddings.generate_async"
43+
target_module = "openrouter.embeddings"
44+
target_path = "Embeddings.generate_async"
45+
wrapper = _embeddings_generate_async_wrapper
46+
47+
48+
class EmbeddingsPatcher(CompositeFunctionWrapperPatcher):
49+
name = "openrouter.embeddings"
50+
sub_patchers = (EmbeddingsGeneratePatcher, EmbeddingsGenerateAsyncPatcher)
51+
52+
53+
class ResponsesSendPatcher(FunctionWrapperPatcher):
54+
name = "openrouter.beta.responses.send"
55+
target_module = "openrouter.responses"
56+
target_path = "Responses.send"
57+
wrapper = _responses_send_wrapper
58+
59+
60+
class ResponsesSendAsyncPatcher(FunctionWrapperPatcher):
61+
name = "openrouter.beta.responses.send_async"
62+
target_module = "openrouter.responses"
63+
target_path = "Responses.send_async"
64+
wrapper = _responses_send_async_wrapper
65+
66+
67+
class ResponsesPatcher(CompositeFunctionWrapperPatcher):
68+
name = "openrouter.beta.responses"
69+
sub_patchers = (ResponsesSendPatcher, ResponsesSendAsyncPatcher)

0 commit comments

Comments
 (0)