mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 12:20:52 +08:00
test(gemini): add test cases and improve compatibility for complex schema cases in CleanJSONSchemaForGemini function
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
||||||
|
|
||||||
// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API.
|
// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API.
|
||||||
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||||
// semantic information as description hints.
|
// semantic information as description hints.
|
||||||
@@ -47,11 +49,12 @@ func convertRefsToHints(jsonStr string) string {
|
|||||||
|
|
||||||
parentPath := trimSuffix(p, ".$ref")
|
parentPath := trimSuffix(p, ".$ref")
|
||||||
hint := fmt.Sprintf("See: %s", defName)
|
hint := fmt.Sprintf("See: %s", defName)
|
||||||
if existing := gjson.Get(jsonStr, parentPath+".description").String(); existing != "" {
|
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
|
||||||
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
replacement := fmt.Sprintf(`{"type":"object","description":"%s"}`, hint)
|
replacement := `{"type":"object","description":""}`
|
||||||
|
replacement, _ = sjson.Set(replacement, "description", hint)
|
||||||
jsonStr = setRawAt(jsonStr, parentPath, replacement)
|
jsonStr = setRawAt(jsonStr, parentPath, replacement)
|
||||||
}
|
}
|
||||||
return jsonStr
|
return jsonStr
|
||||||
@@ -136,7 +139,7 @@ func mergeAllOf(jsonStr string) string {
|
|||||||
for _, item := range allOf.Array() {
|
for _, item := range allOf.Array() {
|
||||||
if props := item.Get("properties"); props.IsObject() {
|
if props := item.Get("properties"); props.IsObject() {
|
||||||
props.ForEach(func(key, value gjson.Result) bool {
|
props.ForEach(func(key, value gjson.Result) bool {
|
||||||
destPath := joinPath(parentPath, "properties."+key.String())
|
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
|
||||||
jsonStr, _ = sjson.SetRaw(jsonStr, destPath, value.Raw)
|
jsonStr, _ = sjson.SetRaw(jsonStr, destPath, value.Raw)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -168,16 +171,23 @@ func flattenAnyOfOneOf(jsonStr string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parentPath := trimSuffix(p, "."+key)
|
||||||
|
parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
|
||||||
|
|
||||||
items := arr.Array()
|
items := arr.Array()
|
||||||
bestIdx, allTypes := selectBest(items)
|
bestIdx, allTypes := selectBest(items)
|
||||||
selected := items[bestIdx].Raw
|
selected := items[bestIdx].Raw
|
||||||
|
|
||||||
|
if parentDesc != "" {
|
||||||
|
selected = mergeDescriptionRaw(selected, parentDesc)
|
||||||
|
}
|
||||||
|
|
||||||
if len(allTypes) > 1 {
|
if len(allTypes) > 1 {
|
||||||
hint := "Accepts: " + strings.Join(allTypes, " | ")
|
hint := "Accepts: " + strings.Join(allTypes, " | ")
|
||||||
selected = appendHintRaw(selected, hint)
|
selected = appendHintRaw(selected, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonStr = setRawAt(jsonStr, trimSuffix(p, "."+key), selected)
|
jsonStr = setRawAt(jsonStr, parentPath, selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return jsonStr
|
return jsonStr
|
||||||
@@ -247,13 +257,14 @@ func flattenTypeArrays(jsonStr string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hasNull {
|
if hasNull {
|
||||||
parts := strings.Split(p, ".")
|
parts := splitGJSONPath(p)
|
||||||
if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
|
if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
|
||||||
fieldName := parts[len(parts)-2]
|
fieldNameEscaped := parts[len(parts)-2]
|
||||||
|
fieldName := unescapeGJSONPathKey(fieldNameEscaped)
|
||||||
objectPath := strings.Join(parts[:len(parts)-3], ".")
|
objectPath := strings.Join(parts[:len(parts)-3], ".")
|
||||||
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
|
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
|
||||||
|
|
||||||
propPath := joinPath(objectPath, "properties."+fieldName)
|
propPath := joinPath(objectPath, "properties."+fieldNameEscaped)
|
||||||
jsonStr = appendHint(jsonStr, propPath, "(nullable)")
|
jsonStr = appendHint(jsonStr, propPath, "(nullable)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,8 +321,9 @@ func cleanupRequiredFields(jsonStr string) string {
|
|||||||
|
|
||||||
var valid []string
|
var valid []string
|
||||||
for _, r := range req.Array() {
|
for _, r := range req.Array() {
|
||||||
if props.Get(r.String()).Exists() {
|
key := r.String()
|
||||||
valid = append(valid, r.String())
|
if props.Get(escapeGJSONPathKey(key)).Exists() {
|
||||||
|
valid = append(valid, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +376,13 @@ func isPropertyDefinition(path string) bool {
|
|||||||
return path == "properties" || strings.HasSuffix(path, ".properties")
|
return path == "properties" || strings.HasSuffix(path, ".properties")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func descriptionPath(parentPath string) string {
|
||||||
|
if parentPath == "" || parentPath == "@this" {
|
||||||
|
return "description"
|
||||||
|
}
|
||||||
|
return parentPath + ".description"
|
||||||
|
}
|
||||||
|
|
||||||
func appendHint(jsonStr, parentPath, hint string) string {
|
func appendHint(jsonStr, parentPath, hint string) string {
|
||||||
descPath := parentPath + ".description"
|
descPath := parentPath + ".description"
|
||||||
if parentPath == "" || parentPath == "@this" {
|
if parentPath == "" || parentPath == "@this" {
|
||||||
@@ -411,3 +430,67 @@ func orDefault(val, def string) string {
|
|||||||
}
|
}
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func escapeGJSONPathKey(key string) string {
|
||||||
|
return gjsonPathKeyReplacer.Replace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeGJSONPathKey(key string) string {
|
||||||
|
if !strings.Contains(key, "\\") {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(key))
|
||||||
|
for i := 0; i < len(key); i++ {
|
||||||
|
if key[i] == '\\' && i+1 < len(key) {
|
||||||
|
i++
|
||||||
|
b.WriteByte(key[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(key[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitGJSONPath(path string) []string {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, strings.Count(path, ".")+1)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(path))
|
||||||
|
|
||||||
|
for i := 0; i < len(path); i++ {
|
||||||
|
c := path[i]
|
||||||
|
if c == '\\' && i+1 < len(path) {
|
||||||
|
b.WriteByte('\\')
|
||||||
|
i++
|
||||||
|
b.WriteByte(path[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '.' {
|
||||||
|
parts = append(parts, b.String())
|
||||||
|
b.Reset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
parts = append(parts, b.String())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeDescriptionRaw(schemaRaw, parentDesc string) string {
|
||||||
|
childDesc := gjson.Get(schemaRaw, "description").String()
|
||||||
|
switch {
|
||||||
|
case childDesc == "":
|
||||||
|
schemaRaw, _ = sjson.Set(schemaRaw, "description", parentDesc)
|
||||||
|
return schemaRaw
|
||||||
|
case childDesc == parentDesc:
|
||||||
|
return schemaRaw
|
||||||
|
default:
|
||||||
|
combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc)
|
||||||
|
schemaRaw, _ = sjson.Set(schemaRaw, "description", combined)
|
||||||
|
return schemaRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -224,6 +224,39 @@ func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) {
|
|||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"definitions": {
|
||||||
|
"User": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"customer": {
|
||||||
|
"description": "He said \"hi\"\\nsecond line",
|
||||||
|
"$ref": "#/definitions/User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"customer": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "He said \"hi\"\\nsecond line (See: User)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -275,6 +308,38 @@ func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) {
|
|||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"my.param": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["my.param"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"b": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["b"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"my.param": { "type": "string" },
|
||||||
|
"b": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["my.param", "b"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
||||||
// A tool has an argument named "pattern" - should NOT be treated as a constraint
|
// A tool has an argument named "pattern" - should NOT be treated as a constraint
|
||||||
input := `{
|
input := `{
|
||||||
@@ -395,6 +460,38 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"my.param": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["my.param", "other"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"my.param": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "(nullable)"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["other"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -433,6 +530,34 @@ func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"description": "Parent desc",
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "string", "description": "Child desc" },
|
||||||
|
{ "type": "integer" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Parent desc (Child desc) (Accepts: string | integer)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
Reference in New Issue
Block a user