diff --git a/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go new file mode 100644 index 00000000000..a1691beb130 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go @@ -0,0 +1,41 @@ +package oaichat + +import ( + "context" + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIChatRequestToGeminiNormalizesNullableToolParameter(t *testing.T) { + got, err := OpenAIChatRequestToGeminiGenerateContent(context.Background(), dto.GeneralOpenAIRequest{ + Model: "gemini-test", + Tools: []dto.ToolCallRequest{{ + Type: "function", + Function: dto.FunctionRequest{ + Name: "update_task", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "due_date": map[string]interface{}{ + "anyOf": []interface{}{ + map[string]interface{}{"type": "string"}, + map[string]interface{}{"type": "null"}, + }, + }, + }, + }, + }, + }}, + }, &convmeta.Values{}) + require.NoError(t, err) + + path := "0.functionDeclarations.0.parameters.properties.due_date" + assert.Equal(t, "STRING", gjson.GetBytes(got.Tools, path+".type").String()) + assert.True(t, gjson.GetBytes(got.Tools, path+".nullable").Bool()) + assert.False(t, gjson.GetBytes(got.Tools, path+".anyOf").Exists()) +} diff --git a/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req_test.go b/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req_test.go new file mode 100644 index 00000000000..029d77c6323 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/to_gemini_chat_req_test.go @@ -0,0 +1,27 @@ +package oairesponses + +import ( + "context" + "encoding/json" + "testing" + + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIResponsesRequestToGeminiNormalizesLiteralToolUnion(t *testing.T) { + got, err := OpenAIResponsesRequestToGeminiChat(context.Background(), &dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Tools: json.RawMessage(`[{"type":"function","name":"update_task","parameters":{"type":"object","properties":{"status":{"anyOf":[{"type":"string","const":"open"},{"type":"string","const":"completed"}]}}}}]`), + }, &convmeta.Values{}) + require.NoError(t, err) + + path := "0.functionDeclarations.0.parameters.properties.status" + assert.Equal(t, "STRING", gjson.GetBytes(got.Tools, path+".type").String()) + assert.Equal(t, "open", gjson.GetBytes(got.Tools, path+".enum.0").String()) + assert.Equal(t, "completed", gjson.GetBytes(got.Tools, path+".enum.1").String()) + assert.False(t, gjson.GetBytes(got.Tools, path+".anyOf").Exists()) +} diff --git a/relaykit/relayconvert/internal/shared/gemini/schema.go b/relaykit/relayconvert/internal/shared/gemini/schema.go index 75cc503473d..3c67e2ebba4 100644 --- a/relaykit/relayconvert/internal/shared/gemini/schema.go +++ b/relaykit/relayconvert/internal/shared/gemini/schema.go @@ -8,6 +8,7 @@ import ( var geminiOpenAPISchemaAllowedFields = map[string]struct{}{ "anyOf": {}, + "const": {}, "default": {}, "description": {}, "enum": {}, @@ -80,6 +81,10 @@ func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) inter cleanedMap["anyOf"] = cleanedNested } + normalizeGeminiSchemaConst(cleanedMap) + normalizeGeminiSchemaAnyOf(cleanedMap) + delete(cleanedMap, "const") + return cleanedMap case []interface{}: cleanedArray := make([]interface{}, len(v)) @@ -102,9 +107,11 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} { } } normalizeGeminiSchemaTypeAndNullable(cleanedMap) + normalizeGeminiSchemaConst(cleanedMap) delete(cleanedMap, "properties") delete(cleanedMap, "items") delete(cleanedMap, "anyOf") + delete(cleanedMap, "const") return cleanedMap case []interface{}: return []interface{}{} @@ -113,6 +120,114 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} { } } +func normalizeGeminiSchemaConst(schema map[string]interface{}) { + constValue, ok := schema["const"] + if !ok { + return + } + if _, hasEnum := schema["enum"]; !hasEnum { + schema["enum"] = []interface{}{constValue} + } +} + +func normalizeGeminiSchemaAnyOf(schema map[string]interface{}) { + branches, ok := schema["anyOf"].([]interface{}) + if !ok || len(branches) == 0 { + return + } + + concreteBranches := make([]map[string]interface{}, 0, len(branches)) + nullable := false + for _, branch := range branches { + branchSchema, ok := branch.(map[string]interface{}) + if !ok { + return + } + if isGeminiNullSchema(branchSchema) { + nullable = true + continue + } + concreteBranches = append(concreteBranches, branchSchema) + } + + if len(concreteBranches) == 1 && nullable { + mergeGeminiSchema(schema, concreteBranches[0]) + schema["nullable"] = true + delete(schema, "anyOf") + return + } + + commonType, sameType := commonGeminiSchemaType(concreteBranches) + if sameType { + if _, hasType := schema["type"]; !hasType { + schema["type"] = commonType + } + } + if nullable { + schema["nullable"] = true + } + + values, enumOnly := geminiEnumUnionValues(concreteBranches) + if sameType && enumOnly { + schema["enum"] = values + delete(schema, "anyOf") + } +} + +func isGeminiNullSchema(schema map[string]interface{}) bool { + if nullable, ok := schema["nullable"].(bool); !ok || !nullable { + return false + } + for key := range schema { + if key != "nullable" { + return false + } + } + return true +} + +func mergeGeminiSchema(target map[string]interface{}, source map[string]interface{}) { + for key, value := range source { + if _, exists := target[key]; !exists { + target[key] = value + } + } +} + +func commonGeminiSchemaType(branches []map[string]interface{}) (string, bool) { + if len(branches) == 0 { + return "", false + } + commonType, ok := branches[0]["type"].(string) + if !ok || commonType == "" { + return "", false + } + for _, branch := range branches[1:] { + branchType, ok := branch["type"].(string) + if !ok || branchType != commonType { + return "", false + } + } + return commonType, true +} + +func geminiEnumUnionValues(branches []map[string]interface{}) ([]interface{}, bool) { + values := make([]interface{}, 0, len(branches)) + for _, branch := range branches { + for key := range branch { + if key != "type" && key != "enum" { + return nil, false + } + } + branchValues, ok := branch["enum"].([]interface{}) + if !ok || len(branchValues) == 0 { + return nil, false + } + values = append(values, branchValues...) + } + return values, true +} + func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) { rawType, ok := schema["type"] if !ok || rawType == nil { diff --git a/relaykit/relayconvert/internal/shared/gemini/schema_test.go b/relaykit/relayconvert/internal/shared/gemini/schema_test.go new file mode 100644 index 00000000000..9c939b28234 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/gemini/schema_test.go @@ -0,0 +1,53 @@ +package gemini + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanFunctionParametersNormalizesNullableUnion(t *testing.T) { + cleaned := CleanFunctionParameters(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "due_date": map[string]interface{}{ + "description": "Optional due date", + "anyOf": []interface{}{ + map[string]interface{}{"type": "string"}, + map[string]interface{}{"type": "null"}, + }, + }, + }, + }) + + root, ok := cleaned.(map[string]interface{}) + require.True(t, ok) + properties, ok := root["properties"].(map[string]interface{}) + require.True(t, ok) + dueDate, ok := properties["due_date"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "OBJECT", root["type"]) + assert.Equal(t, "STRING", dueDate["type"]) + assert.Equal(t, true, dueDate["nullable"]) + assert.Equal(t, "Optional due date", dueDate["description"]) + assert.NotContains(t, dueDate, "anyOf") +} + +func TestCleanFunctionParametersNormalizesLiteralUnion(t *testing.T) { + cleaned := CleanFunctionParameters(map[string]interface{}{ + "anyOf": []interface{}{ + map[string]interface{}{"type": "string", "const": "open"}, + map[string]interface{}{"type": "string", "const": "completed"}, + }, + }) + + schema, ok := cleaned.(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "STRING", schema["type"]) + assert.Equal(t, []interface{}{"open", "completed"}, schema["enum"]) + assert.NotContains(t, schema, "anyOf") + assert.NotContains(t, schema, "const") +}