Skip to content
Merged
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
4 changes: 4 additions & 0 deletions internal/apischema/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,10 @@ type ChatCompletionAssistantMessageParam struct {
Refusal string `json:"refusal,omitempty"`
// The tool calls generated by the model, such as function calls.
ToolCalls []ChatCompletionMessageToolCallParam `json:"tool_calls,omitempty"`
// ThinkingBlocks holds structured thinking metadata (signatures, redacted content) echoed back
// by clients from a previous response's thinking_blocks. Non-standard extension, see
// https://docs.litellm.ai/docs/reasoning_content for the convention.
ThinkingBlocks []ThinkingBlock `json:"thinking_blocks,omitempty"`
}

// ChatCompletionMessageToolCallType The type of the tool. Currently, only `function` is supported.
Expand Down
32 changes: 30 additions & 2 deletions internal/translator/gemini_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const (
gcpMethodRawPredict = "rawPredict"
)

// dummyThoughtSignature is Google's documented compatibility escape for clients that cannot echo
// back a thought_signature on function-call parts in multi-turn requests, see
// https://ai.google.dev/gemini-api/docs/thought-signatures.
var dummyThoughtSignature = []byte("skip_thought_signature_validator")

// geminiResponseMode represents the type of response mode for Gemini requests
type geminiResponseMode string

Expand Down Expand Up @@ -275,6 +280,21 @@ func assistantMsgToGeminiParts(msg *openai.ChatCompletionAssistantMessageParam)
}
}

// Fall back to the first non-empty signature echoed back via thinking_blocks (see
// https://docs.litellm.ai/docs/reasoning_content) when no content-part signature was found.
if thoughtSignature == nil {
for _, block := range msg.ThinkingBlocks {
if block.Signature != "" {
sigBytes, err := base64.StdEncoding.DecodeString(block.Signature)
if err != nil {
return nil, nil, fmt.Errorf("failed to decode thought signature: %w", err)
}
thoughtSignature = sigBytes
break // Only use first signature.
}
}
}

// Handle tool calls in the assistant message.
knownToolCalls := make(map[string]string)
for i, toolCall := range msg.ToolCalls {
Expand All @@ -290,8 +310,16 @@ func assistantMsgToGeminiParts(msg *openai.ChatCompletionAssistantMessageParam)
funcCallPart := genai.NewPartFromFunctionCall(toolCall.Function.Name, parsedArgs)

// According to https://ai.google.dev/gemini-api/docs/thought-signatures, if the model generates parallel function calls in a response, the thought_signature is attached only to the first functionCall part. Subsequent functionCall parts in the same response will not contain a signature.
if i == 0 && thoughtSignature != nil {
funcCallPart.ThoughtSignature = thoughtSignature
if i == 0 {
if thoughtSignature != nil {
funcCallPart.ThoughtSignature = thoughtSignature
} else {
// No signature was echoed back by the client at all (e.g. an OpenAI-schema client
// that doesn't round-trip thinking_blocks). Gemini 3.x rejects function calls in
// multi-turn requests that are missing a thought_signature, so fall back to
// Google's documented compatibility escape.
funcCallPart.ThoughtSignature = dummyThoughtSignature
}
}

parts = append(parts, funcCallPart)
Expand Down
177 changes: 170 additions & 7 deletions internal/translator/gemini_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func TestOpenAIMessagesToGeminiContents(t *testing.T) {
"param1": "value1",
},
},
ThoughtSignature: dummyThoughtSignature,
},
{Text: "This is a assistant message"},
},
Expand Down Expand Up @@ -249,6 +250,7 @@ func TestAssistantMsgToGeminiParts(t *testing.T) {
Args: map[string]any{"location": "New York", "unit": "celsius"},
Name: "get_weather",
},
ThoughtSignature: dummyThoughtSignature,
},
},
expectedToolCalls: map[string]string{
Expand Down Expand Up @@ -282,10 +284,14 @@ func TestAssistantMsgToGeminiParts(t *testing.T) {
},
},
expectedParts: []*genai.Part{
genai.NewPartFromFunctionCall("get_weather", map[string]any{
"location": "New York",
"unit": "celsius",
}),
func() *genai.Part {
p := genai.NewPartFromFunctionCall("get_weather", map[string]any{
"location": "New York",
"unit": "celsius",
})
p.ThoughtSignature = dummyThoughtSignature
return p
}(),
genai.NewPartFromFunctionCall("get_time", map[string]any{
"timezone": "EST",
}),
Expand Down Expand Up @@ -428,9 +434,13 @@ func TestAssistantMsgToGeminiParts(t *testing.T) {
},
},
expectedParts: []*genai.Part{
genai.NewPartFromFunctionCall("get_weather", map[string]any{
"location": "San Francisco",
}),
func() *genai.Part {
p := genai.NewPartFromFunctionCall("get_weather", map[string]any{
"location": "San Francisco",
})
p.ThoughtSignature = dummyThoughtSignature
return p
}(),
{
Text: "I need to call a function to get the weather",
Thought: true,
Expand Down Expand Up @@ -520,6 +530,159 @@ func TestAssistantMsgToGeminiParts(t *testing.T) {
"call_time": "get_time",
},
},
{
name: "thinking_blocks signature with tool calls - signature on first tool call",
msg: openai.ChatCompletionAssistantMessageParam{
Role: openai.ChatMessageRoleAssistant,
ThinkingBlocks: []openai.ThinkingBlock{
{Type: "thinking", Signature: "dGVzdHNpZ25hdHVyZQ=="}, // "testsignature" in base64
},
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: ptr.To("call_weather"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_weather",
Arguments: `{"location":"San Francisco"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
{
ID: ptr.To("call_time"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_time",
Arguments: `{"timezone":"PST"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
},
},
expectedParts: []*genai.Part{
{
FunctionCall: &genai.FunctionCall{
Name: "get_weather",
Args: map[string]any{"location": "San Francisco"},
},
ThoughtSignature: []byte("testsignature"),
},
{
FunctionCall: &genai.FunctionCall{
Name: "get_time",
Args: map[string]any{"timezone": "PST"},
},
},
},
expectedToolCalls: map[string]string{
"call_weather": "get_weather",
"call_time": "get_time",
},
},
{
name: "tool calls with no signature anywhere fall back to dummy signature on first call only",
msg: openai.ChatCompletionAssistantMessageParam{
Role: openai.ChatMessageRoleAssistant,
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: ptr.To("call_weather"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_weather",
Arguments: `{"location":"San Francisco"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
{
ID: ptr.To("call_time"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_time",
Arguments: `{"timezone":"PST"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
},
},
expectedParts: []*genai.Part{
{
FunctionCall: &genai.FunctionCall{
Name: "get_weather",
Args: map[string]any{"location": "San Francisco"},
},
ThoughtSignature: dummyThoughtSignature,
},
{
FunctionCall: &genai.FunctionCall{
Name: "get_time",
Args: map[string]any{"timezone": "PST"},
},
},
},
expectedToolCalls: map[string]string{
"call_weather": "get_weather",
"call_time": "get_time",
},
},
{
name: "content thinking-part signature wins over differing thinking_blocks signature",
msg: openai.ChatCompletionAssistantMessageParam{
Content: openai.StringOrAssistantRoleContentUnion{
Value: []openai.ChatCompletionAssistantMessageParamContent{
{
Type: openai.ChatCompletionAssistantMessageParamContentTypeThinking,
Text: ptr.To("I need to call a function to get the weather"),
Signature: ptr.To("dGVzdHNpZ25hdHVyZQ=="), // "testsignature" in base64
},
},
},
Role: openai.ChatMessageRoleAssistant,
ThinkingBlocks: []openai.ThinkingBlock{
{Type: "thinking", Signature: "b3RoZXJzaWduYXR1cmU="}, // "othersignature" in base64
},
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: ptr.To("call_weather"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_weather",
Arguments: `{"location":"San Francisco"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
},
},
expectedParts: []*genai.Part{
{
FunctionCall: &genai.FunctionCall{
Name: "get_weather",
Args: map[string]any{"location": "San Francisco"},
},
ThoughtSignature: []byte("testsignature"),
},
{
Text: "I need to call a function to get the weather",
Thought: true,
},
},
expectedToolCalls: map[string]string{
"call_weather": "get_weather",
},
},
{
name: "invalid base64 in thinking_blocks signature",
msg: openai.ChatCompletionAssistantMessageParam{
Role: openai.ChatMessageRoleAssistant,
ThinkingBlocks: []openai.ThinkingBlock{
{Type: "thinking", Signature: "not-valid-base64!!!"},
},
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: ptr.To("call_weather"),
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: "get_weather",
Arguments: `{"location":"San Francisco"}`,
},
Type: openai.ChatCompletionMessageToolCallTypeFunction,
},
},
},
expectedErrorMsg: "failed to decode thought signature",
},
{
name: "thinking content with invalid base64 signature",
msg: openai.ChatCompletionAssistantMessageParam{
Expand Down