Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/gemini-thinking-blocks.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion posthog/ai/gemini/gemini_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions posthog/test/ai/gemini/test_gemini_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
]