From 5b6516612daeace98acf9143a4ae95e54fba5b69 Mon Sep 17 00:00:00 2001 From: Marco Gancitano Date: Fri, 31 Jul 2026 11:35:00 -0400 Subject: [PATCH] fix(gemini): normalize tool call/response parts in captured input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .sampo/changesets/gentle-toolsmith-vellamo.md | 5 +++++ posthog/ai/gemini/gemini_converter.py | 17 +++++++++++++---- posthog/test/ai/gemini/test_gemini_converter.py | 11 ++++++++--- 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 .sampo/changesets/gentle-toolsmith-vellamo.md diff --git a/.sampo/changesets/gentle-toolsmith-vellamo.md b/.sampo/changesets/gentle-toolsmith-vellamo.md new file mode 100644 index 00000000..be8069cd --- /dev/null +++ b/.sampo/changesets/gentle-toolsmith-vellamo.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Normalize Gemini tool calls and tool responses in captured input so they render in traces and reach evaluations diff --git a/posthog/ai/gemini/gemini_converter.py b/posthog/ai/gemini/gemini_converter.py index 9ad711d7..b746290e 100644 --- a/posthog/ai/gemini/gemini_converter.py +++ b/posthog/ai/gemini/gemini_converter.py @@ -55,15 +55,24 @@ def _format_part(part: Any) -> Optional[FormattedContentItem]: if "file_data" in plain: media = _format_media_payload(plain["file_data"]) return {"type": _kind_from_mime(media.get("mime_type")), "file_data": media} + # Gemini-native function_call/function_response parts are normalized to the + # provider-agnostic tool-call/tool-result blocks. Emitting the raw Gemini + # shape leaves them unrenderable and invisible to evals. if "function_call" in plain: + call = to_plain(plain["function_call"]) or {} return { - "type": "function_call", - "function_call": to_plain(plain["function_call"]), + "type": "function", + "function": { + "name": call.get("name"), + "arguments": call.get("args"), + }, } if "function_response" in plain: + response = to_plain(plain["function_response"]) or {} return { - "type": "function_response", - "function_response": to_plain(plain["function_response"]), + "type": "function", + "tool_name": response.get("name") or "", + "content": response.get("response"), } if not plain: return None diff --git a/posthog/test/ai/gemini/test_gemini_converter.py b/posthog/test/ai/gemini/test_gemini_converter.py index 3991a8ef..d1388551 100644 --- a/posthog/test/ai/gemini/test_gemini_converter.py +++ b/posthog/test/ai/gemini/test_gemini_converter.py @@ -83,9 +83,14 @@ def test_function_call_and_response_turns_preserved(self): ), ] out = format_gemini_input(contents) - assert out[0]["content"][0]["type"] == "function_call" - assert out[0]["content"][0]["function_call"]["name"] == "get_weather" - assert out[1]["content"][0]["type"] == "function_response" + call = out[0]["content"][0] + assert call["type"] == "function" + assert call["function"] == {"name": "get_weather", "arguments": {"city": "SF"}} + result = out[1]["content"][0] + assert result["type"] == "function" + assert result["tool_name"] == "get_weather" + assert result["content"] == {"temp": "18C"} + assert "function" not in result def test_camel_case_dict_part_preserved(self): out = format_gemini_input(