Skip to content

Commit c5f4e8f

Browse files
marco-g-pmclaude
andauthored
fix(gemini): normalize tool call/response parts in captured input (#823)
Gemini's native `function_call`/`function_response` parts were passed through to `$ai_input` verbatim, in a shape no PostHog consumer recognizes. The trace renderer matches tool results against Anthropic `tool_result`, Vercel `tool-result`, OpenAI Responses `function_call_output`, and `{type: "function", tool_name, content}` — none of which match — so tool responses never rendered, and the eval formatter fell through to a raw `repr()` dump instead of a labeled tool result. The same SDK already normalized tool calls on the response path to `{type: "function", function: {...}}`, which is why a call rendered in the output but vanished once it appeared in the next request's history. Normalize both part kinds in `_format_part`. Tool results use `tool_name`/`content` rather than an id-carrying shape because Gemini matches responses to calls by name and has no call ids to thread. `$ai_output_choices` is unaffected: `format_gemini_response` hits its explicit `function_call` branch before the `_format_part` fallback and already emitted the normalized shape. Fixes #725 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 902bc44 commit c5f4e8f

3 files changed

Lines changed: 26 additions & 7 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: patch
3+
---
4+
5+
Normalize Gemini tool calls and tool responses in captured input so they render in traces and reach evaluations

‎posthog/ai/gemini/gemini_converter.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,24 @@ def _format_part(part: Any) -> Optional[FormattedContentItem]:
5555
if "file_data" in plain:
5656
media = _format_media_payload(plain["file_data"])
5757
return {"type": _kind_from_mime(media.get("mime_type")), "file_data": media}
58+
# Gemini-native function_call/function_response parts are normalized to the
59+
# provider-agnostic tool-call/tool-result blocks. Emitting the raw Gemini
60+
# shape leaves them unrenderable and invisible to evals.
5861
if "function_call" in plain:
62+
call = to_plain(plain["function_call"]) or {}
5963
return {
60-
"type": "function_call",
61-
"function_call": to_plain(plain["function_call"]),
64+
"type": "function",
65+
"function": {
66+
"name": call.get("name"),
67+
"arguments": call.get("args"),
68+
},
6269
}
6370
if "function_response" in plain:
71+
response = to_plain(plain["function_response"]) or {}
6472
return {
65-
"type": "function_response",
66-
"function_response": to_plain(plain["function_response"]),
73+
"type": "function",
74+
"tool_name": response.get("name") or "",
75+
"content": response.get("response"),
6776
}
6877
if not plain:
6978
return None

‎posthog/test/ai/gemini/test_gemini_converter.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,14 @@ def test_function_call_and_response_turns_preserved(self):
8383
),
8484
]
8585
out = format_gemini_input(contents)
86-
assert out[0]["content"][0]["type"] == "function_call"
87-
assert out[0]["content"][0]["function_call"]["name"] == "get_weather"
88-
assert out[1]["content"][0]["type"] == "function_response"
86+
call = out[0]["content"][0]
87+
assert call["type"] == "function"
88+
assert call["function"] == {"name": "get_weather", "arguments": {"city": "SF"}}
89+
result = out[1]["content"][0]
90+
assert result["type"] == "function"
91+
assert result["tool_name"] == "get_weather"
92+
assert result["content"] == {"temp": "18C"}
93+
assert "function" not in result
8994

9095
def test_camel_case_dict_part_preserved(self):
9196
out = format_gemini_input(

0 commit comments

Comments
 (0)