Skip to content
Merged
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
83 changes: 83 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Separate from the generated ci.yml. stlc keeps workflow files across builds, but
# `stlc build --rewrite-scaffold` would drop this one; re-add it after such a run.
name: Tests
on:
# No job-level `if:` (unlike ci.yml): a job skipped on pull_request counts as passing
# for required checks, and the PRs to gate (promote, release-please) are same-repo.
pull_request:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: test-${{ github.ref }}
# Only PR runs: a cancelled run on main would fail the required check for a good commit.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
test:
timeout-minutes: 10
name: full suite (py3.9)
runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Install Rye
run: |
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
env:
RYE_VERSION: '0.44.0'
RYE_INSTALL_OPTION: '--yes'

- name: Bootstrap
run: ./scripts/bootstrap

- name: Run tests
run: ./scripts/test

test-integrations:
timeout-minutes: 10
name: integrations with optional SDKs (py3.12)
runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

# The `test` job skips integration tests whose optional package isn't
# installed; this one installs them. Python 3.12 because claude-agent-sdk
# needs 3.10+, and requirements-dev.lock only resolves on 3.9, so only the
# test tools are pinned from it.
- name: Install dependencies
run: |
uv venv --python 3.12
grep -E '^(pytest|pytest-asyncio|pytest-xdist|respx|dirty-equals|time-machine)==' requirements-dev.lock > test-tools.txt
# Resolve as of a day ago: a release still uploading its wheels forces a
# source build that can fail (SQLAlchemy 2.1.0 did).
# google-adk<2: 2.x wraps callback exceptions and fails test_user_exception_propagates_without_chaining.
uv pip install --exclude-newer "$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)" \
-e '.[aiohttp]' -r test-tools.txt \
openai boto3 claude-agent-sdk 'google-adk<2' google-genai langchain-core wrapt \
azure-cognitiveservices-speech

# Generated API tests need the mock server and already run in `test`.
- name: Run integration tests
run: .venv/bin/pytest --ignore=tests/api_resources

# The one check to require in rulesets, so jobs can be added or renamed freely.
# The integrations job stays advisory until its optional dependencies are pinned.
# always(), not !cancelled(): a skipped required check would count as passing.
required:
if: always()
needs: [test]
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.test.result }}" = success
2 changes: 1 addition & 1 deletion scripts/mock

Large diffs are not rendered by default.

443 changes: 314 additions & 129 deletions src/openlayer/lib/tracing/tracer.py

Large diffs are not rendered by default.

38 changes: 37 additions & 1 deletion tests/integrations/test_claude_agent_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
# ruff: noqa: ARG001

import asyncio
from typing import Any, List
import inspect
from typing import Any, List, Generator
from unittest.mock import patch

import pytest
Expand All @@ -33,6 +34,27 @@ def _disable_publish(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENLAYER_DISABLE_PUBLISH", "true")
monkeypatch.setenv("OPENLAYER_API_KEY", "fake")
monkeypatch.setattr(ol_tracer, "_publish", False, raising=False)
# Publish inline so tests that capture the upload don't race the executor.
monkeypatch.setitem(ol_tracer._tracer_config, "background_publish_enabled", False)


@pytest.fixture(autouse=True)
def _undo_sdk_patches(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
"""trace_claude_agent_sdk() patches the SDK globally; undo it after each test."""
try:
import claude_agent_sdk as cas
except ImportError:
yield
return
monkeypatch.setattr(cas, "query", cas.query)
for name in ("__init__", "query", "receive_response"):
monkeypatch.setattr(
cas.ClaudeSDKClient, name, inspect.getattr_static(cas.ClaudeSDKClient, name)
)
yield
# Not via monkeypatch: some tests delete the flag themselves.
if "_openlayer_patched" in vars(cas.ClaudeSDKClient):
del cas.ClaudeSDKClient._openlayer_patched


def _capture_trace_publish():
Expand Down Expand Up @@ -65,6 +87,7 @@ def test_traced_query_emits_root_agent_step_with_cost_and_tokens():
The root AGENT step gets cost/tokens/session_id populated from the final
``ResultMessage``.
"""
pytest.importorskip("claude_agent_sdk")
from openlayer.lib.integrations.claude_agent_sdk import traced_query

messages = [
Expand Down Expand Up @@ -178,6 +201,7 @@ async def run():

def test_assistant_message_emits_chat_completion_step():
"""Each AssistantMessage becomes a CHAT_COMPLETION child of the root step."""
pytest.importorskip("claude_agent_sdk")
from openlayer.lib.integrations.claude_agent_sdk import traced_query

messages = [
Expand Down Expand Up @@ -242,6 +266,7 @@ def _extract_hook_callbacks(options, event: str):

def test_tool_call_creates_tool_step_with_input_and_output():
"""A tool call yields a TOOL step with input/output/latency/tool_use_id."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -311,6 +336,7 @@ async def run():

def test_mcp_tool_name_is_parsed_into_metadata():
"""A tool named ``mcp__playwright__browser_click`` records the parsed metadata."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -375,6 +401,7 @@ async def run():

def test_subagent_messages_nest_under_agent_tool_step():
"""A message with ``parent_tool_use_id`` nests under the spawning Agent ToolStep."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -481,6 +508,7 @@ def test_subagent_internal_tool_calls_nest_under_agent_step():
message says ``[tool call: Grep]`` but the actual Grep TOOL step was being
created as a child of root instead of the ``Agent: ...`` AGENT step.
"""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -612,6 +640,7 @@ async def run():

def test_result_message_error_subtype_marks_root_step():
"""An error ResultMessage subtype is reflected on the root step's metadata."""
pytest.importorskip("claude_agent_sdk")
from openlayer.lib.integrations.claude_agent_sdk import traced_query

messages = [
Expand Down Expand Up @@ -649,6 +678,7 @@ async def run():

def test_post_tool_use_failure_marks_tool_step_as_error():
"""PostToolUseFailure fires instead of PostToolUse — the tool step is marked errored."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -708,6 +738,7 @@ async def run():

def test_user_hooks_compose_with_openlayer_hooks():
"""User-provided hooks run alongside ours; neither replaces the other."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk as cas

from openlayer.lib.integrations.claude_agent_sdk import traced_query
Expand Down Expand Up @@ -785,6 +816,7 @@ async def run():

def test_mcp_env_is_stripped_from_agent_config_metadata():
"""``env`` and ``headers`` of MCP server configs must be redacted."""
pytest.importorskip("claude_agent_sdk")
from openlayer.lib.integrations.claude_agent_sdk import traced_query

messages = [
Expand Down Expand Up @@ -833,6 +865,7 @@ async def run():

def test_trace_claude_agent_sdk_patches_module_query():
"""``trace_claude_agent_sdk()`` monkey-patches ``claude_agent_sdk.query``."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk

from openlayer.lib.integrations.claude_agent_sdk import trace_claude_agent_sdk
Expand All @@ -854,6 +887,7 @@ def test_trace_claude_agent_sdk_patches_module_query():

def test_trace_claude_agent_sdk_config_persists():
"""Init kwargs are persisted into the module-level config."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk

from openlayer.lib.integrations import claude_agent_sdk as integration
Expand Down Expand Up @@ -882,6 +916,7 @@ def test_trace_claude_agent_sdk_config_persists():

def test_trace_claude_agent_sdk_patches_claude_sdk_client():
"""``trace_claude_agent_sdk()`` also patches ``ClaudeSDKClient.query`` / ``.receive_response``."""
pytest.importorskip("claude_agent_sdk")
import claude_agent_sdk

from openlayer.lib.integrations.claude_agent_sdk import trace_claude_agent_sdk
Expand Down Expand Up @@ -912,6 +947,7 @@ def test_trace_claude_agent_sdk_patches_claude_sdk_client():

def test_wrapped_stream_yields_identical_messages_in_identical_order():
"""The wrapper is a pure observer — output must equal the underlying stream."""
pytest.importorskip("claude_agent_sdk")
from openlayer.lib.integrations.claude_agent_sdk import traced_query

original_messages = [
Expand Down
3 changes: 3 additions & 0 deletions tests/lib/integrations/test_langchain_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def _disable_publish(monkeypatch: pytest.MonkeyPatch) -> None:
from openlayer.lib.tracing import tracer as _tracer

monkeypatch.setattr(_tracer, "_publish", False, raising=False)
# Process traces inline: a background upload outlives this fixture, runs
# after _publish is restored, and builds a real client for later tests.
monkeypatch.setitem(_tracer._tracer_config, "background_publish_enabled", False)


def _tool_call(name: str, args: dict, call_id: str) -> dict:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_async_openai_embedding_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import asyncio
from unittest.mock import Mock, AsyncMock, MagicMock, patch

import pytest


class TestOpenAIAsyncEmbedding:
def _fake_response(self, embeddings, prompt_tokens=4, model="text-embedding-3-small"):
Expand Down Expand Up @@ -117,7 +119,7 @@ def test_handle_embedding_async_labels_azure_clients_correctly(self) -> None:
assert kwargs["provider"] == "Azure"

def test_trace_async_openai_patches_embeddings_create(self) -> None:
import openai # pyright: ignore[reportMissingImports]
openai = pytest.importorskip("openai")

from openlayer.lib.integrations.async_openai_tracer import trace_async_openai

Expand Down
4 changes: 4 additions & 0 deletions tests/test_bedrock_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
import json
from unittest.mock import MagicMock, patch

import pytest

pytest.importorskip("boto3") # bedrock_tracer needs boto3 (and botocore, its dependency)


class TestBedrockChatRegression:
"""Lock in existing chat-completion behaviour before refactoring."""
Expand Down
8 changes: 4 additions & 4 deletions tests/test_litellm_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def test_trace_litellm_raises_import_error_without_dependency(self):
assert "pip install litellm" in str(exc_info.value) # type: ignore

@patch('openlayer.lib.integrations.litellm_tracer.HAVE_LITELLM', True)
@patch('openlayer.lib.integrations.litellm_tracer.litellm')
@patch('openlayer.lib.integrations.litellm_tracer.litellm', create=True)
def test_trace_litellm_patches_completion(self, mock_litellm: Mock) -> None:
"""Test that trace_litellm successfully patches litellm.completion."""
from openlayer.lib.integrations.litellm_tracer import trace_litellm
Expand Down Expand Up @@ -261,7 +261,7 @@ def test_extract_litellm_metadata(self):
assert metadata == expected_metadata

@patch('openlayer.lib.integrations.litellm_tracer.HAVE_LITELLM', True)
@patch('openlayer.lib.integrations.litellm_tracer.litellm')
@patch('openlayer.lib.integrations.litellm_tracer.litellm', create=True)
def test_detect_provider_with_litellm_method(self, mock_litellm: Mock) -> None:
"""Test provider detection using LiteLLM's get_llm_provider method."""
from openlayer.lib.integrations.litellm_tracer import detect_provider_from_response
Expand All @@ -281,7 +281,7 @@ class TestLiteLLMEmbedding:
"""Embedding calls must be traced via add_embedding_step_to_trace."""

@patch("openlayer.lib.integrations.litellm_tracer.HAVE_LITELLM", True)
@patch("openlayer.lib.integrations.litellm_tracer.litellm")
@patch("openlayer.lib.integrations.litellm_tracer.litellm", create=True)
def test_trace_litellm_patches_embedding(self, mock_litellm: Mock) -> None:
from openlayer.lib.integrations import litellm_tracer

Expand Down Expand Up @@ -428,7 +428,7 @@ def test_handle_embedding_failure_does_not_break_client(self) -> None:
assert result is fake_response

@patch("openlayer.lib.integrations.litellm_tracer.HAVE_LITELLM", True)
@patch("openlayer.lib.integrations.litellm_tracer.litellm")
@patch("openlayer.lib.integrations.litellm_tracer.litellm", create=True)
def test_completion_path_unchanged_after_embedding_patch(
self, mock_litellm: Mock
) -> None:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_openai_embedding_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from unittest.mock import Mock, MagicMock, patch

import pytest


class TestOpenAISyncEmbedding:
"""Sync OpenAI client.embeddings.create must be traced."""
Expand Down Expand Up @@ -112,7 +114,7 @@ def test_handle_embedding_labels_azure_clients_correctly(self) -> None:

def test_trace_openai_patches_embeddings_create(self) -> None:
"""After trace_openai, client.embeddings.create is replaced."""
import openai # pyright: ignore[reportMissingImports]
openai = pytest.importorskip("openai")

from openlayer.lib.integrations.openai_tracer import trace_openai

Expand Down
Loading
Loading