diff --git a/products/ai_observability/frontend/utils.test.ts b/products/ai_observability/frontend/utils.test.ts index 02dd3c7882b4..e9beb7edfe7a 100644 --- a/products/ai_observability/frontend/utils.test.ts +++ b/products/ai_observability/frontend/utils.test.ts @@ -1442,6 +1442,10 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm 'custom {type:"function", tool_name, content}', { type: 'function', tool_name: 'lookup', content: 'opaque' }, ], + [ + 'legacy Gemini function_response (posthog-python 7.30.1-7.35.x)', + { type: 'function_response', function_response: { name: 'get_weather', response: { temp: '18C' } } }, + ], ])('returns true for: %s', (_, item) => { expect(isToolResult(item)).toBe(true) }) @@ -1449,6 +1453,11 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm it.each<[name: string, item: unknown]>([ ['plain text part', { type: 'text', text: 'hi' }], ['Anthropic tool_use (a tool CALL, not a result)', { type: 'tool_use', id: 't1', name: 'x', input: {} }], + [ + 'legacy Gemini function_call (a tool CALL, not a result)', + { type: 'function_call', function_call: { name: 'get_weather', args: {} } }, + ], + ['function_response without the nested payload', { type: 'function_response' }], [ 'OpenAI tool CALL with nested function object', { type: 'function', function: { name: 'get_weather', arguments: '{}' } }, @@ -2582,6 +2591,16 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm { type: 'tool-result', toolCallId: 'a', toolName: 'search_docs', result: 'ok' }, true, ], + [ + 'accepts legacy Gemini `function_call`', + { type: 'function_call', function_call: { name: 'get_weather', args: { city: 'SF' } } }, + true, + ], + [ + 'accepts legacy Gemini `function_response`', + { type: 'function_response', function_response: { name: 'get_weather', response: { temp: '18C' } } }, + true, + ], ['rejects text content items', { type: 'text', text: 'hi' }, false], ['rejects image items', { type: 'image_url', image_url: { url: 'x' } }, false], ['rejects file items', { type: 'file', file: { filename: 'f', file_data: 'd' } }, false], diff --git a/products/ai_observability/frontend/utils.ts b/products/ai_observability/frontend/utils.ts index f36f39beb600..b247cff76ff7 100644 --- a/products/ai_observability/frontend/utils.ts +++ b/products/ai_observability/frontend/utils.ts @@ -505,12 +505,28 @@ function isCustomFunctionToolResult(item: unknown): boolean { return isObject(item) && item.type === 'function' && isString(item.tool_name) && !isObject(item.function) } +// Gemini function calls and their responses, in the raw form the google-genai +// SDK emits. posthog-python 7.30.1 through 7.35.x copied that form into +// `$ai_input` without converting it (PostHog/posthog-python#725), so traces from +// that window still contain it. +// `function_call` needs its nested object checked. The OpenAI Responses API uses +// the same `type` with a top-level `name` and `call_id`, and +// `isOpenAIResponsesFunctionCall` already owns that case. +function isLegacyGeminiFunctionResponse(item: unknown): boolean { + return isObject(item) && item.type === 'function_response' && isObject(item.function_response) +} + +function isLegacyGeminiFunctionCall(item: unknown): boolean { + return isObject(item) && item.type === 'function_call' && isObject(item.function_call) +} + export function isToolResult(item: unknown): boolean { return ( isAnthropicToolResultMessage(item) || isVercelSDKToolResultMessage(item) || isOpenAIResponsesFunctionCallOutput(item) || - isCustomFunctionToolResult(item) + isCustomFunctionToolResult(item) || + isLegacyGeminiFunctionResponse(item) ) } @@ -535,7 +551,9 @@ export function isToolStepItem(item: unknown): boolean { isVercelSDKToolCallMessage(item) || isVercelSDKToolResultMessage(item) || isOpenAIResponsesFunctionCall(item) || - isOpenAIResponsesBuiltinToolCall(item) + isOpenAIResponsesBuiltinToolCall(item) || + isLegacyGeminiFunctionCall(item) || + isLegacyGeminiFunctionResponse(item) ) { return true }