// 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" ) var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?") // 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, descriptionPath(parentPath)).String(); existing != "" { hint = fmt.Sprintf("%s (%s)", existing, hint) } replacement := `{"type":"object","description":""}` replacement, _ = sjson.Set(replacement, "description", 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."+escapeGJSONPathKey(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 } parentPath := trimSuffix(p, "."+key) parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() items := arr.Array() bestIdx, allTypes := selectBest(items) selected := items[bestIdx].Raw if parentDesc != "" { selected = mergeDescriptionRaw(selected, parentDesc) } if len(allTypes) > 1 { hint := "Accepts: " + strings.Join(allTypes, " | ") selected = appendHintRaw(selected, hint) } jsonStr = setRawAt(jsonStr, parentPath, 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 := splitGJSONPath(p) if len(parts) >= 3 && parts[len(parts)-3] == "properties" { fieldNameEscaped := parts[len(parts)-2] fieldName := unescapeGJSONPathKey(fieldNameEscaped) objectPath := strings.Join(parts[:len(parts)-3], ".") nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) propPath := joinPath(objectPath, "properties."+fieldNameEscaped) 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() { key := r.String() if props.Get(escapeGJSONPathKey(key)).Exists() { valid = append(valid, key) } } 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 descriptionPath(parentPath string) string { if parentPath == "" || parentPath == "@this" { return "description" } return parentPath + ".description" } 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 } 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 } }