From 166d2d24d9bdb9632591f2397e75bb9851a1be90 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 11 Feb 2026 18:29:17 +1100 Subject: [PATCH] fix(schema): remove Gemini-incompatible tool metadata fields Sanitize tool schemas by stripping prefill, enumTitles, $id, and patternProperties to prevent Gemini INVALID_ARGUMENT 400 errors, and add unit and executor-level tests to lock in the behavior. Co-Authored-By: Claude Opus 4.6 --- .../antigravity_executor_buildrequest_test.go | 159 ++++++++++++++++++ internal/util/gemini_schema.go | 5 +- internal/util/gemini_schema_test.go | 51 ++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 internal/runtime/executor/antigravity_executor_buildrequest_test.go diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go new file mode 100644 index 00000000..c5cba4ee --- /dev/null +++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go @@ -0,0 +1,159 @@ +package executor + +import ( + "context" + "encoding/json" + "io" + "testing" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) { + body := buildRequestBodyFromPayload(t, "gemini-2.5-pro") + + decl := extractFirstFunctionDeclaration(t, body) + if _, ok := decl["parametersJsonSchema"]; ok { + t.Fatalf("parametersJsonSchema should be renamed to parameters") + } + + params, ok := decl["parameters"].(map[string]any) + if !ok { + t.Fatalf("parameters missing or invalid type") + } + assertSchemaSanitizedAndPropertyPreserved(t, params) +} + +func TestAntigravityBuildRequest_SanitizesAntigravityToolSchema(t *testing.T) { + body := buildRequestBodyFromPayload(t, "claude-opus-4-6") + + decl := extractFirstFunctionDeclaration(t, body) + params, ok := decl["parameters"].(map[string]any) + if !ok { + t.Fatalf("parameters missing or invalid type") + } + assertSchemaSanitizedAndPropertyPreserved(t, params) +} + +func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any { + t.Helper() + + executor := &AntigravityExecutor{} + auth := &cliproxyauth.Auth{} + payload := []byte(`{ + "request": { + "tools": [ + { + "function_declarations": [ + { + "name": "tool_1", + "parametersJsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "root-schema", + "type": "object", + "properties": { + "$id": {"type": "string"}, + "arg": { + "type": "object", + "prefill": "hello", + "properties": { + "mode": { + "type": "string", + "enum": ["a", "b"], + "enumTitles": ["A", "B"] + } + } + } + }, + "patternProperties": { + "^x-": {"type": "string"} + } + } + } + ] + } + ] + } + }`) + + req, err := executor.buildRequest(context.Background(), auth, "token", modelName, payload, false, "", "https://example.com") + if err != nil { + t.Fatalf("buildRequest error: %v", err) + } + + raw, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read request body error: %v", err) + } + + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("unmarshal request body error: %v, body=%s", err, string(raw)) + } + return body +} + +func extractFirstFunctionDeclaration(t *testing.T, body map[string]any) map[string]any { + t.Helper() + + request, ok := body["request"].(map[string]any) + if !ok { + t.Fatalf("request missing or invalid type") + } + tools, ok := request["tools"].([]any) + if !ok || len(tools) == 0 { + t.Fatalf("tools missing or empty") + } + tool, ok := tools[0].(map[string]any) + if !ok { + t.Fatalf("first tool invalid type") + } + decls, ok := tool["function_declarations"].([]any) + if !ok || len(decls) == 0 { + t.Fatalf("function_declarations missing or empty") + } + decl, ok := decls[0].(map[string]any) + if !ok { + t.Fatalf("first function declaration invalid type") + } + return decl +} + +func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]any) { + t.Helper() + + if _, ok := params["$id"]; ok { + t.Fatalf("root $id should be removed from schema") + } + if _, ok := params["patternProperties"]; ok { + t.Fatalf("patternProperties should be removed from schema") + } + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatalf("properties missing or invalid type") + } + if _, ok := props["$id"]; !ok { + t.Fatalf("property named $id should be preserved") + } + + arg, ok := props["arg"].(map[string]any) + if !ok { + t.Fatalf("arg property missing or invalid type") + } + if _, ok := arg["prefill"]; ok { + t.Fatalf("prefill should be removed from nested schema") + } + + argProps, ok := arg["properties"].(map[string]any) + if !ok { + t.Fatalf("arg.properties missing or invalid type") + } + mode, ok := argProps["mode"].(map[string]any) + if !ok { + t.Fatalf("mode property missing or invalid type") + } + if _, ok := mode["enumTitles"]; ok { + t.Fatalf("enumTitles should be removed from nested schema") + } +} diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index e74d1271..b8d07bf4 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -428,8 +428,9 @@ func flattenTypeArrays(jsonStr string) string { func removeUnsupportedKeywords(jsonStr string) string { keywords := append(unsupportedConstraints, - "$schema", "$defs", "definitions", "const", "$ref", "additionalProperties", - "propertyNames", // Gemini doesn't support property name validation + "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", + "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords + "enumTitles", "prefill", // Claude/OpenCode schema metadata fields unsupported by Gemini ) deletePaths := make([]string, 0) diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index ea63d111..bb06e956 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -870,6 +870,57 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { } } +func TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) { + input := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "root-schema", + "type": "object", + "properties": { + "payload": { + "type": "object", + "prefill": "hello", + "properties": { + "mode": { + "type": "string", + "enum": ["a", "b"], + "enumTitles": ["A", "B"] + } + }, + "patternProperties": { + "^x-": {"type": "string"} + } + }, + "$id": { + "type": "string", + "description": "property name should not be removed" + } + } + }` + + expected := `{ + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["a", "b"], + "description": "Allowed: a, b" + } + } + }, + "$id": { + "type": "string", + "description": "property name should not be removed" + } + } + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + func TestRemoveExtensionFields(t *testing.T) { tests := []struct { name string