Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 3 additions & 76 deletions backend/internal/infra/llm/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/infra/llm/gemini_interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions backend/internal/infra/llm/gemini_interactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 45 additions & 27 deletions backend/internal/infra/llm/request_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand All @@ -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)
}
}

Expand Down
Loading