detached 583b2a7

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) {
157157 if functionsEnabled && len(req.Tools) > 0 {
158158 decls := make([]gemFunctionDecl, 0, len(req.Tools))
159159 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)
164161 decls = append(decls, gemFunctionDecl{
165162 Name: t.Name,
166163 Description: t.Description,
@@ -226,6 +223,46 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
226223 return out, m.ID, nil
227224 }
228225
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+
229266 func convertGemUserParts(blocks []Content) []gemPart {
230267 var parts []gemPart
231268 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.Schema160+ 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, nil223 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 []gemPart267 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) {
206206
207207 // TestGeminiBuildRequestImageModelOmitsTools confirms image-generation
208208 // 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+
209245 func TestGeminiBuildRequestImageModelOmitsTools(t *testing.T) {
210246 c := NewGemini("k", "https://example.invalid").(*geminiClient)
211247 wire, _, err := c.buildRequest(Request{
@@ -206,6 +206,42 @@ func TestGeminiBuildRequestSystemAndTools(t *testing.T) {
206 206
207 // TestGeminiBuildRequestImageModelOmitsTools confirms image-generation207 // 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{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close