Skip to content
Open
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
41 changes: 41 additions & 0 deletions relaykit/relayconvert/internal/oai_chat/to_gemini_chat_req_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Original file line number Diff line number Diff line change
@@ -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())
}
115 changes: 115 additions & 0 deletions relaykit/relayconvert/internal/shared/gemini/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
"anyOf": {},
"const": {},
"default": {},
"description": {},
"enum": {},
Expand Down Expand Up @@ -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))
Expand All @@ -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{}{}
Expand All @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions relaykit/relayconvert/internal/shared/gemini/schema_test.go
Original file line number Diff line number Diff line change
@@ -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")
}