From 5a8383a1676ea968bbadc838cc2dee522aa89ed8 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 12:01:23 +0100 Subject: [PATCH 1/6] feat(ai): add Claude Agent SDK integration for LLM analytics Add posthog.ai.claude_agent_sdk module that wraps claude_agent_sdk.query() to automatically emit $ai_generation, $ai_span, and $ai_trace events. - PostHogClaudeAgentProcessor with _GenerationTracker that reconstructs per-turn generation metrics from Anthropic StreamEvents - Two entry points: query() drop-in replacement and instrument() for configure-once reuse - Two-slot input tracking to correctly associate tool results with subsequent generations despite SDK message ordering - All instrumentation wrapped in try/except so PostHog errors never interrupt the underlying Claude Agent SDK query - 16 unit tests covering generation, multi-turn, fallback, tool spans, traces, privacy mode, personless mode, custom properties - Example scripts (simple_query.py, instrument_reuse.py) --- .../claude-agent-sdk-integration.md | 5 + .../example-ai-claude-agent-sdk/.env.example | 3 + .../example-ai-claude-agent-sdk/README.md | 24 + .../instrument_reuse.py | 43 ++ .../requirements.txt | 2 + .../simple_query.py | 33 ++ examples/example-ai-claude-agent-sdk/uv.toml | 1 + posthog/ai/claude_agent_sdk/__init__.py | 125 +++++ posthog/ai/claude_agent_sdk/processor.py | 530 ++++++++++++++++++ posthog/test/ai/claude_agent_sdk/__init__.py | 0 .../ai/claude_agent_sdk/test_processor.py | 495 ++++++++++++++++ pyproject.toml | 8 + uv.lock | 330 +++++++++++ 13 files changed, 1599 insertions(+) create mode 100644 .sampo/changesets/claude-agent-sdk-integration.md create mode 100644 examples/example-ai-claude-agent-sdk/.env.example create mode 100644 examples/example-ai-claude-agent-sdk/README.md create mode 100644 examples/example-ai-claude-agent-sdk/instrument_reuse.py create mode 100644 examples/example-ai-claude-agent-sdk/requirements.txt create mode 100644 examples/example-ai-claude-agent-sdk/simple_query.py create mode 100644 examples/example-ai-claude-agent-sdk/uv.toml create mode 100644 posthog/ai/claude_agent_sdk/__init__.py create mode 100644 posthog/ai/claude_agent_sdk/processor.py create mode 100644 posthog/test/ai/claude_agent_sdk/__init__.py create mode 100644 posthog/test/ai/claude_agent_sdk/test_processor.py diff --git a/.sampo/changesets/claude-agent-sdk-integration.md b/.sampo/changesets/claude-agent-sdk-integration.md new file mode 100644 index 00000000..831e2dd9 --- /dev/null +++ b/.sampo/changesets/claude-agent-sdk-integration.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +feat(ai): add Claude Agent SDK integration for LLM analytics diff --git a/examples/example-ai-claude-agent-sdk/.env.example b/examples/example-ai-claude-agent-sdk/.env.example new file mode 100644 index 00000000..8979a330 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/.env.example @@ -0,0 +1,3 @@ +POSTHOG_API_KEY=phc_your_project_api_key +POSTHOG_HOST=https://us.i.posthog.com +ANTHROPIC_API_KEY=sk-ant-your_api_key diff --git a/examples/example-ai-claude-agent-sdk/README.md b/examples/example-ai-claude-agent-sdk/README.md new file mode 100644 index 00000000..0faa9139 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/README.md @@ -0,0 +1,24 @@ +# Claude Agent SDK + PostHog AI Examples + +Track Claude Agent SDK calls with PostHog. + +## Setup + +```bash +pip install -r requirements.txt +cp .env.example .env +# Fill in your API keys in .env +``` + +## Examples + +- **simple_query.py** - Single query using the `query()` drop-in replacement +- **instrument_reuse.py** - Configure-once with `instrument()`, reuse across multiple queries + +## Run + +```bash +source .env +python simple_query.py +python instrument_reuse.py +``` diff --git a/examples/example-ai-claude-agent-sdk/instrument_reuse.py b/examples/example-ai-claude-agent-sdk/instrument_reuse.py new file mode 100644 index 00000000..87e58705 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/instrument_reuse.py @@ -0,0 +1,43 @@ +"""Claude Agent SDK with instrument() for reusable config, tracked by PostHog.""" + +import asyncio +import os + +from claude_agent_sdk import ClaudeAgentOptions, AssistantMessage, TextBlock +from posthog import Posthog +from posthog.ai.claude_agent_sdk import instrument + +posthog = Posthog( + os.environ["POSTHOG_API_KEY"], + host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"), +) + +# Configure once, reuse for multiple queries +ph = instrument( + client=posthog, + distinct_id="example-user", + properties={"app": "demo", "environment": "development"}, +) + + +async def ask(prompt: str) -> None: + print(f"\n> {prompt}") + options = ClaudeAgentOptions( + max_turns=2, + permission_mode="plan", + ) + + async for message in ph.query(prompt=prompt, options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f" {block.text}") + + +async def main(): + await ask("What is the capital of France? Reply in one sentence.") + await ask("What is 15% of 280? Reply in one sentence.") + + +asyncio.run(main()) +posthog.shutdown() diff --git a/examples/example-ai-claude-agent-sdk/requirements.txt b/examples/example-ai-claude-agent-sdk/requirements.txt new file mode 100644 index 00000000..1f47d722 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/requirements.txt @@ -0,0 +1,2 @@ +posthog>=7.9.12 +claude-agent-sdk diff --git a/examples/example-ai-claude-agent-sdk/simple_query.py b/examples/example-ai-claude-agent-sdk/simple_query.py new file mode 100644 index 00000000..2e685755 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/simple_query.py @@ -0,0 +1,33 @@ +"""Claude Agent SDK simple query, tracked by PostHog.""" + +import asyncio +import os + +from claude_agent_sdk import ClaudeAgentOptions +from posthog import Posthog +from posthog.ai.claude_agent_sdk import query + +posthog = Posthog( + os.environ["POSTHOG_API_KEY"], + host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"), +) + + +async def main(): + options = ClaudeAgentOptions( + max_turns=2, + permission_mode="plan", + ) + + async for message in query( + prompt="What is 2 + 2? Reply in one sentence.", + options=options, + posthog_client=posthog, + posthog_distinct_id="example-user", + posthog_properties={"example": "simple_query"}, + ): + print(f"[{type(message).__name__}]") + + +asyncio.run(main()) +posthog.shutdown() diff --git a/examples/example-ai-claude-agent-sdk/uv.toml b/examples/example-ai-claude-agent-sdk/uv.toml new file mode 100644 index 00000000..dc82adcd --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/uv.toml @@ -0,0 +1 @@ +exclude-newer = "7 days" diff --git a/posthog/ai/claude_agent_sdk/__init__.py b/posthog/ai/claude_agent_sdk/__init__.py new file mode 100644 index 00000000..ada4fc89 --- /dev/null +++ b/posthog/ai/claude_agent_sdk/__init__.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union + +if TYPE_CHECKING: + from claude_agent_sdk.types import ClaudeAgentOptions, ResultMessage + + from posthog.client import Client + +try: + import claude_agent_sdk # noqa: F401 +except ImportError: + raise ModuleNotFoundError( + "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" + ) + +from posthog.ai.claude_agent_sdk.processor import PostHogClaudeAgentProcessor + +__all__ = ["PostHogClaudeAgentProcessor", "instrument", "query"] + + +def instrument( + client: Optional[Client] = None, + distinct_id: Optional[Union[str, Callable[[ResultMessage], Optional[str]]]] = None, + privacy_mode: bool = False, + groups: Optional[Dict[str, Any]] = None, + properties: Optional[Dict[str, Any]] = None, +) -> PostHogClaudeAgentProcessor: + """ + Create a PostHog-instrumented query wrapper for the Claude Agent SDK. + + Returns a PostHogClaudeAgentProcessor whose .query() method is a drop-in + replacement for claude_agent_sdk.query() that automatically emits + $ai_generation, $ai_span, and $ai_trace events. + + Args: + client: Optional PostHog client instance. If not provided, uses the default client. + distinct_id: Optional distinct ID to associate with all events. + Can also be a callable that takes a ResultMessage and returns a distinct ID. + privacy_mode: If True, redacts sensitive information in tracking. + groups: Optional PostHog groups to associate with events. + properties: Optional additional properties to include with all events. + + Returns: + PostHogClaudeAgentProcessor: A processor whose .query() method wraps claude_agent_sdk.query(). + + Example: + ```python + from posthog.ai.claude_agent_sdk import instrument + + ph = instrument(distinct_id="my-app", properties={"env": "prod"}) + + async for message in ph.query(prompt="Hello", options=options): + print(message) + ``` + """ + return PostHogClaudeAgentProcessor( + client=client, + distinct_id=distinct_id, + privacy_mode=privacy_mode, + groups=groups, + properties=properties, + ) + + +async def query( + *, + prompt: Any, + options: Optional[ClaudeAgentOptions] = None, + transport: Any = None, + posthog_client: Optional[Client] = None, + posthog_distinct_id: Optional[Union[str, Callable[[ResultMessage], Optional[str]]]] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: bool = False, + posthog_groups: Optional[Dict[str, Any]] = None, +): + """ + Drop-in replacement for claude_agent_sdk.query() with PostHog instrumentation. + + All original messages are yielded unchanged. PostHog events ($ai_generation, + $ai_span, $ai_trace) are emitted automatically. + + Args: + prompt: The prompt (same as claude_agent_sdk.query) + options: ClaudeAgentOptions (same as claude_agent_sdk.query) + transport: Optional transport (same as claude_agent_sdk.query) + posthog_client: Optional PostHog client instance. + posthog_distinct_id: Optional distinct ID for this query. + posthog_trace_id: Optional trace ID (auto-generated if not provided). + posthog_properties: Extra properties to include with all events. + posthog_privacy_mode: If True, redacts sensitive content. + posthog_groups: Optional PostHog groups. + + Example: + ```python + from posthog.ai.claude_agent_sdk import query + + async for message in query( + prompt="Hello", + options=options, + posthog_distinct_id="my-app", + posthog_properties={"pr_number": 123}, + ): + print(message) + ``` + """ + processor = PostHogClaudeAgentProcessor( + client=posthog_client, + distinct_id=posthog_distinct_id, + privacy_mode=posthog_privacy_mode, + groups=posthog_groups, + properties={}, + ) + + async for message in processor.query( + prompt=prompt, + options=options, + transport=transport, + posthog_trace_id=posthog_trace_id, + posthog_properties=posthog_properties, + posthog_privacy_mode=posthog_privacy_mode, + posthog_groups=posthog_groups, + ): + yield message diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py new file mode 100644 index 00000000..85bb5b33 --- /dev/null +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -0,0 +1,530 @@ +"""PostHog LLM Analytics processor for the Claude Agent SDK. + +Wraps claude_agent_sdk.query() to automatically emit $ai_generation, +$ai_span, and $ai_trace events to PostHog. +""" + +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Union + +try: + from claude_agent_sdk import ( + AssistantMessage, + ResultMessage, + ToolUseBlock, + UserMessage, + ) + from claude_agent_sdk import query as original_query + from claude_agent_sdk.types import ClaudeAgentOptions, StreamEvent +except ImportError: + raise ModuleNotFoundError( + "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" + ) + +from posthog import setup +from posthog.client import Client + +log = logging.getLogger("posthog") + + +@dataclass +class _GenerationData: + """Data accumulated for a single LLM generation (one API call).""" + + model: Optional[str] = None + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + start_time: float = 0.0 + end_time: float = 0.0 + span_id: str = field(default_factory=lambda: str(uuid.uuid4())) + + +class _GenerationTracker: + """Tracks StreamEvent boundaries to reconstruct per-generation metrics. + + Each message_start -> message_stop cycle in the Anthropic streaming protocol + represents one API call (one generation). + """ + + def __init__(self) -> None: + self._current: Optional[_GenerationData] = None + self._completed: List[_GenerationData] = [] + self._last_model: Optional[str] = None + self._received_stream_events: bool = False + + def process_stream_event(self, event: "StreamEvent") -> None: + self._received_stream_events = True + raw = event.event + event_type = raw.get("type") + + if event_type == "message_start": + self._current = _GenerationData(start_time=time.time()) + message = raw.get("message", {}) + self._current.model = message.get("model") + usage = message.get("usage", {}) + self._current.input_tokens = usage.get("input_tokens", 0) + self._current.output_tokens = usage.get("output_tokens", 0) + self._current.cache_read_input_tokens = usage.get("cache_read_input_tokens", 0) + self._current.cache_creation_input_tokens = usage.get("cache_creation_input_tokens", 0) + + elif event_type == "message_delta" and self._current is not None: + usage = raw.get("usage", {}) + # message_delta usage reports cumulative output tokens + if usage.get("output_tokens"): + self._current.output_tokens = usage["output_tokens"] + + elif event_type == "message_stop" and self._current is not None: + self._current.end_time = time.time() + self._completed.append(self._current) + self._last_model = self._current.model + self._current = None + + def set_model(self, model: str) -> None: + self._last_model = model + + @property + def last_model(self) -> Optional[str]: + return self._last_model + + def has_completed_generation(self) -> bool: + return len(self._completed) > 0 + + def pop_generation(self) -> _GenerationData: + return self._completed.pop(0) + + def has_pending(self) -> bool: + return self._current is not None + + @property + def generation_count(self) -> int: + return len(self._completed) + + @property + def had_any_stream_events(self) -> bool: + """Whether we received any StreamEvents at all.""" + return self._received_stream_events + + +class PostHogClaudeAgentProcessor: + """Wraps claude_agent_sdk.query() to emit PostHog LLM analytics events. + + Emits: + - $ai_generation: one per Anthropic API call (reconstructed from StreamEvents) + - $ai_span: one per tool use (ToolUseBlock in AssistantMessage) + - $ai_trace: one per query() call (on ResultMessage) + """ + + def __init__( + self, + client: Optional[Client] = None, + distinct_id: Optional[Union[str, Callable[["ResultMessage"], Optional[str]]]] = None, + privacy_mode: bool = False, + groups: Optional[Dict[str, Any]] = None, + properties: Optional[Dict[str, Any]] = None, + ): + self._client = client or setup() + self._distinct_id = distinct_id + self._privacy_mode = privacy_mode + self._groups = groups or {} + self._properties = properties or {} + + def _get_distinct_id(self, result: Optional["ResultMessage"] = None) -> Optional[str]: + if callable(self._distinct_id): + if result: + val = self._distinct_id(result) + if val: + return str(val) + return None + elif self._distinct_id: + return str(self._distinct_id) + return None + + def _with_privacy_mode(self, value: Any) -> Any: + if self._privacy_mode or ( + hasattr(self._client, "privacy_mode") and self._client.privacy_mode + ): + return None + return value + + def _capture_event( + self, + event: str, + properties: Dict[str, Any], + distinct_id: Optional[str] = None, + ) -> None: + try: + if not hasattr(self._client, "capture") or not callable(self._client.capture): + return + + final_properties = { + **properties, + **self._properties, + } + + self._client.capture( + distinct_id=distinct_id or "unknown", + event=event, + properties=final_properties, + groups=self._groups, + ) + except Exception as e: + log.debug(f"Failed to capture PostHog event: {e}") + + async def query( + self, + *, + prompt: Any, + options: Optional[ClaudeAgentOptions] = None, + transport: Any = None, + posthog_distinct_id: Optional[Union[str, Callable[["ResultMessage"], Optional[str]]]] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: Optional[bool] = None, + posthog_groups: Optional[Dict[str, Any]] = None, + ): + """Drop-in replacement for claude_agent_sdk.query() with PostHog instrumentation. + + All original messages are yielded unchanged. PostHog events are emitted + automatically in the background. + + Args: + prompt: The prompt (same as claude_agent_sdk.query) + options: ClaudeAgentOptions (same as claude_agent_sdk.query) + transport: Optional transport (same as claude_agent_sdk.query) + posthog_distinct_id: Override distinct_id for this query + posthog_trace_id: Override trace_id for this query + posthog_properties: Extra properties merged into all events for this query + posthog_privacy_mode: Override privacy mode for this query + posthog_groups: Override groups for this query + """ + from dataclasses import replace + + # Per-call overrides + distinct_id_override = posthog_distinct_id or self._distinct_id + trace_id = posthog_trace_id or str(uuid.uuid4()) + extra_props = posthog_properties or {} + privacy = posthog_privacy_mode if posthog_privacy_mode is not None else self._privacy_mode + groups = posthog_groups or self._groups + + # Ensure partial messages are enabled for per-generation tracking + if options is None: + options = ClaudeAgentOptions(include_partial_messages=True) + elif not options.include_partial_messages: + options = replace(options, include_partial_messages=True) + + tracker = _GenerationTracker() + query_start = time.time() + generation_index = 0 + current_generation_span_id: Optional[str] = None + + # Track input/output for generation events + initial_input: List[Dict[str, Any]] = [] + if isinstance(prompt, str): + initial_input = [{"role": "user", "content": prompt}] + if options and options.system_prompt and isinstance(options.system_prompt, str): + initial_input = [{"role": "system", "content": options.system_prompt}] + initial_input + + # Two-slot input tracking: + # - current_input: input for the generation currently in progress + # - next_input: tool results that arrive mid-turn, queued for the next generation + # + # Message ordering from the SDK is: + # message_start → content_blocks → AssistantMessage → UserMessage(tool result) → message_stop + # So UserMessage arrives *before* message_stop. When message_stop fires we emit + # with current_input, then promote next_input → current_input for the next turn. + current_input: Optional[List[Dict[str, Any]]] = initial_input or None + next_input: Optional[List[Dict[str, Any]]] = None + + # Accumulate assistant output per generation + pending_output: List[Dict[str, Any]] = [] + + async for message in original_query(prompt=prompt, options=options, transport=transport): + # All instrumentation is wrapped in try/except so PostHog errors + # never interrupt the underlying Claude Agent SDK query. + try: + if isinstance(message, StreamEvent): + tracker.process_stream_event(message) + + # Emit $ai_generation when a turn completes + if tracker.has_completed_generation(): + gen = tracker.pop_generation() + generation_index += 1 + current_generation_span_id = gen.span_id + self._emit_generation( + gen, trace_id, generation_index, + current_input, + pending_output or None, + distinct_id_override, extra_props, privacy, groups, + ) + # Promote: tool results from this turn become input for next turn + current_input = next_input + next_input = None + pending_output = [] + + elif isinstance(message, AssistantMessage): + tracker.set_model(message.model) + # Build output content from assistant blocks + output_content: List[Dict[str, Any]] = [] + for block in message.content: + if isinstance(block, ToolUseBlock): + self._emit_tool_span( + block, trace_id, current_generation_span_id, + distinct_id_override, extra_props, privacy, groups, + ) + output_content.append({ + "type": "function", + "function": {"name": block.name, "arguments": block.input}, + }) + elif hasattr(block, "text"): + output_content.append({"type": "text", "text": block.text}) + if output_content: + pending_output = [{"role": "assistant", "content": output_content}] + + elif isinstance(message, UserMessage): + # UserMessages carry tool results. They arrive *before* message_stop + # for the current turn, so queue them as input for the *next* generation. + content = message.content + if isinstance(content, str): + next_input = [{"role": "user", "content": content}] + elif isinstance(content, list): + formatted: List[Dict[str, Any]] = [] + for block in content: + if hasattr(block, "tool_use_id"): + formatted.append({ + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": str(block.content)[:500] if block.content else None, + }) + elif hasattr(block, "text"): + formatted.append({"type": "text", "text": block.text}) + if formatted: + next_input = [{"role": "user", "content": formatted}] + + elif isinstance(message, ResultMessage): + # Fallback: if no StreamEvents were received, emit a single + # generation from ResultMessage aggregate data + if not tracker.had_any_stream_events: + self._emit_generation_from_result( + message, trace_id, tracker.last_model, + query_start, initial_input, pending_output, + distinct_id_override, extra_props, privacy, groups, + ) + + self._emit_trace( + message, trace_id, query_start, + distinct_id_override, extra_props, privacy, groups, + ) + + except Exception as e: + log.debug(f"PostHog instrumentation error (non-fatal): {e}") + + yield message + + def _emit_generation( + self, + gen: _GenerationData, + trace_id: str, + generation_index: int, + input_messages: Optional[List[Dict[str, Any]]], + output_choices: Optional[List[Dict[str, Any]]], + distinct_id: Any, + extra_props: Dict[str, Any], + privacy: bool, + groups: Dict[str, Any], + ) -> None: + resolved_id = self._resolve_distinct_id(distinct_id) + latency = (gen.end_time - gen.start_time) if gen.start_time and gen.end_time else 0 + + properties: Dict[str, Any] = { + "$ai_trace_id": trace_id, + "$ai_span_id": gen.span_id, + "$ai_span_name": f"generation_{generation_index}", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + "$ai_model": gen.model, + "$ai_input_tokens": gen.input_tokens, + "$ai_output_tokens": gen.output_tokens, + "$ai_latency": latency, + **extra_props, + } + + if input_messages is not None: + properties["$ai_input"] = self._with_privacy_mode(input_messages) + if output_choices is not None: + properties["$ai_output_choices"] = self._with_privacy_mode(output_choices) + + if gen.cache_read_input_tokens: + properties["$ai_cache_read_input_tokens"] = gen.cache_read_input_tokens + if gen.cache_creation_input_tokens: + properties["$ai_cache_creation_input_tokens"] = gen.cache_creation_input_tokens + + if resolved_id is None: + properties["$process_person_profile"] = False + + saved_groups = self._groups + self._groups = groups + self._capture_event("$ai_generation", properties, resolved_id or trace_id) + self._groups = saved_groups + + def _emit_generation_from_result( + self, + result: "ResultMessage", + trace_id: str, + model: Optional[str], + query_start: float, + input_messages: Optional[List[Dict[str, Any]]], + output_choices: Optional[List[Dict[str, Any]]], + distinct_id: Any, + extra_props: Dict[str, Any], + privacy: bool, + groups: Dict[str, Any], + ) -> None: + """Fallback: emit a single generation from ResultMessage aggregate data.""" + resolved_id = self._resolve_distinct_id(distinct_id) + usage = result.usage or {} + + properties: Dict[str, Any] = { + "$ai_trace_id": trace_id, + "$ai_span_id": str(uuid.uuid4()), + "$ai_span_name": "generation_1", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + "$ai_model": model, + "$ai_input_tokens": usage.get("input_tokens", 0), + "$ai_output_tokens": usage.get("output_tokens", 0), + "$ai_latency": result.duration_api_ms / 1000.0 if result.duration_api_ms else 0, + "$ai_is_error": result.is_error, + **extra_props, + } + + if input_messages is not None: + properties["$ai_input"] = self._with_privacy_mode(input_messages) + if output_choices is not None: + properties["$ai_output_choices"] = self._with_privacy_mode(output_choices) + + cache_read = usage.get("cache_read_input_tokens", 0) + cache_creation = usage.get("cache_creation_input_tokens", 0) + if cache_read: + properties["$ai_cache_read_input_tokens"] = cache_read + if cache_creation: + properties["$ai_cache_creation_input_tokens"] = cache_creation + + if result.total_cost_usd is not None: + properties["$ai_total_cost_usd"] = result.total_cost_usd + + if resolved_id is None: + properties["$process_person_profile"] = False + + saved_groups = self._groups + self._groups = groups + self._capture_event("$ai_generation", properties, resolved_id or trace_id) + self._groups = saved_groups + + def _emit_tool_span( + self, + block: "ToolUseBlock", + trace_id: str, + parent_span_id: Optional[str], + distinct_id: Any, + extra_props: Dict[str, Any], + privacy: bool, + groups: Dict[str, Any], + ) -> None: + resolved_id = self._resolve_distinct_id(distinct_id) + + properties: Dict[str, Any] = { + "$ai_trace_id": trace_id, + "$ai_span_id": str(uuid.uuid4()), + "$ai_parent_id": parent_span_id, + "$ai_span_name": block.name, + "$ai_span_type": "tool", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + **extra_props, + } + + if not privacy and not (hasattr(self._client, "privacy_mode") and self._client.privacy_mode): + properties["$ai_input_state"] = _ensure_serializable(block.input) + + if resolved_id is None: + properties["$process_person_profile"] = False + + saved_groups = self._groups + self._groups = groups + self._capture_event("$ai_span", properties, resolved_id or trace_id) + self._groups = saved_groups + + def _emit_trace( + self, + result: "ResultMessage", + trace_id: str, + query_start: float, + distinct_id: Any, + extra_props: Dict[str, Any], + privacy: bool, + groups: Dict[str, Any], + ) -> None: + resolved_id = self._resolve_distinct_id(distinct_id, result) + latency = result.duration_ms / 1000.0 if result.duration_ms else (time.time() - query_start) + + properties: Dict[str, Any] = { + "$ai_trace_id": trace_id, + "$ai_trace_name": "claude_agent_sdk_query", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + "$ai_latency": latency, + "$ai_is_error": result.is_error, + **extra_props, + } + + if result.total_cost_usd is not None: + properties["$ai_total_cost_usd"] = result.total_cost_usd + + if resolved_id is None: + properties["$process_person_profile"] = False + + saved_groups = self._groups + self._groups = groups + self._capture_event("$ai_trace", properties, resolved_id or trace_id) + self._groups = saved_groups + + # Flush to ensure events are sent before process exits + try: + if hasattr(self._client, "flush") and callable(self._client.flush): + self._client.flush() + except Exception as e: + log.debug(f"Error flushing PostHog client: {e}") + + def _resolve_distinct_id( + self, + override: Any, + result: Optional["ResultMessage"] = None, + ) -> Optional[str]: + """Resolve distinct_id from override or instance default.""" + if callable(override): + if result: + val = override(result) + if val: + return str(val) + return None + elif override: + return str(override) + # Fall back to instance default + return self._get_distinct_id(result) + + +def _ensure_serializable(obj: Any) -> Any: + """Ensure an object is JSON-serializable.""" + if obj is None: + return None + try: + import json + json.dumps(obj) + return obj + except (TypeError, ValueError): + return str(obj) diff --git a/posthog/test/ai/claude_agent_sdk/__init__.py b/posthog/test/ai/claude_agent_sdk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py new file mode 100644 index 00000000..b10df882 --- /dev/null +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional +from unittest.mock import MagicMock, patch + +import pytest + +try: + from claude_agent_sdk.types import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + StreamEvent, + TextBlock, + ToolUseBlock, + ) + + from posthog.ai.claude_agent_sdk import PostHogClaudeAgentProcessor, instrument, query + + CLAUDE_AGENT_SDK_AVAILABLE = True +except ImportError: + CLAUDE_AGENT_SDK_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not CLAUDE_AGENT_SDK_AVAILABLE, reason="Claude Agent SDK is not available" +) + + +# ── Helpers ────────────────────────────────────────────────────── + +def _make_stream_event(event_type: str, data: Optional[Dict[str, Any]] = None, session_id: str = "sess_123") -> StreamEvent: + event = {"type": event_type, **(data or {})} + return StreamEvent(uuid="evt_1", session_id=session_id, event=event) + + +def _make_message_start( + model: str = "claude-sonnet-4-6", + input_tokens: int = 100, + output_tokens: int = 0, + cache_read: int = 0, + cache_creation: int = 0, +) -> StreamEvent: + return _make_stream_event("message_start", { + "message": { + "model": model, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_input_tokens": cache_read, + "cache_creation_input_tokens": cache_creation, + }, + }, + }) + + +def _make_message_delta(output_tokens: int = 50) -> StreamEvent: + return _make_stream_event("message_delta", { + "usage": {"output_tokens": output_tokens}, + }) + + +def _make_message_stop() -> StreamEvent: + return _make_stream_event("message_stop") + + +def _make_assistant_message( + model: str = "claude-sonnet-4-6", + text: str = "Hello!", + tool_uses: Optional[List[Dict[str, Any]]] = None, +) -> AssistantMessage: + content = [TextBlock(text=text)] + if tool_uses: + for tu in tool_uses: + content.append(ToolUseBlock(id=tu["id"], name=tu["name"], input=tu.get("input", {}))) + return AssistantMessage(content=content, model=model) + + +def _make_result_message( + total_cost_usd: float = 0.01, + input_tokens: int = 100, + output_tokens: int = 50, + duration_ms: int = 2000, + duration_api_ms: int = 1500, + num_turns: int = 3, + is_error: bool = False, + cache_read: int = 0, + cache_creation: int = 0, +) -> ResultMessage: + return ResultMessage( + subtype="success" if not is_error else "error", + duration_ms=duration_ms, + duration_api_ms=duration_api_ms, + is_error=is_error, + num_turns=num_turns, + session_id="sess_123", + total_cost_usd=total_cost_usd, + usage={ + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_input_tokens": cache_read, + "cache_creation_input_tokens": cache_creation, + }, + ) + + +async def _fake_query(messages): + """Create a fake query function that yields pre-defined messages.""" + for msg in messages: + yield msg + + +# ── Fixtures ───────────────────────────────────────────────────── + +@pytest.fixture +def mock_client(): + client = MagicMock() + client.privacy_mode = False + logging.getLogger("posthog").setLevel(logging.DEBUG) + return client + + +@pytest.fixture +def processor(mock_client): + return PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id="test-user", + privacy_mode=False, + ) + + +# ── Tests ──────────────────────────────────────────────────────── + +class TestPostHogClaudeAgentProcessor: + + def test_initialization(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id="user@example.com", + privacy_mode=True, + groups={"company": "acme"}, + properties={"env": "test"}, + ) + assert proc._client == mock_client + assert proc._distinct_id == "user@example.com" + assert proc._privacy_mode is True + assert proc._groups == {"company": "acme"} + assert proc._properties == {"env": "test"} + + def test_initialization_defaults(self): + with patch("posthog.ai.claude_agent_sdk.processor.setup") as mock_setup: + mock_setup.return_value = MagicMock() + proc = PostHogClaudeAgentProcessor() + assert proc._distinct_id is None + assert proc._privacy_mode is False + assert proc._groups == {} + assert proc._properties == {} + + +class TestGenerationEmission: + + @pytest.mark.asyncio + async def test_emits_generation_from_stream_events(self, processor, mock_client): + messages = [ + _make_message_start(input_tokens=100, cache_read=20), + _make_message_delta(output_tokens=50), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + collected = [] + async for msg in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + collected.append(msg) + + # Should have captured $ai_generation + $ai_trace + calls = mock_client.capture.call_args_list + events = [c.kwargs.get("event") or c[1].get("event") for c in calls] + assert "$ai_generation" in events + assert "$ai_trace" in events + + # Check generation properties + gen_call = next(c for c in calls if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation") + props = gen_call.kwargs.get("properties") or gen_call[1].get("properties") + assert props["$ai_provider"] == "anthropic" + assert props["$ai_framework"] == "claude-agent-sdk" + assert props["$ai_model"] == "claude-sonnet-4-6" + assert props["$ai_input_tokens"] == 100 + assert props["$ai_output_tokens"] == 50 + assert props["$ai_cache_read_input_tokens"] == 20 + + @pytest.mark.asyncio + async def test_emits_multiple_generations_for_multi_turn(self, processor, mock_client): + messages = [ + # Turn 1 + _make_message_start(input_tokens=50), + _make_message_delta(output_tokens=30), + _make_message_stop(), + _make_assistant_message(), + # Turn 2 + _make_message_start(input_tokens=80), + _make_message_delta(output_tokens=40), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(num_turns=2), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + gen_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" + ] + assert len(gen_calls) == 2 + + @pytest.mark.asyncio + async def test_fallback_generation_from_result_when_no_stream_events(self, processor, mock_client): + """When StreamEvents are not available, fall back to ResultMessage data.""" + messages = [ + _make_assistant_message(), + _make_result_message(input_tokens=200, output_tokens=100), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + gen_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" + ] + assert len(gen_calls) == 1 + props = gen_calls[0].kwargs.get("properties") or gen_calls[0][1].get("properties") + assert props["$ai_input_tokens"] == 200 + assert props["$ai_output_tokens"] == 100 + + +class TestToolSpanEmission: + + @pytest.mark.asyncio + async def test_emits_span_for_tool_use(self, processor, mock_client): + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(tool_uses=[ + {"id": "tu_1", "name": "Read", "input": {"file_path": "/tmp/test.py"}}, + ]), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query(prompt="Read a file", options=ClaudeAgentOptions()): + pass + + span_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" + ] + assert len(span_calls) == 1 + props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get("properties") + assert props["$ai_span_name"] == "Read" + assert props["$ai_span_type"] == "tool" + assert props["$ai_input_state"] == {"file_path": "/tmp/test.py"} + + +class TestTraceEmission: + + @pytest.mark.asyncio + async def test_emits_trace_on_result(self, processor, mock_client): + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(total_cost_usd=0.05, duration_ms=3000, is_error=False), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + trace_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" + ] + assert len(trace_calls) == 1 + props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get("properties") + assert props["$ai_trace_name"] == "claude_agent_sdk_query" + assert props["$ai_total_cost_usd"] == 0.05 + assert props["$ai_latency"] == 3.0 + assert props["$ai_is_error"] is False + + @pytest.mark.asyncio + async def test_trace_emits_error_status(self, processor, mock_client): + messages = [ + _make_result_message(is_error=True, total_cost_usd=0.0), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + trace_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" + ] + assert len(trace_calls) == 1 + props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get("properties") + assert props["$ai_is_error"] is True + + +class TestPrivacyMode: + + @pytest.mark.asyncio + async def test_privacy_mode_redacts_tool_input(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id="user", + privacy_mode=True, + ) + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(tool_uses=[ + {"id": "tu_1", "name": "Read", "input": {"file_path": "/secret/file"}}, + ]), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in proc.query(prompt="Read secret", options=ClaudeAgentOptions()): + pass + + span_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" + ] + assert len(span_calls) == 1 + props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get("properties") + assert "$ai_input_state" not in props + + +class TestPersonlessMode: + + @pytest.mark.asyncio + async def test_no_distinct_id_sets_process_person_profile_false(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id=None, + ) + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + for call in mock_client.capture.call_args_list: + props = call.kwargs.get("properties") or call[1].get("properties") + assert props.get("$process_person_profile") is False + + +class TestCustomProperties: + + @pytest.mark.asyncio + async def test_instance_properties_merged(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id="user", + properties={"app": "stamphog", "version": "1.0"}, + ) + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + for call in mock_client.capture.call_args_list: + props = call.kwargs.get("properties") or call[1].get("properties") + assert props.get("app") == "stamphog" + assert props.get("version") == "1.0" + + @pytest.mark.asyncio + async def test_per_call_properties_merged(self, processor, mock_client): + messages = [ + _make_message_start(), + _make_message_stop(), + _make_assistant_message(), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in processor.query( + prompt="Hi", + options=ClaudeAgentOptions(), + posthog_properties={"pr_number": 42}, + ): + pass + + for call in mock_client.capture.call_args_list: + props = call.kwargs.get("properties") or call[1].get("properties") + assert props.get("pr_number") == 42 + + +class TestCallableDistinctId: + + @pytest.mark.asyncio + async def test_callable_distinct_id_resolved_on_trace(self, mock_client): + def resolver(result): + return f"user-{result.session_id}" + + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id=resolver, + ) + messages = [ + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): + pass + + trace_calls = [ + c for c in mock_client.capture.call_args_list + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" + ] + assert len(trace_calls) == 1 + did = trace_calls[0].kwargs.get("distinct_id") or trace_calls[0][1].get("distinct_id") + assert did == "user-sess_123" + + +class TestMessagePassthrough: + + @pytest.mark.asyncio + async def test_all_messages_yielded_unchanged(self, processor, mock_client): + original_messages = [ + _make_message_start(), + _make_message_delta(), + _make_message_stop(), + _make_assistant_message(text="Hello world"), + _make_result_message(), + ] + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(original_messages)): + collected = [] + async for msg in processor.query(prompt="Hi", options=ClaudeAgentOptions()): + collected.append(msg) + + assert len(collected) == len(original_messages) + # Verify types match + for orig, got in zip(original_messages, collected): + assert type(orig) == type(got) + + +class TestInstrumentFunction: + + def test_instrument_returns_processor(self, mock_client): + proc = instrument(client=mock_client, distinct_id="test") + assert isinstance(proc, PostHogClaudeAgentProcessor) + assert proc._distinct_id == "test" + assert proc._client == mock_client + + +class TestEnsurePartialMessages: + + @pytest.mark.asyncio + async def test_enables_partial_messages_on_options(self, processor, mock_client): + """Verify that the processor enables include_partial_messages.""" + captured_options = {} + + async def fake_query_capture(**kwargs): + captured_options.update(kwargs) + return + yield # make it an async generator + + with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=fake_query_capture): + async for _ in processor.query( + prompt="Hi", + options=ClaudeAgentOptions(include_partial_messages=False), + ): + pass + + assert captured_options.get("options").include_partial_messages is True diff --git a/pyproject.toml b/pyproject.toml index f4baf6e0..a07a3621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "google-genai", "pydantic>=2.12.0", "parameterized>=0.8.1", + "claude-agent-sdk", ] [tool.setuptools] @@ -89,9 +90,11 @@ packages = [ "posthog.ai.openai_agents", "posthog.ai.anthropic", "posthog.ai.gemini", + "posthog.ai.claude_agent_sdk", "posthog.test", "posthog.test.ai", "posthog.test.ai.openai_agents", + "posthog.test.ai.claude_agent_sdk", "posthog.integrations", ] @@ -104,3 +107,8 @@ asyncio_default_fixture_loop_scope = "function" testpaths = ["posthog/test"] norecursedirs = ["integration_tests"] +[dependency-groups] +dev = [ + "claude-agent-sdk>=0.1.50", +] + diff --git a/uv.lock b/uv.lock index c9f12acf..653590af 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "2026-03-25T10:08:01.571835Z" +exclude-newer-span = "P7D" + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -351,6 +355,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/eb/42a7027a02d3827c6e49f97375a00e6da4708f81295d9afa1a0009ce4abd/claude_agent_sdk-0.1.50.tar.gz", hash = "sha256:e15157792857ecb55274a71f08981efcfda2e169bee7894cbdc245d05ac43203", size = 99070, upload-time = "2026-03-20T23:00:58.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/97/66bc98d5026dbed68b7469a4990de71d8c40d19713e37dafacf32ba3be3b/claude_agent_sdk-0.1.50-py3-none-macosx_11_0_arm64.whl", hash = "sha256:858b1822451209b2c3ad8df27458168d29ac19fd628680853f7707ea017fea73", size = 58223299, upload-time = "2026-03-20T23:01:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/65dda40016faa30a63a950d48b400ad26913e8e333e418651faf04d20673/claude_agent_sdk-0.1.50-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:44e75b9d076bd6030742729f99eb38777b80f052b22338d0a028d8190fc59e52", size = 61019645, upload-time = "2026-03-20T23:01:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c0/e5c7c6b9e378553fe24bb5367caede725e274a494b6d126e719971c53b8b/claude_agent_sdk-0.1.50-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:7363d431dc6efd83fa658a045e14fa4357440352b548002bfb9096d8f04d143c", size = 74590847, upload-time = "2026-03-20T23:01:07.899Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/658a28cb070e0b59ac98e88411536f6f9b8d81e8ddde9a8340106b0b8b0f/claude_agent_sdk-0.1.50-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:493d8cc43f4166291606749cf47b03e822f03b7f371cc77af697564017ccf579", size = 75231505, upload-time = "2026-03-20T23:01:11.45Z" }, + { url = "https://files.pythonhosted.org/packages/41/44/ff1f2c137406392fa0a69e3c3ff37150267da664decddb6dee83b80ba162/claude_agent_sdk-0.1.50-py3-none-win_amd64.whl", hash = "sha256:2e44caf3e5bce56e26a18158acf3e1c2c2784cf8fa15e425afe92816c987eb1a", size = 75846174, upload-time = "2026-03-20T23:01:15.277Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -433,6 +467,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, @@ -442,6 +477,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, @@ -451,14 +489,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, ] [[package]] @@ -994,6 +1038,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "keyring" version = "25.6.0" @@ -1300,6 +1371,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1893,6 +1989,7 @@ langchain = [ ] test = [ { name = "anthropic" }, + { name = "claude-agent-sdk" }, { name = "coverage" }, { name = "django" }, { name = "freezegun" }, @@ -1913,10 +2010,16 @@ test = [ { name = "tiktoken" }, ] +[package.dev-dependencies] +dev = [ + { name = "claude-agent-sdk" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, { name = "backoff", specifier = ">=1.10.0" }, + { name = "claude-agent-sdk", marker = "extra == 'test'" }, { name = "coverage", marker = "extra == 'test'" }, { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'" }, @@ -1962,6 +2065,9 @@ requires-dist = [ ] provides-extras = ["langchain", "dev", "test"] +[package.metadata.requires-dev] +dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] + [[package]] name = "pre-commit" version = "4.2.0" @@ -2253,6 +2359,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -2316,6 +2439,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2383,6 +2537,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2024.11.6" @@ -2502,6 +2670,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -2633,6 +2923,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "tenacity" version = "8.5.0" @@ -2921,6 +3237,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, ] +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + [[package]] name = "virtualenv" version = "20.31.2" From eb07167187e12f3e0b6a13d9d0ee5b5f77b35d96 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 12:23:15 +0100 Subject: [PATCH 2/6] fix(ai): address review feedback on claude agent sdk integration - Honor per-call privacy override for $ai_input/$ai_output_choices in generation events (was only checking instance-level privacy) - Pass groups directly to _capture_event instead of fragile save/restore pattern on self._groups (thread-safe, exception-safe) - Fix tool span parent linkage: use tracker.current_span_id for in-progress generation instead of stale current_generation_span_id --- posthog/ai/claude_agent_sdk/processor.py | 43 ++++++++++++------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 85bb5b33..df09bd0b 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -104,6 +104,11 @@ def has_pending(self) -> bool: def generation_count(self) -> int: return len(self._completed) + @property + def current_span_id(self) -> Optional[str]: + """Span ID of the generation currently in progress (before message_stop).""" + return self._current.span_id if self._current else None + @property def had_any_stream_events(self) -> bool: """Whether we received any StreamEvents at all.""" @@ -156,6 +161,7 @@ def _capture_event( event: str, properties: Dict[str, Any], distinct_id: Optional[str] = None, + groups: Optional[Dict[str, Any]] = None, ) -> None: try: if not hasattr(self._client, "capture") or not callable(self._client.capture): @@ -170,7 +176,7 @@ def _capture_event( distinct_id=distinct_id or "unknown", event=event, properties=final_properties, - groups=self._groups, + groups=groups if groups is not None else self._groups, ) except Exception as e: log.debug(f"Failed to capture PostHog event: {e}") @@ -268,12 +274,17 @@ async def query( elif isinstance(message, AssistantMessage): tracker.set_model(message.model) + # Use the in-progress generation's span_id as parent for tool spans. + # AssistantMessage arrives before message_stop, so current_generation_span_id + # would be stale (from the previous turn). tracker.current_span_id gives us + # the correct in-progress generation. + parent_id = tracker.current_span_id or current_generation_span_id # Build output content from assistant blocks output_content: List[Dict[str, Any]] = [] for block in message.content: if isinstance(block, ToolUseBlock): self._emit_tool_span( - block, trace_id, current_generation_span_id, + block, trace_id, parent_id, distinct_id_override, extra_props, privacy, groups, ) output_content.append({ @@ -354,9 +365,9 @@ def _emit_generation( } if input_messages is not None: - properties["$ai_input"] = self._with_privacy_mode(input_messages) + properties["$ai_input"] = None if privacy else self._with_privacy_mode(input_messages) if output_choices is not None: - properties["$ai_output_choices"] = self._with_privacy_mode(output_choices) + properties["$ai_output_choices"] = None if privacy else self._with_privacy_mode(output_choices) if gen.cache_read_input_tokens: properties["$ai_cache_read_input_tokens"] = gen.cache_read_input_tokens @@ -366,10 +377,7 @@ def _emit_generation( if resolved_id is None: properties["$process_person_profile"] = False - saved_groups = self._groups - self._groups = groups - self._capture_event("$ai_generation", properties, resolved_id or trace_id) - self._groups = saved_groups + self._capture_event("$ai_generation", properties, resolved_id or trace_id, groups) def _emit_generation_from_result( self, @@ -403,9 +411,9 @@ def _emit_generation_from_result( } if input_messages is not None: - properties["$ai_input"] = self._with_privacy_mode(input_messages) + properties["$ai_input"] = None if privacy else self._with_privacy_mode(input_messages) if output_choices is not None: - properties["$ai_output_choices"] = self._with_privacy_mode(output_choices) + properties["$ai_output_choices"] = None if privacy else self._with_privacy_mode(output_choices) cache_read = usage.get("cache_read_input_tokens", 0) cache_creation = usage.get("cache_creation_input_tokens", 0) @@ -420,10 +428,7 @@ def _emit_generation_from_result( if resolved_id is None: properties["$process_person_profile"] = False - saved_groups = self._groups - self._groups = groups - self._capture_event("$ai_generation", properties, resolved_id or trace_id) - self._groups = saved_groups + self._capture_event("$ai_generation", properties, resolved_id or trace_id, groups) def _emit_tool_span( self, @@ -454,10 +459,7 @@ def _emit_tool_span( if resolved_id is None: properties["$process_person_profile"] = False - saved_groups = self._groups - self._groups = groups - self._capture_event("$ai_span", properties, resolved_id or trace_id) - self._groups = saved_groups + self._capture_event("$ai_span", properties, resolved_id or trace_id, groups) def _emit_trace( self, @@ -488,10 +490,7 @@ def _emit_trace( if resolved_id is None: properties["$process_person_profile"] = False - saved_groups = self._groups - self._groups = groups - self._capture_event("$ai_trace", properties, resolved_id or trace_id) - self._groups = saved_groups + self._capture_event("$ai_trace", properties, resolved_id or trace_id, groups) # Flush to ensure events are sent before process exits try: From 61cdeb51007145e3ec9c966620a81ef9c4348a43 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 12:27:12 +0100 Subject: [PATCH 3/6] style: ruff format claude_agent_sdk files --- posthog/ai/claude_agent_sdk/__init__.py | 4 +- posthog/ai/claude_agent_sdk/processor.py | 156 +++++++++---- .../ai/claude_agent_sdk/test_processor.py | 214 ++++++++++++------ 3 files changed, 271 insertions(+), 103 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/__init__.py b/posthog/ai/claude_agent_sdk/__init__.py index ada4fc89..3a0dd9c0 100644 --- a/posthog/ai/claude_agent_sdk/__init__.py +++ b/posthog/ai/claude_agent_sdk/__init__.py @@ -69,7 +69,9 @@ async def query( options: Optional[ClaudeAgentOptions] = None, transport: Any = None, posthog_client: Optional[Client] = None, - posthog_distinct_id: Optional[Union[str, Callable[[ResultMessage], Optional[str]]]] = None, + posthog_distinct_id: Optional[ + Union[str, Callable[[ResultMessage], Optional[str]]] + ] = None, posthog_trace_id: Optional[str] = None, posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index df09bd0b..83a8ea82 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -69,8 +69,12 @@ def process_stream_event(self, event: "StreamEvent") -> None: usage = message.get("usage", {}) self._current.input_tokens = usage.get("input_tokens", 0) self._current.output_tokens = usage.get("output_tokens", 0) - self._current.cache_read_input_tokens = usage.get("cache_read_input_tokens", 0) - self._current.cache_creation_input_tokens = usage.get("cache_creation_input_tokens", 0) + self._current.cache_read_input_tokens = usage.get( + "cache_read_input_tokens", 0 + ) + self._current.cache_creation_input_tokens = usage.get( + "cache_creation_input_tokens", 0 + ) elif event_type == "message_delta" and self._current is not None: usage = raw.get("usage", {}) @@ -127,7 +131,9 @@ class PostHogClaudeAgentProcessor: def __init__( self, client: Optional[Client] = None, - distinct_id: Optional[Union[str, Callable[["ResultMessage"], Optional[str]]]] = None, + distinct_id: Optional[ + Union[str, Callable[["ResultMessage"], Optional[str]]] + ] = None, privacy_mode: bool = False, groups: Optional[Dict[str, Any]] = None, properties: Optional[Dict[str, Any]] = None, @@ -138,7 +144,9 @@ def __init__( self._groups = groups or {} self._properties = properties or {} - def _get_distinct_id(self, result: Optional["ResultMessage"] = None) -> Optional[str]: + def _get_distinct_id( + self, result: Optional["ResultMessage"] = None + ) -> Optional[str]: if callable(self._distinct_id): if result: val = self._distinct_id(result) @@ -164,7 +172,9 @@ def _capture_event( groups: Optional[Dict[str, Any]] = None, ) -> None: try: - if not hasattr(self._client, "capture") or not callable(self._client.capture): + if not hasattr(self._client, "capture") or not callable( + self._client.capture + ): return final_properties = { @@ -187,7 +197,9 @@ async def query( prompt: Any, options: Optional[ClaudeAgentOptions] = None, transport: Any = None, - posthog_distinct_id: Optional[Union[str, Callable[["ResultMessage"], Optional[str]]]] = None, + posthog_distinct_id: Optional[ + Union[str, Callable[["ResultMessage"], Optional[str]]] + ] = None, posthog_trace_id: Optional[str] = None, posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: Optional[bool] = None, @@ -214,7 +226,11 @@ async def query( distinct_id_override = posthog_distinct_id or self._distinct_id trace_id = posthog_trace_id or str(uuid.uuid4()) extra_props = posthog_properties or {} - privacy = posthog_privacy_mode if posthog_privacy_mode is not None else self._privacy_mode + privacy = ( + posthog_privacy_mode + if posthog_privacy_mode is not None + else self._privacy_mode + ) groups = posthog_groups or self._groups # Ensure partial messages are enabled for per-generation tracking @@ -233,7 +249,9 @@ async def query( if isinstance(prompt, str): initial_input = [{"role": "user", "content": prompt}] if options and options.system_prompt and isinstance(options.system_prompt, str): - initial_input = [{"role": "system", "content": options.system_prompt}] + initial_input + initial_input = [ + {"role": "system", "content": options.system_prompt} + ] + initial_input # Two-slot input tracking: # - current_input: input for the generation currently in progress @@ -249,7 +267,9 @@ async def query( # Accumulate assistant output per generation pending_output: List[Dict[str, Any]] = [] - async for message in original_query(prompt=prompt, options=options, transport=transport): + async for message in original_query( + prompt=prompt, options=options, transport=transport + ): # All instrumentation is wrapped in try/except so PostHog errors # never interrupt the underlying Claude Agent SDK query. try: @@ -262,10 +282,15 @@ async def query( generation_index += 1 current_generation_span_id = gen.span_id self._emit_generation( - gen, trace_id, generation_index, + gen, + trace_id, + generation_index, current_input, pending_output or None, - distinct_id_override, extra_props, privacy, groups, + distinct_id_override, + extra_props, + privacy, + groups, ) # Promote: tool results from this turn become input for next turn current_input = next_input @@ -284,17 +309,29 @@ async def query( for block in message.content: if isinstance(block, ToolUseBlock): self._emit_tool_span( - block, trace_id, parent_id, - distinct_id_override, extra_props, privacy, groups, + block, + trace_id, + parent_id, + distinct_id_override, + extra_props, + privacy, + groups, + ) + output_content.append( + { + "type": "function", + "function": { + "name": block.name, + "arguments": block.input, + }, + } ) - output_content.append({ - "type": "function", - "function": {"name": block.name, "arguments": block.input}, - }) elif hasattr(block, "text"): output_content.append({"type": "text", "text": block.text}) if output_content: - pending_output = [{"role": "assistant", "content": output_content}] + pending_output = [ + {"role": "assistant", "content": output_content} + ] elif isinstance(message, UserMessage): # UserMessages carry tool results. They arrive *before* message_stop @@ -306,11 +343,15 @@ async def query( formatted: List[Dict[str, Any]] = [] for block in content: if hasattr(block, "tool_use_id"): - formatted.append({ - "type": "tool_result", - "tool_use_id": block.tool_use_id, - "content": str(block.content)[:500] if block.content else None, - }) + formatted.append( + { + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": str(block.content)[:500] + if block.content + else None, + } + ) elif hasattr(block, "text"): formatted.append({"type": "text", "text": block.text}) if formatted: @@ -321,14 +362,26 @@ async def query( # generation from ResultMessage aggregate data if not tracker.had_any_stream_events: self._emit_generation_from_result( - message, trace_id, tracker.last_model, - query_start, initial_input, pending_output, - distinct_id_override, extra_props, privacy, groups, + message, + trace_id, + tracker.last_model, + query_start, + initial_input, + pending_output, + distinct_id_override, + extra_props, + privacy, + groups, ) self._emit_trace( - message, trace_id, query_start, - distinct_id_override, extra_props, privacy, groups, + message, + trace_id, + query_start, + distinct_id_override, + extra_props, + privacy, + groups, ) except Exception as e: @@ -349,7 +402,9 @@ def _emit_generation( groups: Dict[str, Any], ) -> None: resolved_id = self._resolve_distinct_id(distinct_id) - latency = (gen.end_time - gen.start_time) if gen.start_time and gen.end_time else 0 + latency = ( + (gen.end_time - gen.start_time) if gen.start_time and gen.end_time else 0 + ) properties: Dict[str, Any] = { "$ai_trace_id": trace_id, @@ -365,19 +420,27 @@ def _emit_generation( } if input_messages is not None: - properties["$ai_input"] = None if privacy else self._with_privacy_mode(input_messages) + properties["$ai_input"] = ( + None if privacy else self._with_privacy_mode(input_messages) + ) if output_choices is not None: - properties["$ai_output_choices"] = None if privacy else self._with_privacy_mode(output_choices) + properties["$ai_output_choices"] = ( + None if privacy else self._with_privacy_mode(output_choices) + ) if gen.cache_read_input_tokens: properties["$ai_cache_read_input_tokens"] = gen.cache_read_input_tokens if gen.cache_creation_input_tokens: - properties["$ai_cache_creation_input_tokens"] = gen.cache_creation_input_tokens + properties["$ai_cache_creation_input_tokens"] = ( + gen.cache_creation_input_tokens + ) if resolved_id is None: properties["$process_person_profile"] = False - self._capture_event("$ai_generation", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_generation", properties, resolved_id or trace_id, groups + ) def _emit_generation_from_result( self, @@ -405,15 +468,21 @@ def _emit_generation_from_result( "$ai_model": model, "$ai_input_tokens": usage.get("input_tokens", 0), "$ai_output_tokens": usage.get("output_tokens", 0), - "$ai_latency": result.duration_api_ms / 1000.0 if result.duration_api_ms else 0, + "$ai_latency": result.duration_api_ms / 1000.0 + if result.duration_api_ms + else 0, "$ai_is_error": result.is_error, **extra_props, } if input_messages is not None: - properties["$ai_input"] = None if privacy else self._with_privacy_mode(input_messages) + properties["$ai_input"] = ( + None if privacy else self._with_privacy_mode(input_messages) + ) if output_choices is not None: - properties["$ai_output_choices"] = None if privacy else self._with_privacy_mode(output_choices) + properties["$ai_output_choices"] = ( + None if privacy else self._with_privacy_mode(output_choices) + ) cache_read = usage.get("cache_read_input_tokens", 0) cache_creation = usage.get("cache_creation_input_tokens", 0) @@ -428,7 +497,9 @@ def _emit_generation_from_result( if resolved_id is None: properties["$process_person_profile"] = False - self._capture_event("$ai_generation", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_generation", properties, resolved_id or trace_id, groups + ) def _emit_tool_span( self, @@ -453,7 +524,9 @@ def _emit_tool_span( **extra_props, } - if not privacy and not (hasattr(self._client, "privacy_mode") and self._client.privacy_mode): + if not privacy and not ( + hasattr(self._client, "privacy_mode") and self._client.privacy_mode + ): properties["$ai_input_state"] = _ensure_serializable(block.input) if resolved_id is None: @@ -472,7 +545,11 @@ def _emit_trace( groups: Dict[str, Any], ) -> None: resolved_id = self._resolve_distinct_id(distinct_id, result) - latency = result.duration_ms / 1000.0 if result.duration_ms else (time.time() - query_start) + latency = ( + result.duration_ms / 1000.0 + if result.duration_ms + else (time.time() - query_start) + ) properties: Dict[str, Any] = { "$ai_trace_id": trace_id, @@ -523,6 +600,7 @@ def _ensure_serializable(obj: Any) -> Any: return None try: import json + json.dumps(obj) return obj except (TypeError, ValueError): diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index b10df882..ac1b8e4b 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -16,7 +16,11 @@ ToolUseBlock, ) - from posthog.ai.claude_agent_sdk import PostHogClaudeAgentProcessor, instrument, query + from posthog.ai.claude_agent_sdk import ( + PostHogClaudeAgentProcessor, + instrument, + query, + ) CLAUDE_AGENT_SDK_AVAILABLE = True except ImportError: @@ -29,7 +33,10 @@ # ── Helpers ────────────────────────────────────────────────────── -def _make_stream_event(event_type: str, data: Optional[Dict[str, Any]] = None, session_id: str = "sess_123") -> StreamEvent: + +def _make_stream_event( + event_type: str, data: Optional[Dict[str, Any]] = None, session_id: str = "sess_123" +) -> StreamEvent: event = {"type": event_type, **(data or {})} return StreamEvent(uuid="evt_1", session_id=session_id, event=event) @@ -41,23 +48,29 @@ def _make_message_start( cache_read: int = 0, cache_creation: int = 0, ) -> StreamEvent: - return _make_stream_event("message_start", { - "message": { - "model": model, - "usage": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "cache_read_input_tokens": cache_read, - "cache_creation_input_tokens": cache_creation, + return _make_stream_event( + "message_start", + { + "message": { + "model": model, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_input_tokens": cache_read, + "cache_creation_input_tokens": cache_creation, + }, }, }, - }) + ) def _make_message_delta(output_tokens: int = 50) -> StreamEvent: - return _make_stream_event("message_delta", { - "usage": {"output_tokens": output_tokens}, - }) + return _make_stream_event( + "message_delta", + { + "usage": {"output_tokens": output_tokens}, + }, + ) def _make_message_stop() -> StreamEvent: @@ -72,7 +85,9 @@ def _make_assistant_message( content = [TextBlock(text=text)] if tool_uses: for tu in tool_uses: - content.append(ToolUseBlock(id=tu["id"], name=tu["name"], input=tu.get("input", {}))) + content.append( + ToolUseBlock(id=tu["id"], name=tu["name"], input=tu.get("input", {})) + ) return AssistantMessage(content=content, model=model) @@ -112,6 +127,7 @@ async def _fake_query(messages): # ── Fixtures ───────────────────────────────────────────────────── + @pytest.fixture def mock_client(): client = MagicMock() @@ -131,8 +147,8 @@ def processor(mock_client): # ── Tests ──────────────────────────────────────────────────────── -class TestPostHogClaudeAgentProcessor: +class TestPostHogClaudeAgentProcessor: def test_initialization(self, mock_client): proc = PostHogClaudeAgentProcessor( client=mock_client, @@ -158,7 +174,6 @@ def test_initialization_defaults(self): class TestGenerationEmission: - @pytest.mark.asyncio async def test_emits_generation_from_stream_events(self, processor, mock_client): messages = [ @@ -169,7 +184,10 @@ async def test_emits_generation_from_stream_events(self, processor, mock_client) _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): collected = [] async for msg in processor.query(prompt="Hi", options=ClaudeAgentOptions()): collected.append(msg) @@ -181,7 +199,11 @@ async def test_emits_generation_from_stream_events(self, processor, mock_client) assert "$ai_trace" in events # Check generation properties - gen_call = next(c for c in calls if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation") + gen_call = next( + c + for c in calls + if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" + ) props = gen_call.kwargs.get("properties") or gen_call[1].get("properties") assert props["$ai_provider"] == "anthropic" assert props["$ai_framework"] == "claude-agent-sdk" @@ -191,7 +213,9 @@ async def test_emits_generation_from_stream_events(self, processor, mock_client) assert props["$ai_cache_read_input_tokens"] == 20 @pytest.mark.asyncio - async def test_emits_multiple_generations_for_multi_turn(self, processor, mock_client): + async def test_emits_multiple_generations_for_multi_turn( + self, processor, mock_client + ): messages = [ # Turn 1 _make_message_start(input_tokens=50), @@ -206,68 +230,92 @@ async def test_emits_multiple_generations_for_multi_turn(self, processor, mock_c _make_result_message(num_turns=2), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): pass gen_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" ] assert len(gen_calls) == 2 @pytest.mark.asyncio - async def test_fallback_generation_from_result_when_no_stream_events(self, processor, mock_client): + async def test_fallback_generation_from_result_when_no_stream_events( + self, processor, mock_client + ): """When StreamEvents are not available, fall back to ResultMessage data.""" messages = [ _make_assistant_message(), _make_result_message(input_tokens=200, output_tokens=100), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): pass gen_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_generation" ] assert len(gen_calls) == 1 - props = gen_calls[0].kwargs.get("properties") or gen_calls[0][1].get("properties") + props = gen_calls[0].kwargs.get("properties") or gen_calls[0][1].get( + "properties" + ) assert props["$ai_input_tokens"] == 200 assert props["$ai_output_tokens"] == 100 class TestToolSpanEmission: - @pytest.mark.asyncio async def test_emits_span_for_tool_use(self, processor, mock_client): messages = [ _make_message_start(), _make_message_stop(), - _make_assistant_message(tool_uses=[ - {"id": "tu_1", "name": "Read", "input": {"file_path": "/tmp/test.py"}}, - ]), + _make_assistant_message( + tool_uses=[ + { + "id": "tu_1", + "name": "Read", + "input": {"file_path": "/tmp/test.py"}, + }, + ] + ), _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): - async for _ in processor.query(prompt="Read a file", options=ClaudeAgentOptions()): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): + async for _ in processor.query( + prompt="Read a file", options=ClaudeAgentOptions() + ): pass span_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" ] assert len(span_calls) == 1 - props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get("properties") + props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get( + "properties" + ) assert props["$ai_span_name"] == "Read" assert props["$ai_span_type"] == "tool" assert props["$ai_input_state"] == {"file_path": "/tmp/test.py"} class TestTraceEmission: - @pytest.mark.asyncio async def test_emits_trace_on_result(self, processor, mock_client): messages = [ @@ -277,16 +325,22 @@ async def test_emits_trace_on_result(self, processor, mock_client): _make_result_message(total_cost_usd=0.05, duration_ms=3000, is_error=False), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): pass trace_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 - props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get("properties") + props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get( + "properties" + ) assert props["$ai_trace_name"] == "claude_agent_sdk_query" assert props["$ai_total_cost_usd"] == 0.05 assert props["$ai_latency"] == 3.0 @@ -298,21 +352,26 @@ async def test_trace_emits_error_status(self, processor, mock_client): _make_result_message(is_error=True, total_cost_usd=0.0), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in processor.query(prompt="Hi", options=ClaudeAgentOptions()): pass trace_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 - props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get("properties") + props = trace_calls[0].kwargs.get("properties") or trace_calls[0][1].get( + "properties" + ) assert props["$ai_is_error"] is True class TestPrivacyMode: - @pytest.mark.asyncio async def test_privacy_mode_redacts_tool_input(self, mock_client): proc = PostHogClaudeAgentProcessor( @@ -323,27 +382,40 @@ async def test_privacy_mode_redacts_tool_input(self, mock_client): messages = [ _make_message_start(), _make_message_stop(), - _make_assistant_message(tool_uses=[ - {"id": "tu_1", "name": "Read", "input": {"file_path": "/secret/file"}}, - ]), + _make_assistant_message( + tool_uses=[ + { + "id": "tu_1", + "name": "Read", + "input": {"file_path": "/secret/file"}, + }, + ] + ), _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): - async for _ in proc.query(prompt="Read secret", options=ClaudeAgentOptions()): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): + async for _ in proc.query( + prompt="Read secret", options=ClaudeAgentOptions() + ): pass span_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_span" ] assert len(span_calls) == 1 - props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get("properties") + props = span_calls[0].kwargs.get("properties") or span_calls[0][1].get( + "properties" + ) assert "$ai_input_state" not in props class TestPersonlessMode: - @pytest.mark.asyncio async def test_no_distinct_id_sets_process_person_profile_false(self, mock_client): proc = PostHogClaudeAgentProcessor( @@ -357,7 +429,10 @@ async def test_no_distinct_id_sets_process_person_profile_false(self, mock_clien _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): pass @@ -367,7 +442,6 @@ async def test_no_distinct_id_sets_process_person_profile_false(self, mock_clien class TestCustomProperties: - @pytest.mark.asyncio async def test_instance_properties_merged(self, mock_client): proc = PostHogClaudeAgentProcessor( @@ -382,7 +456,10 @@ async def test_instance_properties_merged(self, mock_client): _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): pass @@ -400,7 +477,10 @@ async def test_per_call_properties_merged(self, processor, mock_client): _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in processor.query( prompt="Hi", options=ClaudeAgentOptions(), @@ -414,7 +494,6 @@ async def test_per_call_properties_merged(self, processor, mock_client): class TestCallableDistinctId: - @pytest.mark.asyncio async def test_callable_distinct_id_resolved_on_trace(self, mock_client): def resolver(result): @@ -428,21 +507,26 @@ def resolver(result): _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): pass trace_calls = [ - c for c in mock_client.capture.call_args_list + c + for c in mock_client.capture.call_args_list if (c.kwargs.get("event") or c[1].get("event")) == "$ai_trace" ] assert len(trace_calls) == 1 - did = trace_calls[0].kwargs.get("distinct_id") or trace_calls[0][1].get("distinct_id") + did = trace_calls[0].kwargs.get("distinct_id") or trace_calls[0][1].get( + "distinct_id" + ) assert did == "user-sess_123" class TestMessagePassthrough: - @pytest.mark.asyncio async def test_all_messages_yielded_unchanged(self, processor, mock_client): original_messages = [ @@ -453,7 +537,10 @@ async def test_all_messages_yielded_unchanged(self, processor, mock_client): _make_result_message(), ] - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(original_messages)): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(original_messages), + ): collected = [] async for msg in processor.query(prompt="Hi", options=ClaudeAgentOptions()): collected.append(msg) @@ -465,7 +552,6 @@ async def test_all_messages_yielded_unchanged(self, processor, mock_client): class TestInstrumentFunction: - def test_instrument_returns_processor(self, mock_client): proc = instrument(client=mock_client, distinct_id="test") assert isinstance(proc, PostHogClaudeAgentProcessor) @@ -474,7 +560,6 @@ def test_instrument_returns_processor(self, mock_client): class TestEnsurePartialMessages: - @pytest.mark.asyncio async def test_enables_partial_messages_on_options(self, processor, mock_client): """Verify that the processor enables include_partial_messages.""" @@ -485,7 +570,10 @@ async def fake_query_capture(**kwargs): return yield # make it an async generator - with patch("posthog.ai.claude_agent_sdk.processor.original_query", side_effect=fake_query_capture): + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=fake_query_capture, + ): async for _ in processor.query( prompt="Hi", options=ClaudeAgentOptions(include_partial_messages=False), From bf0f63f62fbfadb05d04d7abbb08ea4308adedd7 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 13:05:32 +0100 Subject: [PATCH 4/6] feat(ai): add PostHogClaudeSDKClient for stateful multi-turn conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps ClaudeSDKClient to instrument receive_response() with the same generation/span/trace tracking as query(). Supports multi-turn conversations with full history — each turn emits its own $ai_generation events, all linked by a shared $ai_trace_id. The $ai_trace event is emitted on disconnect() to cover the entire session. Usage: async with PostHogClaudeSDKClient(options, posthog_client=ph) as client: await client.query("Hello") async for msg in client.receive_response(): ... await client.query("Follow up") # has conversation history async for msg in client.receive_response(): ... --- posthog/ai/claude_agent_sdk/__init__.py | 14 +- posthog/ai/claude_agent_sdk/processor.py | 227 +++++++++++++++++++++++ 2 files changed, 238 insertions(+), 3 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/__init__.py b/posthog/ai/claude_agent_sdk/__init__.py index 3a0dd9c0..78a2ca9e 100644 --- a/posthog/ai/claude_agent_sdk/__init__.py +++ b/posthog/ai/claude_agent_sdk/__init__.py @@ -14,9 +14,17 @@ "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" ) -from posthog.ai.claude_agent_sdk.processor import PostHogClaudeAgentProcessor - -__all__ = ["PostHogClaudeAgentProcessor", "instrument", "query"] +from posthog.ai.claude_agent_sdk.processor import ( + PostHogClaudeAgentProcessor, + PostHogClaudeSDKClient, +) + +__all__ = [ + "PostHogClaudeAgentProcessor", + "PostHogClaudeSDKClient", + "instrument", + "query", +] def instrument( diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 83a8ea82..f16c4b65 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -13,6 +13,7 @@ try: from claude_agent_sdk import ( AssistantMessage, + ClaudeSDKClient, ResultMessage, ToolUseBlock, UserMessage, @@ -594,6 +595,232 @@ def _resolve_distinct_id( return self._get_distinct_id(result) +class PostHogClaudeSDKClient: + """Wraps ClaudeSDKClient for stateful multi-turn conversations with PostHog instrumentation. + + Usage: + async with PostHogClaudeSDKClient(options, posthog_client=ph, posthog_distinct_id="user") as client: + await client.query("Hello") + async for msg in client.receive_response(): + ... # turn 1, emits $ai_generation events + await client.query("Follow up") + async for msg in client.receive_response(): + ... # turn 2, same trace, has conversation history + """ + + def __init__( + self, + options: Optional["ClaudeAgentOptions"] = None, + transport: Any = None, + *, + posthog_client: Optional[Client] = None, + posthog_distinct_id: Optional[ + Union[str, Callable[["ResultMessage"], Optional[str]]] + ] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: bool = False, + posthog_groups: Optional[Dict[str, Any]] = None, + ): + from dataclasses import replace as dc_replace + + # Ensure partial messages for per-generation tracking + if options is None: + options = ClaudeAgentOptions(include_partial_messages=True) + elif not options.include_partial_messages: + options = dc_replace(options, include_partial_messages=True) + + self._client = ClaudeSDKClient(options, transport) + self._processor = PostHogClaudeAgentProcessor( + client=posthog_client, + distinct_id=posthog_distinct_id, + privacy_mode=posthog_privacy_mode, + groups=posthog_groups, + properties=posthog_properties or {}, + ) + self._trace_id = posthog_trace_id or str(uuid.uuid4()) + self._distinct_id = posthog_distinct_id + self._extra_props = posthog_properties or {} + self._privacy = posthog_privacy_mode + self._groups = posthog_groups or {} + + # Shared state across turns + self._tracker = _GenerationTracker() + self._generation_index = 0 + self._current_generation_span_id: Optional[str] = None + self._current_input: Optional[List[Dict[str, Any]]] = None + self._next_input: Optional[List[Dict[str, Any]]] = None + self._pending_output: List[Dict[str, Any]] = [] + self._query_start = time.time() + + async def connect(self, prompt: Any = None) -> None: + await self._client.connect(prompt) + + async def query(self, prompt: str, session_id: str = "default") -> None: + # Track the prompt as input for the next generation + self._current_input = [{"role": "user", "content": prompt}] + await self._client.query(prompt, session_id) + + async def receive_response(self): + """Instrumented receive_response — yields all messages, emits PostHog events.""" + async for message in self._client.receive_response(): + try: + if isinstance(message, StreamEvent): + self._tracker.process_stream_event(message) + + if self._tracker.has_completed_generation(): + gen = self._tracker.pop_generation() + self._generation_index += 1 + self._current_generation_span_id = gen.span_id + self._processor._emit_generation( + gen, + self._trace_id, + self._generation_index, + self._current_input, + self._pending_output or None, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + self._current_input = self._next_input + self._next_input = None + self._pending_output = [] + + elif isinstance(message, AssistantMessage): + self._tracker.set_model(message.model) + parent_id = ( + self._tracker.current_span_id + or self._current_generation_span_id + ) + output_content: List[Dict[str, Any]] = [] + for block in message.content: + if isinstance(block, ToolUseBlock): + self._processor._emit_tool_span( + block, + self._trace_id, + parent_id, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + output_content.append( + { + "type": "function", + "function": { + "name": block.name, + "arguments": block.input, + }, + } + ) + elif hasattr(block, "text"): + output_content.append({"type": "text", "text": block.text}) + if output_content: + self._pending_output = [ + {"role": "assistant", "content": output_content} + ] + + elif isinstance(message, UserMessage): + content = message.content + if isinstance(content, str): + self._next_input = [{"role": "user", "content": content}] + elif isinstance(content, list): + formatted: List[Dict[str, Any]] = [] + for block in content: + if hasattr(block, "tool_use_id"): + formatted.append( + { + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": str(block.content)[:500] + if block.content + else None, + } + ) + elif hasattr(block, "text"): + formatted.append({"type": "text", "text": block.text}) + if formatted: + self._next_input = [{"role": "user", "content": formatted}] + + elif isinstance(message, ResultMessage): + if not self._tracker.had_any_stream_events: + self._processor._emit_generation_from_result( + message, + self._trace_id, + self._tracker.last_model, + self._query_start, + self._current_input, + self._pending_output, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + # Don't emit trace here — wait for disconnect/close + # so multi-turn sessions get one trace at the end + + except Exception as e: + log.debug(f"PostHog instrumentation error (non-fatal): {e}") + + yield message + + async def disconnect(self) -> None: + # Emit the trace event covering the entire session + try: + latency = time.time() - self._query_start + resolved_id = self._processor._resolve_distinct_id(self._distinct_id) + + properties: Dict[str, Any] = { + "$ai_trace_id": self._trace_id, + "$ai_trace_name": "claude_agent_sdk_session", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + "$ai_latency": latency, + **self._extra_props, + } + + if resolved_id is None: + properties["$process_person_profile"] = False + + self._processor._capture_event( + "$ai_trace", + properties, + resolved_id or self._trace_id, + self._groups, + ) + + try: + ph = self._processor._client + if hasattr(ph, "flush") and callable(ph.flush): + ph.flush() + except Exception as e: + log.debug(f"Error flushing PostHog client: {e}") + + except Exception as e: + log.debug(f"PostHog trace emission error (non-fatal): {e}") + + await self._client.disconnect() + + # Delegate other methods + async def interrupt(self) -> None: + await self._client.interrupt() + + async def set_permission_mode(self, mode: str) -> None: + await self._client.set_permission_mode(mode) + + async def set_model(self, model: Optional[str] = None) -> None: + await self._client.set_model(model) + + async def __aenter__(self) -> "PostHogClaudeSDKClient": + await self.connect() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + await self.disconnect() + return False + + def _ensure_serializable(obj: Any) -> Any: """Ensure an object is JSON-serializable.""" if obj is None: From 794a8f70646fb520c0031e20d9825ab428f2de3c Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 13:08:23 +0100 Subject: [PATCH 5/6] docs(ai): add multi-turn conversation example for Claude Agent SDK --- .../example-ai-claude-agent-sdk/README.md | 2 + .../example-ai-claude-agent-sdk/multi_turn.py | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 examples/example-ai-claude-agent-sdk/multi_turn.py diff --git a/examples/example-ai-claude-agent-sdk/README.md b/examples/example-ai-claude-agent-sdk/README.md index 0faa9139..b2cf0c10 100644 --- a/examples/example-ai-claude-agent-sdk/README.md +++ b/examples/example-ai-claude-agent-sdk/README.md @@ -14,6 +14,7 @@ cp .env.example .env - **simple_query.py** - Single query using the `query()` drop-in replacement - **instrument_reuse.py** - Configure-once with `instrument()`, reuse across multiple queries +- **multi_turn.py** - Multi-turn conversation with history using `PostHogClaudeSDKClient` ## Run @@ -21,4 +22,5 @@ cp .env.example .env source .env python simple_query.py python instrument_reuse.py +python multi_turn.py ``` diff --git a/examples/example-ai-claude-agent-sdk/multi_turn.py b/examples/example-ai-claude-agent-sdk/multi_turn.py new file mode 100644 index 00000000..0c60a8c5 --- /dev/null +++ b/examples/example-ai-claude-agent-sdk/multi_turn.py @@ -0,0 +1,64 @@ +"""Claude Agent SDK multi-turn conversation with history, tracked by PostHog.""" + +import asyncio +import os + +from claude_agent_sdk import AssistantMessage, ResultMessage +from claude_agent_sdk.types import ClaudeAgentOptions, TextBlock, ToolUseBlock +from posthog import Posthog +from posthog.ai.claude_agent_sdk import PostHogClaudeSDKClient + +posthog = Posthog( + os.environ["POSTHOG_API_KEY"], + host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"), +) + + +async def main(): + options = ClaudeAgentOptions( + max_turns=5, + permission_mode="plan", + ) + + async with PostHogClaudeSDKClient( + options, + posthog_client=posthog, + posthog_distinct_id="example-user", + posthog_properties={"example": "multi_turn"}, + ) as client: + # Turn 1 + print("> What is the capital of France?") + await client.query("What is the capital of France? Reply in one sentence.") + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f" {block.text}") + elif isinstance(message, ResultMessage): + print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]") + + # Turn 2 — has full conversation history + print("\n> And what language do they speak there?") + await client.query("And what language do they speak there? Reply in one sentence.") + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f" {block.text}") + elif isinstance(message, ResultMessage): + print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]") + + # Turn 3 — still has context from both previous turns + print("\n> How do you say 'hello' in that language?") + await client.query("How do you say 'hello' in that language? Reply in one sentence.") + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f" {block.text}") + elif isinstance(message, ResultMessage): + print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]") + + +asyncio.run(main()) +posthog.shutdown() From 4916988eded551fd26a6243af6c0b9ea99878188 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 1 Apr 2026 13:24:30 +0100 Subject: [PATCH 6/6] refactor(ai): split PostHogClaudeSDKClient into client.py --- posthog/ai/claude_agent_sdk/__init__.py | 6 +- posthog/ai/claude_agent_sdk/client.py | 259 +++++++++++++++++++++++ posthog/ai/claude_agent_sdk/processor.py | 227 -------------------- 3 files changed, 261 insertions(+), 231 deletions(-) create mode 100644 posthog/ai/claude_agent_sdk/client.py diff --git a/posthog/ai/claude_agent_sdk/__init__.py b/posthog/ai/claude_agent_sdk/__init__.py index 78a2ca9e..5fe1828d 100644 --- a/posthog/ai/claude_agent_sdk/__init__.py +++ b/posthog/ai/claude_agent_sdk/__init__.py @@ -14,10 +14,8 @@ "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" ) -from posthog.ai.claude_agent_sdk.processor import ( - PostHogClaudeAgentProcessor, - PostHogClaudeSDKClient, -) +from posthog.ai.claude_agent_sdk.client import PostHogClaudeSDKClient +from posthog.ai.claude_agent_sdk.processor import PostHogClaudeAgentProcessor __all__ = [ "PostHogClaudeAgentProcessor", diff --git a/posthog/ai/claude_agent_sdk/client.py b/posthog/ai/claude_agent_sdk/client.py new file mode 100644 index 00000000..e1fd5284 --- /dev/null +++ b/posthog/ai/claude_agent_sdk/client.py @@ -0,0 +1,259 @@ +"""PostHog-instrumented ClaudeSDKClient for stateful multi-turn conversations. + +Wraps claude_agent_sdk.ClaudeSDKClient to automatically emit $ai_generation, +$ai_span, and $ai_trace events across multiple conversation turns. +""" + +import logging +import time +import uuid +from typing import Any, Callable, Dict, List, Optional, Union + +try: + from claude_agent_sdk import ( + AssistantMessage, + ClaudeSDKClient, + ResultMessage, + ToolUseBlock, + UserMessage, + ) + from claude_agent_sdk.types import ClaudeAgentOptions, StreamEvent +except ImportError: + raise ModuleNotFoundError( + "Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'" + ) + +from posthog.ai.claude_agent_sdk.processor import ( + PostHogClaudeAgentProcessor, + _GenerationTracker, + _ensure_serializable, +) +from posthog.client import Client + +log = logging.getLogger("posthog") + + +class PostHogClaudeSDKClient: + """Wraps ClaudeSDKClient for stateful multi-turn conversations with PostHog instrumentation. + + Usage: + async with PostHogClaudeSDKClient(options, posthog_client=ph, posthog_distinct_id="user") as client: + await client.query("Hello") + async for msg in client.receive_response(): + ... # turn 1, emits $ai_generation events + await client.query("Follow up") + async for msg in client.receive_response(): + ... # turn 2, same trace, has conversation history + """ + + def __init__( + self, + options: Optional["ClaudeAgentOptions"] = None, + transport: Any = None, + *, + posthog_client: Optional[Client] = None, + posthog_distinct_id: Optional[ + Union[str, Callable[["ResultMessage"], Optional[str]]] + ] = None, + posthog_trace_id: Optional[str] = None, + posthog_properties: Optional[Dict[str, Any]] = None, + posthog_privacy_mode: bool = False, + posthog_groups: Optional[Dict[str, Any]] = None, + ): + from dataclasses import replace as dc_replace + + # Ensure partial messages for per-generation tracking + if options is None: + options = ClaudeAgentOptions(include_partial_messages=True) + elif not options.include_partial_messages: + options = dc_replace(options, include_partial_messages=True) + + self._client = ClaudeSDKClient(options, transport) + self._processor = PostHogClaudeAgentProcessor( + client=posthog_client, + distinct_id=posthog_distinct_id, + privacy_mode=posthog_privacy_mode, + groups=posthog_groups, + properties=posthog_properties or {}, + ) + self._trace_id = posthog_trace_id or str(uuid.uuid4()) + self._distinct_id = posthog_distinct_id + self._extra_props = posthog_properties or {} + self._privacy = posthog_privacy_mode + self._groups = posthog_groups or {} + + # Shared state across turns + self._tracker = _GenerationTracker() + self._generation_index = 0 + self._current_generation_span_id: Optional[str] = None + self._current_input: Optional[List[Dict[str, Any]]] = None + self._next_input: Optional[List[Dict[str, Any]]] = None + self._pending_output: List[Dict[str, Any]] = [] + self._query_start = time.time() + + async def connect(self, prompt: Any = None) -> None: + await self._client.connect(prompt) + + async def query(self, prompt: str, session_id: str = "default") -> None: + # Track the prompt as input for the next generation + self._current_input = [{"role": "user", "content": prompt}] + await self._client.query(prompt, session_id) + + async def receive_response(self): + """Instrumented receive_response -- yields all messages, emits PostHog events.""" + async for message in self._client.receive_response(): + try: + if isinstance(message, StreamEvent): + self._tracker.process_stream_event(message) + + if self._tracker.has_completed_generation(): + gen = self._tracker.pop_generation() + self._generation_index += 1 + self._current_generation_span_id = gen.span_id + self._processor._emit_generation( + gen, + self._trace_id, + self._generation_index, + self._current_input, + self._pending_output or None, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + self._current_input = self._next_input + self._next_input = None + self._pending_output = [] + + elif isinstance(message, AssistantMessage): + self._tracker.set_model(message.model) + parent_id = ( + self._tracker.current_span_id + or self._current_generation_span_id + ) + output_content: List[Dict[str, Any]] = [] + for block in message.content: + if isinstance(block, ToolUseBlock): + self._processor._emit_tool_span( + block, + self._trace_id, + parent_id, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + output_content.append( + { + "type": "function", + "function": { + "name": block.name, + "arguments": block.input, + }, + } + ) + elif hasattr(block, "text"): + output_content.append({"type": "text", "text": block.text}) + if output_content: + self._pending_output = [ + {"role": "assistant", "content": output_content} + ] + + elif isinstance(message, UserMessage): + content = message.content + if isinstance(content, str): + self._next_input = [{"role": "user", "content": content}] + elif isinstance(content, list): + formatted: List[Dict[str, Any]] = [] + for block in content: + if hasattr(block, "tool_use_id"): + formatted.append( + { + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": str(block.content)[:500] + if block.content + else None, + } + ) + elif hasattr(block, "text"): + formatted.append({"type": "text", "text": block.text}) + if formatted: + self._next_input = [{"role": "user", "content": formatted}] + + elif isinstance(message, ResultMessage): + if not self._tracker.had_any_stream_events: + self._processor._emit_generation_from_result( + message, + self._trace_id, + self._tracker.last_model, + self._query_start, + self._current_input, + self._pending_output, + self._distinct_id, + self._extra_props, + self._privacy, + self._groups, + ) + # Don't emit trace here -- wait for disconnect/close + # so multi-turn sessions get one trace at the end + + except Exception as e: + log.debug(f"PostHog instrumentation error (non-fatal): {e}") + + yield message + + async def disconnect(self) -> None: + # Emit the trace event covering the entire session + try: + latency = time.time() - self._query_start + resolved_id = self._processor._resolve_distinct_id(self._distinct_id) + + properties: Dict[str, Any] = { + "$ai_trace_id": self._trace_id, + "$ai_trace_name": "claude_agent_sdk_session", + "$ai_provider": "anthropic", + "$ai_framework": "claude-agent-sdk", + "$ai_latency": latency, + **self._extra_props, + } + + if resolved_id is None: + properties["$process_person_profile"] = False + + self._processor._capture_event( + "$ai_trace", + properties, + resolved_id or self._trace_id, + self._groups, + ) + + try: + ph = self._processor._client + if hasattr(ph, "flush") and callable(ph.flush): + ph.flush() + except Exception as e: + log.debug(f"Error flushing PostHog client: {e}") + + except Exception as e: + log.debug(f"PostHog trace emission error (non-fatal): {e}") + + await self._client.disconnect() + + # Delegate other methods + async def interrupt(self) -> None: + await self._client.interrupt() + + async def set_permission_mode(self, mode: str) -> None: + await self._client.set_permission_mode(mode) + + async def set_model(self, model: Optional[str] = None) -> None: + await self._client.set_model(model) + + async def __aenter__(self) -> "PostHogClaudeSDKClient": + await self.connect() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + await self.disconnect() + return False diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index f16c4b65..83a8ea82 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -13,7 +13,6 @@ try: from claude_agent_sdk import ( AssistantMessage, - ClaudeSDKClient, ResultMessage, ToolUseBlock, UserMessage, @@ -595,232 +594,6 @@ def _resolve_distinct_id( return self._get_distinct_id(result) -class PostHogClaudeSDKClient: - """Wraps ClaudeSDKClient for stateful multi-turn conversations with PostHog instrumentation. - - Usage: - async with PostHogClaudeSDKClient(options, posthog_client=ph, posthog_distinct_id="user") as client: - await client.query("Hello") - async for msg in client.receive_response(): - ... # turn 1, emits $ai_generation events - await client.query("Follow up") - async for msg in client.receive_response(): - ... # turn 2, same trace, has conversation history - """ - - def __init__( - self, - options: Optional["ClaudeAgentOptions"] = None, - transport: Any = None, - *, - posthog_client: Optional[Client] = None, - posthog_distinct_id: Optional[ - Union[str, Callable[["ResultMessage"], Optional[str]]] - ] = None, - posthog_trace_id: Optional[str] = None, - posthog_properties: Optional[Dict[str, Any]] = None, - posthog_privacy_mode: bool = False, - posthog_groups: Optional[Dict[str, Any]] = None, - ): - from dataclasses import replace as dc_replace - - # Ensure partial messages for per-generation tracking - if options is None: - options = ClaudeAgentOptions(include_partial_messages=True) - elif not options.include_partial_messages: - options = dc_replace(options, include_partial_messages=True) - - self._client = ClaudeSDKClient(options, transport) - self._processor = PostHogClaudeAgentProcessor( - client=posthog_client, - distinct_id=posthog_distinct_id, - privacy_mode=posthog_privacy_mode, - groups=posthog_groups, - properties=posthog_properties or {}, - ) - self._trace_id = posthog_trace_id or str(uuid.uuid4()) - self._distinct_id = posthog_distinct_id - self._extra_props = posthog_properties or {} - self._privacy = posthog_privacy_mode - self._groups = posthog_groups or {} - - # Shared state across turns - self._tracker = _GenerationTracker() - self._generation_index = 0 - self._current_generation_span_id: Optional[str] = None - self._current_input: Optional[List[Dict[str, Any]]] = None - self._next_input: Optional[List[Dict[str, Any]]] = None - self._pending_output: List[Dict[str, Any]] = [] - self._query_start = time.time() - - async def connect(self, prompt: Any = None) -> None: - await self._client.connect(prompt) - - async def query(self, prompt: str, session_id: str = "default") -> None: - # Track the prompt as input for the next generation - self._current_input = [{"role": "user", "content": prompt}] - await self._client.query(prompt, session_id) - - async def receive_response(self): - """Instrumented receive_response — yields all messages, emits PostHog events.""" - async for message in self._client.receive_response(): - try: - if isinstance(message, StreamEvent): - self._tracker.process_stream_event(message) - - if self._tracker.has_completed_generation(): - gen = self._tracker.pop_generation() - self._generation_index += 1 - self._current_generation_span_id = gen.span_id - self._processor._emit_generation( - gen, - self._trace_id, - self._generation_index, - self._current_input, - self._pending_output or None, - self._distinct_id, - self._extra_props, - self._privacy, - self._groups, - ) - self._current_input = self._next_input - self._next_input = None - self._pending_output = [] - - elif isinstance(message, AssistantMessage): - self._tracker.set_model(message.model) - parent_id = ( - self._tracker.current_span_id - or self._current_generation_span_id - ) - output_content: List[Dict[str, Any]] = [] - for block in message.content: - if isinstance(block, ToolUseBlock): - self._processor._emit_tool_span( - block, - self._trace_id, - parent_id, - self._distinct_id, - self._extra_props, - self._privacy, - self._groups, - ) - output_content.append( - { - "type": "function", - "function": { - "name": block.name, - "arguments": block.input, - }, - } - ) - elif hasattr(block, "text"): - output_content.append({"type": "text", "text": block.text}) - if output_content: - self._pending_output = [ - {"role": "assistant", "content": output_content} - ] - - elif isinstance(message, UserMessage): - content = message.content - if isinstance(content, str): - self._next_input = [{"role": "user", "content": content}] - elif isinstance(content, list): - formatted: List[Dict[str, Any]] = [] - for block in content: - if hasattr(block, "tool_use_id"): - formatted.append( - { - "type": "tool_result", - "tool_use_id": block.tool_use_id, - "content": str(block.content)[:500] - if block.content - else None, - } - ) - elif hasattr(block, "text"): - formatted.append({"type": "text", "text": block.text}) - if formatted: - self._next_input = [{"role": "user", "content": formatted}] - - elif isinstance(message, ResultMessage): - if not self._tracker.had_any_stream_events: - self._processor._emit_generation_from_result( - message, - self._trace_id, - self._tracker.last_model, - self._query_start, - self._current_input, - self._pending_output, - self._distinct_id, - self._extra_props, - self._privacy, - self._groups, - ) - # Don't emit trace here — wait for disconnect/close - # so multi-turn sessions get one trace at the end - - except Exception as e: - log.debug(f"PostHog instrumentation error (non-fatal): {e}") - - yield message - - async def disconnect(self) -> None: - # Emit the trace event covering the entire session - try: - latency = time.time() - self._query_start - resolved_id = self._processor._resolve_distinct_id(self._distinct_id) - - properties: Dict[str, Any] = { - "$ai_trace_id": self._trace_id, - "$ai_trace_name": "claude_agent_sdk_session", - "$ai_provider": "anthropic", - "$ai_framework": "claude-agent-sdk", - "$ai_latency": latency, - **self._extra_props, - } - - if resolved_id is None: - properties["$process_person_profile"] = False - - self._processor._capture_event( - "$ai_trace", - properties, - resolved_id or self._trace_id, - self._groups, - ) - - try: - ph = self._processor._client - if hasattr(ph, "flush") and callable(ph.flush): - ph.flush() - except Exception as e: - log.debug(f"Error flushing PostHog client: {e}") - - except Exception as e: - log.debug(f"PostHog trace emission error (non-fatal): {e}") - - await self._client.disconnect() - - # Delegate other methods - async def interrupt(self) -> None: - await self._client.interrupt() - - async def set_permission_mode(self, mode: str) -> None: - await self._client.set_permission_mode(mode) - - async def set_model(self, model: Optional[str] = None) -> None: - await self._client.set_model(model) - - async def __aenter__(self) -> "PostHogClaudeSDKClient": - await self.connect() - return self - - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: - await self.disconnect() - return False - - def _ensure_serializable(obj: Any) -> Any: """Ensure an object is JSON-serializable.""" if obj is None: