Skip to content

Commit 44b92a8

Browse files
feat(llma): add $ai_stop_reason extraction for all providers (#499)
* feat(llma): add $ai_stop_reason extraction for all providers Extracts finish_reason/stop_reason from OpenAI, Anthropic, and Gemini responses (both streaming and non-streaming) and captures it as $ai_stop_reason. Also adds support for LangChain, OpenAI Agents SDK, and Claude Agent SDK. * chore(llma): add integration tests for $ai_stop_reason and sampo changeset * fix(llma): address review feedback for $ai_stop_reason - Fix ruff formatting in utils.py - Deduplicate extract_gemini_stop_reason_from_chunk to delegate to extract_gemini_stop_reason - Parameterize stop_reason_captured and stop_reason_max_tokens tests
1 parent 7c5cad8 commit 44b92a8

19 files changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
feat(ai): add $ai_stop_reason extraction for all providers

posthog/ai/anthropic/anthropic.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def _create_streaming(
131131
content_blocks: List[StreamingContentBlock] = []
132132
tools_in_progress: Dict[str, ToolInProgress] = {}
133133
current_text_block: Optional[StreamingContentBlock] = None
134+
stop_reason: Optional[str] = None
134135
response = super().create(**kwargs)
135136

136137
def generator():
@@ -139,6 +140,7 @@ def generator():
139140
nonlocal content_blocks
140141
nonlocal tools_in_progress
141142
nonlocal current_text_block
143+
nonlocal stop_reason
142144

143145
try:
144146
for event in response:
@@ -181,6 +183,14 @@ def generator():
181183
event, content_blocks, tools_in_progress
182184
)
183185

186+
# Capture stop reason from message_delta events
187+
if hasattr(event, "type") and event.type == "message_delta":
188+
delta = getattr(event, "delta", None)
189+
if delta is not None:
190+
delta_stop_reason = getattr(delta, "stop_reason", None)
191+
if delta_stop_reason is not None:
192+
stop_reason = delta_stop_reason
193+
184194
yield event
185195

186196
finally:
@@ -198,6 +208,7 @@ def generator():
198208
latency,
199209
content_blocks,
200210
accumulated_content,
211+
stop_reason=stop_reason,
201212
)
202213

203214
return generator()
@@ -214,6 +225,7 @@ def _capture_streaming_event(
214225
latency: float,
215226
content_blocks: List[StreamingContentBlock],
216227
accumulated_content: str,
228+
stop_reason: Optional[str] = None,
217229
):
218230
from posthog.ai.types import StreamingEventData
219231
from posthog.ai.anthropic.anthropic_converter import (
@@ -242,6 +254,7 @@ def _capture_streaming_event(
242254
properties=posthog_properties,
243255
privacy_mode=posthog_privacy_mode,
244256
groups=posthog_groups,
257+
stop_reason=stop_reason,
245258
)
246259

247260
# Use the common capture function

posthog/ai/anthropic/anthropic_async.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ async def _create_streaming(
131131
content_blocks: List[StreamingContentBlock] = []
132132
tools_in_progress: Dict[str, ToolInProgress] = {}
133133
current_text_block: Optional[StreamingContentBlock] = None
134+
stop_reason: Optional[str] = None
134135
response = await super().create(**kwargs)
135136

136137
async def generator():
@@ -139,6 +140,7 @@ async def generator():
139140
nonlocal content_blocks
140141
nonlocal tools_in_progress
141142
nonlocal current_text_block
143+
nonlocal stop_reason
142144

143145
try:
144146
async for event in response:
@@ -181,6 +183,14 @@ async def generator():
181183
event, content_blocks, tools_in_progress
182184
)
183185

186+
# Capture stop reason from message_delta events
187+
if hasattr(event, "type") and event.type == "message_delta":
188+
delta = getattr(event, "delta", None)
189+
if delta is not None:
190+
delta_stop_reason = getattr(delta, "stop_reason", None)
191+
if delta_stop_reason is not None:
192+
stop_reason = delta_stop_reason
193+
184194
yield event
185195

186196
finally:
@@ -198,6 +208,7 @@ async def generator():
198208
latency,
199209
content_blocks,
200210
accumulated_content,
211+
stop_reason=stop_reason,
201212
)
202213

203214
return generator()
@@ -214,6 +225,7 @@ async def _capture_streaming_event(
214225
latency: float,
215226
content_blocks: List[StreamingContentBlock],
216227
accumulated_content: str,
228+
stop_reason: Optional[str] = None,
217229
):
218230
from posthog.ai.types import StreamingEventData
219231
from posthog.ai.anthropic.anthropic_converter import (
@@ -242,6 +254,7 @@ async def _capture_streaming_event(
242254
properties=posthog_properties,
243255
privacy_mode=posthog_privacy_mode,
244256
groups=posthog_groups,
257+
stop_reason=stop_reason,
245258
)
246259

247260
# Use the common capture function

posthog/ai/anthropic/anthropic_converter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ def extract_anthropic_web_search_count(response: Any) -> int:
190190
return 0
191191

192192

193+
def extract_anthropic_stop_reason(response: Any) -> Optional[str]:
194+
"""Extract stop reason from Anthropic response."""
195+
return getattr(response, "stop_reason", None)
196+
197+
193198
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
194199
"""
195200
Extract usage from a full Anthropic response (non-streaming).

posthog/ai/claude_agent_sdk/processor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class _GenerationData:
4242
start_time: float = 0.0
4343
end_time: float = 0.0
4444
span_id: str = field(default_factory=lambda: str(uuid.uuid4()))
45+
stop_reason: Optional[str] = None
4546

4647

4748
class _GenerationTracker:
@@ -81,6 +82,10 @@ def process_stream_event(self, event: "StreamEvent") -> None:
8182
# message_delta usage reports cumulative output tokens
8283
if usage.get("output_tokens"):
8384
self._current.output_tokens = usage["output_tokens"]
85+
# Extract stop reason from message_delta
86+
delta_stop_reason = raw.get("delta", {}).get("stop_reason")
87+
if delta_stop_reason is not None:
88+
self._current.stop_reason = delta_stop_reason
8489

8590
elif event_type == "message_stop" and self._current is not None:
8691
self._current.end_time = time.time()
@@ -435,6 +440,9 @@ def _emit_generation(
435440
gen.cache_creation_input_tokens
436441
)
437442

443+
if gen.stop_reason is not None:
444+
properties["$ai_stop_reason"] = gen.stop_reason
445+
438446
if resolved_id is None:
439447
properties["$process_person_profile"] = False
440448

posthog/ai/gemini/gemini.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from posthog.ai.gemini.gemini_converter import (
2323
extract_gemini_usage_from_chunk,
2424
extract_gemini_content_from_chunk,
25+
extract_gemini_stop_reason_from_chunk,
2526
format_gemini_streaming_output,
2627
)
2728
from posthog.ai.sanitization import sanitize_gemini
@@ -298,13 +299,15 @@ def _generate_content_streaming(
298299
start_time = time.time()
299300
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
300301
accumulated_content = []
302+
stop_reason: Optional[str] = None
301303

302304
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
303305
response = self._client.models.generate_content_stream(**kwargs_without_stream)
304306

305307
def generator():
306308
nonlocal usage_stats
307309
nonlocal accumulated_content
310+
nonlocal stop_reason
308311
try:
309312
for chunk in response:
310313
# Extract usage stats from chunk
@@ -320,6 +323,11 @@ def generator():
320323
if content_block is not None:
321324
accumulated_content.append(content_block)
322325

326+
# Extract stop reason from chunk
327+
chunk_stop_reason = extract_gemini_stop_reason_from_chunk(chunk)
328+
if chunk_stop_reason is not None:
329+
stop_reason = chunk_stop_reason
330+
323331
yield chunk
324332

325333
finally:
@@ -338,6 +346,7 @@ def generator():
338346
usage_stats,
339347
latency,
340348
accumulated_content,
349+
stop_reason=stop_reason,
341350
)
342351

343352
return generator()
@@ -355,6 +364,7 @@ def _capture_streaming_event(
355364
usage_stats: TokenUsage,
356365
latency: float,
357366
output: Any,
367+
stop_reason: Optional[str] = None,
358368
):
359369
# Prepare standardized event data
360370
formatted_input = self._format_input(contents, **kwargs)
@@ -374,6 +384,7 @@ def _capture_streaming_event(
374384
properties=properties,
375385
privacy_mode=privacy_mode,
376386
groups=groups,
387+
stop_reason=stop_reason,
377388
)
378389

379390
# Use the common capture function

posthog/ai/gemini/gemini_async.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from posthog.ai.gemini.gemini_converter import (
2323
extract_gemini_usage_from_chunk,
2424
extract_gemini_content_from_chunk,
25+
extract_gemini_stop_reason_from_chunk,
2526
format_gemini_streaming_output,
2627
)
2728
from posthog.ai.sanitization import sanitize_gemini
@@ -298,6 +299,7 @@ async def _generate_content_streaming(
298299
start_time = time.time()
299300
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
300301
accumulated_content = []
302+
stop_reason: Optional[str] = None
301303

302304
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
303305
response = await self._client.aio.models.generate_content_stream(
@@ -307,6 +309,7 @@ async def _generate_content_streaming(
307309
async def async_generator():
308310
nonlocal usage_stats
309311
nonlocal accumulated_content
312+
nonlocal stop_reason
310313

311314
try:
312315
async for chunk in response:
@@ -323,6 +326,11 @@ async def async_generator():
323326
if content_block is not None:
324327
accumulated_content.append(content_block)
325328

329+
# Extract stop reason from chunk
330+
chunk_stop_reason = extract_gemini_stop_reason_from_chunk(chunk)
331+
if chunk_stop_reason is not None:
332+
stop_reason = chunk_stop_reason
333+
326334
yield chunk
327335

328336
finally:
@@ -341,6 +349,7 @@ async def async_generator():
341349
usage_stats,
342350
latency,
343351
accumulated_content,
352+
stop_reason=stop_reason,
344353
)
345354

346355
return async_generator()
@@ -358,6 +367,7 @@ def _capture_streaming_event(
358367
usage_stats: TokenUsage,
359368
latency: float,
360369
output: Any,
370+
stop_reason: Optional[str] = None,
361371
):
362372
# Prepare standardized event data
363373
formatted_input = self._format_input(contents, **kwargs)
@@ -377,6 +387,7 @@ def _capture_streaming_event(
377387
properties=properties,
378388
privacy_mode=privacy_mode,
379389
groups=groups,
390+
stop_reason=stop_reason,
380391
)
381392

382393
# Use the common capture function

posthog/ai/gemini/gemini_converter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,24 @@ def format_gemini_response(response: Any) -> List[FormattedMessage]:
287287
return output
288288

289289

290+
def extract_gemini_stop_reason(response: Any) -> Optional[str]:
291+
"""Extract stop reason from Gemini response."""
292+
if response and hasattr(response, "candidates") and response.candidates:
293+
candidate = response.candidates[0]
294+
finish_reason = getattr(candidate, "finish_reason", None)
295+
if finish_reason is not None:
296+
# Gemini uses enum values — convert to string name
297+
if hasattr(finish_reason, "name"):
298+
return finish_reason.name
299+
return str(finish_reason)
300+
return None
301+
302+
303+
def extract_gemini_stop_reason_from_chunk(chunk: Any) -> Optional[str]:
304+
"""Extract stop reason from a Gemini streaming chunk."""
305+
return extract_gemini_stop_reason(chunk)
306+
307+
290308
def extract_gemini_system_instruction(config: Any) -> Optional[str]:
291309
"""
292310
Extract system instruction from Gemini config parameter.

posthog/ai/langchain/callbacks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,15 @@ def _capture_generation(
629629
self._ph_client, self._privacy_mode, completions
630630
)
631631

632+
# Extract stop reason from generation info
633+
if output.generations and output.generations[-1]:
634+
last_gen = output.generations[-1][-1]
635+
gen_info = getattr(last_gen, "generation_info", None)
636+
if isinstance(gen_info, dict):
637+
finish_reason = gen_info.get("finish_reason")
638+
if finish_reason is not None:
639+
event_properties["$ai_stop_reason"] = finish_reason
640+
632641
self._ph_client.capture(
633642
distinct_id=self._distinct_id or trace_id,
634643
event="$ai_generation",

0 commit comments

Comments
 (0)