diff --git a/.sampo/changesets/gemini-thinking-blocks.md b/.sampo/changesets/gemini-thinking-blocks.md new file mode 100644 index 00000000..1ab1aff3 --- /dev/null +++ b/.sampo/changesets/gemini-thinking-blocks.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Capture Gemini thought summaries as `thinking` content blocks. When a request enables `thinking_config.include_thoughts`, parts marked `thought=True` in responses, inputs, and streaming chunks are now formatted as `{"type": "thinking", "thinking": ...}` (matching the Anthropic thinking-block shape PostHog renders as reasoning) instead of plain text blocks. diff --git a/posthog/ai/gemini/gemini_converter.py b/posthog/ai/gemini/gemini_converter.py index 9ad711d7..bd82a161 100644 --- a/posthog/ai/gemini/gemini_converter.py +++ b/posthog/ai/gemini/gemini_converter.py @@ -47,6 +47,10 @@ def _format_part(part: Any) -> Optional[FormattedContentItem]: if not isinstance(plain, dict): return {"type": "unknown", "part": str(plain)} plain = normalize_part_keys(plain) + if plain.get("thought") is True and isinstance(plain.get("text"), str): + # Thought summaries (thinking_config.include_thoughts) become thinking + # blocks, matching the Anthropic shape the PostHog UI renders as reasoning. + return {"type": "thinking", "thinking": plain["text"]} if "text" in plain: return {"type": "text", "text": plain["text"]} if "inline_data" in plain: @@ -245,7 +249,12 @@ def format_gemini_response(response: Any) -> List[FormattedMessage]: # the _format_part delegation below. text = getattr(part, "text", None) if isinstance(text, str) and text: - content.append({"type": "text", "text": text}) + # `is True` so loosely-specced mocks (whose .thought is a + # truthy Mock) still land on the text branch below. + if getattr(part, "thought", None) is True: + content.append({"type": "thinking", "thinking": text}) + else: + content.append({"type": "text", "text": text}) continue if hasattr(part, "function_call") and part.function_call: diff --git a/posthog/test/ai/gemini/test_gemini_converter.py b/posthog/test/ai/gemini/test_gemini_converter.py index 3991a8ef..3d03e2fe 100644 --- a/posthog/test/ai/gemini/test_gemini_converter.py +++ b/posthog/test/ai/gemini/test_gemini_converter.py @@ -244,3 +244,65 @@ def test_streaming_chunk_with_mixed_text_and_image_captures_both(self): assert types_seen == ["text", "image"] assert content[0] == {"type": "text", "text": "here: "} assert content[1]["inline_data"]["data"] == base64.b64encode(PNG).decode() + + +class TestGeminiThoughtParts: + def test_response_thought_part_becomes_thinking_block(self): + resp = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content( + role="model", + parts=[ + types.Part(text="Weighing the options...", thought=True), + types.Part(text='{"verdict": "yes"}'), + ], + ), + ) + ] + ) + out = format_gemini_response(resp) + assert out[0]["content"] == [ + {"type": "thinking", "thinking": "Weighing the options..."}, + {"type": "text", "text": '{"verdict": "yes"}'}, + ] + + def test_input_thought_part_becomes_thinking_block(self): + # Multi-turn callers append the model's prior content (thoughts included) + # back into the conversation, so thought parts also arrive as input. + contents = [ + types.Content( + role="model", + parts=[ + types.Part(text="Considering...", thought=True), + types.Part(text="answer"), + ], + ) + ] + out = format_gemini_input(contents) + assert out[0]["content"] == [ + {"type": "thinking", "thinking": "Considering..."}, + {"type": "text", "text": "answer"}, + ] + + def test_streaming_chunk_thought_part_becomes_thinking_block(self): + chunk = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content( + role="model", + parts=[types.Part(text="Hmm, ", thought=True)], + ) + ) + ] + ) + blocks = extract_gemini_content_from_chunk(chunk) + assert blocks == [{"type": "thinking", "thinking": "Hmm, "}] + + out = format_gemini_streaming_output( + blocks + [{"type": "text", "text": "done"}] + ) + assert out[0]["content"] == [ + {"type": "thinking", "thinking": "Hmm, "}, + {"type": "text", "text": "done"}, + ]