// 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 }