From 9a89fd000d47adb1ae73f388f0d99d1f0b766478 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Sat, 10 Oct 2026 13:14:48 -0700 Subject: [PATCH] fix(ai): fail at setup when an AI integration's client lacks capture_ai --- .../ai-integrations-require-capture-ai.md | 5 + docs/migration-7.x-to-8.0.md | 2 +- posthog/ai/anthropic/anthropic.py | 4 +- posthog/ai/anthropic/anthropic_async.py | 4 +- posthog/ai/anthropic/anthropic_providers.py | 10 +- posthog/ai/claude_agent_sdk/processor.py | 4 +- posthog/ai/gemini/_shared.py | 13 +-- posthog/ai/gemini/gemini.py | 4 +- posthog/ai/gemini/gemini_async.py | 4 +- posthog/ai/langchain/callbacks.py | 4 +- posthog/ai/openai/_embeddings.py | 2 +- posthog/ai/openai/openai.py | 4 +- posthog/ai/openai/openai_async.py | 4 +- posthog/ai/openai/openai_providers.py | 6 +- posthog/ai/openai_agents/processor.py | 4 +- posthog/ai/utils.py | 32 ++++-- .../ai/claude_agent_sdk/test_processor.py | 13 ++- posthog/test/ai/langchain/test_callbacks.py | 2 +- .../test/ai/openai_agents/test_processor.py | 4 +- posthog/test/ai/test_capture_contract.py | 2 +- posthog/test/ai/test_capture_pipeline.py | 2 +- posthog/test/test_ai_capture_lane.py | 101 ++++++++++++++++-- references/public_api_snapshot.txt | 10 +- 23 files changed, 159 insertions(+), 81 deletions(-) create mode 100644 .sampo/changesets/ai-integrations-require-capture-ai.md diff --git a/.sampo/changesets/ai-integrations-require-capture-ai.md b/.sampo/changesets/ai-integrations-require-capture-ai.md new file mode 100644 index 000000000..202f03264 --- /dev/null +++ b/.sampo/changesets/ai-integrations-require-capture-ai.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: major +--- + +The AI integrations no longer fall back to `capture` for a client object without `capture_ai`. Creating an OpenAI, Anthropic or Gemini wrapper, the LangChain `CallbackHandler`, or the OpenAI Agents or Claude Agent SDK processor with such a client now raises `TypeError`. The error shows up when you set the integration up, and not after a provider call. A `Client` or `AsyncPosthog` instance, and the default global client, work as before. diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index ab743c7a6..a1a10cd5d 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -176,7 +176,7 @@ These changes follow from this order: - `capture_ai` and every built-in AI integration send to `/i/v1/ai/events`. The AI endpoint accepts events up to 8 MiB. - `enable_full_ai_capture` now controls only content: string truncation and media redaction. It no longer chooses the endpoint. `_use_ai_lane` and `_enable_multimodal_capture` still work as aliases. -- The AI integrations call your client's `capture_ai`. A client object without `capture_ai` gets `capture` calls instead. A custom client must accept the `options` keyword argument. +- The AI integrations call your client's `capture_ai`, and never `capture`. A custom client object must implement `capture_ai` and accept its `options` keyword argument. If the client has no `capture_ai`, the wrapper, callback handler or tracing processor raises `TypeError` when you create it. Before, such a client got `capture` calls. - Tests that pass a `Mock` client to an AI integration must assert on `mock.capture_ai`, not `mock.capture`. - When an AI integration or MCP falls back to a trace, run or session ID, it turns person processing off with a per-event option. This is the only option the integrations pass. It wins over `super_options`, context options and a `$process_person_profile: true` property. `before_send` can still change it. - The AI integrations have no `options` argument. To set options on their events, use `set_context_option`, `super_options`, or a legacy property such as `$cookieless_mode` in `posthog_properties`. diff --git a/posthog/ai/anthropic/anthropic.py b/posthog/ai/anthropic/anthropic.py index a474fefcf..df8230193 100644 --- a/posthog/ai/anthropic/anthropic.py +++ b/posthog/ai/anthropic/anthropic.py @@ -17,6 +17,7 @@ ToolInProgress as ToolInProgress, ) from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage as call_llm_and_track_usage, merge_usage_stats as merge_usage_stats, ) @@ -29,7 +30,6 @@ handle_anthropic_tool_delta as handle_anthropic_tool_delta, ) from posthog.client import Client as PostHogClient -from posthog import setup class Anthropic(anthropic.Anthropic): @@ -46,7 +46,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Additional arguments passed to the Anthropic client """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) diff --git a/posthog/ai/anthropic/anthropic_async.py b/posthog/ai/anthropic/anthropic_async.py index 9ac091c64..42b7799da 100644 --- a/posthog/ai/anthropic/anthropic_async.py +++ b/posthog/ai/anthropic/anthropic_async.py @@ -10,7 +10,6 @@ import uuid from typing import Any, Dict, List, Optional -from posthog import setup from ..stream import AsyncStreamWrapper as AsyncStreamWrapper from ..types import ( StreamingContentBlock as StreamingContentBlock, @@ -18,6 +17,7 @@ ToolInProgress as ToolInProgress, ) from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage_async as call_llm_and_track_usage_async, merge_usage_stats as merge_usage_stats, ) @@ -46,7 +46,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Additional arguments passed to the Anthropic client """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) diff --git a/posthog/ai/anthropic/anthropic_providers.py b/posthog/ai/anthropic/anthropic_providers.py index 8b816b162..2e8fe2806 100644 --- a/posthog/ai/anthropic/anthropic_providers.py +++ b/posthog/ai/anthropic/anthropic_providers.py @@ -9,8 +9,8 @@ from posthog.ai.anthropic.anthropic import WrappedMessages from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages +from posthog.ai.utils import _resolve_ai_client from posthog.client import Client as PostHogClient -from posthog import setup class AnthropicBedrock(anthropic.AnthropicBedrock): @@ -28,7 +28,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AnthropicBedrock``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) @@ -47,7 +47,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AsyncAnthropicBedrock``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) @@ -66,7 +66,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AnthropicVertex``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = WrappedMessages(self) @@ -85,5 +85,5 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): **kwargs: Arguments passed to ``anthropic.AsyncAnthropicVertex``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) self.messages = AsyncWrappedMessages(self) diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 15a830556..da0afa5d4 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -24,7 +24,6 @@ "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" ) -from posthog import setup from posthog.ai.claude_agent_sdk.formatting import ( format_assistant_blocks, format_tool_result_content, @@ -32,6 +31,7 @@ from posthog.ai.media import ensure_serializable as _ensure_serializable from posthog.ai.utils import ( _capture_processor_event, + _resolve_ai_client, finalize_ai_content, with_privacy_mode as _with_privacy_mode, ) @@ -167,7 +167,7 @@ def __init__( groups: Optional PostHog groups to associate with emitted events. properties: Additional properties included on emitted AI events. """ - self._client = client or setup() + self._client = _resolve_ai_client(client) self._distinct_id = distinct_id self._privacy_mode = privacy_mode self._groups = groups or {} diff --git a/posthog/ai/gemini/_shared.py b/posthog/ai/gemini/_shared.py index 633c0ac86..356abb6fc 100644 --- a/posthog/ai/gemini/_shared.py +++ b/posthog/ai/gemini/_shared.py @@ -9,11 +9,11 @@ "Please install the Google Gemini SDK to use this feature: 'pip install google-genai'" ) -from ... import setup from ...client import Client as PostHogClient from ..types import StreamingEventData, TokenUsage from ..utils import ( _capture_ai_event, + _resolve_ai_client, capture_streaming_event, finalize_ai_content, merge_system_prompt, @@ -27,15 +27,6 @@ _GEMINI_BASE_URL = "https://generativelanguage.googleapis.com" -def _resolve_posthog_client( - posthog_client: Optional[PostHogClient], -) -> PostHogClient: - client = posthog_client or setup() - if client is None: - raise ValueError("posthog_client is required for PostHog tracking") - return client - - def _build_gemini_client_args( *, api_key: Optional[str], @@ -138,7 +129,7 @@ def _initialize_policy( posthog_groups: Optional[Dict[str, Any]], provider_client: Optional[Any] = None, ) -> None: - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) self._default_distinct_id = posthog_distinct_id self._default_properties = posthog_properties or {} self._default_privacy_mode = posthog_privacy_mode diff --git a/posthog/ai/gemini/gemini.py b/posthog/ai/gemini/gemini.py index ff74bd270..573d56512 100644 --- a/posthog/ai/gemini/gemini.py +++ b/posthog/ai/gemini/gemini.py @@ -6,6 +6,7 @@ from ..types import StreamingEventData as StreamingEventData from ..types import TokenUsage from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage, capture_streaming_event as capture_streaming_event, finalize_ai_content as finalize_ai_content, @@ -17,7 +18,6 @@ _GeminiAioNamespace, _GeminiModelsPolicy, _build_gemini_client, - _resolve_posthog_client, ) from .gemini_async import AsyncModels from .gemini_converter import ( @@ -92,7 +92,7 @@ def __init__( **kwargs: Additional arguments (for future compatibility) """ - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) # Built once here and shared by every surface, so a client that uses both # `models` and `aio.models` still opens a single provider client. diff --git a/posthog/ai/gemini/gemini_async.py b/posthog/ai/gemini/gemini_async.py index edede3dca..7dc11fcd2 100644 --- a/posthog/ai/gemini/gemini_async.py +++ b/posthog/ai/gemini/gemini_async.py @@ -7,6 +7,7 @@ from ..types import StreamingEventData as StreamingEventData from ..types import TokenUsage from ..utils import ( + _resolve_ai_client, call_llm_and_track_usage_async, capture_streaming_event as capture_streaming_event, finalize_ai_content as finalize_ai_content, @@ -18,7 +19,6 @@ _GeminiAioNamespace, _GeminiModelsPolicy, _build_gemini_client, - _resolve_posthog_client, ) from .gemini_converter import ( extract_gemini_content_from_chunk, @@ -87,7 +87,7 @@ def __init__( **kwargs: Additional arguments (for future compatibility) """ - self._ph_client = _resolve_posthog_client(posthog_client) + self._ph_client = _resolve_ai_client(posthog_client) # Built once here and shared by every surface, so `models`, `aio.models` # and `files` all go through a single provider client. diff --git a/posthog/ai/langchain/callbacks.py b/posthog/ai/langchain/callbacks.py index a80cd1a1d..103f39e35 100644 --- a/posthog/ai/langchain/callbacks.py +++ b/posthog/ai/langchain/callbacks.py @@ -41,11 +41,11 @@ from langchain_core.outputs import ChatGeneration, LLMResult from pydantic import BaseModel -from posthog import setup from posthog.ai.gateway import warn_if_posthog_ai_gateway from posthog.ai.utils import ( _capture_ai_event, _extract_cache_creation_ttl_breakdown, + _resolve_ai_client, finalize_ai_content, get_model_params, _responses_stop_reason, @@ -145,7 +145,7 @@ def __init__( privacy_mode: Whether to redact the input and output of the trace. groups: Optional additional PostHog groups to use for the trace. """ - self._ph_client = client or setup() + self._ph_client = _resolve_ai_client(client) self._distinct_id = distinct_id self._trace_id = trace_id self._properties = properties or {} diff --git a/posthog/ai/openai/_embeddings.py b/posthog/ai/openai/_embeddings.py index 49dc0f5b5..8c6565d3b 100644 --- a/posthog/ai/openai/_embeddings.py +++ b/posthog/ai/openai/_embeddings.py @@ -38,7 +38,7 @@ def _capture_embedding_event( **(properties or {}), } - if hasattr(posthog_client, "capture"): + if callable(getattr(posthog_client, "capture_ai", None)): _capture_ai_event( posthog_client, "$ai_embedding", diff --git a/posthog/ai/openai/openai.py b/posthog/ai/openai/openai.py index c1950939c..e2cbf7fb6 100644 --- a/posthog/ai/openai/openai.py +++ b/posthog/ai/openai/openai.py @@ -12,6 +12,7 @@ ) from posthog.ai.utils import ( + _resolve_ai_client, call_llm_and_track_usage, extract_available_tool_calls as extract_available_tool_calls, finalize_ai_content as finalize_ai_content, @@ -27,7 +28,6 @@ format_openai_streaming_output as _format_openai_streaming_output, ) from posthog.client import Client as PostHogClient -from posthog import setup from posthog.ai.openai._streaming import ( _ChatCompletionsStreamState, _ResponsesStreamState, @@ -64,7 +64,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _SYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai/openai_async.py b/posthog/ai/openai/openai_async.py index dac3eeb4a..f9e574c4f 100644 --- a/posthog/ai/openai/openai_async.py +++ b/posthog/ai/openai/openai_async.py @@ -12,8 +12,8 @@ "Please install the OpenAI SDK to use this feature: 'pip install openai'" ) -from posthog import setup from posthog.ai.utils import ( + _resolve_ai_client, call_llm_and_track_usage_async, extract_available_tool_calls as extract_available_tool_calls, finalize_ai_content as finalize_ai_content, @@ -66,7 +66,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _ASYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai/openai_providers.py b/posthog/ai/openai/openai_providers.py index 7c0ce04b6..1661903b8 100644 --- a/posthog/ai/openai/openai_providers.py +++ b/posthog/ai/openai/openai_providers.py @@ -23,8 +23,8 @@ ) from .wrapper_utils import _wrap_openai_resources +from posthog.ai.utils import _resolve_ai_client from posthog.client import Client as PostHogClient -from posthog import setup class AzureOpenAI(openai.AzureOpenAI): @@ -49,7 +49,7 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): ``api_key``, ``azure_endpoint``, or ``api_version``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _SYNC_RESOURCE_WRAPPERS) @@ -76,6 +76,6 @@ def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs): ``api_key``, ``azure_endpoint``, or ``api_version``. """ super().__init__(**kwargs) - self._ph_client = posthog_client or setup() + self._ph_client = _resolve_ai_client(posthog_client) _wrap_openai_resources(self, _ASYNC_RESOURCE_WRAPPERS) diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index e90432c2f..f54c43e58 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -19,11 +19,11 @@ TranscriptionSpanData, ) -from posthog import setup from posthog.ai.media import ensure_serializable as _ensure_serializable from posthog.ai.sanitization import _full_ai_capture_enabled, _placeholder from posthog.ai.utils import ( _capture_processor_event, + _resolve_ai_client, finalize_ai_content, with_privacy_mode as _with_privacy_mode, ) @@ -88,7 +88,7 @@ def __init__( groups: Optional PostHog groups to associate with all events. properties: Optional additional properties to include with all events. """ - self._client = client or setup() + self._client = _resolve_ai_client(client) self._distinct_id = distinct_id self._privacy_mode = privacy_mode self._groups = groups or {} diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index 2a6d20cce..d27059aa5 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -3,7 +3,7 @@ import uuid from typing import Any, Callable, Dict, List, Optional, Tuple, cast -from posthog import get_tags, identify_context, new_context, tag, contexts +from posthog import get_tags, identify_context, new_context, setup, tag, contexts from posthog.ai.gateway import warn_if_posthog_ai_gateway from posthog.ai.sanitization import redact_media from posthog.ai.sanitization import sanitize_messages # noqa: F401 -- re-exported for back-compat @@ -82,11 +82,22 @@ def _capture_ai_event(ph_client, event: str, *, personless: bool = False, **kwar **(kwargs.get("options") or {}), "process_person_profile": False, } - # Clients without capture_ai, such as custom stand-ins, keep the analytics lane. - capture_ai = getattr(ph_client, "capture_ai", None) - if callable(capture_ai): - return capture_ai(event=event, **kwargs) - return ph_client.capture(event=event, **kwargs) + return ph_client.capture_ai(event=event, **kwargs) + + +def _resolve_ai_client(posthog_client: Optional[PostHogClient]) -> PostHogClient: + """Return the client an AI integration sends through, or the global client. + + The check runs when the integration is built. A client object without + ``capture_ai`` then fails at setup, and not after a provider call that the + user already paid for. + """ + client = posthog_client or setup() + if not callable(getattr(client, "capture_ai", None)): + raise TypeError( + "The PostHog client for an AI integration must implement capture_ai()" + ) + return client def _capture_processor_event( @@ -101,8 +112,7 @@ def _capture_processor_event( ) -> None: """Apply the shared capture policy used by AI SDK processors.""" try: - capture = getattr(ph_client, "capture", None) - if not callable(capture): + if not callable(getattr(ph_client, "capture_ai", None)): return _capture_ai_event( @@ -605,7 +615,7 @@ def call_llm_and_track_usage( ) # send the event to posthog - if hasattr(ph_client, "capture") and callable(ph_client.capture): + if callable(getattr(ph_client, "capture_ai", None)): sdk_tags = get_tags() merged_properties = { **sdk_tags, @@ -770,7 +780,7 @@ async def call_llm_and_track_usage_async( ) # send the event to posthog - if hasattr(ph_client, "capture") and callable(ph_client.capture): + if callable(getattr(ph_client, "capture_ai", None)): sdk_tags = get_tags() merged_properties = { **sdk_tags, @@ -970,7 +980,7 @@ def capture_streaming_event( ) # Send event to PostHog - if hasattr(ph_client, "capture"): + if callable(getattr(ph_client, "capture_ai", None)): _capture_ai_event( ph_client, "$ai_generation", diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 2b888b128..4ccb2f4c0 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -173,7 +173,7 @@ def test_initialization(self, mock_client): assert proc._properties == {"env": "test"} def test_initialization_defaults(self): - with patch("posthog.ai.claude_agent_sdk.processor.setup") as mock_setup: + with patch("posthog.ai.utils.setup") as mock_setup: mock_setup.return_value = MagicMock() proc = PostHogClaudeAgentProcessor() assert proc._distinct_id is None @@ -507,7 +507,7 @@ async def test_per_call_privacy_mode_cannot_disable_processor_mode( @pytest.mark.asyncio async def test_client_without_privacy_mode_captures_content(self): - client = SimpleNamespace(capture=MagicMock()) + client = SimpleNamespace(capture_ai=MagicMock()) proc = PostHogClaudeAgentProcessor(client=client, distinct_id="user") messages = [_make_message_start(), _make_message_stop()] @@ -518,7 +518,7 @@ async def test_client_without_privacy_mode_captures_content(self): async for _ in proc.query(prompt="visible", options=ClaudeAgentOptions()): pass - properties = client.capture.call_args.kwargs["properties"] + properties = client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] == [{"role": "user", "content": "visible"}] @pytest.mark.asyncio @@ -701,10 +701,9 @@ def test_default_properties_keep_existing_precedence(self, mock_client): "$ai_trace_id": "trace-id", } - def test_client_without_capture_capability_is_ignored(self): - proc = PostHogClaudeAgentProcessor(client=object()) - - proc._capture_event("$ai_trace", {}) + def test_client_without_capture_ai_is_rejected_at_construction(self): + with pytest.raises(TypeError, match="capture_ai"): + PostHogClaudeAgentProcessor(client=object()) def test_capture_errors_are_logged_and_suppressed(self, mock_client, caplog): mock_client.capture_ai.side_effect = RuntimeError("capture failed") diff --git a/posthog/test/ai/langchain/test_callbacks.py b/posthog/test/ai/langchain/test_callbacks.py index 326eb0ddb..32c9f1e3d 100644 --- a/posthog/test/ai/langchain/test_callbacks.py +++ b/posthog/test/ai/langchain/test_callbacks.py @@ -2082,7 +2082,7 @@ def test_openai_reasoning_tokens_o4_mini(mock_client): def test_callback_handler_without_client(): """Test that CallbackHandler works properly when no PostHog client is passed.""" - with patch("posthog.ai.langchain.callbacks.setup") as mock_setup: + with patch("posthog.ai.utils.setup") as mock_setup: mock_client = mock_setup.return_value callbacks = CallbackHandler() diff --git a/posthog/test/ai/openai_agents/test_processor.py b/posthog/test/ai/openai_agents/test_processor.py index 203434abf..74f2336ac 100644 --- a/posthog/test/ai/openai_agents/test_processor.py +++ b/posthog/test/ai/openai_agents/test_processor.py @@ -440,7 +440,7 @@ def test_client_privacy_mode_redacts_content(self, mock_client, mock_span): assert properties["$ai_output_choices"] is None def test_client_without_privacy_mode_captures_content(self, mock_span): - client = SimpleNamespace(capture=MagicMock()) + client = SimpleNamespace(capture_ai=MagicMock()) processor = PostHogTracingProcessor(client=client) mock_span.span_data = GenerationSpanData( input=[{"role": "user", "content": "Visible message"}], @@ -450,7 +450,7 @@ def test_client_without_privacy_mode_captures_content(self, mock_span): processor.on_span_start(mock_span) processor.on_span_end(mock_span) - properties = client.capture.call_args.kwargs["properties"] + properties = client.capture_ai.call_args.kwargs["properties"] assert properties["$ai_input"] == [ {"role": "user", "content": "Visible message"} ] diff --git a/posthog/test/ai/test_capture_contract.py b/posthog/test/ai/test_capture_contract.py index 5b4951c76..987e39bec 100644 --- a/posthog/test/ai/test_capture_contract.py +++ b/posthog/test/ai/test_capture_contract.py @@ -246,7 +246,7 @@ class FakePH: def __init__(self): self.events = [] - def capture(self, *args, **kwargs): + def capture_ai(self, *args, **kwargs): self.events.append(kwargs) def flush(self): diff --git a/posthog/test/ai/test_capture_pipeline.py b/posthog/test/ai/test_capture_pipeline.py index 916ad8416..cacbac8d3 100644 --- a/posthog/test/ai/test_capture_pipeline.py +++ b/posthog/test/ai/test_capture_pipeline.py @@ -12,7 +12,7 @@ class FakePH: def __init__(self): self.events = [] - def capture(self, *args, **kwargs): + def capture_ai(self, *args, **kwargs): self.events.append(kwargs) def flush(self): diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index a2c90f138..dd3892c04 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -4,6 +4,7 @@ import uuid from unittest import mock +import pytest from parameterized import parameterized import posthog @@ -506,16 +507,96 @@ def test_adds_ai_library_identity_and_preserves_provider_and_model(self): self.assertEqual(properties["$ai_provider"], "openai") self.assertEqual(properties["$ai_model"], "gpt-4o") - @parameterized.expand([("default", False), ("full_capture", True)]) - def test_client_without_capture_ai_falls_back_to_capture(self, _name, full_capture): - client = mock.Mock(spec=["capture", "enable_full_ai_capture"]) - client.enable_full_ai_capture = full_capture - _capture_ai_event(client, "$ai_generation", distinct_id="d") - client.capture.assert_called_once_with( - event="$ai_generation", - distinct_id="d", - properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, - ) + +_AZURE_KWARGS = { + "api_key": "test-key", + "azure_endpoint": "https://example.openai.azure.com", + "api_version": "2024-02-01", +} +_BEDROCK_KWARGS = { + "aws_region": "us-east-1", + "aws_access_key": "test-access-key", + "aws_secret_key": "test-secret-key", +} +_VERTEX_KWARGS = {"region": "us-east5", "project_id": "test", "access_token": "test"} + + +class TestAiIntegrationsRequireCaptureAi(unittest.TestCase): + @parameterized.expand( + [ + ("posthog.ai.openai", "OpenAI", "posthog_client", {"api_key": "test-key"}), + ( + "posthog.ai.openai", + "AsyncOpenAI", + "posthog_client", + {"api_key": "test-key"}, + ), + ("posthog.ai.openai", "AzureOpenAI", "posthog_client", _AZURE_KWARGS), + ("posthog.ai.openai", "AsyncAzureOpenAI", "posthog_client", _AZURE_KWARGS), + ( + "posthog.ai.anthropic", + "Anthropic", + "posthog_client", + {"api_key": "test-key"}, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropic", + "posthog_client", + {"api_key": "test-key"}, + ), + ( + "posthog.ai.anthropic", + "AnthropicBedrock", + "posthog_client", + _BEDROCK_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropicBedrock", + "posthog_client", + _BEDROCK_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AnthropicVertex", + "posthog_client", + _VERTEX_KWARGS, + ), + ( + "posthog.ai.anthropic", + "AsyncAnthropicVertex", + "posthog_client", + _VERTEX_KWARGS, + ), + ("posthog.ai.gemini", "Client", "posthog_client", {"api_key": "test-key"}), + ( + "posthog.ai.gemini", + "AsyncClient", + "posthog_client", + {"api_key": "test-key"}, + ), + ("posthog.ai.langchain", "CallbackHandler", "client", {}), + ("posthog.ai.openai_agents", "PostHogTracingProcessor", "client", {}), + ( + "posthog.ai.claude_agent_sdk", + "PostHogClaudeAgentProcessor", + "client", + {}, + ), + ], + name_func=lambda func, _num, param: f"{func.__name__}_{param.args[1]}", + ) + def test_integration_rejects_client_without_capture_ai_at_construction( + self, module, integration, client_argument, kwargs + ): + integration_class = getattr(pytest.importorskip(module), integration) + client = mock.Mock(spec=["capture", "privacy_mode"]) + + with self.assertRaisesRegex(TypeError, "capture_ai"): + integration_class(**{client_argument: client}, **kwargs) + + client.capture.assert_not_called() class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index f8bbd37a6..058feae19 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -51,7 +51,6 @@ alias posthog.ai.anthropic.anthropic.handle_anthropic_content_block_start -> pos alias posthog.ai.anthropic.anthropic.handle_anthropic_text_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_text_delta alias posthog.ai.anthropic.anthropic.handle_anthropic_tool_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_tool_delta alias posthog.ai.anthropic.anthropic.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.anthropic.anthropic.setup -> posthog.setup alias posthog.ai.anthropic.anthropic_async.AsyncStreamWrapper -> posthog.ai.stream.AsyncStreamWrapper alias posthog.ai.anthropic.anthropic_async.PostHogClient -> posthog.client.Client alias posthog.ai.anthropic.anthropic_async.StreamingContentBlock -> posthog.ai.types.StreamingContentBlock @@ -64,7 +63,6 @@ alias posthog.ai.anthropic.anthropic_async.handle_anthropic_content_block_start alias posthog.ai.anthropic.anthropic_async.handle_anthropic_text_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_text_delta alias posthog.ai.anthropic.anthropic_async.handle_anthropic_tool_delta -> posthog.ai.anthropic.anthropic_converter.handle_anthropic_tool_delta alias posthog.ai.anthropic.anthropic_async.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.anthropic.anthropic_async.setup -> posthog.setup alias posthog.ai.anthropic.anthropic_converter.FormattedContentItem -> posthog.ai.types.FormattedContentItem alias posthog.ai.anthropic.anthropic_converter.FormattedFunctionCall -> posthog.ai.types.FormattedFunctionCall alias posthog.ai.anthropic.anthropic_converter.FormattedMessage -> posthog.ai.types.FormattedMessage @@ -77,7 +75,6 @@ alias posthog.ai.anthropic.anthropic_converter.to_plain -> posthog.ai.media.to_p alias posthog.ai.anthropic.anthropic_providers.AsyncWrappedMessages -> posthog.ai.anthropic.anthropic_async.AsyncWrappedMessages alias posthog.ai.anthropic.anthropic_providers.PostHogClient -> posthog.client.Client alias posthog.ai.anthropic.anthropic_providers.WrappedMessages -> posthog.ai.anthropic.anthropic.WrappedMessages -alias posthog.ai.anthropic.anthropic_providers.setup -> posthog.setup alias posthog.ai.anthropic.extract_anthropic_tools -> posthog.ai.anthropic.anthropic_converter.extract_anthropic_tools alias posthog.ai.anthropic.format_anthropic_input -> posthog.ai.anthropic.anthropic_converter.format_anthropic_input alias posthog.ai.anthropic.format_anthropic_response -> posthog.ai.anthropic.anthropic_converter.format_anthropic_response @@ -94,7 +91,6 @@ alias posthog.ai.claude_agent_sdk.processor.Client -> posthog.client.Client alias posthog.ai.claude_agent_sdk.processor.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.claude_agent_sdk.processor.format_assistant_blocks -> posthog.ai.claude_agent_sdk.formatting.format_assistant_blocks alias posthog.ai.claude_agent_sdk.processor.format_tool_result_content -> posthog.ai.claude_agent_sdk.formatting.format_tool_result_content -alias posthog.ai.claude_agent_sdk.processor.setup -> posthog.setup alias posthog.ai.gemini.AsyncClient -> posthog.ai.gemini.gemini_async.AsyncClient alias posthog.ai.gemini.Client -> posthog.ai.gemini.gemini.Client alias posthog.ai.gemini.extract_gemini_tools -> posthog.ai.gemini.gemini_converter.extract_gemini_tools @@ -143,7 +139,6 @@ alias posthog.ai.langchain.CallbackHandler -> posthog.ai.langchain.callbacks.Cal alias posthog.ai.langchain.callbacks.Client -> posthog.client.Client alias posthog.ai.langchain.callbacks.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.langchain.callbacks.get_model_params -> posthog.ai.utils.get_model_params -alias posthog.ai.langchain.callbacks.setup -> posthog.setup alias posthog.ai.langchain.callbacks.warn_if_posthog_ai_gateway -> posthog.ai.gateway.warn_if_posthog_ai_gateway alias posthog.ai.langchain.callbacks.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.AsyncAzureOpenAI -> posthog.ai.openai.openai_providers.AsyncAzureOpenAI @@ -165,7 +160,6 @@ alias posthog.ai.openai.openai.extract_openai_usage_from_chunk -> posthog.ai.ope alias posthog.ai.openai.openai.finalize_ai_content -> posthog.ai.utils.finalize_ai_content alias posthog.ai.openai.openai.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.openai.openai.setup -> posthog.setup alias posthog.ai.openai.openai.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.openai_async.AsyncStreamWrapper -> posthog.ai.stream.AsyncStreamWrapper alias posthog.ai.openai.openai_async.PostHogClient -> posthog.client.Client @@ -182,7 +176,6 @@ alias posthog.ai.openai.openai_async.format_openai_streaming_output -> posthog.a alias posthog.ai.openai.openai_async.get_model_params -> posthog.ai.utils.get_model_params alias posthog.ai.openai.openai_async.merge_provider_override -> posthog.ai.openai.wrapper_utils.merge_provider_override alias posthog.ai.openai.openai_async.merge_usage_stats -> posthog.ai.utils.merge_usage_stats -alias posthog.ai.openai.openai_async.setup -> posthog.setup alias posthog.ai.openai.openai_async.with_privacy_mode -> posthog.ai.utils.with_privacy_mode alias posthog.ai.openai.openai_converter.FormattedContentItem -> posthog.ai.types.FormattedContentItem alias posthog.ai.openai.openai_converter.FormattedFunctionCall -> posthog.ai.types.FormattedFunctionCall @@ -201,11 +194,9 @@ alias posthog.ai.openai.openai_providers.WrappedBeta -> posthog.ai.openai.openai alias posthog.ai.openai.openai_providers.WrappedChat -> posthog.ai.openai.openai.WrappedChat alias posthog.ai.openai.openai_providers.WrappedEmbeddings -> posthog.ai.openai.openai.WrappedEmbeddings alias posthog.ai.openai.openai_providers.WrappedResponses -> posthog.ai.openai.openai.WrappedResponses -alias posthog.ai.openai.openai_providers.setup -> posthog.setup alias posthog.ai.openai_agents.PostHogTracingProcessor -> posthog.ai.openai_agents.processor.PostHogTracingProcessor alias posthog.ai.openai_agents.processor.Client -> posthog.client.Client alias posthog.ai.openai_agents.processor.finalize_ai_content -> posthog.ai.utils.finalize_ai_content -alias posthog.ai.openai_agents.processor.setup -> posthog.setup alias posthog.ai.otel.PostHogSpanProcessor -> posthog.ai.otel.processor.PostHogSpanProcessor alias posthog.ai.otel.PostHogTraceExporter -> posthog.ai.otel.exporter.PostHogTraceExporter alias posthog.ai.otel.exporter.DEFAULT_HOST -> posthog.ai.otel.spans.DEFAULT_HOST @@ -228,6 +219,7 @@ alias posthog.ai.utils.identify_context -> posthog.identify_context alias posthog.ai.utils.new_context -> posthog.new_context alias posthog.ai.utils.redact_media -> posthog.ai.sanitization.redact_media alias posthog.ai.utils.sanitize_messages -> posthog.ai.sanitization.sanitize_messages +alias posthog.ai.utils.setup -> posthog.setup alias posthog.ai.utils.tag -> posthog.tag alias posthog.ai.utils.warn_if_posthog_ai_gateway -> posthog.ai.gateway.warn_if_posthog_ai_gateway alias posthog.args.FeatureFlagEvaluations -> posthog.feature_flag_evaluations.FeatureFlagEvaluations