mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Merge pull request #575 from soilSpoon/feature/antigravity-gemini-compat
feature: Improves Antigravity(gemini-claude) JSON schema compatibility
This commit is contained in:
@@ -545,27 +545,9 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
||||||
}
|
}
|
||||||
|
|
||||||
strJSON = util.DeleteKey(strJSON, "$schema")
|
// Use the centralized schema cleaner to handle unsupported keywords,
|
||||||
strJSON = util.DeleteKey(strJSON, "maxItems")
|
// const->enum conversion, and flattening of types/anyOf.
|
||||||
strJSON = util.DeleteKey(strJSON, "minItems")
|
strJSON = util.CleanJSONSchemaForGemini(strJSON)
|
||||||
strJSON = util.DeleteKey(strJSON, "minLength")
|
|
||||||
strJSON = util.DeleteKey(strJSON, "maxLength")
|
|
||||||
strJSON = util.DeleteKey(strJSON, "exclusiveMinimum")
|
|
||||||
strJSON = util.DeleteKey(strJSON, "exclusiveMaximum")
|
|
||||||
strJSON = util.DeleteKey(strJSON, "$ref")
|
|
||||||
strJSON = util.DeleteKey(strJSON, "$defs")
|
|
||||||
|
|
||||||
paths = make([]string, 0)
|
|
||||||
util.Walk(gjson.Parse(strJSON), "", "anyOf", &paths)
|
|
||||||
for _, p := range paths {
|
|
||||||
anyOf := gjson.Get(strJSON, p)
|
|
||||||
if anyOf.IsArray() {
|
|
||||||
anyOfItems := anyOf.Array()
|
|
||||||
if len(anyOfItems) > 0 {
|
|
||||||
strJSON, _ = sjson.SetRaw(strJSON, p[:len(p)-len(".anyOf")], anyOfItems[0].Raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = []byte(strJSON)
|
payload = []byte(strJSON)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
|
|||||||
usedTool = true
|
usedTool = true
|
||||||
fcName := functionCallResult.Get("name").String()
|
fcName := functionCallResult.Get("name").String()
|
||||||
|
|
||||||
|
// FIX: Handle streaming split/delta where name might be empty in subsequent chunks.
|
||||||
|
// If we are already in tool use mode and name is empty, treat as continuation (delta).
|
||||||
|
if (*param).(*Params).ResponseType == 3 && fcName == "" {
|
||||||
|
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||||
|
output = output + "event: content_block_delta\n"
|
||||||
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex), "delta.partial_json", fcArgsResult.Raw)
|
||||||
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
|
}
|
||||||
|
// Continue to next part without closing/opening logic
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Handle state transitions when switching to function calls
|
// Handle state transitions when switching to function calls
|
||||||
// Close any existing function call block first
|
// Close any existing function call block first
|
||||||
if (*param).(*Params).ResponseType == 3 {
|
if (*param).(*Params).ResponseType == 3 {
|
||||||
|
|||||||
413
internal/util/gemini_schema.go
Normal file
413
internal/util/gemini_schema.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
// Package util provides utility functions for the CLI Proxy API server.
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API.
|
||||||
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||||
|
// semantic information as description hints.
|
||||||
|
func CleanJSONSchemaForGemini(jsonStr string) string {
|
||||||
|
// Phase 1: Convert and add hints
|
||||||
|
jsonStr = convertRefsToHints(jsonStr)
|
||||||
|
jsonStr = convertConstToEnum(jsonStr)
|
||||||
|
jsonStr = addEnumHints(jsonStr)
|
||||||
|
jsonStr = addAdditionalPropertiesHints(jsonStr)
|
||||||
|
jsonStr = moveConstraintsToDescription(jsonStr)
|
||||||
|
|
||||||
|
// Phase 2: Flatten complex structures
|
||||||
|
jsonStr = mergeAllOf(jsonStr)
|
||||||
|
jsonStr = flattenAnyOfOneOf(jsonStr)
|
||||||
|
jsonStr = flattenTypeArrays(jsonStr)
|
||||||
|
|
||||||
|
// Phase 3: Cleanup
|
||||||
|
jsonStr = removeUnsupportedKeywords(jsonStr)
|
||||||
|
jsonStr = cleanupRequiredFields(jsonStr)
|
||||||
|
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).
|
||||||
|
func convertRefsToHints(jsonStr string) string {
|
||||||
|
paths := findPaths(jsonStr, "$ref")
|
||||||
|
sortByDepth(paths)
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
refVal := gjson.Get(jsonStr, p).String()
|
||||||
|
defName := refVal
|
||||||
|
if idx := strings.LastIndex(refVal, "/"); idx >= 0 {
|
||||||
|
defName = refVal[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPath := trimSuffix(p, ".$ref")
|
||||||
|
hint := fmt.Sprintf("See: %s", defName)
|
||||||
|
if existing := gjson.Get(jsonStr, parentPath+".description").String(); existing != "" {
|
||||||
|
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
replacement := fmt.Sprintf(`{"type":"object","description":"%s"}`, hint)
|
||||||
|
jsonStr = setRawAt(jsonStr, parentPath, replacement)
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertConstToEnum(jsonStr string) string {
|
||||||
|
for _, p := range findPaths(jsonStr, "const") {
|
||||||
|
val := gjson.Get(jsonStr, p)
|
||||||
|
if !val.Exists() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enumPath := trimSuffix(p, ".const") + ".enum"
|
||||||
|
if !gjson.Get(jsonStr, enumPath).Exists() {
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, enumPath, []interface{}{val.Value()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEnumHints(jsonStr string) string {
|
||||||
|
for _, p := range findPaths(jsonStr, "enum") {
|
||||||
|
arr := gjson.Get(jsonStr, p)
|
||||||
|
if !arr.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items := arr.Array()
|
||||||
|
if len(items) <= 1 || len(items) > 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var vals []string
|
||||||
|
for _, item := range items {
|
||||||
|
vals = append(vals, item.String())
|
||||||
|
}
|
||||||
|
jsonStr = appendHint(jsonStr, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", "))
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAdditionalPropertiesHints(jsonStr string) string {
|
||||||
|
for _, p := range findPaths(jsonStr, "additionalProperties") {
|
||||||
|
if gjson.Get(jsonStr, p).Type == gjson.False {
|
||||||
|
jsonStr = appendHint(jsonStr, trimSuffix(p, ".additionalProperties"), "No extra properties allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
var unsupportedConstraints = []string{
|
||||||
|
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
||||||
|
"pattern", "minItems", "maxItems",
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveConstraintsToDescription(jsonStr string) string {
|
||||||
|
for _, key := range unsupportedConstraints {
|
||||||
|
for _, p := range findPaths(jsonStr, key) {
|
||||||
|
val := gjson.Get(jsonStr, p)
|
||||||
|
if !val.Exists() || val.IsObject() || val.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentPath := trimSuffix(p, "."+key)
|
||||||
|
if isPropertyDefinition(parentPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf("%s: %s", key, val.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAllOf(jsonStr string) string {
|
||||||
|
paths := findPaths(jsonStr, "allOf")
|
||||||
|
sortByDepth(paths)
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
allOf := gjson.Get(jsonStr, p)
|
||||||
|
if !allOf.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentPath := trimSuffix(p, ".allOf")
|
||||||
|
|
||||||
|
for _, item := range allOf.Array() {
|
||||||
|
if props := item.Get("properties"); props.IsObject() {
|
||||||
|
props.ForEach(func(key, value gjson.Result) bool {
|
||||||
|
destPath := joinPath(parentPath, "properties."+key.String())
|
||||||
|
jsonStr, _ = sjson.SetRaw(jsonStr, destPath, value.Raw)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req := item.Get("required"); req.IsArray() {
|
||||||
|
reqPath := joinPath(parentPath, "required")
|
||||||
|
current := getStrings(jsonStr, reqPath)
|
||||||
|
for _, r := range req.Array() {
|
||||||
|
if s := r.String(); !contains(current, s) {
|
||||||
|
current = append(current, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenAnyOfOneOf(jsonStr string) string {
|
||||||
|
for _, key := range []string{"anyOf", "oneOf"} {
|
||||||
|
paths := findPaths(jsonStr, key)
|
||||||
|
sortByDepth(paths)
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
arr := gjson.Get(jsonStr, p)
|
||||||
|
if !arr.IsArray() || len(arr.Array()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items := arr.Array()
|
||||||
|
bestIdx, allTypes := selectBest(items)
|
||||||
|
selected := items[bestIdx].Raw
|
||||||
|
|
||||||
|
if len(allTypes) > 1 {
|
||||||
|
hint := "Accepts: " + strings.Join(allTypes, " | ")
|
||||||
|
selected = appendHintRaw(selected, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr = setRawAt(jsonStr, trimSuffix(p, "."+key), selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBest(items []gjson.Result) (bestIdx int, types []string) {
|
||||||
|
bestScore := -1
|
||||||
|
for i, item := range items {
|
||||||
|
t := item.Get("type").String()
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case t == "object" || item.Get("properties").Exists():
|
||||||
|
score, t = 3, orDefault(t, "object")
|
||||||
|
case t == "array" || item.Get("items").Exists():
|
||||||
|
score, t = 2, orDefault(t, "array")
|
||||||
|
case t != "" && t != "null":
|
||||||
|
score = 1
|
||||||
|
default:
|
||||||
|
t = orDefault(t, "null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if t != "" {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore, bestIdx = score, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenTypeArrays(jsonStr string) string {
|
||||||
|
paths := findPaths(jsonStr, "type")
|
||||||
|
sortByDepth(paths)
|
||||||
|
|
||||||
|
nullableFields := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
res := gjson.Get(jsonStr, p)
|
||||||
|
if !res.IsArray() || len(res.Array()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNull := false
|
||||||
|
var nonNullTypes []string
|
||||||
|
for _, item := range res.Array() {
|
||||||
|
s := item.String()
|
||||||
|
if s == "null" {
|
||||||
|
hasNull = true
|
||||||
|
} else if s != "" {
|
||||||
|
nonNullTypes = append(nonNullTypes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstType := "string"
|
||||||
|
if len(nonNullTypes) > 0 {
|
||||||
|
firstType = nonNullTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, p, firstType)
|
||||||
|
|
||||||
|
parentPath := trimSuffix(p, ".type")
|
||||||
|
if len(nonNullTypes) > 1 {
|
||||||
|
hint := "Accepts: " + strings.Join(nonNullTypes, " | ")
|
||||||
|
jsonStr = appendHint(jsonStr, parentPath, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasNull {
|
||||||
|
parts := strings.Split(p, ".")
|
||||||
|
if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
|
||||||
|
fieldName := parts[len(parts)-2]
|
||||||
|
objectPath := strings.Join(parts[:len(parts)-3], ".")
|
||||||
|
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
|
||||||
|
|
||||||
|
propPath := joinPath(objectPath, "properties."+fieldName)
|
||||||
|
jsonStr = appendHint(jsonStr, propPath, "(nullable)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for objectPath, fields := range nullableFields {
|
||||||
|
reqPath := joinPath(objectPath, "required")
|
||||||
|
req := gjson.Get(jsonStr, reqPath)
|
||||||
|
if !req.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
for _, r := range req.Array() {
|
||||||
|
if !contains(fields, r.String()) {
|
||||||
|
filtered = append(filtered, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
||||||
|
} else {
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeUnsupportedKeywords(jsonStr string) string {
|
||||||
|
keywords := append(unsupportedConstraints,
|
||||||
|
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
|
||||||
|
)
|
||||||
|
for _, key := range keywords {
|
||||||
|
for _, p := range findPaths(jsonStr, key) {
|
||||||
|
if isPropertyDefinition(trimSuffix(p, "."+key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupRequiredFields(jsonStr string) string {
|
||||||
|
for _, p := range findPaths(jsonStr, "required") {
|
||||||
|
parentPath := trimSuffix(p, ".required")
|
||||||
|
propsPath := joinPath(parentPath, "properties")
|
||||||
|
|
||||||
|
req := gjson.Get(jsonStr, p)
|
||||||
|
props := gjson.Get(jsonStr, propsPath)
|
||||||
|
if !req.IsArray() || !props.IsObject() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid []string
|
||||||
|
for _, r := range req.Array() {
|
||||||
|
if props.Get(r.String()).Exists() {
|
||||||
|
valid = append(valid, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) != len(req.Array()) {
|
||||||
|
if len(valid) == 0 {
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
} else {
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, p, valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func findPaths(jsonStr, field string) []string {
|
||||||
|
var paths []string
|
||||||
|
Walk(gjson.Parse(jsonStr), "", field, &paths)
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortByDepth(paths []string) {
|
||||||
|
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSuffix(path, suffix string) string {
|
||||||
|
if path == strings.TrimPrefix(suffix, ".") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(path, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(base, suffix string) string {
|
||||||
|
if base == "" {
|
||||||
|
return suffix
|
||||||
|
}
|
||||||
|
return base + "." + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRawAt(jsonStr, path, value string) string {
|
||||||
|
if path == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
result, _ := sjson.SetRaw(jsonStr, path, value)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPropertyDefinition(path string) bool {
|
||||||
|
return path == "properties" || strings.HasSuffix(path, ".properties")
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHint(jsonStr, parentPath, hint string) string {
|
||||||
|
descPath := parentPath + ".description"
|
||||||
|
if parentPath == "" || parentPath == "@this" {
|
||||||
|
descPath = "description"
|
||||||
|
}
|
||||||
|
existing := gjson.Get(jsonStr, descPath).String()
|
||||||
|
if existing != "" {
|
||||||
|
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||||
|
}
|
||||||
|
jsonStr, _ = sjson.Set(jsonStr, descPath, hint)
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHintRaw(jsonRaw, hint string) string {
|
||||||
|
existing := gjson.Get(jsonRaw, "description").String()
|
||||||
|
if existing != "" {
|
||||||
|
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||||
|
}
|
||||||
|
jsonRaw, _ = sjson.Set(jsonRaw, "description", hint)
|
||||||
|
return jsonRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStrings(jsonStr, path string) []string {
|
||||||
|
var result []string
|
||||||
|
if arr := gjson.Get(jsonStr, path); arr.IsArray() {
|
||||||
|
for _, r := range arr.Array() {
|
||||||
|
result = append(result, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(val, def string) string {
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
488
internal/util/gemini_schema_test.go
Normal file
488
internal/util/gemini_schema_test.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "InsightVizNode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["InsightVizNode"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "other"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "(nullable)"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["other"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of tags",
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User name",
|
||||||
|
"minLength": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
// minItems should be REMOVED and moved to description
|
||||||
|
if strings.Contains(result, `"minItems"`) {
|
||||||
|
t.Errorf("minItems keyword should be removed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "minItems: 1") {
|
||||||
|
t.Errorf("minItems hint missing in description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// minLength should be moved to description
|
||||||
|
if !strings.Contains(result, "minLength: 3") {
|
||||||
|
t.Errorf("minLength hint missing in description")
|
||||||
|
}
|
||||||
|
if strings.Contains(result, `"minLength":`) || strings.Contains(result, `"minLength" :`) {
|
||||||
|
t.Errorf("minLength keyword should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "null" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Accepts: null | object",
|
||||||
|
"properties": {
|
||||||
|
"kind": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "type": "string" },
|
||||||
|
{ "type": "integer" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Accepts: string | integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"a": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["a"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"b": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["b"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": { "type": "string" },
|
||||||
|
"b": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["a", "b"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"definitions": {
|
||||||
|
"User": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"customer": { "$ref": "#/definitions/User" }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"customer": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "See: User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"definitions": {
|
||||||
|
"Node": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"child": { "$ref": "#/definitions/Node" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$ref": "#/definitions/Node"
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
var resMap map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(result), &resMap)
|
||||||
|
|
||||||
|
if resMap["type"] != "object" {
|
||||||
|
t.Errorf("Expected type: object, got: %v", resMap["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, ok := resMap["description"].(string)
|
||||||
|
if !ok || !strings.Contains(desc, "Node") {
|
||||||
|
t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "string"},
|
||||||
|
"b": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["a", "b", "c"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "string"},
|
||||||
|
"b": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["a", "b"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
||||||
|
// A tool has an argument named "pattern" - should NOT be treated as a constraint
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The regex pattern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The regex pattern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
|
||||||
|
var resMap map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(result), &resMap)
|
||||||
|
props, _ := resMap["properties"].(map[string]interface{})
|
||||||
|
if _, ok := props["description"]; ok {
|
||||||
|
t.Errorf("Invalid 'description' property injected into properties map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"my.param": {
|
||||||
|
"type": "string",
|
||||||
|
"$ref": "#/definitions/MyType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"MyType": { "type": "string" }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
var resMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(result), &resMap); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
props, ok := resMap["properties"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("properties missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := props["my.param"]; !ok {
|
||||||
|
t.Fatalf("Key 'my.param' is missing. Result: %s", result)
|
||||||
|
} else {
|
||||||
|
valMap, _ := val.(map[string]interface{})
|
||||||
|
if _, hasRef := valMap["$ref"]; hasRef {
|
||||||
|
t.Errorf("Key 'my.param' still contains $ref")
|
||||||
|
}
|
||||||
|
if _, ok := props["my"]; ok {
|
||||||
|
t.Errorf("Artifact key 'my' created by sjson splitting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "string" },
|
||||||
|
{ "type": "integer" },
|
||||||
|
{ "type": "null" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "Accepts:") {
|
||||||
|
t.Errorf("Expected alternative types hint, got: %s", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "string") || !strings.Contains(result, "integer") {
|
||||||
|
t.Errorf("Expected all alternative types in hint, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "User name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "(nullable)") {
|
||||||
|
t.Errorf("Expected nullable hint, got: %s", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "User name") {
|
||||||
|
t.Errorf("Expected original description to be preserved, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["active", "inactive", "pending"],
|
||||||
|
"description": "Current status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "Allowed:") {
|
||||||
|
t.Errorf("Expected enum values hint, got: %s", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "active") || !strings.Contains(result, "inactive") {
|
||||||
|
t.Errorf("Expected enum values in hint, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "No extra properties allowed") {
|
||||||
|
t.Errorf("Expected additionalProperties hint, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["fixed"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if strings.Contains(result, "Allowed:") {
|
||||||
|
t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": ["string", "integer", "boolean"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "Accepts:") {
|
||||||
|
t.Errorf("Expected multiple types hint, got: %s", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "string") || !strings.Contains(result, "integer") || !strings.Contains(result, "boolean") {
|
||||||
|
t.Errorf("Expected all types in hint, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
|
||||||
|
var expMap, actMap map[string]interface{}
|
||||||
|
errExp := json.Unmarshal([]byte(expectedJSON), &expMap)
|
||||||
|
errAct := json.Unmarshal([]byte(actualJSON), &actMap)
|
||||||
|
|
||||||
|
if errExp != nil || errAct != nil {
|
||||||
|
t.Fatalf("JSON Unmarshal error. Exp: %v, Act: %v", errExp, errAct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(expMap, actMap) {
|
||||||
|
expBytes, _ := json.MarshalIndent(expMap, "", " ")
|
||||||
|
actBytes, _ := json.MarshalIndent(actMap, "", " ")
|
||||||
|
t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package util
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -28,10 +29,17 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
|
|||||||
// For JSON objects and arrays, iterate through each child
|
// For JSON objects and arrays, iterate through each child
|
||||||
value.ForEach(func(key, val gjson.Result) bool {
|
value.ForEach(func(key, val gjson.Result) bool {
|
||||||
var childPath string
|
var childPath string
|
||||||
|
// Escape special characters for gjson/sjson path syntax
|
||||||
|
// . -> \.
|
||||||
|
// * -> \*
|
||||||
|
// ? -> \?
|
||||||
|
var keyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
||||||
|
safeKey := keyReplacer.Replace(key.String())
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
childPath = key.String()
|
childPath = safeKey
|
||||||
} else {
|
} else {
|
||||||
childPath = path + "." + key.String()
|
childPath = path + "." + safeKey
|
||||||
}
|
}
|
||||||
if key.String() == field {
|
if key.String() == field {
|
||||||
*paths = append(*paths, childPath)
|
*paths = append(*paths, childPath)
|
||||||
|
|||||||
Reference in New Issue
Block a user