sanitize gemini tool schemas
- commit
- 583b2a7db2111aea3412ddbf3c6fae90cb5fd3da
- author
- patriceckhart · · 2026-05-26 16:10 UTC
- parent
- 37ef90b
- signature
- unsigned
2 files · +77 -4
M internal/provider/gemini.go +41 -4
| @@ -157,10 +157,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 157 | 157 | if functionsEnabled && len(req.Tools) > 0 { | |
| 158 | 158 | decls := make([]gemFunctionDecl, 0, len(req.Tools)) | |
| 159 | 159 | for _, t := range req.Tools { | |
| 160 | - | schema := t.Schema | |
| 161 | - | if len(schema) == 0 || !json.Valid(schema) { | |
| 162 | - | schema = json.RawMessage(`{"type":"object","properties":{}}`) | |
| 163 | - | } | |
| 160 | + | schema := sanitizeGeminiToolSchema(t.Schema) | |
| 164 | 161 | decls = append(decls, gemFunctionDecl{ | |
| 165 | 162 | Name: t.Name, | |
| 166 | 163 | Description: t.Description, | |
| @@ -226,6 +223,46 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 226 | 223 | return out, m.ID, nil | |
| 227 | 224 | } | |
| 228 | 225 | ||
| 226 | + | func sanitizeGeminiToolSchema(schema json.RawMessage) json.RawMessage { | |
| 227 | + | if len(schema) == 0 || !json.Valid(schema) { | |
| 228 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |
| 229 | + | } | |
| 230 | + | var v any | |
| 231 | + | if err := json.Unmarshal(schema, &v); err != nil { | |
| 232 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |
| 233 | + | } | |
| 234 | + | v = stripGeminiUnsupportedSchemaFields(v) | |
| 235 | + | out, err := json.Marshal(v) | |
| 236 | + | if err != nil || len(out) == 0 || !json.Valid(out) { | |
| 237 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |
| 238 | + | } | |
| 239 | + | return out | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func stripGeminiUnsupportedSchemaFields(v any) any { | |
| 243 | + | switch x := v.(type) { | |
| 244 | + | case map[string]any: | |
| 245 | + | out := make(map[string]any, len(x)) | |
| 246 | + | for k, val := range x { | |
| 247 | + | switch k { | |
| 248 | + | case "additionalProperties", "$schema": | |
| 249 | + | continue | |
| 250 | + | default: | |
| 251 | + | out[k] = stripGeminiUnsupportedSchemaFields(val) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return out | |
| 255 | + | case []any: | |
| 256 | + | out := make([]any, len(x)) | |
| 257 | + | for i, val := range x { | |
| 258 | + | out[i] = stripGeminiUnsupportedSchemaFields(val) | |
| 259 | + | } | |
| 260 | + | return out | |
| 261 | + | default: | |
| 262 | + | return v | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 229 | 266 | func convertGemUserParts(blocks []Content) []gemPart { | |
| 230 | 267 | var parts []gemPart | |
| 231 | 268 | for _, b := range blocks { | |
| @@ -157,10 +157,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 157 | if functionsEnabled && len(req.Tools) > 0 { | 157 | if functionsEnabled && len(req.Tools) > 0 { | ||
| 158 | decls := make([]gemFunctionDecl, 0, len(req.Tools)) | 158 | decls := make([]gemFunctionDecl, 0, len(req.Tools)) | ||
| 159 | for _, t := range req.Tools { | 159 | for _, t := range req.Tools { | ||
| 160 | - | schema := t.Schema | 160 | + | schema := sanitizeGeminiToolSchema(t.Schema) |
| 161 | - | if len(schema) == 0 || !json.Valid(schema) { | |||
| 162 | - | schema = json.RawMessage(`{"type":"object","properties":{}}`) | |||
| 163 | - | } | |||
| 164 | decls = append(decls, gemFunctionDecl{ | 161 | decls = append(decls, gemFunctionDecl{ | ||
| 165 | Name: t.Name, | 162 | Name: t.Name, | ||
| 166 | Description: t.Description, | 163 | Description: t.Description, | ||
| @@ -226,6 +223,46 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 226 | return out, m.ID, nil | 223 | return out, m.ID, nil | ||
| 227 | } | 224 | } | ||
| 228 | 225 | ||||
| 226 | + | func sanitizeGeminiToolSchema(schema json.RawMessage) json.RawMessage { | |||
| 227 | + | if len(schema) == 0 || !json.Valid(schema) { | |||
| 228 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |||
| 229 | + | } | |||
| 230 | + | var v any | |||
| 231 | + | if err := json.Unmarshal(schema, &v); err != nil { | |||
| 232 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |||
| 233 | + | } | |||
| 234 | + | v = stripGeminiUnsupportedSchemaFields(v) | |||
| 235 | + | out, err := json.Marshal(v) | |||
| 236 | + | if err != nil || len(out) == 0 || !json.Valid(out) { | |||
| 237 | + | return json.RawMessage(`{"type":"object","properties":{}}`) | |||
| 238 | + | } | |||
| 239 | + | return out | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func stripGeminiUnsupportedSchemaFields(v any) any { | |||
| 243 | + | switch x := v.(type) { | |||
| 244 | + | case map[string]any: | |||
| 245 | + | out := make(map[string]any, len(x)) | |||
| 246 | + | for k, val := range x { | |||
| 247 | + | switch k { | |||
| 248 | + | case "additionalProperties", "$schema": | |||
| 249 | + | continue | |||
| 250 | + | default: | |||
| 251 | + | out[k] = stripGeminiUnsupportedSchemaFields(val) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return out | |||
| 255 | + | case []any: | |||
| 256 | + | out := make([]any, len(x)) | |||
| 257 | + | for i, val := range x { | |||
| 258 | + | out[i] = stripGeminiUnsupportedSchemaFields(val) | |||
| 259 | + | } | |||
| 260 | + | return out | |||
| 261 | + | default: | |||
| 262 | + | return v | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 229 | func convertGemUserParts(blocks []Content) []gemPart { | 266 | func convertGemUserParts(blocks []Content) []gemPart { | ||
| 230 | var parts []gemPart | 267 | var parts []gemPart | ||
| 231 | for _, b := range blocks { | 268 | for _, b := range blocks { | ||
M internal/provider/gemini_test.go +36 -0
| @@ -206,6 +206,42 @@ func TestGeminiBuildRequestSystemAndTools(t *testing.T) { | |||
| 206 | 206 | ||
| 207 | 207 | // TestGeminiBuildRequestImageModelOmitsTools confirms image-generation | |
| 208 | 208 | // models receive direct multimodal prompts without function declarations. | |
| 209 | + | func TestGeminiBuildRequestStripsUnsupportedSchemaFields(t *testing.T) { | |
| 210 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |
| 211 | + | wire, _, err := c.buildRequest(Request{ | |
| 212 | + | Model: "gemini-2.5-pro", | |
| 213 | + | Tools: []Tool{{ | |
| 214 | + | Name: "edit", | |
| 215 | + | Description: "edit a file", | |
| 216 | + | Schema: json.RawMessage(`{ | |
| 217 | + | "$schema":"http://json-schema.org/draft-07/schema#", | |
| 218 | + | "type":"object", | |
| 219 | + | "additionalProperties":false, | |
| 220 | + | "properties":{ | |
| 221 | + | "edits":{ | |
| 222 | + | "type":"array", | |
| 223 | + | "items":{ | |
| 224 | + | "type":"object", | |
| 225 | + | "additionalProperties":false, | |
| 226 | + | "properties":{"oldText":{"type":"string"},"newText":{"type":"string"}} | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | }`), | |
| 231 | + | }}, | |
| 232 | + | }) | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatal(err) | |
| 235 | + | } | |
| 236 | + | got := string(wire.Tools[0].FunctionDeclarations[0].Parameters) | |
| 237 | + | if strings.Contains(got, "additionalProperties") || strings.Contains(got, "$schema") { | |
| 238 | + | t.Fatalf("Gemini schema should strip unsupported fields, got %s", got) | |
| 239 | + | } | |
| 240 | + | if !strings.Contains(got, `"oldText"`) || !strings.Contains(got, `"newText"`) { | |
| 241 | + | t.Fatalf("Gemini schema lost nested properties, got %s", got) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 209 | 245 | func TestGeminiBuildRequestImageModelOmitsTools(t *testing.T) { | |
| 210 | 246 | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |
| 211 | 247 | wire, _, err := c.buildRequest(Request{ | |
| @@ -206,6 +206,42 @@ func TestGeminiBuildRequestSystemAndTools(t *testing.T) { | |||||
| 206 | 206 | ||||
| 207 | // TestGeminiBuildRequestImageModelOmitsTools confirms image-generation | 207 | // TestGeminiBuildRequestImageModelOmitsTools confirms image-generation | ||
| 208 | // models receive direct multimodal prompts without function declarations. | 208 | // models receive direct multimodal prompts without function declarations. | ||
| 209 | + | func TestGeminiBuildRequestStripsUnsupportedSchemaFields(t *testing.T) { | |||
| 210 | + | c := NewGemini("k", "https://example.invalid").(*geminiClient) | |||
| 211 | + | wire, _, err := c.buildRequest(Request{ | |||
| 212 | + | Model: "gemini-2.5-pro", | |||
| 213 | + | Tools: []Tool{{ | |||
| 214 | + | Name: "edit", | |||
| 215 | + | Description: "edit a file", | |||
| 216 | + | Schema: json.RawMessage(`{ | |||
| 217 | + | "$schema":"http://json-schema.org/draft-07/schema#", | |||
| 218 | + | "type":"object", | |||
| 219 | + | "additionalProperties":false, | |||
| 220 | + | "properties":{ | |||
| 221 | + | "edits":{ | |||
| 222 | + | "type":"array", | |||
| 223 | + | "items":{ | |||
| 224 | + | "type":"object", | |||
| 225 | + | "additionalProperties":false, | |||
| 226 | + | "properties":{"oldText":{"type":"string"},"newText":{"type":"string"}} | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | }`), | |||
| 231 | + | }}, | |||
| 232 | + | }) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatal(err) | |||
| 235 | + | } | |||
| 236 | + | got := string(wire.Tools[0].FunctionDeclarations[0].Parameters) | |||
| 237 | + | if strings.Contains(got, "additionalProperties") || strings.Contains(got, "$schema") { | |||
| 238 | + | t.Fatalf("Gemini schema should strip unsupported fields, got %s", got) | |||
| 239 | + | } | |||
| 240 | + | if !strings.Contains(got, `"oldText"`) || !strings.Contains(got, `"newText"`) { | |||
| 241 | + | t.Fatalf("Gemini schema lost nested properties, got %s", got) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 209 | func TestGeminiBuildRequestImageModelOmitsTools(t *testing.T) { | 245 | func TestGeminiBuildRequestImageModelOmitsTools(t *testing.T) { | ||
| 210 | c := NewGemini("k", "https://example.invalid").(*geminiClient) | 246 | c := NewGemini("k", "https://example.invalid").(*geminiClient) | ||
| 211 | wire, _, err := c.buildRequest(Request{ | 247 | wire, _, err := c.buildRequest(Request{ | ||