Files
new-api-custom/relaykit/relayconvert/internal/shared/gemini/schema.go
T

542 lines
15 KiB
Go

package gemini
import (
"context"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
"anyOf": {},
"default": {},
"description": {},
"enum": {},
"example": {},
"format": {},
"items": {},
"maxItems": {},
"maxLength": {},
"maxProperties": {},
"maximum": {},
"minItems": {},
"minLength": {},
"minProperties": {},
"minimum": {},
"nullable": {},
"pattern": {},
"properties": {},
"propertyOrdering": {},
"required": {},
"title": {},
"type": {},
}
const geminiFunctionSchemaMaxDepth = 64
func CleanFunctionParameters(params interface{}) interface{} {
return cleanGeminiFunctionParametersWithDepth(params, 0)
}
func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) interface{} {
if params == nil {
return nil
}
if depth >= geminiFunctionSchemaMaxDepth {
return cleanGeminiFunctionParametersShallow(params)
}
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
sanitizeGeminiEnum(cleanedMap)
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
cleanedProps := make(map[string]interface{})
for propName, propValue := range props {
cleanedProps[propName] = cleanGeminiFunctionParametersWithDepth(propValue, depth+1)
}
cleanedMap["properties"] = cleanedProps
}
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(items, depth+1)
}
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(itemsArray[0], depth+1)
}
if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
cleanedNested := make([]interface{}, len(nested))
for i, item := range nested {
cleanedNested[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
cleanedMap["anyOf"] = cleanedNested
}
return cleanedMap
case []interface{}:
cleanedArray := make([]interface{}, len(v))
for i, item := range v {
cleanedArray[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
return cleanedArray
default:
return params
}
}
func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
sanitizeGeminiEnum(cleanedMap)
delete(cleanedMap, "properties")
delete(cleanedMap, "items")
delete(cleanedMap, "anyOf")
return cleanedMap
case []interface{}:
return []interface{}{}
default:
return params
}
}
// sanitizeGeminiEnum keeps the enum constraint only when every element is a
// string, the only form Gemini's legacy Schema accepts. Any other shape is
// dropped (e.g. boolean or number elements, which valid JSON Schema allows but
// Gemini rejects with a 400). This widens the value domain for that schema, a
// deliberate lossy relaxation: the declared type is preserved, string enums
// pass through untouched.
func sanitizeGeminiEnum(schema map[string]interface{}) {
raw, exists := schema["enum"]
if !exists {
return
}
values, ok := raw.([]interface{})
if !ok {
delete(schema, "enum")
return
}
for _, value := range values {
if _, isString := value.(string); !isString {
delete(schema, "enum")
return
}
}
}
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
rawType, ok := schema["type"]
if !ok || rawType == nil {
return
}
normalize := func(t string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "object":
return "OBJECT", false
case "array":
return "ARRAY", false
case "string":
return "STRING", false
case "integer":
return "INTEGER", false
case "number":
return "NUMBER", false
case "boolean":
return "BOOLEAN", false
case "null":
return "", true
default:
return t, false
}
}
switch typed := rawType.(type) {
case string:
normalized, isNull := normalize(typed)
if isNull {
schema["nullable"] = true
delete(schema, "type")
return
}
schema["type"] = normalized
case []interface{}:
nullable := false
var chosen string
for _, item := range typed {
if value, ok := item.(string); ok {
normalized, isNull := normalize(value)
if isNull {
nullable = true
continue
}
if chosen == "" {
chosen = normalized
}
}
}
if nullable {
schema["nullable"] = true
}
if chosen != "" {
schema["type"] = chosen
} else {
delete(schema, "type")
}
}
}
// FunctionParameters returns the Gemini function declaration field that
// carries schema, and its value. A schema that fits Gemini's typed Schema is
// cleaned as before, and still left out when its properties are empty. Any
// other schema goes unchanged to parametersJsonSchema when jsonSchema is
// true; otherwise it is cleaned anyway and misfits names what the typed
// Schema cannot carry.
func FunctionParameters(schema any, jsonSchema bool) (field string, value any, misfits []string) {
misfits = schemaMisfits(schema)
object, _ := schema.(map[string]any)
properties, hasProperties := object["properties"].(map[string]any)
emptyObject := hasProperties && len(properties) == 0
if emptyObject {
for key, item := range object {
switch key {
case "type", "properties":
continue
case "required":
if required, _ := item.([]any); len(required) == 0 {
continue
}
}
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; allowed {
misfits = append(misfits, "constraints on an empty object")
break
}
}
}
if len(misfits) > 0 && jsonSchema {
return "parametersJsonSchema", schema, nil
}
if emptyObject {
return "parameters", nil, misfits
}
return "parameters", CleanFunctionParameters(schema), misfits
}
// ApplyResponseSchema sets the structured output schema from the json_schema
// object of an OpenAI response format. A schema that fits Gemini's typed
// Schema keeps the responseSchema cleanup. Any other schema is sent unchanged
// as responseJsonSchema when jsonSchema is true; otherwise it is cleaned
// anyway and a diagnostic at path names what responseSchema cannot carry.
func ApplyResponseSchema(ctx context.Context, config *dto.GeminiChatGenerationConfig, rawFormat json.RawMessage, path string, jsonSchema bool) {
var format dto.FormatJsonSchema
if err := kitutil.Unmarshal(rawFormat, &format); err != nil {
return
}
misfits := schemaMisfits(format.Schema)
if len(misfits) > 0 && jsonSchema {
// The raw member keeps the client's key order.
var raw struct {
Schema json.RawMessage `json:"schema"`
}
if err := kitutil.Unmarshal(rawFormat, &raw); err == nil {
config.ResponseJsonSchema = raw.Schema
return
}
}
config.ResponseSchema = RemoveAdditionalProperties(format.Schema, 0)
if len(misfits) > 0 {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "gemini_schema_keyword_dropped",
Path: path,
Message: fmt.Sprintf("Gemini response schema cannot carry %s", strings.Join(misfits, ", ")),
Severity: types.ConversionDiagnosticWarning,
})
}
}
// schemaMisfits lists what Gemini's typed Schema cannot hold of a JSON Schema
// as written: keywords outside its fields, type arrays, non-string enum
// values, tuple items, boolean schemas, and nesting deeper than
// CleanFunctionParameters keeps. additionalProperties and $schema are not
// misfits: agent tools and OpenAI strict schemas carry them on nearly every
// object, and dropping them as before keeps those requests unchanged for
// upstreams that do not pass the JSON Schema fields through.
func schemaMisfits(schema any) []string {
found := make(map[string]struct{})
collectSchemaMisfits(schema, 0, found)
misfits := make([]string, 0, len(found))
for misfit := range found {
misfits = append(misfits, misfit)
}
slices.Sort(misfits)
return misfits
}
func collectSchemaMisfits(schema any, depth int, found map[string]struct{}) {
if depth > geminiFunctionSchemaMaxDepth {
found[fmt.Sprintf("nesting deeper than %d levels", geminiFunctionSchemaMaxDepth)] = struct{}{}
return
}
value, ok := schema.(map[string]any)
if !ok {
if _, isBool := schema.(bool); isBool {
found["boolean schemas"] = struct{}{}
}
return
}
for key, item := range value {
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; !allowed {
if key != "additionalProperties" && key != "$schema" {
found[key] = struct{}{}
}
continue
}
switch key {
case "type":
if _, isArray := item.([]any); isArray {
found["type arrays"] = struct{}{}
}
case "enum":
values, _ := item.([]any)
if slices.ContainsFunc(values, func(value any) bool {
_, isString := value.(string)
return !isString
}) {
found["non-string enum values"] = struct{}{}
}
case "properties":
properties, _ := item.(map[string]any)
for _, property := range properties {
collectSchemaMisfits(property, depth+1, found)
}
case "items":
entries, isTuple := item.([]any)
if !isTuple {
collectSchemaMisfits(item, depth+1, found)
continue
}
found["tuple items"] = struct{}{}
for _, entry := range entries {
collectSchemaMisfits(entry, depth+1, found)
}
case "anyOf":
variants, _ := item.([]any)
for _, variant := range variants {
collectSchemaMisfits(variant, depth+1, found)
}
}
}
}
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
if depth >= 5 {
return schema
}
value, ok := schema.(map[string]interface{})
if !ok || len(value) == 0 {
return schema
}
delete(value, "title")
delete(value, "$schema")
if typeVal, exists := value["type"]; !exists || (typeVal != "object" && typeVal != "array") {
return schema
}
switch value["type"] {
case "object":
delete(value, "additionalProperties")
if properties, ok := value["properties"].(map[string]interface{}); ok {
for key, nested := range properties {
properties[key] = RemoveAdditionalProperties(nested, depth+1)
}
}
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
if nested, ok := value[field].([]interface{}); ok {
for i, item := range nested {
nested[i] = RemoveAdditionalProperties(item, depth+1)
}
}
}
case "array":
if items, ok := value["items"].(map[string]interface{}); ok {
value["items"] = RemoveAdditionalProperties(items, depth+1)
}
}
return value
}
func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
if toolChoice == nil {
return nil
}
if toolChoiceStr, ok := toolChoice.(string); ok {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{},
}
switch toolChoiceStr {
case "auto":
config.FunctionCallingConfig.Mode = "AUTO"
case "none":
config.FunctionCallingConfig.Mode = "NONE"
case "required":
config.FunctionCallingConfig.Mode = "ANY"
default:
config.FunctionCallingConfig.Mode = "AUTO"
}
return config
}
if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
if toolChoiceMap["type"] == "function" {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{
Mode: "ANY",
},
}
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if name, ok := function["name"].(string); ok && name != "" {
config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
}
}
return config
}
return nil
}
return nil
}
// geminiSchemaInt64Fields are Gemini Schema bounds typed int64, which the
// proto JSON encoding may send as strings.
var geminiSchemaInt64Fields = map[string]struct{}{
"maxItems": {},
"minItems": {},
"maxLength": {},
"minLength": {},
"maxProperties": {},
"minProperties": {},
}
// OpenAPISchemaToJSONSchema rewrites a schema in Gemini's OpenAPI subset as
// JSON Schema: type names are lowercased, nullable becomes a "null" type,
// example becomes examples, and int64 bounds sent as strings become numbers.
// It also returns the Gemini-only keywords JSON Schema cannot express, which
// are dropped (propertyOrdering).
func OpenAPISchemaToJSONSchema(schema any) (any, []string) {
dropped := make(map[string]struct{})
converted := openAPISchemaToJSONSchema(schema, 0, dropped)
keywords := make([]string, 0, len(dropped))
for keyword := range dropped {
keywords = append(keywords, keyword)
}
slices.Sort(keywords)
return converted, keywords
}
func openAPISchemaToJSONSchema(schema any, depth int, dropped map[string]struct{}) any {
value, ok := schema.(map[string]any)
if !ok || depth >= geminiFunctionSchemaMaxDepth {
return schema
}
converted := make(map[string]any, len(value))
nullable := false
for key, item := range value {
switch key {
case "type":
typeName, isString := item.(string)
if !isString {
converted[key] = item
continue
}
typeName = strings.ToLower(strings.TrimSpace(typeName))
if typeName != "" && typeName != "type_unspecified" {
converted[key] = typeName
}
case "nullable":
nullable, _ = item.(bool)
case "propertyOrdering":
dropped[key] = struct{}{}
case "example":
if _, exists := value["examples"]; exists {
dropped[key] = struct{}{}
continue
}
converted["examples"] = []any{item}
case "properties":
properties, isMap := item.(map[string]any)
if !isMap {
converted[key] = item
continue
}
convertedProperties := make(map[string]any, len(properties))
for name, property := range properties {
convertedProperties[name] = openAPISchemaToJSONSchema(property, depth+1, dropped)
}
converted[key] = convertedProperties
case "items":
converted[key] = openAPISchemaToJSONSchema(item, depth+1, dropped)
case "anyOf":
variants, isArray := item.([]any)
if !isArray {
converted[key] = item
continue
}
convertedVariants := make([]any, len(variants))
for index, variant := range variants {
convertedVariants[index] = openAPISchemaToJSONSchema(variant, depth+1, dropped)
}
converted[key] = convertedVariants
default:
if _, isInt64 := geminiSchemaInt64Fields[key]; isInt64 {
if text, isString := item.(string); isString {
if number, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64); err == nil {
converted[key] = number
continue
}
}
}
converted[key] = item
}
}
if !nullable {
return converted
}
switch typed := converted["type"].(type) {
case string:
converted["type"] = []any{typed, "null"}
default:
if variants, isArray := converted["anyOf"].([]any); isArray {
converted["anyOf"] = append(variants, map[string]any{"type": "null"})
}
}
if values, isArray := converted["enum"].([]any); isArray && !slices.Contains(values, nil) {
converted["enum"] = append(values, nil)
}
return converted
}