From 12e80e8f57666fcac2d3973707cd51f66fd36295 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 26 Mar 2026 12:05:20 -0700 Subject: [PATCH] feat: Add openrouter integration --- py/noxfile.py | 12 +- py/requirements-optional.txt | 1 + py/src/braintrust/__init__.py | 3 + py/src/braintrust/auto.py | 5 + py/src/braintrust/integrations/__init__.py | 2 + .../auto_test_scripts/test_auto_openrouter.py | 32 + .../integrations/openrouter/__init__.py | 10 + ...outer_integration_setup_creates_spans.yaml | 52 ++ .../test_wrap_openrouter_chat_send_async.yaml | 52 ++ ...wrap_openrouter_chat_send_stream_sync.yaml | 67 ++ .../test_wrap_openrouter_chat_send_sync.yaml | 52 ++ ...t_wrap_openrouter_embeddings_generate.yaml | 51 ++ .../test_wrap_openrouter_responses_send.yaml | 53 ++ ...penrouter_responses_send_async_stream.yaml | 84 +++ .../integrations/openrouter/integration.py | 18 + .../integrations/openrouter/patchers.py | 69 ++ .../openrouter/test_openrouter.py | 285 ++++++++ .../integrations/openrouter/tracing.py | 638 ++++++++++++++++++ .../cassettes/test_auto_openrouter.yaml | 52 ++ ...test_openrouter_chat_completion_async.yaml | 26 +- .../test_openrouter_chat_completion_sync.yaml | 26 +- .../test_openrouter_streaming_sync.yaml | 20 +- ...r.py => test_openai_openrouter_gateway.py} | 0 23 files changed, 1559 insertions(+), 51 deletions(-) create mode 100644 py/src/braintrust/integrations/auto_test_scripts/test_auto_openrouter.py create mode 100644 py/src/braintrust/integrations/openrouter/__init__.py create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_openrouter_integration_setup_creates_spans.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_async.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_stream_sync.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_sync.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_embeddings_generate.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send.yaml create mode 100644 py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send_async_stream.yaml create mode 100644 py/src/braintrust/integrations/openrouter/integration.py create mode 100644 py/src/braintrust/integrations/openrouter/patchers.py create mode 100644 py/src/braintrust/integrations/openrouter/test_openrouter.py create mode 100644 py/src/braintrust/integrations/openrouter/tracing.py create mode 100644 py/src/braintrust/wrappers/cassettes/test_auto_openrouter.yaml rename py/src/braintrust/wrappers/{test_openrouter.py => test_openai_openrouter_gateway.py} (100%) diff --git a/py/noxfile.py b/py/noxfile.py index 5e51312b..f935eadc 100644 --- a/py/noxfile.py +++ b/py/noxfile.py @@ -70,6 +70,7 @@ def _pinned_python_version(): "autoevals", "braintrust_core", "litellm", + "openrouter", "opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-http", @@ -100,6 +101,7 @@ def _pinned_python_version(): GENAI_VERSIONS = (LATEST,) DSPY_VERSIONS = (LATEST,) GOOGLE_ADK_VERSIONS = (LATEST, "1.14.1") +OPENROUTER_VERSIONS = (LATEST, "0.6.0") # temporalio 1.19.0+ requires Python >= 3.10; skip Python 3.9 entirely TEMPORAL_VERSIONS = (LATEST, "1.20.0", "1.19.0") PYTEST_VERSIONS = (LATEST, "8.4.2") @@ -210,6 +212,7 @@ def test_openai(session, version): # openai-agents requires Python >= 3.10 _install(session, "openai-agents") _run_tests(session, f"{WRAPPER_DIR}/test_openai.py") + _run_tests(session, f"{WRAPPER_DIR}/test_openai_openrouter_gateway.py") _run_core_tests(session) @@ -224,11 +227,12 @@ def test_openai_http2_streaming(session): @nox.session() -def test_openrouter(session): - """Test wrap_openai with OpenRouter. Requires OPENROUTER_API_KEY env var.""" +@nox.parametrize("version", OPENROUTER_VERSIONS, ids=OPENROUTER_VERSIONS) +def test_openrouter(session, version): + """Test the native OpenRouter SDK integration.""" _install_test_deps(session) - _install(session, "openai") - _run_tests(session, f"{WRAPPER_DIR}/test_openrouter.py") + _install(session, "openrouter", version) + _run_tests(session, f"{INTEGRATION_DIR}/openrouter/test_openrouter.py") @nox.session() diff --git a/py/requirements-optional.txt b/py/requirements-optional.txt index 1fdb21fd..90e8711a 100644 --- a/py/requirements-optional.txt +++ b/py/requirements-optional.txt @@ -7,3 +7,4 @@ google-adk==1.14.1 dspy==3.1.3 langsmith==0.7.12 litellm==1.82.0 +openrouter==0.7.11 diff --git a/py/src/braintrust/__init__.py b/py/src/braintrust/__init__.py index c961ac72..f40d80a3 100644 --- a/py/src/braintrust/__init__.py +++ b/py/src/braintrust/__init__.py @@ -73,6 +73,9 @@ def is_equal(expected, output): from .integrations.anthropic import ( wrap_anthropic, # noqa: F401 # type: ignore[reportUnusedImport] ) +from .integrations.openrouter import ( + wrap_openrouter, # noqa: F401 # type: ignore[reportUnusedImport] +) from .logger import * from .logger import ( _internal_get_global_state, # noqa: F401 # type: ignore[reportUnusedImport] diff --git a/py/src/braintrust/auto.py b/py/src/braintrust/auto.py index 48276ea3..dfc0a30c 100644 --- a/py/src/braintrust/auto.py +++ b/py/src/braintrust/auto.py @@ -13,6 +13,7 @@ AnthropicIntegration, ClaudeAgentSDKIntegration, GoogleGenAIIntegration, + OpenRouterIntegration, ) @@ -39,6 +40,7 @@ def auto_instrument( litellm: bool = True, pydantic_ai: bool = True, google_genai: bool = True, + openrouter: bool = True, agno: bool = True, claude_agent_sdk: bool = True, dspy: bool = True, @@ -59,6 +61,7 @@ def auto_instrument( litellm: Enable LiteLLM instrumentation (default: True) pydantic_ai: Enable Pydantic AI instrumentation (default: True) google_genai: Enable Google GenAI instrumentation (default: True) + openrouter: Enable OpenRouter instrumentation (default: True) agno: Enable Agno instrumentation (default: True) claude_agent_sdk: Enable Claude Agent SDK instrumentation (default: True) dspy: Enable DSPy instrumentation (default: True) @@ -120,6 +123,8 @@ def auto_instrument( results["pydantic_ai"] = _instrument_pydantic_ai() if google_genai: results["google_genai"] = _instrument_integration(GoogleGenAIIntegration) + if openrouter: + results["openrouter"] = _instrument_integration(OpenRouterIntegration) if agno: results["agno"] = _instrument_integration(AgnoIntegration) if claude_agent_sdk: diff --git a/py/src/braintrust/integrations/__init__.py b/py/src/braintrust/integrations/__init__.py index db4f048f..5ea8a4b8 100644 --- a/py/src/braintrust/integrations/__init__.py +++ b/py/src/braintrust/integrations/__init__.py @@ -3,6 +3,7 @@ from .anthropic import AnthropicIntegration from .claude_agent_sdk import ClaudeAgentSDKIntegration from .google_genai import GoogleGenAIIntegration +from .openrouter import OpenRouterIntegration __all__ = [ @@ -11,4 +12,5 @@ "AnthropicIntegration", "ClaudeAgentSDKIntegration", "GoogleGenAIIntegration", + "OpenRouterIntegration", ] diff --git a/py/src/braintrust/integrations/auto_test_scripts/test_auto_openrouter.py b/py/src/braintrust/integrations/auto_test_scripts/test_auto_openrouter.py new file mode 100644 index 00000000..a794753a --- /dev/null +++ b/py/src/braintrust/integrations/auto_test_scripts/test_auto_openrouter.py @@ -0,0 +1,32 @@ +"""Test auto_instrument for OpenRouter.""" + +import os + +import openrouter +from braintrust.auto import auto_instrument +from braintrust.wrappers.test_utils import autoinstrument_test_context + + +results = auto_instrument() +assert results.get("openrouter") == True + +results2 = auto_instrument() +assert results2.get("openrouter") == True + +with autoinstrument_test_context("test_auto_openrouter") as memory_logger: + client = openrouter.OpenRouter(api_key=os.environ.get("OPENROUTER_API_KEY")) + response = client.chat.send( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "What is 2+2? Reply with just the number."}], + max_tokens=10, + ) + assert "4" in response.choices[0].message.content + + spans = memory_logger.pop() + assert len(spans) == 1, f"Expected 1 span, got {len(spans)}" + span = spans[0] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert "4" in span["output"][0]["message"]["content"] + +print("SUCCESS") diff --git a/py/src/braintrust/integrations/openrouter/__init__.py b/py/src/braintrust/integrations/openrouter/__init__.py new file mode 100644 index 00000000..9d632f29 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/__init__.py @@ -0,0 +1,10 @@ +"""Braintrust integration for the OpenRouter Python SDK.""" + +from .integration import OpenRouterIntegration +from .tracing import wrap_openrouter + + +__all__ = [ + "OpenRouterIntegration", + "wrap_openrouter", +] diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_openrouter_integration_setup_creates_spans.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_openrouter_integration_setup_creates_spans.yaml new file mode 100644 index 00000000..c615bc36 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_openrouter_integration_setup_creates_spans.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 2+2? Reply with just the + number."}],"model":"openai/gpt-4o-mini","max_tokens":10.0,"stream":false,"temperature":1.0,"top_p":1.0}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '175' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: "\n \n{\"id\":\"gen-1774558315-QxPl7aBABQW8bOKxzdxL\",\"object\":\"chat.completion\",\"created\":1774558315,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"4\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e2909425a3b053a-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:51:56 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '785' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_async.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_async.yaml new file mode 100644 index 00000000..d18b3ba7 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_async.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 3+3? Reply with just the + number."}],"model":"openai/gpt-4o-mini","max_tokens":10.0,"stream":false,"temperature":1.0,"top_p":1.0}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '175' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: "\n \n{\"id\":\"gen-1774558313-OLdiGZAnrgirxGutvM1d\",\"object\":\"chat.completion\",\"created\":1774558313,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"6\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e2909316898c52e-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:51:53 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '785' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_stream_sync.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_stream_sync.yaml new file mode 100644 index 00000000..8abc4c3c --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_stream_sync.yaml @@ -0,0 +1,67 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 5+5? Reply with just the + number."}],"model":"openai/gpt-4o-mini","max_tokens":10.0,"stream":true,"temperature":1.0,"top_p":1.0}' + headers: + Accept: + - text/event-stream + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '174' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: ': OPENROUTER PROCESSING + + + data: {"id":"gen-1774558312-UWJRiYAjZxsKV2WOReFJ","object":"chat.completion.chunk","created":1774558312,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"10","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} + + + data: {"id":"gen-1774558312-UWJRiYAjZxsKV2WOReFJ","object":"chat.completion.chunk","created":1774558312,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}]} + + + data: {"id":"gen-1774558312-UWJRiYAjZxsKV2WOReFJ","object":"chat.completion.chunk","created":1774558312,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":1,"total_tokens":21,"cost":0.0000036,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000036,"upstream_inference_prompt_cost":0.000003,"upstream_inference_completions_cost":6e-7},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0,"audio_tokens":0}}} + + + data: [DONE] + + + ' + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e29092c8dbc552b-SJC + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Type: + - text/event-stream + Date: + - Thu, 26 Mar 2026 20:51:52 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_sync.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_sync.yaml new file mode 100644 index 00000000..a176b920 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_chat_send_sync.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 2+2? Reply with just the + number."}],"model":"openai/gpt-4o-mini","max_tokens":10.0,"stream":false,"temperature":1.0,"top_p":1.0}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '175' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: "\n \n{\"id\":\"gen-1774558311-aXJHnsIUyX9yfP5jKa4u\",\"object\":\"chat.completion\",\"created\":1774558311,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"4\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e290926cb7af591-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:51:52 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '785' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_embeddings_generate.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_embeddings_generate.yaml new file mode 100644 index 00000000..0f231ba2 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_embeddings_generate.yaml @@ -0,0 +1,51 @@ +interactions: +- request: + body: '{"input":"braintrust tracing","model":"openai/text-embedding-3-small","input_type":"query"}' + headers: + Accept: + - application/json;q=1, text/event-stream;q=0 + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '91' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/embeddings + response: + body: + string: '{"object":"list","data":[{"object":"embedding","embedding":[0.0024269486,0.026935222,0.030582156,0.0045499858,-0.0095428135,0.017105862,-0.0356705,0.053557847,-0.05334945,-0.009455982,0.06456812,0.0017312088,0.031415742,-0.018182578,0.028237699,-0.022524167,-0.03113788,-0.031103147,-0.058871955,0.0045760353,0.008027599,0.033725467,0.03775446,0.016089931,-0.008453075,0.018321507,-0.02264573,-0.01565577,0.0225589,-0.02335775,0.04296437,-0.038761713,0.012460362,-0.0012894521,-0.014605107,0.02641423,0.0120088365,-0.029453343,0.013128967,0.046507105,0.01581207,-0.040949874,0.0033365116,0.04886893,0.038588047,-0.03275295,-0.042096052,-0.014205681,0.008691862,0.05602387,0.02136062,-0.0039052598,-0.03681668,0.08488676,-0.06873605,0.039838426,-0.012382213,0.021916343,0.00059968204,0.06012233,0.009924874,-0.018911963,0.02665736,0.0071332315,0.017852616,0.010723726,-0.032110397,0.02130852,0.012278015,0.022298403,0.012712174,0.013441561,-0.017939448,-0.027490944,0.04244338,-0.00920417,-0.0070507415,0.009811992,-0.006712097,0.003204093,0.03127681,-0.027768806,0.021638483,-0.013346046,-0.006386478,-0.028185599,-0.008348877,0.00088188535,-0.02832453,-0.065818496,-0.0101245865,-0.027265182,-0.008635421,0.029435977,0.0050188773,-0.056822725,0.008127456,0.026709458,-0.00080645026,0.023236187,0.017114546,-0.019050894,-0.009603596,0.018946696,0.005231615,0.029401245,-0.017079813,-0.018512538,-0.014118849,0.04067201,-0.057656307,0.020249173,-0.03223196,0.0017811371,-0.00712889,-0.011340232,-0.021377986,-0.024712328,0.044839937,0.037823927,0.017974181,-0.05178648,0.024799159,0.0073546525,-0.014761404,-0.008027599,-0.026674725,-0.011861223,0.020874362,-0.05192541,0.020353371,-0.02264573,-0.02297569,0.015230296,-0.025059655,0.010497963,0.0008715741,0.00639082,-0.0065210676,-0.024503931,-0.00046020848,0.005044927,0.004335077,-0.019415589,-0.004211342,0.03356917,0.020318639,0.022350503,-0.005591967,-0.000711478,0.009811992,0.023618247,-0.011053687,0.02532015,-0.01087134,-0.037511334,-0.01753134,0.004649842,-0.0016432917,-0.012720857,-0.00581773,0.017218744,0.007541341,-0.032839783,0.010810558,0.059775002,-0.013285263,-0.045187265,-0.03398596,-0.013467611,-0.047618553,-0.034680616,-0.03237089,0.052376937,-0.017513972,0.0032106054,-0.022663098,-0.014023334,-0.013441561,-0.06536697,-0.02160375,0.01439671,-0.01887723,-0.025111753,-0.010194052,0.007897351,0.015091365,0.0142230475,0.03848385,0.017939448,0.010671627,0.030790552,0.013319996,0.009516764,0.038067058,-0.042825438,0.028046668,-0.0012362676,0.024330268,0.028307164,-0.0543567,-0.046020847,-0.009473348,0.015803386,-0.05612807,0.044735737,0.07536999,-0.007606465,-0.018373607,0.02179478,0.048070077,-0.071341,-0.015777336,0.029679107,-0.03980369,-0.020318639,-0.0030998948,0.02007551,-0.007289529,0.078704335,-0.019432954,0.014110166,0.02736938,0.002199015,0.0057916804,0.09732107,0.041470863,-0.00017420627,-0.030026432,0.020961193,0.04539566,-0.006186765,-0.011895956,-0.06710361,0.022385236,0.005687482,-0.0022001006,-0.030668989,0.045187265,-0.04911206,0.0012047911,0.0062866216,0.011244717,-0.033499703,-0.029904868,0.03890064,-0.01330263,-0.0009426676,-0.03914377,0.0016997323,-0.013893086,0.005101368,0.011809124,0.010940805,-0.02302779,-0.011652826,-0.019085627,-0.035132144,0.022993058,0.0068987855,-0.0028567659,-0.034506954,-0.0088785505,0.0075890985,-0.0337081,-0.033725467,0.007966816,-0.02870659,-0.055155553,-0.03151994,-0.039282702,0.0006219327,0.007979842,-0.018460438,-0.05883722,-0.009438615,0.03322184,0.024764426,-0.044596806,0.042755973,-0.029505443,0.035739966,0.02660526,-0.035548933,-0.001999302,0.037962858,0.07634251,0.04768802,0.032770317,0.016619604,-0.004387176,-0.021343254,-0.0022988715,0.009021823,0.009907507,-0.030929483,-0.013111601,-0.026501061,-0.035218973,-0.037302937,-0.0016454624,0.01892933,0.015091365,-0.0033560486,-0.0011049345,0.011018954,0.012260648,-0.0012916229,0.04793115,0.011817806,-0.03289188,-0.010793191,-0.019294024,0.010324299,-0.018668834,-0.041262466,0.035844162,-0.016671704,0.020370739,-0.03410753,-0.010376398,0.0030760162,0.020058144,0.032648753,-0.0056093335,0.020752797,-0.038831178,-0.033534437,-0.008466099,-0.015464742,-0.005826413,0.009673062,0.026848389,-0.01685405,-0.008852501,-0.02674419,0.034194358,0.008071015,-0.010411131,-0.016411208,-0.03304818,-0.0014500909,0.0066252653,-0.031415742,0.002496414,0.05963607,0.0071375733,-0.03337814,0.03204093,0.028567659,-0.0042135124,0.007519633,-0.008995773,0.013919136,-0.012060936,-0.032943983,-0.024573397,0.030443225,0.008500832,0.0187904,-0.042651776,0.0056093335,0.030234829,0.021273788,-0.044075817,0.06675628,-0.026466329,-0.020822262,0.017392408,-0.05126549,-0.028585026,-0.049424656,-0.00073155784,0.006429894,-0.027681975,0.0319541,0.028150866,0.03963003,0.0069074687,0.051682282,0.051855944,-0.082733326,0.00991619,-0.041609794,0.046125047,0.027525676,0.050848696,-0.026240567,-0.0617895,-0.032978714,0.025771676,-0.0097077945,0.041748725,-0.05102236,-0.009872775,-0.019988678,-0.006811954,0.021256423,0.049806714,0.033065546,0.010306933,-0.005552893,-0.12496831,0.01635911,0.013936502,0.044909403,-0.05612807,0.038657513,-0.016029147,0.023965575,0.027681975,-0.042061318,0.026171101,-0.021968443,0.062762015,-0.0033408531,0.009838042,0.006230181,-0.0069943005,0.03886591,0.013745472,-0.015690504,0.0024204361,-0.008565956,0.03886591,0.015516841,0.027716707,-0.015968366,0.11517368,-0.061824232,0.020891728,0.010454548,-0.022749929,0.009829358,-0.007298212,-0.039560564,0.03313501,0.0394911,-0.034454852,0.0036664724,-0.035635766,0.020197075,-0.014570374,-0.02136062,-0.014431443,-0.031398375,-0.006377795,-0.027352015,0.008722253,-0.06446392,0.040845674,0.0009008798,-0.0014045042,0.016428575,0.021499552,-0.009490714,0.006178082,0.07502267,-0.0008943674,-0.009317051,0.028116133,0.023618247,-0.016454624,0.023722446,0.0046802335,-0.012295381,-0.002264139,0.0010691164,0.02884552,0.02193371,-0.031641506,-0.05154335,0.02351405,-0.021516917,0.011314183,0.014544325,0.031207345,-0.035983093,0.05963607,-0.052342203,-0.037407134,-0.0049841446,0.010072487,0.026205834,-0.015308444,0.02360088,-0.032648753,0.026396863,0.028393995,0.040012088,-0.008987091,-0.015725236,0.023479316,-0.024243435,0.01764422,-0.046646036,0.015846802,0.00017298521,0.0030065507,-0.031589404,0.0506403,-0.002824204,-0.06623529,0.0010023644,-0.0028611075,0.061997898,-0.015230296,-0.0074588507,0.011956737,0.04466627,-0.014127532,-0.01657619,-0.011696242,-0.010602161,-0.013189749,-0.028689222,-0.03494111,0.025042288,0.0068249786,-0.01332868,0.0025854164,-0.0065427753,-0.022923592,0.0045934017,0.020544402,0.004400201,-0.0062432056,-0.023288285,-0.032075662,-0.026153736,0.011696242,-0.030964216,-0.00056712015,-0.03013063,-0.022940958,-0.017357675,-0.00991619,-0.048313208,0.0032084347,0.036747213,0.0057569477,0.024035038,-0.026900489,-0.0134762935,-0.013658641,-0.00868752,-0.01628096,-0.017974181,0.02146482,0.039595295,0.01485692,-0.036747213,0.027213084,-0.0018582003,0.02507702,-0.030200096,0.019085627,0.009212852,0.040637277,0.013241848,0.019815015,0.017904716,0.015916267,-0.022871492,0.0140407,-0.00035492494,0.020301273,0.025789041,0.041991852,-0.0052185906,0.010168003,-0.01278164,-0.01690615,-0.03518424,0.07398068,0.030356394,0.00032290572,0.003646935,0.015629722,-0.023739811,-0.03633042,0.02264573,-0.0060955915,0.0028328872,-0.017800517,-0.01821731,-0.0027243474,-0.007819203,0.01704508,-0.006017443,-0.013102917,0.017609486,0.003937822,0.0057048486,0.031867266,-0.021325888,0.008257703,-0.010185369,0.015942317,0.013971235,0.008874209,-0.0065123844,0.0068770777,-0.014414077,0.017566072,0.015829435,-0.012868471,0.010411131,-0.00087428757,-0.01674117,-0.0036881804,0.0006300732,-0.00071744766,-0.0108452905,0.012746907,-0.018460438,0.019224558,-0.01253851,0.029679107,0.035983093,-0.011105786,0.030530058,0.012868471,-0.012408263,-0.029140748,0.027317282,0.0069508846,-0.016272277,-0.0030955533,0.018252043,-0.034784816,0.013337363,-0.0063647702,0.0017909056,-0.00666434,0.028064035,0.035479467,0.027091518,-0.016011782,-0.042373914,0.041366663,0.0065514585,-0.008344535,-0.023305653,0.015013216,-0.006607899,-0.059531875,0.01297267,0.016324377,0.002611466,-0.02335775,-0.020874362,0.036608282,0.0525506,0.01807838,-0.0050840015,-0.010619528,0.013927819,-0.012495095,0.0010837693,0.010524013,0.010688993,-0.0078105195,0.011227351,-0.0052967393,0.018373607,-0.005591967,0.006169399,0.019380856,0.06408186,-0.029140748,0.038136523,0.022993058,0.0074414844,-0.022472067,0.011861223,-0.0028285456,-0.032787684,0.011930688,-0.006959568,0.026049538,0.005813388,-0.018686201,-0.016098613,0.052446403,0.014205681,0.022090007,-0.038449116,0.0064125275,-0.03212776,-0.016185446,0.008162188,-0.003329999,0.013076868,0.015551574,-0.03475008,-0.007024692,0.03351707,-0.02799457,-0.07259138,-0.02327092,-0.022628365,-0.010471914,-0.04247811,-0.0060738837,-0.04244338,-0.013484977,0.020197075,0.024434466,-0.03646935,-0.033082914,0.032822415,0.011687559,0.03228406,-0.010992904,0.027768806,-0.0029218898,-0.007871302,-0.019380856,-0.0020112414,-0.017149279,-0.017036397,-0.025719576,-0.014318562,0.024278168,-0.019606618,0.022090007,-0.013528393,0.034559052,-0.00917812,-0.034020696,0.0060087596,0.05178648,-0.0062258393,-0.009647012,0.048000615,-0.03619149,-0.018616736,0.0075239744,-0.006451602,-0.0054400116,-0.044041082,-0.0026049537,-0.0006805441,-0.0078105195,0.027004687,-0.014370661,0.024052406,-0.02116959,-0.008856842,-0.036538817,0.019207193,-0.0055225017,0.0007353567,-0.044284213,-0.04292964,-0.01715796,-0.023427216,-0.00532713,0.03963003,0.04640291,-0.008565956,-0.00045939445,0.014631157,0.00016430202,0.004745357,-0.026553161,0.00005420203,-0.0025680503,-0.013971235,-0.008040624,0.004523936,0.033447605,-0.016862733,0.034680616,0.012851105,-0.013059502,0.04779222,0.01988448,-0.0047627236,0.006842345,0.011218667,-0.048660535,0.02947071,-0.059531875,0.004567352,-0.04376322,-0.0137541555,0.0010870255,0.022142107,0.0033864398,0.036781944,-0.01721006,-0.042234983,-0.00046943437,-0.02769934,-0.0033712443,-0.07398068,-0.02302779,-0.0075369994,-0.017583437,0.04668077,0.029106015,0.01925929,0.050848696,-0.006125983,-0.0043546143,-0.01089739,-0.0057916804,0.002195759,-0.017062446,0.03695561,0.033638634,-0.01917246,-0.0042786365,-0.024243435,0.029366512,-0.024625495,0.008908941,-0.012868471,-0.003267046,-0.025181219,0.024799159,-0.020613866,0.013259214,-0.032579288,-0.0067424886,0.027438845,-0.005457378,0.006612241,0.020943828,-0.030530058,-0.0069856173,0.0056223585,-0.023618247,-0.0012254136,-0.0010370972,-0.026153736,-0.012321431,-0.004519595,0.028862886,-0.006659998,-0.00824902,-0.039977357,0.018061012,-0.03443749,0.038726978,-0.05001511,-0.02078753,0.036643013,-0.014405394,-0.035739966,0.040116288,0.022454701,-0.04348536,-0.009629645,-0.01540396,-0.009212852,-0.015230296,0.028064035,-0.0074110935,0.005005853,0.03480218,-0.023288285,-0.019554518,0.028098768,0.018061012,0.020700699,-0.0020774505,-0.02974857,0.04640291,0.03075582,0.0071419147,-0.005921928,-0.02092646,-0.008648446,-0.00994224,0.021239055,0.012903204,0.00685537,0.010619528,-0.014092799,0.0016150713,0.0041852924,0.005509477,-0.004476179,0.022350503,0.046229243,0.0127295405,-0.01089739,0.004007287,0.043415897,0.02941861,-0.06213683,-0.009777259,0.009047872,0.015152147,-0.03523634,0.004914679,-0.010306933,-0.030460592,-0.022784662,-0.004654184,0.006755513,0.01892933,-0.032648753,-0.017852616,0.023323018,0.015160831,0.0011993641,-0.0394911,0.022836762,-0.017765785,0.018373607,0.015134781,0.002190332,0.004523936,0.032770317,-0.003764158,-0.03903957,0.0046585253,0.009473348,-0.009525447,0.0187383,0.011183934,0.0154994745,0.018998796,0.0010685737,0.03671248,-0.015343177,-0.0016400354,-0.016324377,-0.033412874,-0.034263823,0.02594534,-0.0043068565,0.017079813,-0.020631233,0.005118734,0.021812145,0.009403883,-0.050327703,-0.020492302,-0.017262159,-0.018147845,-0.023496682,-0.009924874,0.051473886,0.01425778,-0.011435746,0.009699111,0.00450657,0.039838426,0.02570221,-0.012121717,-0.048834197,0.020388104,-0.010662944,0.011079736,-0.012017519,0.021951076,-0.0017865641,0.03400333,-0.020874362,-0.019676084,0.00450657,0.019901846,-0.01368469,-0.032579288,0.011722292,0.00044175674,-0.0039139427,0.019033529,0.011227351,0.030061165,0.011270766,0.004871263,0.028046668,0.03367337,0.004949412,-0.009568864,0.014474859,0.014014651,-0.016020466,0.016428575,-0.01160941,-0.0021816487,0.044562075,0.03351707,-0.04734069,0.020179708,-0.034506954,-0.008696204,0.010966855,0.024208702,-0.007645539,-0.026396863,-0.07266084,-0.021013293,0.025042288,0.025441714,0.042130783,-0.0051274174,0.015933633,-0.01073241,-0.044457875,-0.014544325,-0.0059175864,0.008639763,-0.028602391,0.042269714,-0.013293947,0.02712625,-0.030825285,-0.020197075,-0.013102917,0.007719346,-0.010715043,-0.015412643,0.03285715,0.02078753,-0.030651622,0.021951076,-0.03129418,0.0029414268,-0.029314412,-0.019068262,0.013050818,-0.010246151,-0.005943636,0.03480218,-0.007645539,0.03336077,0.027838271,-0.008830793,-0.0074805585,0.0026722483,-0.0051621497,-0.009377833,-0.018425705,0.019658716,-0.0008259874,-0.056058604,-0.0038835518,0.00994224,-0.023323018,0.00029956969,0.02603217,0.034593783,0.008018916,-0.020909095,-0.021690581,-0.034680616,0.00385099,-0.00816653,-0.00936915,0.01917246,-0.0058958787,-0.050466634,-0.0026288324,0.0036404228,-0.018425705,-0.0008639763,0.0065080426,0.0045022285,0.0020546573,-0.013971235,-0.00063170126,0.0048104813,0.017557388,-0.010888706,-0.0085181985,-0.01657619,-0.012121717,-0.032683484,-0.018599369,0.020943828,-0.015855486,-0.026917854,-0.014579058,0.04654184,0.005678799,-0.041748725,-0.024642862,-0.03384703,-0.043659024,0.0205965,0.0055181603,-0.047722753,0.010801875,-0.01816521,-0.032006197,0.008791719,-0.01199147,-0.021621116,0.008743961,0.0037815245,-0.022489434,-0.011496529,-0.021377986,-0.00035438224,-0.006725122,-0.05216854,0.012390897,0.012286698,-0.028064035,0.028220331,-0.008387951,0.032197226,0.03131154,-0.0040984605,0.0005676628,0.0009638329,0.024955457,-0.016428575,0.006968251,-0.026257934,-0.034211725,-0.025632745,0.017678952,-0.019189825,-0.0027894713,0.01237353,-0.0070984988,0.0028220331,-0.028237699,0.012764273,0.0024095823,0.0026765899,-0.01635911,-0.018425705,0.033204477,0.024799159,0.0044023716,-0.02532015,0.0066817063,-0.007992866,0.025858507,-0.021569017,0.013068184,0.027907737,0.011922005,-0.0012254136,0.0020394616,-0.025980072,0.039247967,0.033656,0.1308034,-0.008214287,-0.014969801,0.025841141,-0.021812145,-0.0037880368,-0.009568864,0.0003047253,-0.039352167,0.014414077,-0.04306857,-0.01275559,0.0015879363,-0.022524167,-0.017566072,0.03270085,-0.002589758,-0.03605256,-0.009994339,-0.028289797,0.025580645,0.032249328,-0.008292436,-0.010029072,0.015143464,-0.011305499,-0.0006192192,-0.023444584,-0.025962705,-0.00054893974,0.03785866,0.0037185713,-0.017852616,0.00581773,0.019676084,-0.013050818,-0.014370661,0.017887348,-0.0024378025,-0.026570527,0.0017898203,-0.006976934,0.004914679,-0.005088343,-0.013858354,0.007024692,-0.016037831,0.032648753,-0.026553161,-0.010775825,-0.040324684,0.008005891,-0.005869829,0.019988678,-0.014648523,-0.045847185,-0.01945032,0.013632591,0.041887656,0.0065427753,-0.02346195,0.0036773263,0.0017713686,-0.014353295,0.022541532,-0.04101934,-0.021377986,-0.00022250647,0.010454548,0.0052055656,-0.004471837,0.013085551,0.049633052,-0.008917625,0.0013187578,-0.016298328,0.01704508,0.040012088,-0.0038010615,-0.038310185,-0.018529903,0.04167926,0.05067503,-0.026692092,-0.01010722,-0.02698732,0.012382213,-0.0070029837,0.0153865935,0.025719576,-0.01892933,0.003998604,-0.021273788,0.023722446,-0.010194052,-0.0317978,0.002261968,-0.016150713,-0.0021480015,-0.013094234,0.02217684,0.03506268,-0.01835624,0.003490638,-0.020405471,-0.0010387253,-0.024382366,-0.015256345,-0.004749699,0.03051269,-0.00778447,0.02122169,-0.04324223,-0.002013412,-0.02217684,0.010332983,0.011722292,-0.0070811324,-0.015716555,-0.01887723,-0.0012720857,-0.014327245,-0.027959837,-0.0017605146,-0.0053531798,-0.012929253,0.022749929,0.005453036,-0.027386747,-0.0034038061,0.029279679,0.032770317,-0.010775825,0.004050703,0.006356087,0.03398596,0.014049384,0.007602123,-0.014909018,0.013484977,-0.020822262,0.03963003,0.005583284,-0.030860018,0.024521297,-0.003247509,0.020527035,0.001968911,-0.004020312,-0.009377833,0.03226669,-0.042373914,-0.012868471,-0.03952583,0.00022237079,0.022819394,-0.010932122,-0.007888668,-0.0104024485,0.0051361006,-0.025181219,-0.00093561254,-0.028984452,-0.028880253,0.034993213,0.0136152245,-0.04185292,-0.0044132257,0.0451178,0.01144443,0.037407134,-0.012547194,0.0069552264,-0.03646935,-0.008978407,-0.03051269,-0.028411362,-0.013823621,-0.01816521,0.008344535,0.0022076983,-0.021013293,0.021117492,-0.0026483696,-0.0034407096,-0.026639992,-0.027143618,-0.0053965957,0.007159281,-0.0011657168,-0.0033408531,0.009004457,0.02603217,0.025042288,0.01835624,0.025806408,-0.0076238313,0.01835624,-0.006833662,0.018095745,0.026587894,-0.008735278,0.008726595,-0.012191183,-0.0014566033,0.01144443,-0.0048061395,-0.035548933,0.011149202,-0.028914986,0.0052099074,-0.004745357,-0.005426987,0.038761713,-0.012833739,-0.026518429,-0.008370585,0.011904638,-0.016584871,0.0027873004,-0.02660526,-0.024712328,0.005444353,-0.038171254,-0.009699111,-0.002971818,-0.0035297123,-0.027734073,-0.00014815675,-0.031172613,-0.035548933,-0.00903919,0.019780282,-0.01332868,0.015994417,-0.0038401359,-0.05473876,-0.0075977817,-0.021673216,0.020162342,0.014370661,-0.014379344,0.0019493736,-0.012034886,-0.010081171,-0.038692247,0.006981276,-0.0021349767,-0.015742604,-0.0014435785,-0.02679629,0.00072504545,-0.052133806,0.028585026,-0.021638483,-0.02269783,0.001972167,-0.023149354,0.026900489,-0.014952434,0.013189749,0.004326394,0.0028676197,0.027664607,-0.0068987855,0.012095668,-0.012356164,-0.0046976,0.0243129,-0.004126681,-0.016801951,0.009872775,-0.008943674,0.00059425505,-0.004610768,0.028428728,-0.03075582,0.044180013,-0.0020893898,-0.00581773,-0.00712889,-0.05154335,0.011904638,-0.0029218898,0.0036404228,0.0047670654,0.011783074,-0.013728105,0.043381162,-0.018234676,-0.005891537,0.008205604,0.01892933,0.046333443,0.016437259,-0.009273635,-0.0075760735,-0.0013589174,-0.022541532,0.023722446,0.030147998,0.031033682,0.008717911,-0.030009067,0.010359032,0.032544553,-0.0012818543,-0.014092799,0.0025202928,-0.023861377,0.008453075,0.0010061633,-0.005010194,-0.018321507,0.027907737,0.0006751172,0.01783525,0.009872775,0.007285187,-0.0056180167,0.07425854,0.009551497,-0.021846877,0.018373607,0.041297197,0.009325734,-0.0059392946,-0.0011744,0.027925104,-0.041609794,-0.0037684997,-0.026171101,0.015464742,-0.016793268,0.026310032,-0.0187383,-0.029106015,0.0028372288,-0.004419738,-0.02365298,-0.0021262935,-0.016628288,-0.029783303,-0.021082759,-0.03226669,0.021239055,-0.03162414,0.0074458257,0.00024733492,0.0030499666,0.024469199,-0.019815015,-0.033065546,-0.0008596347,-0.013224482,-0.00849649,0.0187904,0.0041614133,-0.025111753,0.021569017,-0.0022706513,0.047479622,0.0127295405,0.004523936,-0.011374964,0.028776055,-0.0079755,-0.008344535,-0.033794932,-0.0039399923,-0.023635613,0.02045757,-0.0024616811,-0.008552931,-0.009021823,-0.006751172,0.0016053027,-0.0025984412,-0.0030803578,-0.030998949,-0.006833662,0.0046585253,-0.0011668021,-0.06453338,0.00007882698,0.00803194,-0.025233317,0.005765631,-0.039074305],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":3,"total_tokens":3,"cost":6e-8},"provider":"OpenAI","id":"gen-emb-1774558313-lIbPjgGsJ1aMvSANiBl4"}' + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e290935dae53c7d-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:51:53 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '19456' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send.yaml new file mode 100644 index 00000000..401b835c --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send.yaml @@ -0,0 +1,53 @@ +interactions: +- request: + body: '{"input":"Say one short sentence about observability.","model":"openai/gpt-4o-mini","max_output_tokens":16.0,"temperature":0.0,"store":false,"service_tier":"auto","stream":false}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '178' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/responses + response: + body: + string: "\n \n{\"id\":\"gen-1774558314-oKq2CwgV9UxzPe1GiLSd\",\"object\":\"response\",\"created_at\":1774558314,\"model\":\"openai/gpt-4o-mini\",\"status\":\"completed\",\"completed_at\":1774558314,\"output\":[{\"id\":\"msg_tmp_xayda62t7g\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Observability + enables organizations to understand and monitor the internal state of complex + systems through external\",\"annotations\":[],\"logprobs\":[]}]}],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":16,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"usage\":{\"input_tokens\":15,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":16,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":31,\"cost\":0.00001185,\"is_byok\":false,\"cost_details\":{\"upstream_inference_cost\":0.00001185,\"upstream_inference_input_cost\":0.00000225,\"upstream_inference_output_cost\":0.0000096}}}" + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e290937cbd9888e-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:51:54 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '1262' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send_async_stream.yaml b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send_async_stream.yaml new file mode 100644 index 00000000..791eff19 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/cassettes/test_wrap_openrouter_responses_send_async_stream.yaml @@ -0,0 +1,84 @@ +interactions: +- request: + body: '{"input":"What is 7+7? Reply with just the number.","model":"openai/gpt-4o-mini","store":false,"service_tier":"auto","stream":true}' + headers: + Accept: + - text/event-stream + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '131' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/responses + response: + body: + string: ': OPENROUTER PROCESSING + + + data: {"type":"response.created","response":{"id":"gen-1774558315-g19MVRVGotjqi32J3OZZ","object":"response","created_at":1774558315,"model":"openai/gpt-4o-mini","status":"in_progress","completed_at":null,"output":[],"error":null,"incomplete_details":null,"tools":[],"tool_choice":"auto","parallel_tool_calls":true,"max_output_tokens":null,"temperature":1,"top_p":1,"presence_penalty":0,"frequency_penalty":0,"top_logprobs":0,"max_tool_calls":null,"metadata":{},"background":false,"previous_response_id":null,"service_tier":"auto","truncation":"disabled","store":false,"instructions":null,"text":{"format":{"type":"text"}},"reasoning":null,"safety_identifier":null,"prompt_cache_key":null,"usage":null},"sequence_number":0} + + + data: {"type":"response.in_progress","response":{"id":"gen-1774558315-g19MVRVGotjqi32J3OZZ","object":"response","created_at":1774558315,"model":"openai/gpt-4o-mini","status":"in_progress","completed_at":null,"output":[],"error":null,"incomplete_details":null,"tools":[],"tool_choice":"auto","parallel_tool_calls":true,"max_output_tokens":null,"temperature":1,"top_p":1,"presence_penalty":0,"frequency_penalty":0,"top_logprobs":0,"max_tool_calls":null,"metadata":{},"background":false,"previous_response_id":null,"service_tier":"auto","truncation":"disabled","store":false,"instructions":null,"text":{"format":{"type":"text"}},"reasoning":null,"safety_identifier":null,"prompt_cache_key":null,"usage":null},"sequence_number":1} + + + data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_tmp_hpa06btx1fl","type":"message","status":"in_progress","role":"assistant","content":[]},"sequence_number":2} + + + data: {"type":"response.content_part.added","output_index":0,"item_id":"msg_tmp_hpa06btx1fl","content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"sequence_number":3} + + + data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_tmp_hpa06btx1fl","content_index":0,"delta":"14","logprobs":[],"sequence_number":4} + + + data: {"type":"response.output_text.done","output_index":0,"item_id":"msg_tmp_hpa06btx1fl","content_index":0,"text":"14","logprobs":[],"sequence_number":5} + + + data: {"type":"response.content_part.done","output_index":0,"item_id":"msg_tmp_hpa06btx1fl","content_index":0,"part":{"type":"output_text","text":"14","annotations":[],"logprobs":[]},"sequence_number":6} + + + data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_tmp_hpa06btx1fl","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"14","annotations":[],"logprobs":[]}]},"sequence_number":7} + + + data: {"type":"response.completed","response":{"id":"gen-1774558315-g19MVRVGotjqi32J3OZZ","object":"response","created_at":1774558315,"model":"openai/gpt-4o-mini","status":"completed","completed_at":1774558315,"output":[{"id":"msg_tmp_hpa06btx1fl","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"14","annotations":[],"logprobs":[]}]}],"error":null,"incomplete_details":null,"tools":[],"tool_choice":"auto","parallel_tool_calls":true,"max_output_tokens":null,"temperature":1,"top_p":1,"presence_penalty":0,"frequency_penalty":0,"top_logprobs":0,"max_tool_calls":null,"metadata":{},"background":false,"previous_response_id":null,"service_tier":"auto","truncation":"disabled","store":false,"instructions":null,"text":{"format":{"type":"text"}},"reasoning":null,"safety_identifier":null,"prompt_cache_key":null,"usage":{"input_tokens":20,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":21,"cost":0.0000036,"is_byok":false,"cost_details":{"upstream_inference_cost":0.0000036,"upstream_inference_input_cost":0.000003,"upstream_inference_output_cost":6e-7}}},"sequence_number":8} + + + data: [DONE] + + + ' + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e29093d6956cec9-SJC + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Type: + - text/event-stream + Date: + - Thu, 26 Mar 2026 20:51:55 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/integrations/openrouter/integration.py b/py/src/braintrust/integrations/openrouter/integration.py new file mode 100644 index 00000000..2ec64107 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/integration.py @@ -0,0 +1,18 @@ +"""OpenRouter integration orchestration.""" + +from braintrust.integrations.base import BaseIntegration + +from .patchers import ChatPatcher, EmbeddingsPatcher, ResponsesPatcher + + +class OpenRouterIntegration(BaseIntegration): + """Braintrust instrumentation for the OpenRouter Python SDK.""" + + name = "openrouter" + import_names = ("openrouter",) + min_version = "0.6.0" + patchers = ( + ChatPatcher, + EmbeddingsPatcher, + ResponsesPatcher, + ) diff --git a/py/src/braintrust/integrations/openrouter/patchers.py b/py/src/braintrust/integrations/openrouter/patchers.py new file mode 100644 index 00000000..73e9ed62 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/patchers.py @@ -0,0 +1,69 @@ +"""OpenRouter patchers.""" + +from braintrust.integrations.base import CompositeFunctionWrapperPatcher, FunctionWrapperPatcher + +from .tracing import ( + _chat_send_async_wrapper, + _chat_send_wrapper, + _embeddings_generate_async_wrapper, + _embeddings_generate_wrapper, + _responses_send_async_wrapper, + _responses_send_wrapper, +) + + +class ChatSendPatcher(FunctionWrapperPatcher): + name = "openrouter.chat.send" + target_module = "openrouter.chat" + target_path = "Chat.send" + wrapper = _chat_send_wrapper + + +class ChatSendAsyncPatcher(FunctionWrapperPatcher): + name = "openrouter.chat.send_async" + target_module = "openrouter.chat" + target_path = "Chat.send_async" + wrapper = _chat_send_async_wrapper + + +class ChatPatcher(CompositeFunctionWrapperPatcher): + name = "openrouter.chat" + sub_patchers = (ChatSendPatcher, ChatSendAsyncPatcher) + + +class EmbeddingsGeneratePatcher(FunctionWrapperPatcher): + name = "openrouter.embeddings.generate" + target_module = "openrouter.embeddings" + target_path = "Embeddings.generate" + wrapper = _embeddings_generate_wrapper + + +class EmbeddingsGenerateAsyncPatcher(FunctionWrapperPatcher): + name = "openrouter.embeddings.generate_async" + target_module = "openrouter.embeddings" + target_path = "Embeddings.generate_async" + wrapper = _embeddings_generate_async_wrapper + + +class EmbeddingsPatcher(CompositeFunctionWrapperPatcher): + name = "openrouter.embeddings" + sub_patchers = (EmbeddingsGeneratePatcher, EmbeddingsGenerateAsyncPatcher) + + +class ResponsesSendPatcher(FunctionWrapperPatcher): + name = "openrouter.beta.responses.send" + target_module = "openrouter.responses" + target_path = "Responses.send" + wrapper = _responses_send_wrapper + + +class ResponsesSendAsyncPatcher(FunctionWrapperPatcher): + name = "openrouter.beta.responses.send_async" + target_module = "openrouter.responses" + target_path = "Responses.send_async" + wrapper = _responses_send_async_wrapper + + +class ResponsesPatcher(CompositeFunctionWrapperPatcher): + name = "openrouter.beta.responses" + sub_patchers = (ResponsesSendPatcher, ResponsesSendAsyncPatcher) diff --git a/py/src/braintrust/integrations/openrouter/test_openrouter.py b/py/src/braintrust/integrations/openrouter/test_openrouter.py new file mode 100644 index 00000000..e4b82f7c --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/test_openrouter.py @@ -0,0 +1,285 @@ +import inspect +import os +import time +from pathlib import Path + +import pytest +from braintrust import logger +from braintrust.integrations.openrouter import OpenRouterIntegration, wrap_openrouter +from braintrust.test_helpers import init_test_logger +from braintrust.wrappers.test_utils import assert_metrics_are_valid, verify_autoinstrument_script + + +openrouter = pytest.importorskip("openrouter") +from openrouter import OpenRouter +from openrouter.chat import Chat +from openrouter.embeddings import Embeddings +from openrouter.responses import Responses + + +PROJECT_NAME = "test-openrouter-sdk" +CHAT_MODEL = "openai/gpt-4o-mini" +EMBEDDING_MODEL = "openai/text-embedding-3-small" + + +@pytest.fixture(scope="module") +def vcr_cassette_dir(): + return str(Path(__file__).resolve().parent / "cassettes") + + +@pytest.fixture +def memory_logger(): + init_test_logger(PROJECT_NAME) + with logger._internal_with_memory_background_logger() as bgl: + yield bgl + + +def _get_client(): + return OpenRouter(api_key=os.environ.get("OPENROUTER_API_KEY")) + + +@pytest.mark.vcr +def test_wrap_openrouter_chat_send_sync(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + response = client.chat.send( + model=CHAT_MODEL, + messages=[{"role": "user", "content": "What is 2+2? Reply with just the number."}], + max_tokens=10, + ) + end = time.time() + + assert "4" in response.choices[0].message.content + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["input"] == [{"role": "user", "content": "What is 2+2? Reply with just the number."}] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert "4" in span["output"][0]["message"]["content"] + assert_metrics_are_valid(span["metrics"], start, end) + + +@pytest.mark.vcr +def test_wrap_openrouter_chat_send_stream_sync(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + result = client.chat.send( + model=CHAT_MODEL, + messages=[{"role": "user", "content": "What is 5+5? Reply with just the number."}], + max_tokens=10, + stream=True, + ) + chunks = list(result) + end = time.time() + + assert chunks + content = "".join( + choice.delta.content or "" + for chunk in chunks + for choice in (chunk.choices or []) + if getattr(choice, "delta", None) is not None + ) + assert "10" in content + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert span["metrics"]["time_to_first_token"] >= 0 + assert "10" in span["output"][0]["message"]["content"] + assert_metrics_are_valid(span["metrics"], start, end) + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_wrap_openrouter_chat_send_async(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + response = await client.chat.send_async( + model=CHAT_MODEL, + messages=[{"role": "user", "content": "What is 3+3? Reply with just the number."}], + max_tokens=10, + ) + end = time.time() + + assert "6" in response.choices[0].message.content + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert "6" in span["output"][0]["message"]["content"] + assert_metrics_are_valid(span["metrics"], start, end) + + +@pytest.mark.vcr +def test_wrap_openrouter_embeddings_generate(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + response = client.embeddings.generate( + model=EMBEDDING_MODEL, + input="braintrust tracing", + input_type="query", + ) + end = time.time() + + assert response.data + assert response.data[0].embedding + embedding_length = len(response.data[0].embedding) + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + # The response model field from the embeddings API is "text-embedding-3-small" + # (without the "openai/" prefix), so the provider falls back to "openrouter". + assert span["metadata"]["provider"] in ("openai", "openrouter") + assert span["metadata"]["model"] == "text-embedding-3-small" + assert span["metadata"]["embedding_model"] == "text-embedding-3-small" + assert span["metadata"]["input_type"] == "query" + assert span["output"]["embedding_length"] == embedding_length + assert span["output"]["embeddings_count"] == 1 + assert span["metrics"]["prompt_tokens"] > 0 + assert span["metrics"]["tokens"] > 0 + # Embeddings don't have completion_tokens, so we check duration directly + # instead of using assert_metrics_are_valid (which expects completion_tokens). + assert start <= span["metrics"]["start"] <= span["metrics"]["end"] <= end + 1 + + +@pytest.mark.vcr +def test_wrap_openrouter_responses_send(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + response = client.beta.responses.send( + model=CHAT_MODEL, + input="Say one short sentence about observability.", + max_output_tokens=16, + temperature=0, + ) + end = time.time() + + # output_text may be None in some SDK versions; fall back to output content + output_text = response.output_text or response.output[0].content[0].text + assert output_text + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["input"] == "Say one short sentence about observability." + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert span["metadata"]["status"] == "completed" + assert span["metadata"]["id"] + assert span["metrics"]["prompt_tokens"] > 0 + assert span["metrics"]["completion_tokens"] > 0 + assert span["metrics"]["tokens"] > 0 + assert span["output"][0]["type"] == "message" + assert_metrics_are_valid(span["metrics"], start, end) + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_wrap_openrouter_responses_send_async_stream(memory_logger): + assert not memory_logger.pop() + + client = wrap_openrouter(_get_client()) + start = time.time() + result = await client.beta.responses.send_async( + model=CHAT_MODEL, + input="What is 7+7? Reply with just the number.", + stream=True, + ) + + items = [] + async for item in result: + items.append(item) + end = time.time() + + assert len(items) >= 1 + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert span["metadata"]["status"] == "completed" + assert span["metrics"]["prompt_tokens"] > 0 + assert span["metrics"]["completion_tokens"] > 0 + assert span["metrics"]["tokens"] > 0 + assert span["metrics"]["time_to_first_token"] >= 0 + assert span["output"][0]["content"][0]["text"] + assert_metrics_are_valid(span["metrics"], start, end) + + +@pytest.mark.vcr +def test_openrouter_integration_setup_creates_spans(memory_logger, monkeypatch): + assert not memory_logger.pop() + + original_send = inspect.getattr_static(Chat, "send") + original_generate = inspect.getattr_static(Embeddings, "generate") + original_responses_send = inspect.getattr_static(Responses, "send") + + assert OpenRouterIntegration.setup() + client = _get_client() + start = time.time() + response = client.chat.send( + model=CHAT_MODEL, + messages=[{"role": "user", "content": "What is 2+2? Reply with just the number."}], + max_tokens=10, + ) + end = time.time() + + monkeypatch.setattr(Chat, "send", original_send) + monkeypatch.setattr(Embeddings, "generate", original_generate) + monkeypatch.setattr(Responses, "send", original_responses_send) + + assert "4" in response.choices[0].message.content + + spans = memory_logger.pop() + assert len(spans) == 1 + span = spans[0] + assert span["metadata"]["provider"] == "openai" + assert span["metadata"]["model"] == "gpt-4o-mini" + assert "4" in span["output"][0]["message"]["content"] + assert_metrics_are_valid(span["metrics"], start, end) + + +def test_openrouter_integration_setup_is_idempotent(monkeypatch): + first_send = inspect.getattr_static(Chat, "send") + first_generate = inspect.getattr_static(Embeddings, "generate") + first_responses_send = inspect.getattr_static(Responses, "send") + + assert OpenRouterIntegration.setup() + patched_send = inspect.getattr_static(Chat, "send") + patched_generate = inspect.getattr_static(Embeddings, "generate") + patched_responses_send = inspect.getattr_static(Responses, "send") + + assert OpenRouterIntegration.setup() + assert inspect.getattr_static(Chat, "send") is patched_send + assert inspect.getattr_static(Embeddings, "generate") is patched_generate + assert inspect.getattr_static(Responses, "send") is patched_responses_send + assert patched_send is not None + assert patched_generate is not None + assert patched_responses_send is not None + + monkeypatch.setattr(Chat, "send", first_send) + monkeypatch.setattr(Embeddings, "generate", first_generate) + monkeypatch.setattr(Responses, "send", first_responses_send) + + +class TestAutoInstrumentOpenRouter: + def test_auto_instrument_openrouter(self): + verify_autoinstrument_script("test_auto_openrouter.py") diff --git a/py/src/braintrust/integrations/openrouter/tracing.py b/py/src/braintrust/integrations/openrouter/tracing.py new file mode 100644 index 00000000..1c12a3b1 --- /dev/null +++ b/py/src/braintrust/integrations/openrouter/tracing.py @@ -0,0 +1,638 @@ +"""OpenRouter-specific tracing helpers.""" + +import logging +import time +from collections.abc import AsyncIterator, Iterator +from numbers import Real +from typing import TYPE_CHECKING, Any + +from braintrust.bt_json import bt_safe_deep_copy +from braintrust.logger import start_span +from braintrust.span_types import SpanTypeAttribute + + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +_OMITTED_KEYS = { + "execute", + "render", + "nextTurnParams", + "next_turn_params", + "requireApproval", + "require_approval", +} +_TOKEN_NAME_MAP = { + "promptTokens": "prompt_tokens", + "inputTokens": "prompt_tokens", + "completionTokens": "completion_tokens", + "outputTokens": "completion_tokens", + "totalTokens": "tokens", + "prompt_tokens": "prompt_tokens", + "input_tokens": "prompt_tokens", + "completion_tokens": "completion_tokens", + "output_tokens": "completion_tokens", + "total_tokens": "tokens", +} +_TOKEN_DETAIL_PREFIX_MAP = { + "promptTokensDetails": "prompt", + "inputTokensDetails": "prompt", + "completionTokensDetails": "completion", + "outputTokensDetails": "completion", + "costDetails": "cost", + "prompt_tokens_details": "prompt", + "input_tokens_details": "prompt", + "completion_tokens_details": "completion", + "output_tokens_details": "completion", + "cost_details": "cost", +} + + +def _camel_to_snake(value: str) -> str: + out = [] + for char in value: + if char.isupper(): + out.append("_") + out.append(char.lower()) + else: + out.append(char) + return "".join(out).lstrip("_") + + +def _is_supported_metric_value(value: Any) -> bool: + return isinstance(value, Real) and not isinstance(value, bool) + + +def sanitize_openrouter_logged_value(value: Any) -> Any: + safe = bt_safe_deep_copy(value) + + if callable(safe): + return "[Function]" + if isinstance(safe, list): + return [sanitize_openrouter_logged_value(item) for item in safe] + if isinstance(safe, tuple): + return [sanitize_openrouter_logged_value(item) for item in safe] + if isinstance(safe, dict): + sanitized = {} + for key, entry in safe.items(): + if key in _OMITTED_KEYS: + continue + sanitized[key] = sanitize_openrouter_logged_value(entry) + return sanitized + return safe + + +def _parse_openrouter_model_string(model: Any) -> dict[str, Any]: + if not isinstance(model, str): + return {"model": model} + + slash_index = model.find("/") + if 0 < slash_index < len(model) - 1: + return { + "provider": model[:slash_index], + "model": model[slash_index + 1 :], + } + + return {"model": model} + + +def _build_openrouter_metadata(metadata: dict[str, Any], *, embedding: bool = False) -> dict[str, Any]: + sanitized = sanitize_openrouter_logged_value(metadata) + record = sanitized if isinstance(sanitized, dict) else {} + model = record.pop("model", None) + provider_routing = record.pop("provider", None) + normalized_model = _parse_openrouter_model_string(model) + + result = dict(record) + if normalized_model.get("model") is not None: + result["model"] = normalized_model["model"] + if provider_routing is not None: + result["provider_routing"] = provider_routing + result["provider"] = normalized_model.get("provider") or "openrouter" + if embedding and isinstance(result.get("model"), str): + result["embedding_model"] = result["model"] + return result + + +def _extract_openrouter_usage_metadata(usage: Any) -> dict[str, Any]: + if not isinstance(usage, dict): + usage = sanitize_openrouter_logged_value(usage) + if not isinstance(usage, dict): + return {} + + if isinstance(usage.get("is_byok"), bool): + return {"is_byok": usage["is_byok"]} + if isinstance(usage.get("isByok"), bool): + return {"is_byok": usage["isByok"]} + return {} + + +def _parse_openrouter_metrics_from_usage(usage: Any) -> dict[str, float]: + if not isinstance(usage, dict): + usage = sanitize_openrouter_logged_value(usage) + if not isinstance(usage, dict): + return {} + + metrics = {} + for name, value in usage.items(): + if _is_supported_metric_value(value): + metrics[_TOKEN_NAME_MAP.get(name, _camel_to_snake(name))] = float(value) + continue + + if not isinstance(value, dict): + continue + + prefix = _TOKEN_DETAIL_PREFIX_MAP.get(name) + if prefix is None: + continue + + for nested_name, nested_value in value.items(): + if _is_supported_metric_value(nested_value): + metrics[f"{prefix}_{_camel_to_snake(nested_name)}"] = float(nested_value) + + return metrics + + +def _timing_metrics(start_time: float, first_token_time: float | None = None) -> dict[str, float]: + end_time = time.time() + metrics = { + "start": start_time, + "end": end_time, + "duration": end_time - start_time, + } + if first_token_time is not None: + metrics["time_to_first_token"] = first_token_time - start_time + return metrics + + +def _merge_metrics(start_time: float, usage: Any, first_token_time: float | None = None) -> dict[str, float]: + return { + **_timing_metrics(start_time, first_token_time), + **_parse_openrouter_metrics_from_usage(usage), + } + + +def _response_to_output(response: Any, *, fallback_output: Any | None = None) -> Any: + if hasattr(response, "output") and getattr(response, "output") is not None: + return sanitize_openrouter_logged_value(getattr(response, "output")) + if hasattr(response, "choices") and getattr(response, "choices") is not None: + return sanitize_openrouter_logged_value(getattr(response, "choices")) + if fallback_output is not None: + return sanitize_openrouter_logged_value(fallback_output) + return None + + +def _response_to_metadata(response: Any, *, embedding: bool = False) -> dict[str, Any]: + if response is None: + return {} + + data = sanitize_openrouter_logged_value(response) + if not isinstance(data, dict): + return {} + + data.pop("output", None) + data.pop("choices", None) + data.pop("data", None) + usage = data.pop("usage", None) + metadata = _build_openrouter_metadata(data, embedding=embedding) + metadata.update(_extract_openrouter_usage_metadata(usage)) + return metadata + + +def _embeddings_output(response: Any) -> dict[str, Any]: + items = getattr(response, "data", None) or [] + first = items[0] if items else None + embedding = getattr(first, "embedding", None) if first is not None else None + return { + "embedding_length": len(embedding) if embedding is not None else None, + "embeddings_count": len(items), + } + + +def _start_span(name: str, span_input: Any, metadata: dict[str, Any]): + return start_span( + name=name, + type=SpanTypeAttribute.LLM, + input=sanitize_openrouter_logged_value(span_input), + metadata=metadata, + ) + + +def _log_and_end( + span: Any, *, output: Any = None, metrics: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None +): + event = {} + if output is not None: + event["output"] = output + if metrics: + event["metrics"] = metrics + if metadata: + event["metadata"] = metadata + if event: + span.log(**event) + span.end() + + +def _log_error_and_end(span: Any, error: Exception): + span.log(error=error) + span.end() + + +class _TracedOpenRouterSyncStream: + def __init__(self, stream: Any, span: Any, metadata: dict[str, Any], kind: str, start_time: float): + self._stream = stream + self._span = span + self._metadata = metadata + self._kind = kind + self._start_time = start_time + self._first_token_time = None + self._items = [] + self._closed = False + + def __iter__(self) -> Iterator[Any]: + return self + + def __next__(self) -> Any: + try: + item = next(self._stream) + except StopIteration: + self._finalize() + raise + except Exception as error: + self._finalize(error=error) + raise + + if self._first_token_time is None and _chunk_has_output(item): + self._first_token_time = time.time() + self._items.append(item) + return item + + def __enter__(self): + if hasattr(self._stream, "__enter__"): + self._stream.__enter__() + return self + + def __exit__(self, exc_type, exc_value, traceback): + try: + if hasattr(self._stream, "__exit__"): + return self._stream.__exit__(exc_type, exc_value, traceback) + return False + finally: + self._finalize(error=exc_value) + + def _finalize(self, *, error: Exception | None = None): + if self._closed: + return + self._closed = True + + if error is not None: + _log_error_and_end(self._span, error) + return + + if self._kind == "chat": + output, usage = _aggregate_chat_stream(self._items) + metadata = dict(self._metadata) + else: + output, usage, response_metadata = _aggregate_responses_stream(self._items) + metadata = {**self._metadata, **response_metadata} + + _log_and_end( + self._span, + output=output, + metrics=_merge_metrics(self._start_time, usage, self._first_token_time), + metadata=metadata, + ) + + +class _TracedOpenRouterAsyncStream: + def __init__(self, stream: Any, span: Any, metadata: dict[str, Any], kind: str, start_time: float): + self._stream = stream + self._span = span + self._metadata = metadata + self._kind = kind + self._start_time = start_time + self._first_token_time = None + self._items = [] + self._closed = False + + def __aiter__(self) -> AsyncIterator[Any]: + return self + + async def __anext__(self) -> Any: + try: + item = await self._stream.__anext__() + except StopAsyncIteration: + self._finalize() + raise + except Exception as error: + self._finalize(error=error) + raise + + if self._first_token_time is None and _chunk_has_output(item): + self._first_token_time = time.time() + self._items.append(item) + return item + + async def __aenter__(self): + if hasattr(self._stream, "__aenter__"): + await self._stream.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + try: + if hasattr(self._stream, "__aexit__"): + return await self._stream.__aexit__(exc_type, exc_value, traceback) + return False + finally: + self._finalize(error=exc_value) + + def _finalize(self, *, error: Exception | None = None): + if self._closed: + return + self._closed = True + + if error is not None: + _log_error_and_end(self._span, error) + return + + if self._kind == "chat": + output, usage = _aggregate_chat_stream(self._items) + metadata = dict(self._metadata) + else: + output, usage, response_metadata = _aggregate_responses_stream(self._items) + metadata = {**self._metadata, **response_metadata} + + _log_and_end( + self._span, + output=output, + metrics=_merge_metrics(self._start_time, usage, self._first_token_time), + metadata=metadata, + ) + + +def _chunk_has_output(item: Any) -> bool: + item_type = getattr(item, "type", None) + if isinstance(item_type, str) and ".delta" in item_type: + return True + + if hasattr(item, "choices"): + for choice in getattr(item, "choices", []) or []: + delta = getattr(choice, "delta", None) + if delta is None: + continue + if ( + getattr(delta, "content", None) + or getattr(delta, "reasoning", None) + or getattr(delta, "tool_calls", None) + ): + return True + + return False + + +def _aggregate_chat_stream(chunks: list[Any]) -> tuple[list[dict[str, Any]], Any]: + choices = {} + usage = None + + for chunk in chunks: + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage is not None: + usage = chunk_usage + + for choice in getattr(chunk, "choices", []) or []: + index = int(getattr(choice, "index", 0) or 0) + state = choices.setdefault( + index, + { + "message": {"role": "assistant", "content": ""}, + "finish_reason": None, + }, + ) + delta = getattr(choice, "delta", None) + if delta is not None: + role = getattr(delta, "role", None) + if role is not None: + state["message"]["role"] = role + + content = getattr(delta, "content", None) + if isinstance(content, str): + state["message"]["content"] += content + + reasoning = getattr(delta, "reasoning", None) + if isinstance(reasoning, str): + state["message"]["reasoning"] = state["message"].get("reasoning", "") + reasoning + + refusal = getattr(delta, "refusal", None) + if isinstance(refusal, str): + state["message"]["refusal"] = state["message"].get("refusal", "") + refusal + + tool_calls = getattr(delta, "tool_calls", None) or [] + if tool_calls: + tools = state["message"].setdefault("tool_calls", []) + for tool_call in tool_calls: + tool_index = int(getattr(tool_call, "index", len(tools)) or 0) + while len(tools) <= tool_index: + tools.append({"function": {"arguments": ""}}) + current = tools[tool_index] + tool_id = getattr(tool_call, "id", None) + if tool_id is not None: + current["id"] = tool_id + tool_type = getattr(tool_call, "type", None) + if tool_type is not None: + current["type"] = tool_type + function = getattr(tool_call, "function", None) + if function is not None: + if getattr(function, "name", None) is not None: + current.setdefault("function", {})["name"] = function.name + arguments = getattr(function, "arguments", None) + if isinstance(arguments, str): + current.setdefault("function", {}).setdefault("arguments", "") + current["function"]["arguments"] += arguments + + finish_reason = getattr(choice, "finish_reason", None) + if finish_reason is not None: + state["finish_reason"] = finish_reason + + output = [] + for index in sorted(choices): + choice = choices[index] + if not choice["message"].get("tool_calls"): + choice["message"].pop("tool_calls", None) + output.append(choice) + return output, usage + + +def _aggregate_responses_stream(chunks: list[Any]) -> tuple[Any, Any, dict[str, Any]]: + completed_response = None + usage = None + output_items = {} + + for chunk in chunks: + chunk_type = getattr(chunk, "type", None) + if chunk_type == "response.completed": + completed_response = getattr(chunk, "response", None) + usage = getattr(completed_response, "usage", None) + elif chunk_type == "response.output_item.done": + output_index = int(getattr(chunk, "output_index", 0) or 0) + output_items[output_index] = getattr(chunk, "item", None) + + if completed_response is not None: + return ( + _response_to_output(completed_response), + getattr(completed_response, "usage", None), + _response_to_metadata(completed_response), + ) + + output = [output_items[index] for index in sorted(output_items)] + return sanitize_openrouter_logged_value(output), usage, {} + + +def _finalize_chat_response(span: Any, request_metadata: dict[str, Any], result: Any, start_time: float): + _log_and_end( + span, + output=_response_to_output(result), + metrics=_merge_metrics(start_time, getattr(result, "usage", None)), + metadata={**request_metadata, **_response_to_metadata(result)}, + ) + + +def _finalize_embeddings_response(span: Any, request_metadata: dict[str, Any], result: Any, start_time: float): + _log_and_end( + span, + output=_embeddings_output(result), + metrics=_merge_metrics(start_time, getattr(result, "usage", None)), + metadata={**request_metadata, **_response_to_metadata(result, embedding=True)}, + ) + + +def _finalize_responses_response(span: Any, request_metadata: dict[str, Any], result: Any, start_time: float): + _log_and_end( + span, + output=_response_to_output(result, fallback_output=getattr(result, "output_text", None)), + metrics=_merge_metrics(start_time, getattr(result, "usage", None)), + metadata={**request_metadata, **_response_to_metadata(result)}, + ) + + +def _chat_send_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs)) + span = _start_span("openrouter.chat.send", kwargs.get("messages"), request_metadata) + start_time = time.time() + + try: + result = wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + if kwargs.get("stream"): + return _TracedOpenRouterSyncStream(result, span, request_metadata, "chat", start_time) + + _finalize_chat_response(span, request_metadata, result, start_time) + return result + + +async def _chat_send_async_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs)) + span = _start_span("openrouter.chat.send", kwargs.get("messages"), request_metadata) + start_time = time.time() + + try: + result = await wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + if kwargs.get("stream"): + return _TracedOpenRouterAsyncStream(result, span, request_metadata, "chat", start_time) + + _finalize_chat_response(span, request_metadata, result, start_time) + return result + + +def _embeddings_generate_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs), embedding=True) + span = _start_span("openrouter.embeddings.generate", kwargs.get("input"), request_metadata) + start_time = time.time() + + try: + result = wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + _finalize_embeddings_response(span, request_metadata, result, start_time) + return result + + +async def _embeddings_generate_async_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs), embedding=True) + span = _start_span("openrouter.embeddings.generate", kwargs.get("input"), request_metadata) + start_time = time.time() + + try: + result = await wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + _finalize_embeddings_response(span, request_metadata, result, start_time) + return result + + +def _responses_send_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs)) + span = _start_span("openrouter.beta.responses.send", kwargs.get("input"), request_metadata) + start_time = time.time() + + try: + result = wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + if kwargs.get("stream"): + return _TracedOpenRouterSyncStream(result, span, request_metadata, "responses", start_time) + + _finalize_responses_response(span, request_metadata, result, start_time) + return result + + +async def _responses_send_async_wrapper(wrapped, instance, args, kwargs): + request_metadata = _build_openrouter_metadata(dict(kwargs)) + span = _start_span("openrouter.beta.responses.send", kwargs.get("input"), request_metadata) + start_time = time.time() + + try: + result = await wrapped(*args, **kwargs) + except Exception as error: + _log_error_and_end(span, error) + raise + + if kwargs.get("stream"): + return _TracedOpenRouterAsyncStream(result, span, request_metadata, "responses", start_time) + + _finalize_responses_response(span, request_metadata, result, start_time) + return result + + +def wrap_openrouter(client: Any) -> Any: + """Wrap a single OpenRouter client instance for tracing.""" + from .patchers import ChatPatcher, EmbeddingsPatcher, ResponsesPatcher + + chat = getattr(client, "chat", None) + if chat is not None: + ChatPatcher.wrap_target(chat) + + embeddings = getattr(client, "embeddings", None) + if embeddings is not None: + EmbeddingsPatcher.wrap_target(embeddings) + + beta = getattr(client, "beta", None) + responses = getattr(beta, "responses", None) if beta is not None else None + if responses is not None: + ResponsesPatcher.wrap_target(responses) + + return client diff --git a/py/src/braintrust/wrappers/cassettes/test_auto_openrouter.yaml b/py/src/braintrust/wrappers/cassettes/test_auto_openrouter.yaml new file mode 100644 index 00000000..d41bfff8 --- /dev/null +++ b/py/src/braintrust/wrappers/cassettes/test_auto_openrouter.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"What is 2+2? Reply with just the + number."}],"model":"openai/gpt-4o-mini","max_tokens":10.0,"stream":false,"temperature":1.0,"top_p":1.0}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '175' + Host: + - openrouter.ai + content-type: + - application/json + user-agent: + - speakeasy-sdk/python 0.7.11 2.768.0 1.0.0 openrouter + method: POST + uri: https://openrouter.ai/api/v1/chat/completions + response: + body: + string: "\n \n{\"id\":\"gen-1774558371-5zRtxWOSxSfpEfdFoe85\",\"object\":\"chat.completion\",\"created\":1774558371,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"4\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" + headers: + Access-Control-Allow-Origin: + - '*' + CF-RAY: + - 9e290aa09c520b82-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 26 Mar 2026 20:52:52 GMT + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + content-length: + - '785' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_async.yaml b/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_async.yaml index d0514a09..8c7bcd3b 100644 --- a/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_async.yaml +++ b/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_async.yaml @@ -16,7 +16,7 @@ interactions: Host: - openrouter.ai User-Agent: - - AsyncOpenAI/Python 2.9.0 + - AsyncOpenAI/Python 2.30.0 X-Stainless-Arch: - arm64 X-Stainless-Async: @@ -26,13 +26,13 @@ interactions: X-Stainless-OS: - MacOS X-Stainless-Package-Version: - - 2.9.0 + - 2.30.0 X-Stainless-Raw-Response: - 'true' X-Stainless-Runtime: - CPython X-Stainless-Runtime-Version: - - 3.11.13 + - 3.13.3 x-stainless-read-timeout: - '600' x-stainless-retry-count: @@ -41,28 +41,18 @@ interactions: uri: https://openrouter.ai/api/v1/chat/completions response: body: - string: !!binary | - H4sIAAAAAAAAAwAAAP//dFHBbpwwFPyXOZsUwobd+tZDK0WqlF5yqKoKeeEBrzG262eiRiv+vaJk - N0Ta+jhvPDNv3gncQqMnlxX7anfY78tDmd097h4ew5fhW7wv3dfDnfz+/hkKIfpnbilC4yGQ+3QP - hdG3ZKHhAznDH/qQsp3PRnYMBX/8RU2CRjOYdNP4MVhK7B0UmkgmUQv9ZqvQDJ4bEugfJ1jfh+iP - Au0maxU6dixDHcmId9CQ5AMUnEn8TPV/puxa+gOdK4wkYnqCPiF6S9AwIizJuLSk8S6RW5JWUIjU - TWLs2XkVZdevwDz/VJAXSTQutj3FEPnf3y7URdGV+e3HrjpCYTo7hujHkOrkn8gJ9G2+OJ7LuMCF - QvLJ2DdesfAkQec3+fLKSoGlPr74J+jOWCH1XrtuKRm2spg2phmovYjlCmZq2W+B5ZobYF7ttiJT - kBTJjDW7jiK5huo10VrNlfFrnve51XWhcwXySq8o289XutkmulxjuwiPpqfNIvP8FwAA//8DADfo - HoPZAgAA + string: "\n \n{\"id\":\"gen-1774558350-ijjGWJGcVmXz4wCCKoeD\",\"object\":\"chat.completion\",\"created\":1774558350,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"6\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" headers: Access-Control-Allow-Origin: - '*' CF-RAY: - - 9a8dca6118a01c37-PDX + - 9e290a198e8c07e8-SJC Connection: - keep-alive - Content-Encoding: - - gzip Content-Type: - application/json Date: - - Thu, 04 Dec 2025 19:43:04 GMT + - Thu, 26 Mar 2026 20:52:30 GMT Permissions-Policy: - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") @@ -72,10 +62,10 @@ interactions: - cloudflare Transfer-Encoding: - chunked - Vary: - - Accept-Encoding X-Content-Type-Options: - nosniff + content-length: + - '785' status: code: 200 message: OK diff --git a/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_sync.yaml b/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_sync.yaml index faae3a00..1f33f3e1 100644 --- a/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_sync.yaml +++ b/py/src/braintrust/wrappers/cassettes/test_openrouter_chat_completion_sync.yaml @@ -16,7 +16,7 @@ interactions: Host: - openrouter.ai User-Agent: - - OpenAI/Python 2.9.0 + - OpenAI/Python 2.30.0 X-Stainless-Arch: - arm64 X-Stainless-Async: @@ -26,13 +26,13 @@ interactions: X-Stainless-OS: - MacOS X-Stainless-Package-Version: - - 2.9.0 + - 2.30.0 X-Stainless-Raw-Response: - 'true' X-Stainless-Runtime: - CPython X-Stainless-Runtime-Version: - - 3.11.13 + - 3.13.3 x-stainless-read-timeout: - '600' x-stainless-retry-count: @@ -41,28 +41,18 @@ interactions: uri: https://openrouter.ai/api/v1/chat/completions response: body: - string: !!binary | - H4sIAAAAAAAAA+JSgAEuAAAAAP//dFHRauMwEPyXeZZbOw5JqrdrnwqBwnFPdxxGsdeOrrJWaOXS - Evzvh+smdSHV4+xoZnb2BNtAoyOfFdvNerfdlrsy+x327ePP/f1q91Cu1mF/X7hfUAiRX2xDERpP - gfyPRyj03JCDBgfyxt52IWVrznrrLRT48I/qBI36aNJNzX1wlCx7KNSRTKIG+tNWoT6yrUmg/5zg - uAuRDwLtB+cUWuutHKtIRthDQxIHKHiT7AtV30ytb+gVOlfoScR0BH1CZEfQMCJWkvFpSsM+kZ+S - rqEQqR3EuLPzLGp9NwPj+FdB3iRRP9l2FEO073/bUBVFW+aru3ZzgMJwdgyR+5CqxM/kBXqVT47n - Mi5woZA4GffJKyaeJOj8Jp9euVGwUh3e+Bm6NU5IfdWuGkrGOplMa1MfqbmI5QpmaCwvgemaC2Cc - 7ZYiQ5AUyfSV9S1F8jVVc6K5mivjjzxfc6vrQucK5IO+oWw7XulmmehyjeUitjcdLRYZx/8AAAD/ - /wMAr5knr+QCAAA= + string: "\n \n{\"id\":\"gen-1774558349-aRu60cDQpslr1ttYBa4w\",\"object\":\"chat.completion\",\"created\":1774558349,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_f85b8886b6\",\"choices\":[{\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"4\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":1,\"total_tokens\":21,\"cost\":0.0000036,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000036,\"upstream_inference_prompt_cost\":0.000003,\"upstream_inference_completions_cost\":6e-7},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}" headers: Access-Control-Allow-Origin: - '*' CF-RAY: - - 9a8dca5cbc33e8e9-PDX + - 9e290a156ec03c79-SJC Connection: - keep-alive - Content-Encoding: - - gzip Content-Type: - application/json Date: - - Thu, 04 Dec 2025 19:43:03 GMT + - Thu, 26 Mar 2026 20:52:30 GMT Permissions-Policy: - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") @@ -72,10 +62,10 @@ interactions: - cloudflare Transfer-Encoding: - chunked - Vary: - - Accept-Encoding X-Content-Type-Options: - nosniff + content-length: + - '785' status: code: 200 message: OK diff --git a/py/src/braintrust/wrappers/cassettes/test_openrouter_streaming_sync.yaml b/py/src/braintrust/wrappers/cassettes/test_openrouter_streaming_sync.yaml index a2dd4a2f..6026f2be 100644 --- a/py/src/braintrust/wrappers/cassettes/test_openrouter_streaming_sync.yaml +++ b/py/src/braintrust/wrappers/cassettes/test_openrouter_streaming_sync.yaml @@ -16,7 +16,7 @@ interactions: Host: - openrouter.ai User-Agent: - - OpenAI/Python 2.9.0 + - OpenAI/Python 2.30.0 X-Stainless-Arch: - arm64 X-Stainless-Async: @@ -26,13 +26,13 @@ interactions: X-Stainless-OS: - MacOS X-Stainless-Package-Version: - - 2.9.0 + - 2.30.0 X-Stainless-Raw-Response: - 'true' X-Stainless-Runtime: - CPython X-Stainless-Runtime-Version: - - 3.11.13 + - 3.13.3 x-stainless-read-timeout: - '600' x-stainless-retry-count: @@ -41,16 +41,16 @@ interactions: uri: https://openrouter.ai/api/v1/chat/completions response: body: - string: 'data: {"id":"gen-1764877384-kItWc9W8XjV58kFEXXmd","provider":"OpenAI","model":"openai/gpt-4o-mini","object":"chat.completion.chunk","created":1764877384,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fp_11f3029f6b"} + string: ': OPENROUTER PROCESSING - data: {"id":"gen-1764877384-kItWc9W8XjV58kFEXXmd","provider":"OpenAI","model":"openai/gpt-4o-mini","object":"chat.completion.chunk","created":1764877384,"choices":[{"index":0,"delta":{"role":"assistant","content":"10"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":"fp_11f3029f6b"} + data: {"id":"gen-1774558351-mKeRYZ3LRhALYAGOy6cg","object":"chat.completion.chunk","created":1774558351,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"10","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]} - data: {"id":"gen-1764877384-kItWc9W8XjV58kFEXXmd","provider":"OpenAI","model":"openai/gpt-4o-mini","object":"chat.completion.chunk","created":1764877384,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}],"system_fingerprint":"fp_11f3029f6b"} + data: {"id":"gen-1774558351-mKeRYZ3LRhALYAGOy6cg","object":"chat.completion.chunk","created":1774558351,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}]} - data: {"id":"gen-1764877384-kItWc9W8XjV58kFEXXmd","provider":"OpenAI","model":"openai/gpt-4o-mini","object":"chat.completion.chunk","created":1764877384,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":20,"completion_tokens":1,"total_tokens":21,"cost":0.0000036,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.000003,"upstream_inference_completions_cost":6e-7},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}} + data: {"id":"gen-1774558351-mKeRYZ3LRhALYAGOy6cg","object":"chat.completion.chunk","created":1774558351,"model":"openai/gpt-4o-mini","provider":"OpenAI","system_fingerprint":"fp_f85b8886b6","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":1,"total_tokens":21,"cost":0.0000036,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000036,"upstream_inference_prompt_cost":0.000003,"upstream_inference_completions_cost":6e-7},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0,"audio_tokens":0}}} data: [DONE] @@ -61,7 +61,7 @@ interactions: Access-Control-Allow-Origin: - '*' CF-RAY: - - 9a8dca64e90e28b9-PDX + - 9e290a1d49f55fe3-SJC Cache-Control: - no-cache Connection: @@ -69,7 +69,7 @@ interactions: Content-Type: - text/event-stream Date: - - Thu, 04 Dec 2025 19:43:04 GMT + - Thu, 26 Mar 2026 20:52:31 GMT Permissions-Policy: - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") @@ -79,8 +79,6 @@ interactions: - cloudflare Transfer-Encoding: - chunked - Vary: - - Accept-Encoding X-Content-Type-Options: - nosniff status: diff --git a/py/src/braintrust/wrappers/test_openrouter.py b/py/src/braintrust/wrappers/test_openai_openrouter_gateway.py similarity index 100% rename from py/src/braintrust/wrappers/test_openrouter.py rename to py/src/braintrust/wrappers/test_openai_openrouter_gateway.py