Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/openrouter-python-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: OpenRouter Python SDK contracts

on:
pull_request:
branches: [main, dev]
paths:
- 'python/frameworks/openrouter/**'
- 'python/frameworks/openai/**'
- 'python/fi_instrumentation/**'
- '.github/workflows/openrouter-python-tests.yml'
push:
branches: [dev]
paths:
- 'python/frameworks/openrouter/**'
- 'python/frameworks/openai/**'
- 'python/fi_instrumentation/**'
- '.github/workflows/openrouter-python-tests.yml'

permissions:
contents: read

jobs:
sdk-contract:
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- python: '3.10'
openrouter: '1.3.0'
artifact: wheel
- python: '3.13'
openrouter: '1.3.107'
artifact: sdist
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python }}
- name: Build and install packages
shell: bash
env:
ARTIFACT: ${{ matrix.artifact }}
OPENROUTER_VERSION: ${{ matrix.openrouter }}
run: |
python -m venv .openrouter-test-venv
.openrouter-test-venv/bin/pip install build
.openrouter-test-venv/bin/python -m build "--$ARTIFACT" python/frameworks/openrouter
.openrouter-test-venv/bin/python -m build "--$ARTIFACT" python/frameworks/openai
if [ "$ARTIFACT" = wheel ]; then
packages=(python/frameworks/{openrouter,openai}/dist/*.whl)
else
packages=(python/frameworks/{openrouter,openai}/dist/*.tar.gz)
fi
.openrouter-test-venv/bin/pip install "${packages[@]}" \
"openrouter==$OPENROUTER_VERSION" pytest pytest-asyncio
- name: Run SDK and attribution tests without external traffic
shell: bash
run: |
sudo unshare --net bash -c '
set -e
ip link set lo up
"$1" -I -m pytest python/frameworks/openrouter/tests \
python/frameworks/openai/tests/test_openai_compatible_spans.py \
-q -p no:cacheprovider
' _ "$PWD/.openrouter-test-venv/bin/python"
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,6 @@
"python": "1.2.2",
"typescript/packages/traceai_elevenlabs": "0.1.0",
"go/bedrock": "0.1.0",
"typescript/packages/traceai_huggingface_transformers": "0.1.0"
"typescript/packages/traceai_huggingface_transformers": "0.1.0",
"python/frameworks/openrouter": "0.1.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ async def run():

@pytest.mark.parametrize("base_url,provider", [
("https://api.openai.com/v1", "openai"),
("https://openrouter.ai/api/v1", "openrouter"),
("https://OPENROUTER.AI./api/v1", "openrouter"),
("https://openrouter.ai.customer.example/api/v1", "openai"),
("https://my-resource.openai.azure.com/openai/v1/", "azure"),
("https://my-resource.services.ai.azure.com/openai/v1/", "azure"),
("https://generativelanguage.googleapis.com/v1beta/openai/", "google"),
Expand Down
1 change: 1 addition & 0 deletions python/frameworks/openai/traceai_openai/_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# Exact API hosts.
_HOSTS = {
"api.openai.com": FiLLMProviderValues.OPENAI.value,
"openrouter.ai": "openrouter",
"ollama.com": "ollama",
"generativelanguage.googleapis.com": FiLLMProviderValues.GOOGLE.value,
"api.anthropic.com": FiLLMProviderValues.ANTHROPIC.value,
Expand Down
5 changes: 5 additions & 0 deletions python/frameworks/openrouter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 0.1.0

- Instrument the dedicated OpenRouter Python SDK's chat, embeddings, and Responses methods, including asynchronous requests and stream completion.
32 changes: 32 additions & 0 deletions python/frameworks/openrouter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# OpenRouter Python SDK tracing

Trace calls made through the dedicated [`openrouter` Python SDK](https://pypi.org/project/openrouter/) with the maintained `traceAI-openrouter` package. For the OpenAI-compatible client, use `traceAI-openai` instead; do not instrument the same request with both packages or HTTPX tracing.

```bash
pip install traceAI-openrouter
```

Set `FI_API_KEY`, `FI_SECRET_KEY`, and `OPENROUTER_API_KEY` in your environment. Then:

```python
import os

from fi_instrumentation import register
from fi_instrumentation.fi_types import ProjectType
from openrouter import OpenRouter
from traceai_openrouter import OpenRouterInstrumentor

provider = register(project_type=ProjectType.OBSERVE, project_name="openrouter-demo")
OpenRouterInstrumentor().instrument(tracer_provider=provider)
client = OpenRouter(api_key=os.environ["OPENROUTER_API_KEY"])
answer = client.chat.send(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "What is the capital of Japan?"}],
)
print(answer.choices[0].message.content)
provider.force_flush()
```

Find the `openrouter.chat` span in your Future AGI project. The adapter covers synchronous and asynchronous Chat, Embeddings, Responses and deprecated `beta.responses` calls. Streams finish when fully consumed or closed; final usage is available only when the server sends a usage chunk. For early exits, close the stream, or use a context manager.

The default TraceConfig captures plain-text chat messages, plain-text Responses inputs and textual outputs, capped at 32 KiB. Structured or multimodal input, embedding vectors and content, image payloads, exception messages and stacks, and authorization headers are never included. Pass `TraceConfig(hide_inputs=True, hide_outputs=True)` to `instrument(config=...)` to remove prompt/response content. Directional message, text and image privacy flags also suppress the corresponding generic input/output attributes; hiding input images suppresses the entire input value because a text string can contain an image reference. The package does not send requests to OpenRouter on its own.
26 changes: 26 additions & 0 deletions python/frameworks/openrouter/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[tool.poetry]
name = "traceAI-openrouter"
version = "0.1.0"
description = "OpenTelemetry instrumentation for the dedicated OpenRouter Python SDK"
authors = ["Future AGI <no-reply@futureagi.com>"]
readme = "README.md"
license = "Apache-2.0"
packages = [{ include = "traceai_openrouter" }]
classifiers = ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Framework :: OpenTelemetry :: Instrumentations"]

[tool.poetry.dependencies]
python = ">=3.10,<3.14"
fi-instrumentation-otel = ">=1.1.0"
openrouter = ">=1.3.0,<2"
wrapt = ">=1.15.0"

[tool.poetry.group.dev.dependencies]
pytest = ">=7"
pytest-asyncio = ">=0.23"

[tool.poetry.urls]
Repository = "https://github.com/future-agi/traceAI/tree/dev/python/frameworks/openrouter"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
216 changes: 216 additions & 0 deletions python/frameworks/openrouter/tests/test_instrumentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Real OpenRouter SDK calls with synthetic responses and no external traffic."""
import asyncio
import json

import httpx
import pytest
from openrouter import OpenRouter
from fi_instrumentation import TraceConfig
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from traceai_openrouter import OpenRouterInstrumentor


@pytest.fixture
def spans():
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
instrumentor = OpenRouterInstrumentor()
instrumentor.instrument(tracer_provider=provider)
yield exporter
instrumentor.uninstrument()
provider.shutdown()


def sdk(handler):
transport = httpx.MockTransport(handler)
return OpenRouter(api_key="synthetic-key", client=httpx.Client(transport=transport),
async_client=httpx.AsyncClient(transport=transport))


def chat_body():
return {"id": "chat-1", "object": "chat.completion", "created": 1,
"model": "vendor/served", "system_fingerprint": None, "choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "Tokyo"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}


def attributes(spans):
finished = spans.get_finished_spans()
assert len(finished) == 1
return dict(finished[0].attributes)


def test_chat_sync_records_model_usage_content_without_secret(spans):
seen = []
def handle(req):
seen.append(req)
return httpx.Response(200, json=chat_body())
client = sdk(handle)
result = client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}])
assert result.choices[0].message.content == "Tokyo"
a = attributes(spans)
assert len(seen) == 1 and seen[0].url.host == "openrouter.ai"
assert a["gen_ai.provider.name"] == "openrouter"
assert a["gen_ai.request.model"] == "vendor/request"
assert a["gen_ai.response.model"] == "vendor/served"
assert a["gen_ai.usage.input_tokens"] == 3
assert a["gen_ai.usage.output_tokens"] == 2
assert a["output.value"] == "Tokyo"
assert "synthetic-key" not in str(a)


def test_chat_async(spans):
client = sdk(lambda req: httpx.Response(200, json=chat_body()))
result = asyncio.run(client.chat.send_async(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}]))
assert result.choices[0].message.content == "Tokyo"
assert attributes(spans)["gen_ai.usage.input_tokens"] == 3


def test_error_does_not_export_response_or_key(spans):
client = sdk(lambda req: httpx.Response(401, json={"error": {"message": "synthetic-key is invalid"}}))
with pytest.raises(Exception):
client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}])
a = attributes(spans)
assert a["error.type"]
assert "synthetic-key" not in str(a)


def test_chat_stream_finishes_after_consumption_with_final_usage(spans):
chunks = [{"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]}
for text in ("Tok", "yo")]
chunks.append({"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}})
body = "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n"
client = sdk(lambda req: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()))
stream = client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}], stream=True)
assert not spans.get_finished_spans()
assert list(stream)
a = attributes(spans)
assert a["output.value"] == "Tokyo"
assert a["gen_ai.usage.input_tokens"] == 3


def test_embedding_has_usage_and_no_vector_content(spans):
client = sdk(lambda req: httpx.Response(200, json={
"object": "list", "model": "vendor/embed", "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
"usage": {"prompt_tokens": 4, "total_tokens": 4}}))
answer = client.embeddings.generate(model="vendor/embed", input="secret input")
assert len(answer.data[0].embedding) == 2
a = attributes(spans)
assert a["gen_ai.operation.name"] == "embeddings"
assert a["gen_ai.usage.input_tokens"] == 4
assert "input.value" not in a and "output.value" not in a


def test_responses_and_beta_alias(spans):
body = {"id": "resp_1", "object": "response", "created_at": 1, "completed_at": 2,
"error": None, "frequency_penalty": None, "incomplete_details": None,
"instructions": None, "metadata": None, "model": "vendor/served",
"output": [], "output_text": "Tokyo", "parallel_tool_calls": False,
"presence_penalty": None, "status": "completed", "temperature": None,
"tool_choice": "auto", "tools": [], "top_p": None,
"usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0}}}
client = sdk(lambda req: httpx.Response(200, json=body))
assert client.responses.send(model="vendor/request", input="Japan?").output_text == "Tokyo"
assert client.beta.responses.send(model="vendor/request", input="Japan?").output_text == "Tokyo"
finished = spans.get_finished_spans()
assert len(finished) == 2
assert all(s.attributes["gen_ai.provider.name"] == "openrouter" for s in finished)
assert all(s.attributes.get("gen_ai.usage.output_tokens") == 2 for s in finished), [dict(s.attributes) for s in finished]


def test_responses_stream_final_event_usage(spans):
response = {"id": "resp_1", "object": "response", "created_at": 1, "completed_at": 2,
"error": None, "frequency_penalty": None, "incomplete_details": None,
"instructions": None, "metadata": None, "model": "vendor/served",
"output": [], "output_text": "Tokyo", "parallel_tool_calls": False,
"presence_penalty": None, "status": "completed", "temperature": None,
"tool_choice": "auto", "tools": [], "top_p": None,
"usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0}}}
event = {"type": "response.completed", "sequence_number": 1, "response": response}
payload = "data: " + json.dumps(event) + "\n\ndata: [DONE]\n\n"
client = sdk(lambda req: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=payload.encode()))
stream = client.responses.send(model="vendor/request", input="Japan?", stream=True)
assert list(stream)
a = attributes(spans)
assert a["gen_ai.response.model"] == "vendor/served"
assert a["gen_ai.usage.output_tokens"] == 2
assert a["output.value"] == "Tokyo"


def test_stream_early_close_marks_incomplete_without_claiming_final_text(spans):
chunks = [{"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [{"index": 0, "delta": {"content": "Tok"}, "finish_reason": None}]}]
body = "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n"
client = sdk(lambda req: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()))
with client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}], stream=True) as stream:
next(stream)
a = attributes(spans)
assert a["openrouter.stream.incomplete"] is True
assert "output.value" not in a


def test_chat_async_stream_usage(spans):
chunks = [{"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [{"index": 0, "delta": {"content": "Tokyo"}, "finish_reason": None}]},
{"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}]
body = "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n"
client = sdk(lambda req: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()))
async def run():
stream = await client.chat.send_async(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}], stream=True)
return [chunk async for chunk in stream]
assert len(asyncio.run(run())) == 2
a = attributes(spans)
assert a["output.value"] == "Tokyo"
assert a["gen_ai.usage.input_tokens"] == 3


def test_chat_async_close_ends_incomplete_span(spans):
chunk = {"id": "chat-1", "object": "chat.completion.chunk", "created": 1,
"model": "vendor/served", "choices": [{"index": 0, "delta": {"content": "Tok"}, "finish_reason": None}]}
body = "data: " + json.dumps(chunk) + "\n\ndata: [DONE]\n\n"
client = sdk(lambda req: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()))
async def run():
stream = await client.chat.send_async(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}], stream=True)
await stream.__anext__()
await stream.close()
asyncio.run(run())
assert attributes(spans)["openrouter.stream.incomplete"] is True


def test_hide_inputs_outputs(spans):
OpenRouterInstrumentor().uninstrument()
provider = TracerProvider()
exporter = InMemorySpanExporter()
provider.add_span_processor(SimpleSpanProcessor(exporter))
instrumentor = OpenRouterInstrumentor()
instrumentor.instrument(tracer_provider=provider, config=TraceConfig(hide_inputs=True, hide_outputs=True))
try:
client = sdk(lambda req: httpx.Response(200, json=chat_body()))
client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "secret prompt"}])
a = attributes(exporter)
assert "input.value" not in a and "output.value" not in a
assert a["gen_ai.usage.input_tokens"] == 3
finally:
instrumentor.uninstrument()
provider.shutdown()


def test_uninstrument_restores_methods(spans):
instrumentor = OpenRouterInstrumentor()
instrumentor.uninstrument()
client = sdk(lambda req: httpx.Response(200, json=chat_body()))
client.chat.send(model="vendor/request", messages=[{"role": "user", "content": "Japan?"}])
assert spans.get_finished_spans() == ()
Loading
Loading