llms/googleai: add comprehensive tests for signature handling in Gemini LLM and fix specific case only for gemini provider

- Introduced new tests in `googleai_core_unit_test.go` to validate signature deduplication and placement strategies for Gemini LLM interactions.
- Implemented multiple scenarios to ensure correct behavior when handling empty and non-empty text parts with reasoning signatures, preventing duplicate signatures in responses.
- Enhanced the `convertParts` function to optimize signature handling based on the presence of tool calls, ensuring efficient processing of content parts.
- Added detailed comments and logging to clarify the expected behavior and outcomes for each test case, improving maintainability and understanding of the code.
This commit is contained in:
Dmitry Ng
2026-01-28 19:53:31 +03:00
parent f79b68a0db
commit bb226cf639
2 changed files with 440 additions and 0 deletions
+21
View File
@@ -526,11 +526,32 @@ func convertResponse(resp *genai.GenerateContentResponse) (*llms.ContentResponse
func convertParts(parts []llms.ContentPart) ([]*genai.Part, error) {
convertedParts := make([]*genai.Part, 0, len(parts))
// Pre-scan to detect signature placement strategy:
// If we have tool calls with signatures, we should NOT add signature to empty TextContent
// This prevents duplicate signatures in a single message.
hasToolCallWithSignature := false
for _, part := range parts {
if tc, ok := part.(llms.ToolCall); ok {
if tc.Reasoning != nil && len(tc.Reasoning.Signature) > 0 {
hasToolCallWithSignature = true
break
}
}
}
for _, part := range parts {
var genaiPart *genai.Part
switch p := part.(type) {
case llms.TextContent:
// Optimization: skip empty text parts that only carry signature
// when we already have tool call with signature.
// This prevents duplicate signatures in the same message.
if p.Text == "" && hasToolCallWithSignature && p.Reasoning != nil {
// Skip this part - signature will be in tool call
continue
}
genaiPart = &genai.Part{
Text: p.Text,
ThoughtSignature: extractThoughtSignature(p.Reasoning),
+419
View File
@@ -5,11 +5,430 @@ import (
"testing"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/reasoning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genai"
)
// TestSignatureDeduplication tests that empty TextContent with signature
// is skipped when ToolCall already has signature, preventing duplicate signatures.
//
// Context: When client code uses universal pattern for Anthropic compatibility:
//
// aiParts := []llms.ContentPart{
// llms.TextPartWithReasoning(choice.Content, choice.Reasoning),
// toolCall1, // toolCall1 already has Reasoning with signature
// }
//
// For Gemini:
// - choice.Reasoning is nil when tool calls present
// - choice.ToolCalls[0].Reasoning contains the signature
//
// Result: TextPartWithReasoning("", nil) + ToolCall(signature) works correctly
// But if client mistakenly adds TextPartWithReasoning("", signature), we should handle it.
func TestSignatureDeduplication(t *testing.T) {
t.Parallel()
testSig := []byte("gemini-thought-signature-xyz123")
testReasoning := &reasoning.ContentReasoning{
Content: "Step 1: analyze the problem...",
Signature: testSig,
}
t.Run("scenario_1_universal_pattern_correct", func(t *testing.T) {
// Correct universal pattern: empty text (Gemini has no choice.Reasoning), tool call has signature
parts := []llms.ContentPart{
llms.TextContent{
Text: "", // Empty because Gemini puts reasoning in tool call
Reasoning: nil, // nil for Gemini when tool calls present
},
llms.ToolCall{
ID: "call_123",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"query": "weather"}`,
},
Reasoning: testReasoning, // Gemini puts signature here
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// Should get: empty text part (kept because no signature to dedupe) + tool call
assert.Len(t, result, 2, "Should have both parts")
// Only tool call should have signature
assert.Nil(t, result[0].ThoughtSignature, "Empty text without reasoning should have no signature")
assert.Equal(t, testSig, result[1].ThoughtSignature, "Tool call should have signature")
})
t.Run("scenario_2_mistaken_duplicate_signature", func(t *testing.T) {
// Mistaken pattern: client adds reasoning to BOTH text and tool call
// This could happen if client code doesn't check provider-specific reasoning location
parts := []llms.ContentPart{
llms.TextContent{
Text: "", // Empty content
Reasoning: testReasoning, // Mistakenly added (should be nil for Gemini with tools)
},
llms.ToolCall{
ID: "call_123",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"query": "weather"}`,
},
Reasoning: testReasoning, // Correct - Gemini puts signature here
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// OPTIMIZATION: Empty text with signature is SKIPPED when tool call has signature
assert.Len(t, result, 1, "Empty text part should be skipped to prevent duplicate signature")
// Only tool call should remain with signature
assert.Equal(t, testSig, result[0].ThoughtSignature, "Only tool call signature should remain")
assert.NotNil(t, result[0].FunctionCall, "Remaining part should be function call")
})
t.Run("scenario_3_non_empty_text_with_signature", func(t *testing.T) {
// Edge case: non-empty text with signature + tool call with signature
// This could happen with Anthropic pattern applied to Gemini
parts := []llms.ContentPart{
llms.TextContent{
Text: "I will search for the weather", // Non-empty
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_123",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"query": "weather"}`,
},
Reasoning: testReasoning,
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// Non-empty text is KEPT even with tool call signature
// (text content may be important)
assert.Len(t, result, 2, "Non-empty text part should be kept")
// Both parts should have signature
assert.Equal(t, testSig, result[0].ThoughtSignature, "Text part should have signature")
assert.Equal(t, testSig, result[1].ThoughtSignature, "Tool call should have signature")
t.Log("WARNING: This creates duplicate signatures - may not be optimal for Gemini")
})
t.Run("scenario_4_empty_text_no_tool_calls", func(t *testing.T) {
// Edge case: empty text with signature, no tool calls
// Signature must be preserved (no tool call to carry it)
parts := []llms.ContentPart{
llms.TextContent{
Text: "", // Empty
Reasoning: testReasoning,
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// Empty text is KEPT when there are no tool calls (signature would be lost otherwise)
assert.Len(t, result, 1, "Empty text part must be kept when no tool calls")
assert.Equal(t, testSig, result[0].ThoughtSignature, "Signature must be preserved")
})
t.Run("scenario_5_empty_text_with_signature_tool_without", func(t *testing.T) {
// Edge case: empty text with signature + tool call WITHOUT signature
// Both must be kept (tool call can't carry the signature)
parts := []llms.ContentPart{
llms.TextContent{
Text: "", // Empty
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_123",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"query": "weather"}`,
},
Reasoning: nil, // No signature in tool call
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// Empty text is KEPT because tool call doesn't have signature
assert.Len(t, result, 2, "Empty text part must be kept when tool call lacks signature")
assert.Equal(t, testSig, result[0].ThoughtSignature, "Text part should have signature")
assert.Nil(t, result[1].ThoughtSignature, "Tool call should NOT have signature")
})
}
// TestSignaturePlacementInRequest verifies the actual genai.Part structure
// that would be sent to Gemini API in various scenarios.
func TestSignaturePlacementInRequest(t *testing.T) {
t.Parallel()
signature := []byte("test-sig")
reasoning := &reasoning.ContentReasoning{
Content: "thinking...",
Signature: signature,
}
t.Run("gemini_tool_call_pattern", func(t *testing.T) {
// Typical Gemini response pattern:
// - choice.Reasoning is nil
// - choice.ToolCalls[0].Reasoning has signature
//
// Client should add: ToolCall part (signature embedded)
parts := []llms.ContentPart{
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"q": "test"}`,
},
Reasoning: reasoning,
},
}
result, err := convertParts(parts)
require.NoError(t, err)
require.Len(t, result, 1)
// Verify structure
assert.NotNil(t, result[0].FunctionCall, "Should be function call")
assert.Equal(t, signature, result[0].ThoughtSignature, "Should have signature")
assert.Equal(t, "search", result[0].FunctionCall.Name)
})
t.Run("anthropic_pattern_on_gemini", func(t *testing.T) {
// Anthropic pattern applied to Gemini (suboptimal but may happen):
// - Client adds: TextPartWithReasoning(choice.Content, choice.Reasoning) + ToolCall
// - For Anthropic: choice.Reasoning has signature
// - For Gemini: choice.Reasoning is nil, ToolCall.Reasoning has signature
//
// After extraction from Gemini response, client builds:
// - TextPartWithReasoning("", toolCall.Reasoning) <- empty text with signature
// - ToolCall <- already has signature
parts := []llms.ContentPart{
llms.TextContent{
Text: "", // Empty (Gemini doesn't put content in choice when tool calls)
Reasoning: reasoning,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "search",
Arguments: `{"q": "test"}`,
},
Reasoning: reasoning, // Same signature
},
}
result, err := convertParts(parts)
require.NoError(t, err)
// OPTIMIZATION: Empty text should be skipped
require.Len(t, result, 1, "Empty text part should be skipped")
assert.NotNil(t, result[0].FunctionCall, "Remaining part should be function call")
assert.Equal(t, signature, result[0].ThoughtSignature, "Tool call should have signature")
})
}
func TestConvertParts_DuplicateSignatures(t *testing.T) {
t.Parallel()
testSignature := []byte("test-signature-bytes")
testReasoning := &reasoning.ContentReasoning{
Content: "Test reasoning content",
Signature: testSignature,
}
tests := []struct {
name string
parts []llms.ContentPart
expectedPartCount int
expectedSigCount int // Number of parts with non-nil ThoughtSignature
description string
}{
{
name: "non-empty text with signature + tool call with signature",
parts: []llms.ContentPart{
llms.TextContent{
Text: "I will call the tool", // Non-empty text
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: testReasoning,
},
},
expectedPartCount: 2,
expectedSigCount: 2, // Both parts have signature - acceptable for non-empty text
description: "Non-empty text and tool call both have signatures - kept because text has content",
},
{
name: "empty text with signature + tool call with signature",
parts: []llms.ContentPart{
llms.TextContent{
Text: "", // Empty text but has reasoning
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: testReasoning,
},
},
expectedPartCount: 1, // Empty text part is SKIPPED (optimization)
expectedSigCount: 1, // Only tool call gets signature
description: "Empty text part with signature is skipped when tool call has signature - prevents duplicate",
},
{
name: "text with signature + tool call without signature",
parts: []llms.ContentPart{
llms.TextContent{
Text: "I will call the tool",
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: nil, // No reasoning in tool call
},
},
expectedPartCount: 2,
expectedSigCount: 1, // Only text part has signature
description: "Text has signature, tool call doesn't",
},
{
name: "text without signature + tool call with signature",
parts: []llms.ContentPart{
llms.TextContent{
Text: "I will call the tool",
Reasoning: nil,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: testReasoning,
},
},
expectedPartCount: 2,
expectedSigCount: 1, // Only tool call has signature
description: "Tool call has signature, text doesn't - this is typical for Gemini",
},
{
name: "empty text with signature + NO tool calls",
parts: []llms.ContentPart{
llms.TextContent{
Text: "", // Empty text but has reasoning
Reasoning: testReasoning,
},
},
expectedPartCount: 1, // Part is KEPT (no tool calls to carry signature)
expectedSigCount: 1, // Text part keeps signature
description: "Empty text with signature is preserved when no tool calls - prevents losing signature",
},
{
name: "multiple tool calls - only first has signature",
parts: []llms.ContentPart{
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: testReasoning, // First has signature
},
llms.ToolCall{
ID: "call_2",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "London"}`,
},
Reasoning: nil, // Second doesn't have signature
},
},
expectedPartCount: 2,
expectedSigCount: 1, // Only first tool call has signature
description: "Parallel tool calls - signature only in first (correct pattern)",
},
{
name: "empty text with signature + tool call WITHOUT signature",
parts: []llms.ContentPart{
llms.TextContent{
Text: "", // Empty text but has reasoning
Reasoning: testReasoning,
},
llms.ToolCall{
ID: "call_1",
FunctionCall: &llms.FunctionCall{
Name: "get_weather",
Arguments: `{"location": "Paris"}`,
},
Reasoning: nil, // Tool call has NO signature
},
},
expectedPartCount: 2, // Both parts kept (tool call has no signature to carry)
expectedSigCount: 1, // Only text part has signature
description: "Empty text with signature preserved when tool call lacks signature",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result, err := convertParts(tt.parts)
assert.NoError(t, err)
assert.Len(t, result, tt.expectedPartCount, tt.description)
// Count parts with ThoughtSignature
sigCount := 0
for i, part := range result {
if len(part.ThoughtSignature) > 0 {
sigCount++
t.Logf("Part %d has signature: %d bytes", i, len(part.ThoughtSignature))
// Verify signature matches expected
assert.Equal(t, testSignature, part.ThoughtSignature,
"Part %d signature should match test signature", i)
}
}
assert.Equal(t, tt.expectedSigCount, sigCount,
"Expected %d parts with signature, got %d. %s",
tt.expectedSigCount, sigCount, tt.description)
// Log what would be sent to API
t.Logf("Would send to Gemini API: %d parts, %d with signatures", len(result), sigCount)
})
}
}
func TestConvertParts(t *testing.T) {
t.Parallel()