Files
zeekay 3ca982343e rebrand: remove all compat aliases, zero posthog refs
- Remove Posthog = Insights alias from __init__.py
- Remove PosthogContextMiddleware alias from django.py
- Remove PostHogTracingProcessor alias from processor.py
- Change $lib from "posthog-python" to "insights-python"
- Change ingestion URLs from posthog.com to insights.hanzo.ai
- Rename all posthog_* kwargs to insights_* across AI wrappers
- Rename __posthog_exception_captured to __insights_exception_captured
- Rename posthog_context_stack contextvar to insights_context_stack
- Rename posthog🎏 Redis prefix to insights🎏
- Rename $$_posthog_redacted_* sentinels to $$_insights_redacted_*
- Remove POSTHOG_MW_* Django settings fallback, X-POSTHOG-* headers
- Rename Prompts(posthog=) param to Prompts(client=)
- Update APP_ENDPOINT to us.insights.hanzo.ai
- Update all tests, examples, docs, mypy config
2026-03-13 20:29:13 -07:00

1403 lines
47 KiB
Python

import json
from unittest.mock import patch
import pytest
from hanzo_insights import identify_context, new_context
try:
from anthropic.types import Message, Usage
from hanzo_insights.ai.anthropic import Anthropic, AsyncAnthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
# Skip all tests if Anthropic is not available
pytestmark = pytest.mark.skipif(
not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available"
)
# =======================
# Reusable Mock Helpers
# =======================
class MockContent:
"""Reusable mock content class for Anthropic responses."""
def __init__(self, text="Bar", content_type="text"):
self.type = content_type
self.text = text
class MockUsage:
"""Reusable mock usage class for Anthropic responses."""
def __init__(
self,
input_tokens=18,
output_tokens=1,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
):
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.cache_read_input_tokens = cache_read_input_tokens
self.cache_creation_input_tokens = cache_creation_input_tokens
class MockResponse:
"""Reusable mock response class for Anthropic messages."""
def __init__(
self,
content_text="Bar",
model="claude-3-opus-20240229",
input_tokens=18,
output_tokens=1,
cache_read=0,
cache_creation=0,
):
self.content = [MockContent(text=content_text)]
self.model = model
self.usage = MockUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_input_tokens=cache_read,
cache_creation_input_tokens=cache_creation,
)
def create_mock_response(**kwargs):
"""Factory function to create mock responses with custom parameters."""
return MockResponse(**kwargs)
# Streaming mock helpers
class MockStreamEvent:
"""Reusable mock event class for streaming responses."""
def __init__(self, event_type=None, **kwargs):
self.type = event_type
for key, value in kwargs.items():
setattr(self, key, value)
class MockContentBlock:
"""Reusable mock content block for streaming."""
def __init__(self, block_type, **kwargs):
self.type = block_type
for key, value in kwargs.items():
setattr(self, key, value)
class MockDelta:
"""Reusable mock delta for streaming events."""
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@pytest.fixture
def mock_anthropic_response():
return Message(
id="msg_123",
type="message",
role="assistant",
content=[{"type": "text", "text": "Test response"}],
model="claude-3-opus-20240229",
usage=Usage(
input_tokens=20,
output_tokens=10,
),
stop_reason="end_turn",
stop_sequence=None,
)
@pytest.fixture
def mock_anthropic_stream_with_tools():
"""Mock stream events for tool calls."""
class MockMessage:
def __init__(self):
self.usage = MockUsage(
input_tokens=50,
cache_creation_input_tokens=0,
cache_read_input_tokens=5,
)
def stream_generator():
# Message start with usage
event = MockStreamEvent("message_start")
event.message = MockMessage()
yield event
# Text block start
event = MockStreamEvent("content_block_start")
event.content_block = MockContentBlock("text")
event.index = 0
yield event
# Text delta
event = MockStreamEvent("content_block_delta")
event.delta = MockDelta(text="I'll check the weather for you.")
event.index = 0
yield event
# Text block stop
event = MockStreamEvent("content_block_stop")
event.index = 0
yield event
# Tool use block start
event = MockStreamEvent("content_block_start")
event.content_block = MockContentBlock(
"tool_use", id="toolu_stream123", name="get_weather"
)
event.index = 1
yield event
# Tool input delta 1
event = MockStreamEvent("content_block_delta")
event.delta = MockDelta(
type="input_json_delta", partial_json='{"location": "San'
)
event.index = 1
yield event
# Tool input delta 2
event = MockStreamEvent("content_block_delta")
event.delta = MockDelta(
type="input_json_delta", partial_json=' Francisco", "unit": "celsius"}'
)
event.index = 1
yield event
# Tool block stop
event = MockStreamEvent("content_block_stop")
event.index = 1
yield event
# Message delta with final usage
event = MockStreamEvent("message_delta")
event.usage = MockUsage(output_tokens=25)
yield event
# Message stop
event = MockStreamEvent("message_stop")
yield event
return stream_generator()
@pytest.fixture
def mock_anthropic_response_with_cached_tokens():
# Create a mock Usage object with cached_tokens in input_tokens_details
usage = Usage(
input_tokens=20,
output_tokens=10,
cache_read_input_tokens=15,
cache_creation_input_tokens=2,
)
return Message(
id="msg_123",
type="message",
role="assistant",
content=[{"type": "text", "text": "Test response"}],
model="claude-3-opus-20240229",
usage=usage,
stop_reason="end_turn",
stop_sequence=None,
)
@pytest.fixture
def mock_anthropic_response_with_tool_calls():
return Message(
id="msg_456",
type="message",
role="assistant",
content=[
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": {"location": "San Francisco"},
},
],
model="claude-3-5-sonnet-20241022",
usage=Usage(
input_tokens=25,
output_tokens=15,
),
stop_reason="tool_use",
stop_sequence=None,
)
@pytest.fixture
def mock_anthropic_response_tool_calls_only():
return Message(
id="msg_789",
type="message",
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_def456",
"name": "get_weather",
"input": {"location": "New York", "unit": "fahrenheit"},
}
],
model="claude-3-5-sonnet-20241022",
usage=Usage(
input_tokens=30,
output_tokens=12,
),
stop_reason="tool_use",
stop_sequence=None,
)
def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert props["$ai_tokens_source"] == "sdk"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_tokens_source_passthrough(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"$ai_input_tokens": 99999},
)
props = mock_client.capture.call_args[1]["properties"]
assert props["$ai_tokens_source"] == "passthrough"
assert props["$ai_input_tokens"] == 99999
def test_groups(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_groups={"company": "test_company"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
assert call_args["groups"] == {"company": "test_company"}
def test_privacy_mode_local(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=True,
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_privacy_mode_global(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
mock_client.privacy_mode = True
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=False,
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_basic_integration(mock_client):
"""Test basic non-streaming integration."""
with patch(
"anthropic.resources.Messages.create",
return_value=create_mock_response(),
):
client = Anthropic(insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Foo"}],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
system="You must always answer with 'Bar'.",
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [
{"role": "system", "content": "You must always answer with 'Bar'."},
{"role": "user", "content": "Foo"},
]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_output_choices"][0]["content"] == [
{"type": "text", "text": "Bar"}
]
assert props["$ai_input_tokens"] == 18
assert props["$ai_output_tokens"] == 1
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
async def test_basic_async_integration(mock_client):
"""Test async non-streaming integration."""
# Make the mock async
async def mock_async_create(**kwargs):
return create_mock_response(input_tokens=16)
with patch(
"anthropic.resources.messages.AsyncMessages.create",
side_effect=mock_async_create,
):
client = AsyncAnthropic(insights_client=mock_client)
await client.messages.create(
model="claude-3-opus-20240229",
messages=[
{"role": "user", "content": "You must always answer with 'Bar'."}
],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [
{"role": "user", "content": "You must always answer with 'Bar'."}
]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_input_tokens"] == 16
assert props["$ai_output_tokens"] == 1
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
async def test_async_streaming_system_prompt(mock_client):
"""Test async streaming with system prompt."""
# Create a simple mock async stream using reusable helpers
async def mock_async_stream():
# Yield some events
yield MockStreamEvent(type="message_start")
yield MockStreamEvent(type="content_block_start")
yield MockStreamEvent(type="content_block_delta", text="Bar")
# Final message with usage
final_msg = MockStreamEvent(type="message_delta")
final_msg.usage = MockUsage(
input_tokens=10,
output_tokens=5,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
)
yield final_msg
# Mock create to return a coroutine that yields the async generator
# This matches the actual behavior when stream=True with await
async def async_create_wrapper(**kwargs):
return mock_async_stream()
with patch(
"anthropic.resources.messages.AsyncMessages.create",
side_effect=async_create_wrapper,
):
client = AsyncAnthropic(insights_client=mock_client)
response = await client.messages.create(
model="claude-3-opus-20240229",
system="You must always answer with 'Bar'.",
messages=[{"role": "user", "content": "Foo"}],
stream=True,
max_tokens=1,
)
# Consume the stream - async finally block completes before this returns
[c async for c in response]
# Capture happens in the async finally block before generator completes
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{"role": "system", "content": "You must always answer with 'Bar'."},
{"role": "user", "content": "Foo"},
]
def test_error(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", side_effect=Exception("Test error")
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with pytest.raises(Exception):
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_is_error"] is True
assert props["$ai_error"] == "Test error"
def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_cached_tokens,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_cached_tokens
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
assert props["$ai_cache_creation_input_tokens"] == 2
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_definition(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a specific location",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location name to get weather for",
}
},
"required": ["location"],
},
}
]
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
temperature=0.7,
tools=tools,
messages=[{"role": "user", "content": "hey"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
def test_tool_calls_in_output_choices(
mock_client, mock_anthropic_response_with_tool_calls
):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_calls,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
],
insights_distinct_id="test-id",
)
assert response == mock_anthropic_response_with_tool_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"id": "toolu_abc123",
"function": {
"name": "get_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_tool_calls_only_no_content(
mock_client, mock_anthropic_response_tool_calls_only
):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_tool_calls_only,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{"role": "user", "content": "Get weather for New York"}],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
],
insights_distinct_id="test-id",
)
assert response == mock_anthropic_response_tool_calls_only
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "function",
"id": "toolu_def456",
"function": {
"name": "get_weather",
"arguments": {"location": "New York", "unit": "fahrenheit"},
},
}
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 12
assert props["$ai_http_status"] == 200
def test_async_tool_calls_in_output_choices(
mock_client, mock_anthropic_response_with_tool_calls
):
import asyncio
async def mock_async_create(**kwargs):
return mock_anthropic_response_with_tool_calls
with patch(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async def run_test():
return await async_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
],
insights_distinct_id="test-id",
)
response = asyncio.run(run_test())
assert response == mock_anthropic_response_with_tool_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll help you check the weather."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"id": "toolu_abc123",
"function": {
"name": "get_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
"""Test that tool calls are properly captured in streaming mode."""
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_tools,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
system="You are a helpful weather assistant.",
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather information",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
],
stream=True,
insights_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
list(response)
# Capture happens synchronously when generator is exhausted
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
# Verify system prompt is included in input
assert props["$ai_input"] == [
{"role": "system", "content": "You are a helpful weather assistant."},
{"role": "user", "content": "What's the weather in San Francisco?"},
]
# Verify that tools are captured in the properties
assert props["$ai_tools"] == [
{
"name": "get_weather",
"description": "Get weather information",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
]
# Verify output contains both text and tool call
output_choices = props["$ai_output_choices"]
assert len(output_choices) == 1
assistant_message = output_choices[0]
assert assistant_message["role"] == "assistant"
content = assistant_message["content"]
assert isinstance(content, list)
assert len(content) == 2
# Verify text block
text_block = content[0]
assert text_block["type"] == "text"
assert text_block["text"] == "I'll check the weather for you."
# Verify tool call block
tool_block = content[1]
assert tool_block["type"] == "function"
assert tool_block["id"] == "toolu_stream123"
assert tool_block["function"]["name"] == "get_weather"
assert tool_block["function"]["arguments"] == {
"location": "San Francisco",
"unit": "celsius",
}
# Check token usage
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
assert props["$ai_cache_read_input_tokens"] == 5
assert props["$ai_cache_creation_input_tokens"] == 0
assert props["$ai_tokens_source"] == "sdk"
# Verify raw usage is captured in streaming mode (merged from events)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from message_start and message_delta)
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
"""Test that tool calls are properly captured in async streaming mode."""
import asyncio
async def mock_async_generator():
# Convert regular generator to async generator
for event in mock_anthropic_stream_with_tools:
yield event
async def mock_async_create(**kwargs):
# Return the async generator (to be awaited by the implementation)
return mock_async_generator()
with patch(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-5-sonnet-20241022",
system="You are a helpful weather assistant.",
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"name": "get_weather",
"description": "Get weather information",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
],
stream=True,
insights_distinct_id="test-id",
)
# Consume the async stream
[event async for event in response]
# asyncio.run() waits for all async operations to complete
asyncio.run(run_test())
# Capture completes before asyncio.run() returns
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
# Verify system prompt is included in input
assert props["$ai_input"] == [
{"role": "system", "content": "You are a helpful weather assistant."},
{"role": "user", "content": "What's the weather in San Francisco?"},
]
# Verify that tools are captured in the properties
assert props["$ai_tools"] == [
{
"name": "get_weather",
"description": "Get weather information",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string"},
},
"required": ["location"],
},
}
]
# Verify output contains both text and tool call
output_choices = props["$ai_output_choices"]
assert len(output_choices) == 1
assistant_message = output_choices[0]
assert assistant_message["role"] == "assistant"
content = assistant_message["content"]
assert isinstance(content, list)
assert len(content) == 2
# Verify text block
text_block = content[0]
assert text_block["type"] == "text"
assert text_block["text"] == "I'll check the weather for you."
# Verify tool call block
tool_block = content[1]
assert tool_block["type"] == "function"
assert tool_block["id"] == "toolu_stream123"
assert tool_block["function"]["name"] == "get_weather"
assert tool_block["function"]["arguments"] == {
"location": "San Francisco",
"unit": "celsius",
}
# Check token usage
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
assert props["$ai_cache_read_input_tokens"] == 5
assert props["$ai_cache_creation_input_tokens"] == 0
def test_web_search_count(mock_client):
"""Test that web search count is properly tracked from Anthropic responses."""
# Create a mock usage with web search
class MockServerToolUse:
def __init__(self):
self.web_search_requests = 3
class MockUsageWithWebSearch:
def __init__(self):
self.input_tokens = 100
self.output_tokens = 50
self.cache_read_input_tokens = 0
self.cache_creation_input_tokens = 0
self.server_tool_use = MockServerToolUse()
class MockResponseWithWebSearch:
def __init__(self):
self.content = [MockContent(text="Search results show...")]
self.model = "claude-3-opus-20240229"
self.usage = MockUsageWithWebSearch()
mock_response = MockResponseWithWebSearch()
with patch("anthropic.resources.Messages.create", return_value=mock_response):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is captured
assert props["$ai_web_search_count"] == 3
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 50
@pytest.fixture
def mock_anthropic_stream_with_web_search():
"""Mock stream events for web search."""
class MockServerToolUse:
def __init__(self):
self.web_search_requests = 2
class MockMessage:
def __init__(self):
self.usage = MockUsage(
input_tokens=50,
cache_creation_input_tokens=0,
cache_read_input_tokens=5,
)
def stream_generator():
# Message start with usage
event = MockStreamEvent("message_start")
event.message = MockMessage()
yield event
# Text block start
event = MockStreamEvent("content_block_start")
event.content_block = MockContentBlock("text")
event.index = 0
yield event
# Text delta
event = MockStreamEvent("content_block_delta")
event.delta = MockDelta(text="Here are the search results...")
event.index = 0
yield event
# Text block stop
event = MockStreamEvent("content_block_stop")
event.index = 0
yield event
# Message delta with final usage including web search
event = MockStreamEvent("message_delta")
usage = MockUsage(output_tokens=25)
usage.server_tool_use = MockServerToolUse()
event.usage = usage
yield event
# Message stop
event = MockStreamEvent("message_stop")
yield event
return stream_generator()
def test_streaming_with_web_search(mock_client, mock_anthropic_stream_with_web_search):
"""Test that web search count is properly captured in streaming mode."""
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_web_search,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
list(response)
# Capture happens synchronously when generator is exhausted
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is captured
assert props["$ai_web_search_count"] == 2
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
def test_async_with_web_search(mock_client):
"""Test that web search count is properly tracked in async non-streaming mode."""
import asyncio
# Create a mock usage with web search
class MockServerToolUse:
def __init__(self):
self.web_search_requests = 3
class MockUsageWithWebSearch:
def __init__(self):
self.input_tokens = 100
self.output_tokens = 50
self.cache_read_input_tokens = 0
self.cache_creation_input_tokens = 0
self.server_tool_use = MockServerToolUse()
class MockResponseWithWebSearch:
def __init__(self):
self.content = [MockContent(text="Search results show...")]
self.model = "claude-3-opus-20240229"
self.usage = MockUsageWithWebSearch()
mock_response = MockResponseWithWebSearch()
async def mock_async_create(**kwargs):
return mock_response
with patch(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
)
return response
# asyncio.run() waits for all async operations to complete
response = asyncio.run(run_test())
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is captured
assert props["$ai_web_search_count"] == 3
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 50
def test_async_streaming_with_web_search(
mock_client, mock_anthropic_stream_with_web_search
):
"""Test that web search count is properly captured in async streaming mode."""
import asyncio
async def mock_async_generator():
# Convert regular generator to async generator
for event in mock_anthropic_stream_with_web_search:
yield event
async def mock_async_create(**kwargs):
# Return the async generator (to be awaited by the implementation)
return mock_async_generator()
with patch(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
)
# Consume the async stream
[event async for event in response]
# asyncio.run() waits for all async operations to complete
asyncio.run(run_test())
# Capture completes before asyncio.run() returns
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is captured
assert props["$ai_web_search_count"] == 2
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
# =======================
# Distinct ID Context Tests
# =======================
def test_no_distinct_id_uses_trace_id_and_personless(
mock_client, mock_anthropic_response
):
"""When no distinct_id is provided and no outer context, trace_id is used and event is personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "trace-123"
assert props["$process_person_profile"] is False
def test_explicit_distinct_id_creates_person_profile(
mock_client, mock_anthropic_response
):
"""When insights_distinct_id is explicitly passed, it is used and event is not personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="user-123",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "user-123"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response):
"""When an outer context has a distinct_id, it should be used instead of trace_id."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "outer-user-456"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_explicit_distinct_id_overrides_outer_context(
mock_client, mock_anthropic_response
):
"""When both outer context and explicit insights_distinct_id are set, explicit wins."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="explicit-user-789",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
assert call_args["distinct_id"] == "explicit-user-789"