Fix Gemini Patrol tool-call readiness

Refs #1530
This commit is contained in:
rcourtman 2026-07-06 23:01:16 +01:00
parent 6f9f310ba6
commit af7f7c5d11
10 changed files with 447 additions and 103 deletions

View file

@ -153,6 +153,24 @@ Provider definitions that declare a BaseURLField (today OpenAI, Ollama, and
Z.ai) expose a user-overridable endpoint via the AI settings payload; the Z.ai) expose a user-overridable endpoint via the AI settings payload; the
registry default base URL applies when no override is stored, so one provider registry default base URL applies when no override is stored, so one provider
can serve both standard and alternate (e.g. Z.ai coding) endpoint tiers. can serve both standard and alternate (e.g. Z.ai coding) endpoint tiers.
Native provider tool-calling is a transport projection over the shared
provider-neutral `Tool`, `ToolChoice`, `ToolCall`, and `ToolResult` shapes.
Nil `ToolChoice` means provider-owned automatic selection, `none` disables
tools by omitting them from the provider request, and `required` is reserved for
callers that explicitly need provider-native forced tool use. That required mode
maps to OpenAI `required`, Anthropic `any`, and Gemini
`FunctionCallingConfig` `ANY`; normal chat and Patrol runs must not force tool
use unless the caller sets that override. Patrol's static model-readiness
self-test may use required mode for Gemini because it is a fixed capability
probe with no infrastructure identifiers and needs deterministic tool-call
proof. Provider-specific schema restrictions belong at the native transport
edge: Gemini strips unsupported `additionalProperties` only from Gemini function
declarations, while the registry-owned provider-neutral schemas and providers
that support stricter JSON Schema keep that field. Gemini function-call
continuations must preserve the provider `functionCall.id` on parsed
`ToolCall.ID` values and return that same value as `functionResponse.id`; the
function name remains a separate field resolved from the preceding assistant
call when building tool-result turns.
## Canonical Files ## Canonical Files

View file

@ -199,6 +199,9 @@ func (s *Service) RunPatrolToolPreflight(ctx context.Context, providerName, mode
}, },
MaxTokens: 256, MaxTokens: 256,
} }
if parsedProvider == config.AIProviderGemini {
req.ToolChoice = &providers.ToolChoice{Type: providers.ToolChoiceRequired}
}
resp, err := provider.Chat(ctx, req) resp, err := provider.Chat(ctx, req)
result.DurationMs = time.Since(started).Milliseconds() result.DurationMs = time.Since(started).Milliseconds()

View file

@ -73,10 +73,24 @@ type anthropicRequest struct {
ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"` ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"`
} }
// anthropicToolChoice controls whether tools are available or disabled. // anthropicToolChoice controls whether tools are automatic, required, or disabled.
// Pulse uses automatic selection by default and none only as a safety brake. // Pulse omits automatic selection by default and only serializes explicit overrides.
type anthropicToolChoice struct { type anthropicToolChoice struct {
Type string `json:"type"` // "auto" or "none" Type string `json:"type"` // "auto", "any", or "none"
}
func convertToolChoiceToAnthropic(tc *ToolChoice) *anthropicToolChoice {
if tc == nil {
return nil
}
switch tc.Type {
case ToolChoiceNone:
return &anthropicToolChoice{Type: "none"}
case ToolChoiceRequired:
return &anthropicToolChoice{Type: "any"}
default:
return nil
}
} }
type anthropicMessage struct { type anthropicMessage struct {
@ -269,12 +283,10 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"}
} }
// Add tool_choice if specified. Pulse only sends automatic selection or // Add tool_choice only for explicit overrides. Nil keeps Anthropic's default
// text-only safety brakes. // automatic tool selection.
if shouldAddTools && req.ToolChoice != nil { if shouldAddTools && req.ToolChoice != nil {
anthropicReq.ToolChoice = &anthropicToolChoice{ anthropicReq.ToolChoice = convertToolChoiceToAnthropic(req.ToolChoice)
Type: string(req.ToolChoice.Type),
}
} }
body, err := json.Marshal(anthropicReq) body, err := json.Marshal(anthropicReq)
@ -606,12 +618,9 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb
anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"}
} }
// Add tool_choice if specified (same as non-streaming). Pulse only sends // Add tool_choice only for explicit overrides, same as non-streaming.
// automatic selection or text-only safety brakes.
if shouldAddTools && req.ToolChoice != nil { if shouldAddTools && req.ToolChoice != nil {
anthropicReq.ToolChoice = &anthropicToolChoice{ anthropicReq.ToolChoice = convertToolChoiceToAnthropic(req.ToolChoice)
Type: string(req.ToolChoice.Type),
}
} }
body, err := json.Marshal(anthropicReq) body, err := json.Marshal(anthropicReq)

View file

@ -210,6 +210,73 @@ func TestAnthropicClient_Chat_ToolChoiceNone_DropsTools(t *testing.T) {
} }
} }
func TestAnthropicClient_Chat_ToolChoiceRequired_KeepsToolsAndUsesAny(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]any
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode request: %v", err)
}
toolChoice, ok := got["tool_choice"].(map[string]any)
if !ok {
t.Fatalf("tool_choice field should be present when tools are required: %+v", got["tool_choice"])
}
if toolChoice["type"] != "any" {
t.Fatalf("tool_choice.type = %v, want any", toolChoice["type"])
}
tools, ok := got["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools field should contain one tool when tool_choice is required: %+v", got["tools"])
}
tool, ok := tools[0].(map[string]any)
if !ok || tool["name"] != "get_time" {
t.Fatalf("unexpected tool definition: %+v", tools[0])
}
_ = json.NewEncoder(w).Encode(anthropicResponse{
ID: "msg_123",
Type: "message",
Role: "assistant",
Model: "claude-3-5-sonnet",
StopReason: "tool_use",
Content: []anthropicContent{
{
Type: "tool_use",
ID: "tool_1",
Name: "get_time",
Input: map[string]interface{}{"tz": "UTC"},
},
},
})
}))
defer server.Close()
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL+"/v1/messages", 0)
out, err := client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hello"}},
Tools: []Tool{
{
Name: "get_time",
Description: "get time",
InputSchema: map[string]any{"type": "object"},
},
},
ToolChoice: &ToolChoice{Type: ToolChoiceRequired},
})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out.StopReason != "tool_use" {
t.Fatalf("StopReason = %q, want tool_use", out.StopReason)
}
if len(out.ToolCalls) != 1 || out.ToolCalls[0].ID != "tool_1" || out.ToolCalls[0].Name != "get_time" {
t.Fatalf("unexpected ToolCalls: %+v", out.ToolCalls)
}
if out.ToolCalls[0].Input["tz"] != "UTC" {
t.Fatalf("unexpected ToolCall input: %+v", out.ToolCalls[0].Input)
}
}
func TestAnthropicClient_Chat_ToolResultInRequest(t *testing.T) { func TestAnthropicClient_Chat_ToolResultInRequest(t *testing.T) {
var got map[string]any var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -34,7 +34,6 @@ func NewGeminiClient(apiKey, model, baseURL string, timeout time.Duration) *Gemi
baseURL = geminiAPIURL baseURL = geminiAPIURL
} }
// Strip provider prefix if present - the model should be just the model name // Strip provider prefix if present - the model should be just the model name
// Strip provider prefix if present - the model should be just the model name
model = strings.TrimPrefix(model, "gemini:") model = strings.TrimPrefix(model, "gemini:")
if timeout <= 0 { if timeout <= 0 {
timeout = 300 * time.Second // Default 5 minutes timeout = 300 * time.Second // Default 5 minutes
@ -87,11 +86,13 @@ type geminiPart struct {
} }
type geminiFunctionCall struct { type geminiFunctionCall struct {
ID string `json:"id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Args map[string]interface{} `json:"args"` Args map[string]interface{} `json:"args"`
} }
type geminiFunctionResponse struct { type geminiFunctionResponse struct {
ID string `json:"id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Response struct { Response struct {
Content string `json:"content"` Content string `json:"content"`
@ -258,13 +259,92 @@ func sanitizeGeminiContents(contents []geminiContent) []geminiContent {
} }
// convertToolChoiceToGemini converts our ToolChoice to Gemini's mode string. // convertToolChoiceToGemini converts our ToolChoice to Gemini's mode string.
// Pulse omits automatic tool config so tool use stays model-owned. // Pulse omits automatic tool config so normal tool use stays model-owned.
// See: https://ai.google.dev/api/caching#FunctionCallingConfig // See: https://ai.google.dev/gemini-api/docs/generate-content#function_calling
func convertToolChoiceToGemini(tc *ToolChoice) string { func convertToolChoiceToGemini(tc *ToolChoice) string {
if tc != nil && tc.Type == ToolChoiceNone { if tc == nil {
return "NONE" return ""
}
switch tc.Type {
case ToolChoiceNone:
return "NONE"
case ToolChoiceRequired:
return "ANY"
default:
return ""
}
}
func buildGeminiFunctionDeclarations(tools []Tool) []geminiFunctionDeclaration {
funcDecls := make([]geminiFunctionDeclaration, 0, len(tools))
for _, t := range tools {
if t.Type != "" && t.Type != "function" {
continue
}
funcDecls = append(funcDecls, geminiFunctionDeclaration{
Name: t.Name,
Description: t.Description,
Parameters: sanitizeGeminiToolSchema(t.InputSchema),
})
}
return funcDecls
}
func applyGeminiTools(geminiReq *geminiRequest, tools []Tool, toolChoice *ToolChoice) []geminiFunctionDeclaration {
shouldAddTools := len(tools) > 0
if toolChoice != nil && toolChoice.Type == ToolChoiceNone {
shouldAddTools = false
}
if !shouldAddTools {
return nil
}
funcDecls := buildGeminiFunctionDeclarations(tools)
if len(funcDecls) == 0 {
return nil
}
geminiReq.Tools = []geminiToolDef{{FunctionDeclarations: funcDecls}}
if mode := convertToolChoiceToGemini(toolChoice); mode != "" {
geminiReq.ToolConfig = &geminiToolConfig{
FunctionCallingConfig: &geminiFunctionCallingConfig{Mode: mode},
}
}
return funcDecls
}
// sanitizeGeminiToolSchema projects Pulse's provider-neutral JSON schema onto
// Gemini's function declaration subset. Gemini rejects additionalProperties in
// function parameters, while other providers rely on it for stricter schemas.
func sanitizeGeminiToolSchema(schema map[string]interface{}) map[string]interface{} {
if schema == nil {
return nil
}
sanitized, _ := sanitizeGeminiSchemaValue(schema).(map[string]interface{})
return sanitized
}
func sanitizeGeminiSchemaValue(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(v))
for key, item := range v {
if key == "additionalProperties" {
continue
}
out[key] = sanitizeGeminiSchemaValue(item)
}
return out
case []interface{}:
out := make([]interface{}, len(v))
for i, item := range v {
out[i] = sanitizeGeminiSchemaValue(item)
}
return out
case []string:
return append([]string(nil), v...)
default:
return v
} }
return ""
} }
// convertMessagesToGemini converts provider-neutral messages into Gemini's // convertMessagesToGemini converts provider-neutral messages into Gemini's
@ -302,13 +382,13 @@ func convertMessagesToGemini(reqMessages []Message) []geminiContent {
return id return id
} }
parts := []geminiPart{geminiFunctionResponsePart(resolveName(m.ToolResult.ToolUseID), m.ToolResult.Content)} parts := []geminiPart{geminiFunctionResponsePart(m.ToolResult.ToolUseID, resolveName(m.ToolResult.ToolUseID), m.ToolResult.Content)}
for i+1 < len(reqMessages) { for i+1 < len(reqMessages) {
next := reqMessages[i+1] next := reqMessages[i+1]
if next.ToolResult == nil { if next.ToolResult == nil {
break break
} }
parts = append(parts, geminiFunctionResponsePart(resolveName(next.ToolResult.ToolUseID), next.ToolResult.Content)) parts = append(parts, geminiFunctionResponsePart(next.ToolResult.ToolUseID, resolveName(next.ToolResult.ToolUseID), next.ToolResult.Content))
i++ i++
} }
@ -327,6 +407,7 @@ func convertMessagesToGemini(reqMessages []Message) []geminiContent {
for _, tc := range m.ToolCalls { for _, tc := range m.ToolCalls {
parts = append(parts, geminiPart{ parts = append(parts, geminiPart{
FunctionCall: &geminiFunctionCall{ FunctionCall: &geminiFunctionCall{
ID: tc.ID,
Name: tc.Name, Name: tc.Name,
Args: tc.Input, Args: tc.Input,
}, },
@ -355,8 +436,8 @@ func convertMessagesToGemini(reqMessages []Message) []geminiContent {
return sanitizeGeminiContents(contents) return sanitizeGeminiContents(contents)
} }
func geminiFunctionResponsePart(name, content string) geminiPart { func geminiFunctionResponsePart(id, name, content string) geminiPart {
response := geminiFunctionResponse{Name: name} response := geminiFunctionResponse{ID: id, Name: name}
response.Response.Content = content response.Response.Content = content
return geminiPart{FunctionResponse: &response} return geminiPart{FunctionResponse: &response}
} }
@ -398,41 +479,15 @@ func (c *GeminiClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
geminiReq.GenerationConfig.Temperature = req.Temperature geminiReq.GenerationConfig.Temperature = req.Temperature
} }
// Add tools if provided (unless ToolChoice is None) funcDecls := applyGeminiTools(&geminiReq, req.Tools, req.ToolChoice)
shouldAddTools := len(req.Tools) > 0 if len(funcDecls) > 0 {
if req.ToolChoice != nil && req.ToolChoice.Type == ToolChoiceNone { log.Debug().Int("tool_count", len(funcDecls)).Strs("tool_names", func() []string {
shouldAddTools = false names := make([]string, len(funcDecls))
} for i, f := range funcDecls {
names[i] = f.Name
if shouldAddTools {
funcDecls := make([]geminiFunctionDeclaration, 0, len(req.Tools))
for _, t := range req.Tools {
// Skip non-function tools
if t.Type != "" && t.Type != "function" {
continue
} }
funcDecls = append(funcDecls, geminiFunctionDeclaration{ return names
Name: t.Name, }()).Msg("gemini request includes tools")
Description: t.Description,
Parameters: t.InputSchema,
})
}
if len(funcDecls) > 0 {
geminiReq.Tools = []geminiToolDef{{FunctionDeclarations: funcDecls}}
if mode := convertToolChoiceToGemini(req.ToolChoice); mode != "" {
geminiReq.ToolConfig = &geminiToolConfig{
FunctionCallingConfig: &geminiFunctionCallingConfig{Mode: mode},
}
}
log.Debug().Int("tool_count", len(funcDecls)).Strs("tool_names", func() []string {
names := make([]string, len(funcDecls))
for i, f := range funcDecls {
names[i] = f.Name
}
return names
}()).Msg("gemini request includes tools")
}
} }
body, err := json.Marshal(geminiReq) body, err := json.Marshal(geminiReq)
@ -576,9 +631,10 @@ func (c *GeminiClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
textContent += part.Text textContent += part.Text
} }
if part.FunctionCall != nil { if part.FunctionCall != nil {
// Generate a unique ID for this tool call since Gemini doesn't provide one toolID := strings.TrimSpace(part.FunctionCall.ID)
// Use name + index to ensure uniqueness when same function is called multiple times if toolID == "" {
toolID := fmt.Sprintf("%s_%d", part.FunctionCall.Name, len(toolCalls)) toolID = fmt.Sprintf("%s_%d", part.FunctionCall.Name, len(toolCalls))
}
signature := part.ThoughtSignature signature := part.ThoughtSignature
if len(signature) == 0 { if len(signature) == 0 {
signature = part.ThoughtSignatureSnake signature = part.ThoughtSignatureSnake
@ -677,42 +733,16 @@ func (c *GeminiClient) ChatStream(ctx context.Context, req ChatRequest, callback
geminiReq.GenerationConfig.Temperature = req.Temperature geminiReq.GenerationConfig.Temperature = req.Temperature
} }
// Add tools if provided (unless ToolChoice is None) - same as non-streaming funcDecls := applyGeminiTools(&geminiReq, req.Tools, req.ToolChoice)
shouldAddTools := len(req.Tools) > 0 if len(funcDecls) > 0 {
if req.ToolChoice != nil && req.ToolChoice.Type == ToolChoiceNone { toolNames := make([]string, len(funcDecls))
shouldAddTools = false for i, f := range funcDecls {
} toolNames[i] = f.Name
if shouldAddTools {
funcDecls := make([]geminiFunctionDeclaration, 0, len(req.Tools))
for _, t := range req.Tools {
if t.Type != "" && t.Type != "function" {
continue
}
funcDecls = append(funcDecls, geminiFunctionDeclaration{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
})
}
if len(funcDecls) > 0 {
geminiReq.Tools = []geminiToolDef{{FunctionDeclarations: funcDecls}}
if mode := convertToolChoiceToGemini(req.ToolChoice); mode != "" {
geminiReq.ToolConfig = &geminiToolConfig{
FunctionCallingConfig: &geminiFunctionCallingConfig{Mode: mode},
}
}
// Log tool names for debugging tool selection issues
toolNames := make([]string, len(funcDecls))
for i, f := range funcDecls {
toolNames[i] = f.Name
}
log.Debug().
Int("tool_count", len(funcDecls)).
Strs("tool_names", toolNames).
Msg("Gemini stream request includes tools")
} }
log.Debug().
Int("tool_count", len(funcDecls)).
Strs("tool_names", toolNames).
Msg("Gemini stream request includes tools")
} }
body, err := json.Marshal(geminiReq) body, err := json.Marshal(geminiReq)
@ -878,7 +908,10 @@ func (c *GeminiClient) ChatStream(ctx context.Context, req ChatRequest, callback
} }
if part.FunctionCall != nil { if part.FunctionCall != nil {
toolID := fmt.Sprintf("%s_%d", part.FunctionCall.Name, len(toolCalls)) toolID := strings.TrimSpace(part.FunctionCall.ID)
if toolID == "" {
toolID = fmt.Sprintf("%s_%d", part.FunctionCall.Name, len(toolCalls))
}
signature := part.ThoughtSignature signature := part.ThoughtSignature
if len(signature) == 0 { if len(signature) == 0 {
signature = part.ThoughtSignatureSnake signature = part.ThoughtSignatureSnake

View file

@ -150,3 +150,42 @@ func TestChatStream_ToolResults_MultipleMerged(t *testing.T) {
assert.Equal(t, "func3", mergedUserMsg.Parts[2].FunctionResponse.Name) assert.Equal(t, "func3", mergedUserMsg.Parts[2].FunctionResponse.Name)
assert.Equal(t, "res3", mergedUserMsg.Parts[2].FunctionResponse.Response.Content) assert.Equal(t, "res3", mergedUserMsg.Parts[2].FunctionResponse.Response.Content)
} }
func TestGeminiClient_ChatStream_ToolChoiceRequiredUsesAnyAndSanitizesSchema(t *testing.T) {
var capturedBody geminiRequest
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {}\n\n"))
}))
defer ts.Close()
client := NewGeminiClient("fake-key", "gemini-pro", ts.URL, 10*time.Second)
err := client.ChatStream(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Run the self-test"}},
Tools: []Tool{{
Name: "verify_pulse_patrol",
InputSchema: map[string]interface{}{
"type": "object",
"additionalProperties": false,
"properties": map[string]interface{}{
"ok": map[string]interface{}{
"type": "boolean",
"additionalProperties": false,
},
},
"required": []string{"ok"},
},
}},
ToolChoice: &ToolChoice{Type: ToolChoiceRequired},
}, func(event StreamEvent) {})
assert.NoError(t, err)
if assert.NotNil(t, capturedBody.ToolConfig) && assert.NotNil(t, capturedBody.ToolConfig.FunctionCallingConfig) {
assert.Equal(t, "ANY", capturedBody.ToolConfig.FunctionCallingConfig.Mode)
}
if assert.Len(t, capturedBody.Tools, 1) && assert.Len(t, capturedBody.Tools[0].FunctionDeclarations, 1) {
assert.False(t, hasMapKeyDeep(capturedBody.Tools[0].FunctionDeclarations[0].Parameters, "additionalProperties"))
}
}

View file

@ -277,6 +277,7 @@ func TestGeminiClient_Chat_ToolCalls(t *testing.T) {
Parts: []geminiPart{ Parts: []geminiPart{
{ {
FunctionCall: &geminiFunctionCall{ FunctionCall: &geminiFunctionCall{
ID: "gemini-call-weather",
Name: "get_weather", Name: "get_weather",
Args: map[string]interface{}{"location": "NYC"}, Args: map[string]interface{}{"location": "NYC"},
}, },
@ -320,6 +321,77 @@ func TestGeminiClient_Chat_ToolCalls(t *testing.T) {
if resp.ToolCalls[0].Name != "get_weather" { if resp.ToolCalls[0].Name != "get_weather" {
t.Errorf("expected tool name 'get_weather', got %q", resp.ToolCalls[0].Name) t.Errorf("expected tool name 'get_weather', got %q", resp.ToolCalls[0].Name)
} }
if resp.ToolCalls[0].ID != "gemini-call-weather" {
t.Errorf("expected provider tool id to be preserved, got %q", resp.ToolCalls[0].ID)
}
}
func TestGeminiClient_Chat_ToolChoiceRequiredUsesAnyAndSanitizesSchema(t *testing.T) {
var receivedReq geminiRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedReq)
resp := geminiResponse{
Candidates: []geminiCandidate{
{
Content: geminiContent{
Parts: []geminiPart{{
FunctionCall: &geminiFunctionCall{
ID: "call-verify-1",
Name: "verify_pulse_patrol",
Args: map[string]interface{}{"ok": true},
},
}},
},
FinishReason: "STOP",
},
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL, 0)
resp, err := client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Run the self-test"}},
Tools: []Tool{{
Name: "verify_pulse_patrol",
Description: "Verify Patrol tool calling",
InputSchema: map[string]interface{}{
"type": "object",
"additionalProperties": false,
"properties": map[string]interface{}{
"ok": map[string]interface{}{
"type": "boolean",
"description": "Always true.",
"additionalProperties": false,
},
},
"required": []string{"ok"},
},
}},
ToolChoice: &ToolChoice{Type: ToolChoiceRequired},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedReq.ToolConfig == nil || receivedReq.ToolConfig.FunctionCallingConfig == nil {
t.Fatalf("expected Gemini toolConfig in required mode, got %+v", receivedReq.ToolConfig)
}
if receivedReq.ToolConfig.FunctionCallingConfig.Mode != "ANY" {
t.Fatalf("Gemini function calling mode = %q, want ANY", receivedReq.ToolConfig.FunctionCallingConfig.Mode)
}
if len(receivedReq.Tools) != 1 || len(receivedReq.Tools[0].FunctionDeclarations) != 1 {
t.Fatalf("expected one Gemini function declaration, got %+v", receivedReq.Tools)
}
params := receivedReq.Tools[0].FunctionDeclarations[0].Parameters
if hasMapKeyDeep(params, "additionalProperties") {
t.Fatalf("Gemini function parameters leaked unsupported additionalProperties: %#v", params)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call-verify-1" {
t.Fatalf("expected provider function-call id to survive response parsing, got %+v", resp.ToolCalls)
}
} }
func TestGeminiClient_Chat_APIError(t *testing.T) { func TestGeminiClient_Chat_APIError(t *testing.T) {
@ -612,7 +684,7 @@ func TestGeminiClient_Chat_ToolResultsAndAssistantToolCalls(t *testing.T) {
_, err := client.Chat(context.Background(), ChatRequest{ _, err := client.Chat(context.Background(), ChatRequest{
Messages: []Message{ Messages: []Message{
{Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{{ID: "tc1", Name: "get_time", Input: map[string]any{"tz": "UTC"}}}}, {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{{ID: "tc1", Name: "get_time", Input: map[string]any{"tz": "UTC"}}}},
{Role: "user", ToolResult: &ToolResult{ToolUseID: "get_time", Content: "{\"time\":\"00:00\"}"}}, {Role: "user", ToolResult: &ToolResult{ToolUseID: "tc1", Content: "{\"time\":\"00:00\"}"}},
}, },
}) })
if err != nil { if err != nil {
@ -626,10 +698,19 @@ func TestGeminiClient_Chat_ToolResultsAndAssistantToolCalls(t *testing.T) {
if got.Contents[0].Role != "model" || got.Contents[0].Parts[1].FunctionCall == nil { if got.Contents[0].Role != "model" || got.Contents[0].Parts[1].FunctionCall == nil {
t.Errorf("Expected model role with function call, got %+v", got.Contents[0]) t.Errorf("Expected model role with function call, got %+v", got.Contents[0])
} }
if got.Contents[0].Parts[1].FunctionCall.ID != "tc1" {
t.Errorf("Expected model function call id tc1, got %+v", got.Contents[0].Parts[1].FunctionCall)
}
// Check tool result // Check tool result
if got.Contents[1].Role != "user" || got.Contents[1].Parts[0].FunctionResponse == nil { if got.Contents[1].Role != "user" || got.Contents[1].Parts[0].FunctionResponse == nil {
t.Errorf("Expected user role with function response, got %+v", got.Contents[1]) t.Errorf("Expected user role with function response, got %+v", got.Contents[1])
} }
if got.Contents[1].Parts[0].FunctionResponse.ID != "tc1" {
t.Errorf("Expected function response id tc1, got %+v", got.Contents[1].Parts[0].FunctionResponse)
}
if got.Contents[1].Parts[0].FunctionResponse.Name != "get_time" {
t.Errorf("Expected function response name get_time, got %+v", got.Contents[1].Parts[0].FunctionResponse)
}
} }
func TestGeminiClient_Chat_ResolvesAndGroupsToolResults(t *testing.T) { func TestGeminiClient_Chat_ResolvesAndGroupsToolResults(t *testing.T) {
@ -673,9 +754,33 @@ func TestGeminiClient_Chat_ResolvesAndGroupsToolResults(t *testing.T) {
if resultParts[0].FunctionResponse == nil || resultParts[0].FunctionResponse.Name != "get_time" { if resultParts[0].FunctionResponse == nil || resultParts[0].FunctionResponse.Name != "get_time" {
t.Fatalf("first function response = %+v, want get_time", resultParts[0].FunctionResponse) t.Fatalf("first function response = %+v, want get_time", resultParts[0].FunctionResponse)
} }
if resultParts[0].FunctionResponse.ID != "call-time" {
t.Fatalf("first function response id = %q, want call-time", resultParts[0].FunctionResponse.ID)
}
if resultParts[1].FunctionResponse == nil || resultParts[1].FunctionResponse.Name != "get_weather" { if resultParts[1].FunctionResponse == nil || resultParts[1].FunctionResponse.Name != "get_weather" {
t.Fatalf("second function response = %+v, want get_weather", resultParts[1].FunctionResponse) t.Fatalf("second function response = %+v, want get_weather", resultParts[1].FunctionResponse)
} }
if resultParts[1].FunctionResponse.ID != "call-weather" {
t.Fatalf("second function response id = %q, want call-weather", resultParts[1].FunctionResponse.ID)
}
}
func hasMapKeyDeep(value interface{}, key string) bool {
switch v := value.(type) {
case map[string]interface{}:
for k, item := range v {
if k == key || hasMapKeyDeep(item, key) {
return true
}
}
case []interface{}:
for _, item := range v {
if hasMapKeyDeep(item, key) {
return true
}
}
}
return false
} }
func TestGeminiClient_Chat_Retry(t *testing.T) { func TestGeminiClient_Chat_Retry(t *testing.T) {

View file

@ -224,7 +224,7 @@ type openaiRequest struct {
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
Tools []openaiTool `json:"tools,omitempty"` Tools []openaiTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto" or "none" ToolChoice interface{} `json:"tool_choice,omitempty"` // "none", "required", or provider-specific choices
} }
// openaiTool represents a function tool in OpenAI format // openaiTool represents a function tool in OpenAI format
@ -353,13 +353,20 @@ func (c *OpenAIClient) requiresMaxCompletionTokens(model string) bool {
// convertToolChoiceToOpenAI converts our ToolChoice to OpenAI's format. // convertToolChoiceToOpenAI converts our ToolChoice to OpenAI's format.
// Pulse omits automatic tool_choice so tool use stays model-owned, and only // Pulse omits automatic tool_choice so tool use stays model-owned, and only
// uses text-only safety brakes when tools are removed from the request. // serializes native override modes when the caller explicitly requests them.
// See: https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice // See: https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice
func convertToolChoiceToOpenAI(tc *ToolChoice) interface{} { func convertToolChoiceToOpenAI(tc *ToolChoice) interface{} {
if tc != nil && tc.Type == ToolChoiceNone { if tc == nil {
return "none" return nil
}
switch tc.Type {
case ToolChoiceNone:
return "none"
case ToolChoiceRequired:
return "required"
default:
return nil
} }
return nil
} }
// convertMessagesToOpenAI converts provider-neutral messages into the // convertMessagesToOpenAI converts provider-neutral messages into the

View file

@ -732,6 +732,66 @@ func TestOpenAIClient_Chat_ToolChoiceNone_DropsTools(t *testing.T) {
assert.Equal(t, "No tools", resp.Content) assert.Equal(t, "No tools", resp.Content)
} }
func TestOpenAIClient_Chat_ToolChoiceRequired_KeepsToolsAndRequiresCall(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "required", got["tool_choice"])
tools, ok := got["tools"].([]interface{})
require.True(t, ok, "tools field should be present when tool_choice is required")
require.Len(t, tools, 1)
tool, ok := tools[0].(map[string]interface{})
require.True(t, ok, "tool should be an object")
assert.Equal(t, "function", tool["type"])
fn, ok := tool["function"].(map[string]interface{})
require.True(t, ok, "function definition should be an object")
assert.Equal(t, "get_time", fn["name"])
_ = json.NewEncoder(w).Encode(openaiResponse{
ID: "chatcmpl-required-tools",
Model: "gpt-4",
Choices: []openaiChoice{
{
Message: openaiRespMsg{
Role: "assistant",
ToolCalls: []openaiToolCall{
{
ID: "call_1",
Type: "function",
Function: openaiToolFunction{
Name: "get_time",
Arguments: `{"tz":"UTC"}`,
},
},
},
},
FinishReason: "tool_calls",
},
},
})
}))
defer server.Close()
client := NewOpenAIClient("sk-test", "gpt-4", server.URL, 0)
resp, err := client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
Tools: []Tool{
{
Name: "get_time",
Description: "get time",
InputSchema: map[string]interface{}{"type": "object"},
},
},
ToolChoice: &ToolChoice{Type: ToolChoiceRequired},
})
require.NoError(t, err)
require.Len(t, resp.ToolCalls, 1)
assert.Equal(t, "call_1", resp.ToolCalls[0].ID)
assert.Equal(t, "get_time", resp.ToolCalls[0].Name)
assert.Equal(t, "UTC", resp.ToolCalls[0].Input["tz"])
}
func TestOpenAIClient_ChatStream_ToolChoiceNone_DropsTools(t *testing.T) { func TestOpenAIClient_ChatStream_ToolChoiceNone_DropsTools(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{} var got map[string]interface{}

View file

@ -57,6 +57,9 @@ type ToolChoiceType string
const ( const (
// ToolChoiceNone prevents the model from using any tools // ToolChoiceNone prevents the model from using any tools
ToolChoiceNone ToolChoiceType = "none" ToolChoiceNone ToolChoiceType = "none"
// ToolChoiceRequired asks providers with a native forcing mechanism to
// require at least one tool call.
ToolChoiceRequired ToolChoiceType = "required"
) )
// ToolChoice controls how the model selects tools // ToolChoice controls how the model selects tools
@ -73,7 +76,7 @@ type ChatRequest struct {
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
System string `json:"system,omitempty"` // System prompt (Anthropic style) System string `json:"system,omitempty"` // System prompt (Anthropic style)
Tools []Tool `json:"tools,omitempty"` // Available tools Tools []Tool `json:"tools,omitempty"` // Available tools
ToolChoice *ToolChoice `json:"tool_choice,omitempty"` // nil = model-owned automatic selection; none = text-only safety brake ToolChoice *ToolChoice `json:"tool_choice,omitempty"` // nil = model-owned automatic selection; none = text-only safety brake; required = provider-native forced tool use where supported
} }
func (r ChatRequest) NormalizeCollections() ChatRequest { func (r ChatRequest) NormalizeCollections() ChatRequest {