diff --git a/backend/internal/infra/llm/gemini.go b/backend/internal/infra/llm/gemini.go index bddcc958..7fec9d49 100644 --- a/backend/internal/infra/llm/gemini.go +++ b/backend/internal/infra/llm/gemini.go @@ -519,9 +519,9 @@ func buildGeminiTools(tools []ToolDefinition) []map[string]interface{} { continue } declarations = append(declarations, map[string]interface{}{ - "name": name, - "description": strings.TrimSpace(tool.Description), - "parameters": geminiToolParameterSchema(decodeToolSchema(tool.InputSchema)), + "name": name, + "description": strings.TrimSpace(tool.Description), + "parametersJsonSchema": decodeToolSchema(tool.InputSchema), }) } if len(declarations) == 0 { @@ -530,79 +530,6 @@ func buildGeminiTools(tools []ToolDefinition) []map[string]interface{} { return []map[string]interface{}{{"functionDeclarations": declarations}} } -func geminiToolParameterSchema(schema map[string]interface{}) map[string]interface{} { - if len(schema) == 0 { - return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} - } - normalized := sanitizeGeminiSchema(schema) - if strings.TrimSpace(getString(normalized["type"])) == "" { - normalized["type"] = "object" - } - if _, ok := normalized["properties"]; !ok && strings.EqualFold(getString(normalized["type"]), "object") { - normalized["properties"] = map[string]interface{}{} - } - return normalized -} - -func sanitizeGeminiSchema(schema map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}, len(schema)) - for key, value := range schema { - switch key { - case "type", "format", "title", "description", "nullable", "enum", "required", "propertyOrdering": - if !isEmptyGeminiPayloadValue(value) { - result[key] = value - } - case "properties": - properties := sanitizeGeminiSchemaProperties(asMap(value)) - if len(properties) > 0 { - result[key] = properties - } - case "items": - itemSchema := sanitizeGeminiSchema(asMap(value)) - if len(itemSchema) > 0 { - result[key] = itemSchema - } - case "anyOf": - anyOf := sanitizeGeminiSchemaList(asSlice(value)) - if len(anyOf) > 0 { - result[key] = anyOf - } - case "minItems", "maxItems": - result[key] = value - } - } - return result -} - -func sanitizeGeminiSchemaProperties(properties map[string]interface{}) map[string]interface{} { - if len(properties) == 0 { - return nil - } - result := make(map[string]interface{}, len(properties)) - for name, raw := range properties { - property := sanitizeGeminiSchema(asMap(raw)) - if len(property) == 0 { - continue - } - result[name] = property - } - return result -} - -func sanitizeGeminiSchemaList(items []interface{}) []interface{} { - if len(items) == 0 { - return nil - } - result := make([]interface{}, 0, len(items)) - for _, raw := range items { - item := sanitizeGeminiSchema(asMap(raw)) - if len(item) > 0 { - result = append(result, item) - } - } - return result -} - func buildGeminiProviderTools(tools []map[string]interface{}) []map[string]interface{} { if len(tools) == 0 { return nil diff --git a/backend/internal/infra/llm/gemini_interactions.go b/backend/internal/infra/llm/gemini_interactions.go index d791a8be..995f3200 100644 --- a/backend/internal/infra/llm/gemini_interactions.go +++ b/backend/internal/infra/llm/gemini_interactions.go @@ -577,7 +577,7 @@ func buildGeminiInteractionTools(tools []ToolDefinition) []map[string]interface{ "type": "function", "name": name, "description": strings.TrimSpace(tool.Description), - "parameters": geminiToolParameterSchema(decodeToolSchema(tool.InputSchema)), + "parameters": decodeToolSchema(tool.InputSchema), }) } return items diff --git a/backend/internal/infra/llm/gemini_interactions_test.go b/backend/internal/infra/llm/gemini_interactions_test.go index 576afaaf..6189eeeb 100644 --- a/backend/internal/infra/llm/gemini_interactions_test.go +++ b/backend/internal/infra/llm/gemini_interactions_test.go @@ -193,6 +193,52 @@ func TestBuildGeminiInteractionRequestBodySupportsUniversalOptionsAndTools(t *te } } +func TestBuildGeminiInteractionToolsPreservesJSONSchemaReferences(t *testing.T) { + payload, err := buildGeminiInteractionRequestBody(RouteConfig{ + Endpoint: EndpointInteractions, + UpstreamModel: "gemini-3-flash-preview", + }, GenerateInput{ + Messages: []Message{{Role: "user", Content: "Run the workflow."}}, + Tools: []ToolDefinition{{ + Name: "run_workflow", + Description: "Runs a workflow.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "headers": {"type": "object"}, + "actions": {"type": "array", "items": {"$ref": "#/properties/headers"}}, + "parser": {"anyOf": [{"$ref": "#/$defs/parser"}, {"type": "null"}]} + }, + "$defs": {"parser": {"type": "object"}}, + "required": ["actions"] + }`), + }}, + }) + if err != nil { + t.Fatalf("build Gemini interaction request body: %v", err) + } + + tools, ok := payload["tools"].([]map[string]interface{}) + if !ok || len(tools) != 1 { + t.Fatalf("expected one Interactions tool, got %#v", payload["tools"]) + } + parameters, ok := tools[0]["parameters"].(map[string]interface{}) + if !ok { + t.Fatalf("expected native JSON Schema parameters, got %#v", tools[0]) + } + properties := asMap(parameters["properties"]) + if asMap(asMap(properties["actions"])["items"])["$ref"] != "#/properties/headers" { + t.Fatalf("expected array item reference to be preserved, got %#v", properties["actions"]) + } + anyOf := asSlice(asMap(properties["parser"])["anyOf"]) + if len(anyOf) != 2 || asMap(anyOf[0])["$ref"] != "#/$defs/parser" { + t.Fatalf("expected anyOf reference to be preserved, got %#v", anyOf) + } + if asMap(asMap(parameters["$defs"])["parser"])["type"] != "object" { + t.Fatalf("expected JSON Schema definitions to be preserved, got %#v", parameters["$defs"]) + } +} + func TestBuildGeminiInteractionRequestBodyAcceptsTypedResponseFormatList(t *testing.T) { payload, err := buildGeminiInteractionRequestBody(RouteConfig{ Endpoint: EndpointInteractions, diff --git a/backend/internal/infra/llm/request_tools_test.go b/backend/internal/infra/llm/request_tools_test.go index 4c5b24ac..25332981 100644 --- a/backend/internal/infra/llm/request_tools_test.go +++ b/backend/internal/infra/llm/request_tools_test.go @@ -547,25 +547,37 @@ func TestBuildGeminiToolsPreservesExplicitToolConfigWhenMixedTools(t *testing.T) } } -func TestBuildGeminiToolsSanitizesJSONSchemaForFunctionDeclarations(t *testing.T) { +func TestBuildGeminiToolsPreservesJSONSchemaForFunctionDeclarations(t *testing.T) { schema := json.RawMessage(`{ - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "type": "object", "properties": { - "query": { - "anyOf": [ - {"type": "string", "default": ""}, - {"type": "array", "items": {"type": "string", "additionalProperties": false}} - ], - "description": "Search terms" + "headers": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "actions": { + "type": "array", + "items": {"$ref": "#/properties/headers"} }, - "num": { - "type": "number", - "default": 30 + "request": {"$ref": "#/$defs/request"}, + "parser": { + "anyOf": [ + {"$ref": "#/$defs/parser"}, + {"type": "null"} + ] } }, - "required": ["query"] + "$defs": { + "request": { + "type": "object", + "properties": {"url": {"type": "string", "format": "uri"}}, + "required": ["url"] + }, + "parser": {"type": "object"} + }, + "required": ["actions"] }`) payload := mustBuildGeminiRequestBody(t, GenerateInput{ Messages: []Message{{Role: "user", Content: "search"}}, @@ -578,26 +590,32 @@ func TestBuildGeminiToolsSanitizesJSONSchemaForFunctionDeclarations(t *testing.T tools := payload["tools"].([]map[string]interface{}) declarations := tools[0]["functionDeclarations"].([]map[string]interface{}) - parameters := declarations[0]["parameters"].(map[string]interface{}) - if _, ok := parameters["$schema"]; ok { - t.Fatalf("expected $schema to be removed for Gemini, got %#v", parameters) + declaration := declarations[0] + if _, ok := declaration["parameters"]; ok { + t.Fatalf("expected Generate Content to use parametersJsonSchema, got %#v", declaration) + } + parameters, ok := declaration["parametersJsonSchema"].(map[string]interface{}) + if !ok { + t.Fatalf("expected native JSON Schema parameters, got %#v", declaration) } - if _, ok := parameters["additionalProperties"]; ok { - t.Fatalf("expected additionalProperties to be removed for Gemini, got %#v", parameters) + if parameters["$schema"] != "https://json-schema.org/draft/2020-12/schema" || parameters["additionalProperties"] != false { + t.Fatalf("expected root JSON Schema fields to be preserved, got %#v", parameters) + } + definitions := asMap(parameters["$defs"]) + if asMap(definitions["request"])["type"] != "object" || asMap(definitions["parser"])["type"] != "object" { + t.Fatalf("expected JSON Schema definitions to be preserved, got %#v", definitions) } properties := parameters["properties"].(map[string]interface{}) - query := properties["query"].(map[string]interface{}) - anyOf := query["anyOf"].([]interface{}) - if _, ok := anyOf[0].(map[string]interface{})["default"]; ok { - t.Fatalf("expected nested default to be removed for Gemini, got %#v", anyOf[0]) + actions := asMap(properties["actions"]) + if asMap(actions["items"])["$ref"] != "#/properties/headers" { + t.Fatalf("expected array item reference to be preserved, got %#v", actions) } - arraySchema := anyOf[1].(map[string]interface{}) - items := arraySchema["items"].(map[string]interface{}) - if _, ok := items["additionalProperties"]; ok { - t.Fatalf("expected nested additionalProperties to be removed for Gemini, got %#v", items) + if asMap(properties["request"])["$ref"] != "#/$defs/request" { + t.Fatalf("expected property reference to be preserved, got %#v", properties["request"]) } - if parameters["type"] != "object" || len(parameters["required"].([]interface{})) != 1 { - t.Fatalf("expected supported schema fields to remain, got %#v", parameters) + anyOf := asSlice(asMap(properties["parser"])["anyOf"]) + if len(anyOf) != 2 || asMap(anyOf[0])["$ref"] != "#/$defs/parser" { + t.Fatalf("expected anyOf reference to be preserved, got %#v", anyOf) } }