mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
548 lines
14 KiB
Go
548 lines
14 KiB
Go
// 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(".", "\\.", "*", "\\*", "?", "\\?")
|
|
|
|
// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API.
|
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
|
// semantic information as description hints.
|
|
func CleanJSONSchemaForAntigravity(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)
|
|
|
|
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
|
jsonStr = addEmptySchemaPlaceholder(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", "format",
|
|
"default", "examples", // Claude rejects these in VALIDATED mode
|
|
}
|
|
|
|
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",
|
|
"propertyNames", // Gemini doesn't support property name validation
|
|
)
|
|
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
|
|
}
|
|
|
|
// addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas.
|
|
// Claude VALIDATED mode requires at least one property in tool schemas.
|
|
func addEmptySchemaPlaceholder(jsonStr string) string {
|
|
// Find all "type" fields
|
|
paths := findPaths(jsonStr, "type")
|
|
|
|
// Process from deepest to shallowest (to handle nested objects properly)
|
|
sortByDepth(paths)
|
|
|
|
for _, p := range paths {
|
|
typeVal := gjson.Get(jsonStr, p)
|
|
if typeVal.String() != "object" {
|
|
continue
|
|
}
|
|
|
|
// Get the parent path (the object containing "type")
|
|
parentPath := trimSuffix(p, ".type")
|
|
|
|
// Check if properties exists and is empty or missing
|
|
propsPath := joinPath(parentPath, "properties")
|
|
propsVal := gjson.Get(jsonStr, propsPath)
|
|
|
|
needsPlaceholder := false
|
|
if !propsVal.Exists() {
|
|
// No properties field at all
|
|
needsPlaceholder = true
|
|
} else if propsVal.IsObject() && len(propsVal.Map()) == 0 {
|
|
// Empty properties object
|
|
needsPlaceholder = true
|
|
}
|
|
|
|
if needsPlaceholder {
|
|
// Add placeholder "reason" property
|
|
reasonPath := joinPath(propsPath, "reason")
|
|
jsonStr, _ = sjson.Set(jsonStr, reasonPath+".type", "string")
|
|
jsonStr, _ = sjson.Set(jsonStr, reasonPath+".description", "Brief explanation of why you are calling this tool")
|
|
|
|
// Add to required array
|
|
reqPath := joinPath(parentPath, "required")
|
|
jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|