1541 lines
44 KiB
Go
1541 lines
44 KiB
Go
// Package util provides utility functions for the CLI Proxy API server.
|
|
package util
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
|
|
|
const placeholderReasonDescription = "Brief explanation of why you are calling this tool"
|
|
|
|
// Pass a single JSON schema to the functions below — never a whole request document.
|
|
//
|
|
// Cleaning walks every node and rewrites keys by name, and schema keywords such as "title",
|
|
// "format", "default" and "const" are also ordinary data keys. Handing these functions a request
|
|
// silently rewrites tool-call arguments inside the conversation history: the guard that protects
|
|
// a key under ".properties" does not apply to argument values, so the keys are deleted outright
|
|
// and replacements such as "enum" and "type" are fabricated. That regression reached production
|
|
// once already; scope every call site to the schema itself.
|
|
|
|
type jsonSchemaCleanOptions struct {
|
|
addPlaceholder bool
|
|
antigravitySemantics bool
|
|
removeToolTitle bool
|
|
removeGeminiMetadata bool
|
|
flattenUnions bool
|
|
forceEnumStringType bool
|
|
dropAllEnums bool
|
|
dropBooleanEnums bool
|
|
preserveAdditionalPropertiesFalse bool
|
|
}
|
|
|
|
// CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API.
|
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
|
// semantic information as description hints and adding placeholders required by VALIDATED mode.
|
|
func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
|
return CleanJSONSchemaForAntigravityTool(jsonStr, true)
|
|
}
|
|
|
|
// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private
|
|
// backend accepts enum members only as strings, but the declared type still controls the JSON
|
|
// type of generated function arguments, so numeric and boolean types must not be rewritten.
|
|
// requirePlaceholder is used only for Claude VALIDATED mode.
|
|
func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
|
|
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
|
|
addPlaceholder: requirePlaceholder,
|
|
antigravitySemantics: true,
|
|
removeToolTitle: !requirePlaceholder,
|
|
flattenUnions: true,
|
|
dropAllEnums: true,
|
|
})
|
|
}
|
|
|
|
// CleanJSONSchemaForAntigravityResponse transforms a response schema without applying tool-only
|
|
// compatibility rewrites that would alter the client's structured output contract.
|
|
//
|
|
// Sanitization policy:
|
|
// - Passthrough: type, properties, items, required, description, enum, nullable, and
|
|
// additionalProperties: false (which Antigravity natively enforces for response schemas).
|
|
// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
|
|
// - Flattened: allOf merged into properties/required.
|
|
// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
|
|
// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
|
|
// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
|
|
// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
|
|
func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
|
|
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
|
|
antigravitySemantics: true,
|
|
flattenUnions: true,
|
|
dropBooleanEnums: true,
|
|
preserveAdditionalPropertiesFalse: true,
|
|
})
|
|
}
|
|
|
|
// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling.
|
|
// It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders.
|
|
func CleanJSONSchemaForGemini(jsonStr string) string {
|
|
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
|
|
removeGeminiMetadata: true,
|
|
flattenUnions: true,
|
|
forceEnumStringType: true,
|
|
})
|
|
}
|
|
|
|
// cleanJSONSchema performs the core cleaning operations on the JSON schema.
|
|
func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
|
// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools)
|
|
jsonStr = normalizeMalformedSchemaObjects(jsonStr)
|
|
|
|
// Phase 1: Convert and add hints
|
|
if options.antigravitySemantics {
|
|
jsonStr = inlineLocalRefs(jsonStr)
|
|
}
|
|
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
|
|
jsonStr = convertConstToEnum(jsonStr)
|
|
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
|
|
jsonStr = addEnumHints(jsonStr)
|
|
jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
|
|
if !options.preserveAdditionalPropertiesFalse {
|
|
jsonStr = addAdditionalPropertiesHints(jsonStr)
|
|
}
|
|
jsonStr = moveConstraintsToDescription(jsonStr, options)
|
|
if options.antigravitySemantics {
|
|
jsonStr = moveNotToDescription(jsonStr)
|
|
}
|
|
|
|
// Phase 2: Flatten complex structures
|
|
jsonStr = mergeConditionals(jsonStr)
|
|
jsonStr = mergeAllOf(jsonStr)
|
|
if options.flattenUnions {
|
|
jsonStr = flattenAnyOfOneOf(jsonStr)
|
|
}
|
|
jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
|
|
|
|
// Phase 3: Cleanup
|
|
jsonStr = removeUnsupportedKeywords(jsonStr, options)
|
|
if options.removeGeminiMetadata {
|
|
// Gemini schema cleanup: remove nullable/title and placeholder-only fields.
|
|
jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
|
|
jsonStr = removePlaceholderFields(jsonStr)
|
|
} else if options.removeToolTitle {
|
|
// Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title.
|
|
// Keep that harmless metadata policy without losing Antigravity's native nullable support.
|
|
jsonStr = removeKeywords(jsonStr, []string{"title"})
|
|
}
|
|
jsonStr = cleanupRequiredFields(jsonStr)
|
|
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
|
if options.addPlaceholder {
|
|
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
|
}
|
|
|
|
return jsonStr
|
|
}
|
|
|
|
// removeKeywords removes all occurrences of specified keywords from the JSON schema.
|
|
func removeKeywords(jsonStr string, keywords []string) string {
|
|
deletePaths := make([]string, 0)
|
|
pathsByField := findPathsByFields(jsonStr, keywords)
|
|
for _, key := range keywords {
|
|
for _, p := range pathsByField[key] {
|
|
if isPropertyDefinition(trimSuffix(p, "."+key)) {
|
|
continue
|
|
}
|
|
deletePaths = append(deletePaths, p)
|
|
}
|
|
}
|
|
sortByDepth(deletePaths)
|
|
for _, p := range deletePaths {
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
// removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries.
|
|
func removePlaceholderFields(jsonStr string) string {
|
|
// Remove "_" placeholder properties.
|
|
paths := findPaths(jsonStr, "_")
|
|
sortByDepth(paths)
|
|
for _, p := range paths {
|
|
if !strings.HasSuffix(p, ".properties._") {
|
|
continue
|
|
}
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
parentPath := trimSuffix(p, ".properties._")
|
|
reqPath := joinPath(parentPath, "required")
|
|
req := gjson.Get(jsonStr, reqPath)
|
|
if req.IsArray() {
|
|
var filtered []string
|
|
for _, r := range req.Array() {
|
|
if r.String() != "_" {
|
|
filtered = append(filtered, r.String())
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
|
} else {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove placeholder-only "reason" objects.
|
|
reasonPaths := findPaths(jsonStr, "reason")
|
|
sortByDepth(reasonPaths)
|
|
for _, p := range reasonPaths {
|
|
if !strings.HasSuffix(p, ".properties.reason") {
|
|
continue
|
|
}
|
|
parentPath := trimSuffix(p, ".properties.reason")
|
|
props := gjson.Get(jsonStr, joinPath(parentPath, "properties"))
|
|
if !props.IsObject() || len(props.Map()) != 1 {
|
|
continue
|
|
}
|
|
desc := gjson.Get(jsonStr, p+".description").String()
|
|
if desc != placeholderReasonDescription {
|
|
continue
|
|
}
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
reqPath := joinPath(parentPath, "required")
|
|
req := gjson.Get(jsonStr, reqPath)
|
|
if req.IsArray() {
|
|
var filtered []string
|
|
for _, r := range req.Array() {
|
|
if r.String() != "reason" {
|
|
filtered = append(filtered, r.String())
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
|
} else {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
}
|
|
|
|
return jsonStr
|
|
}
|
|
|
|
// normalizeMalformedSchemaObjects normalizes malformed JSON schema nodes commonly produced by
|
|
// certain MCP tool definitions (e.g. Asana MCP server):
|
|
// 1. Bare property maps missing the "type": "object" and "properties": {...} wrappers are wrapped.
|
|
// 2. Boolean "required": true on property definitions are stripped and promoted to the parent's "required" array.
|
|
func normalizeMalformedSchemaObjects(jsonStr string) string {
|
|
if jsonStr == "" {
|
|
return jsonStr
|
|
}
|
|
|
|
decoder := json.NewDecoder(strings.NewReader(jsonStr))
|
|
decoder.UseNumber()
|
|
var root any
|
|
if err := decoder.Decode(&root); err != nil {
|
|
return jsonStr
|
|
}
|
|
|
|
rootMap, ok := root.(map[string]any)
|
|
if !ok || isAPIRequestDocument(rootMap) {
|
|
return jsonStr
|
|
}
|
|
|
|
// If wrapped in single-key {"schema": ...} by cleanNestedSchema, unwrap, repair, and re-wrap.
|
|
if len(rootMap) == 1 {
|
|
if innerSchema, ok := rootMap["schema"].(map[string]any); ok {
|
|
repairedInner, modified := repairSchemaNode(innerSchema)
|
|
if !modified {
|
|
return jsonStr
|
|
}
|
|
out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": repairedInner})
|
|
if err != nil {
|
|
return jsonStr
|
|
}
|
|
return string(out)
|
|
}
|
|
}
|
|
|
|
repaired, modified := repairSchemaNode(rootMap)
|
|
if !modified {
|
|
return jsonStr
|
|
}
|
|
|
|
out, err := marshalJSONNoHTMLEscape(repaired)
|
|
if err != nil {
|
|
return jsonStr
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func marshalJSONNoHTMLEscape(v any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
b := buf.Bytes()
|
|
if len(b) > 0 && b[len(b)-1] == '\n' {
|
|
b = b[:len(b)-1]
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func isKnownSchemaKeywordOrExtension(key string) bool {
|
|
if strings.HasPrefix(key, "x-") {
|
|
return true
|
|
}
|
|
switch key {
|
|
case "properties", "patternProperties", "additionalProperties", "items", "prefixItems",
|
|
"$defs", "definitions", "dependentSchemas", "dependentRequired", "dependencies",
|
|
"if", "then", "else", "not", "contains", "propertyNames",
|
|
"unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems",
|
|
"default", "const", "example", "examples", "discriminator", "xml", "externalDocs",
|
|
"enumDescriptions", "enumTitles":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isNonObjectDeclaredType(t any) bool {
|
|
if s, ok := t.(string); ok {
|
|
return s != "" && s != "object"
|
|
}
|
|
if arr, ok := t.([]any); ok {
|
|
for _, item := range arr {
|
|
if s, ok := item.(string); ok && s == "object" {
|
|
return false
|
|
}
|
|
}
|
|
return len(arr) > 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isAPIRequestDocument(m map[string]any) bool {
|
|
if _, ok := m["tools"].([]any); ok {
|
|
return true
|
|
}
|
|
if _, ok := m["contents"].([]any); ok {
|
|
return true
|
|
}
|
|
if _, ok := m["messages"].([]any); ok {
|
|
return true
|
|
}
|
|
if _, ok := m["functionDeclarations"].([]any); ok {
|
|
return true
|
|
}
|
|
if _, ok := m["function_declarations"].([]any); ok {
|
|
return true
|
|
}
|
|
if reqMap, ok := m["request"].(map[string]any); ok {
|
|
if isAPIRequestDocument(reqMap) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func repairSchemaNode(node map[string]any) (map[string]any, bool) {
|
|
if node == nil {
|
|
return nil, false
|
|
}
|
|
|
|
modified := false
|
|
clone := make(map[string]any, len(node))
|
|
for k, v := range node {
|
|
clone[k] = v
|
|
}
|
|
|
|
// 1. If not declared as a primitive/array type, collect bare property definition maps
|
|
if !isNonObjectDeclaredType(clone["type"]) {
|
|
var bareProps map[string]any
|
|
for k, v := range clone {
|
|
if childMap, isMap := v.(map[string]any); isMap {
|
|
if !isKnownSchemaKeywordOrExtension(k) {
|
|
if bareProps == nil {
|
|
bareProps = make(map[string]any)
|
|
}
|
|
bareProps[k] = childMap
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(bareProps) > 0 {
|
|
repairedProps, promotedReqs, _ := repairPropertyMap(bareProps)
|
|
for k := range bareProps {
|
|
delete(clone, k)
|
|
}
|
|
|
|
if existingProps, ok := clone["properties"].(map[string]any); ok {
|
|
newProps := make(map[string]any, len(existingProps)+len(repairedProps))
|
|
for k, v := range existingProps {
|
|
newProps[k] = v
|
|
}
|
|
for k, v := range repairedProps {
|
|
newProps[k] = v
|
|
}
|
|
clone["properties"] = newProps
|
|
} else {
|
|
clone["properties"] = repairedProps
|
|
if _, hasType := clone["type"]; !hasType {
|
|
clone["type"] = "object"
|
|
}
|
|
}
|
|
|
|
if len(promotedReqs) > 0 {
|
|
existingReqs := extractStringArray(clone["required"])
|
|
merged := mergeStringSlices(existingReqs, promotedReqs)
|
|
clone["required"] = merged
|
|
}
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
// 2. If node has a "properties" map, recursively repair all properties inside it
|
|
if propsVal, ok := clone["properties"].(map[string]any); ok {
|
|
repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal)
|
|
if propsMod {
|
|
clone["properties"] = repairedProps
|
|
modified = true
|
|
}
|
|
if len(promotedReqs) > 0 {
|
|
existingReqs := extractStringArray(clone["required"])
|
|
merged := mergeStringSlices(existingReqs, promotedReqs)
|
|
clone["required"] = merged
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
// 3. Recurse into all other standard schema containers
|
|
if itemsVal, ok := clone["items"].(map[string]any); ok {
|
|
repairedItems, itemsMod := repairSchemaNode(itemsVal)
|
|
if itemsMod {
|
|
clone["items"] = repairedItems
|
|
modified = true
|
|
}
|
|
} else if itemsList, ok := clone["items"].([]any); ok {
|
|
repairedList, listMod := repairSchemaList(itemsList)
|
|
if listMod {
|
|
clone["items"] = repairedList
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
if addProps, ok := clone["additionalProperties"].(map[string]any); ok {
|
|
repairedAddProps, addPropsMod := repairSchemaNode(addProps)
|
|
if addPropsMod {
|
|
clone["additionalProperties"] = repairedAddProps
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
if patProps, ok := clone["patternProperties"].(map[string]any); ok {
|
|
repairedPatProps, _, patMod := repairPropertyMap(patProps)
|
|
if patMod {
|
|
clone["patternProperties"] = repairedPatProps
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
for _, key := range []string{"if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems"} {
|
|
if subVal, ok := clone[key].(map[string]any); ok {
|
|
repairedSub, subMod := repairSchemaNode(subVal)
|
|
if subMod {
|
|
clone[key] = repairedSub
|
|
modified = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
|
|
if listVal, ok := clone[key].([]any); ok {
|
|
repairedList, listMod := repairSchemaList(listVal)
|
|
if listMod {
|
|
clone[key] = repairedList
|
|
modified = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, key := range []string{"$defs", "definitions", "dependentSchemas"} {
|
|
if defsVal, ok := clone[key].(map[string]any); ok {
|
|
repairedDefs := make(map[string]any, len(defsVal))
|
|
defsModified := false
|
|
for dk, dv := range defsVal {
|
|
if defMap, ok := dv.(map[string]any); ok {
|
|
repairedDef, defMod := repairSchemaNode(defMap)
|
|
repairedDefs[dk] = repairedDef
|
|
if defMod {
|
|
defsModified = true
|
|
modified = true
|
|
}
|
|
} else {
|
|
repairedDefs[dk] = dv
|
|
}
|
|
}
|
|
if defsModified {
|
|
clone[key] = repairedDefs
|
|
}
|
|
}
|
|
}
|
|
|
|
return clone, modified
|
|
}
|
|
|
|
func repairSchemaList(list []any) ([]any, bool) {
|
|
var repairedList []any
|
|
listModified := false
|
|
for _, item := range list {
|
|
if itemMap, ok := item.(map[string]any); ok {
|
|
repairedItem, itemMod := repairSchemaNode(itemMap)
|
|
repairedList = append(repairedList, repairedItem)
|
|
if itemMod {
|
|
listModified = true
|
|
}
|
|
} else {
|
|
repairedList = append(repairedList, item)
|
|
}
|
|
}
|
|
return repairedList, listModified
|
|
}
|
|
|
|
func repairPropertyMap(props map[string]any) (map[string]any, []string, bool) {
|
|
out := make(map[string]any, len(props))
|
|
var promotedReqs []string
|
|
modified := false
|
|
|
|
for k, v := range props {
|
|
childMap, isMap := v.(map[string]any)
|
|
if !isMap {
|
|
out[k] = v
|
|
continue
|
|
}
|
|
|
|
childClone := make(map[string]any, len(childMap))
|
|
for ck, cv := range childMap {
|
|
childClone[ck] = cv
|
|
}
|
|
|
|
if reqBool, isBool := childClone["required"].(bool); isBool {
|
|
delete(childClone, "required")
|
|
modified = true
|
|
if reqBool {
|
|
promotedReqs = append(promotedReqs, k)
|
|
}
|
|
}
|
|
|
|
repairedChild, childMod := repairSchemaNode(childClone)
|
|
if childMod {
|
|
modified = true
|
|
}
|
|
out[k] = repairedChild
|
|
}
|
|
|
|
sort.Strings(promotedReqs)
|
|
return out, promotedReqs, modified
|
|
}
|
|
|
|
func extractStringArray(val any) []string {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
arr, ok := val.([]any)
|
|
if !ok {
|
|
if strArr, ok := val.([]string); ok {
|
|
return strArr
|
|
}
|
|
return nil
|
|
}
|
|
var res []string
|
|
for _, item := range arr {
|
|
if s, ok := item.(string); ok {
|
|
res = append(res, s)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func mergeStringSlices(existing, promoted []string) []string {
|
|
seen := make(map[string]bool)
|
|
var res []string
|
|
for _, s := range existing {
|
|
if !seen[s] && s != "" {
|
|
seen[s] = true
|
|
res = append(res, s)
|
|
}
|
|
}
|
|
for _, s := range promoted {
|
|
if !seen[s] && s != "" {
|
|
seen[s] = true
|
|
res = append(res, s)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
|
|
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
|
|
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
|
|
func inlineLocalRefs(jsonStr string) string {
|
|
if !strings.Contains(jsonStr, `"$ref"`) {
|
|
return jsonStr
|
|
}
|
|
|
|
decoder := json.NewDecoder(strings.NewReader(jsonStr))
|
|
decoder.UseNumber()
|
|
var root any
|
|
if err := decoder.Decode(&root); err != nil {
|
|
return jsonStr
|
|
}
|
|
|
|
resolved := resolveLocalRefs(root, root, make(map[string]bool))
|
|
out, err := json.Marshal(resolved)
|
|
if err != nil {
|
|
return jsonStr
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func resolveLocalRefs(root, value any, active map[string]bool) any {
|
|
switch node := value.(type) {
|
|
case []any:
|
|
out := make([]any, len(node))
|
|
for i, item := range node {
|
|
out[i] = resolveLocalRefs(root, item, active)
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
ref, hasRef := node["$ref"].(string)
|
|
if hasRef && strings.HasPrefix(ref, "#/") {
|
|
if target, ok := resolveJSONPointer(root, ref); ok {
|
|
if active[ref] {
|
|
return cyclicRefFallback(node, target, ref)
|
|
}
|
|
active[ref] = true
|
|
resolvedTarget := resolveLocalRefs(root, target, active)
|
|
delete(active, ref)
|
|
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
|
|
out := make(map[string]any, len(targetMap)+len(node))
|
|
for key, item := range targetMap {
|
|
out[key] = item
|
|
}
|
|
for key, item := range node {
|
|
if key == "$ref" {
|
|
continue
|
|
}
|
|
out[key] = resolveLocalRefs(root, item, active)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
out := make(map[string]any, len(node))
|
|
for key, item := range node {
|
|
out[key] = resolveLocalRefs(root, item, active)
|
|
}
|
|
return out
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func resolveJSONPointer(root any, ref string) (any, bool) {
|
|
current := root
|
|
for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
|
|
part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~")
|
|
switch node := current.(type) {
|
|
case map[string]any:
|
|
var ok bool
|
|
current, ok = node[part]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
case []any:
|
|
index, err := strconv.Atoi(part)
|
|
if err != nil || index < 0 || index >= len(node) {
|
|
return nil, false
|
|
}
|
|
current = node[index]
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any {
|
|
out := make(map[string]any, len(node)+2)
|
|
if targetMap, ok := target.(map[string]any); ok {
|
|
for _, key := range []string{"type", "nullable", "description"} {
|
|
if value, exists := targetMap[key]; exists {
|
|
out[key] = value
|
|
}
|
|
}
|
|
}
|
|
for key, value := range node {
|
|
if key != "$ref" {
|
|
out[key] = value
|
|
}
|
|
}
|
|
name := refName(ref)
|
|
hint := "See: " + name
|
|
if description, _ := out["description"].(string); description != "" {
|
|
out["description"] = mergeHint(description, hint)
|
|
} else {
|
|
out["description"] = hint
|
|
}
|
|
return out
|
|
}
|
|
|
|
func refName(ref string) string {
|
|
if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) {
|
|
return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~")
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// convertRefsToHints retains sibling keywords and converts only unresolved or external references
|
|
// to descriptions. Local references have already been expanded by inlineLocalRefs.
|
|
func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
|
|
paths := findPaths(jsonStr, "$ref")
|
|
sortByDepth(paths)
|
|
|
|
for _, p := range paths {
|
|
refVal := gjson.Get(jsonStr, p).String()
|
|
defName := refName(refVal)
|
|
|
|
parentPath := trimSuffix(p, ".$ref")
|
|
hint := fmt.Sprintf("See: %s", defName)
|
|
if !preserveSiblings {
|
|
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
|
|
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
|
}
|
|
replacement := `{"type":"object","description":""}`
|
|
replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
|
|
jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes))
|
|
continue
|
|
}
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
jsonStr = appendHint(jsonStr, parentPath, hint)
|
|
}
|
|
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() {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), enumPath, []interface{}{val.Value()})
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
// convertEnumValuesToStrings ensures all enum values use the string representation required by
|
|
// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
|
|
// emitted JSON type on both response and function-argument paths.
|
|
func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
|
|
for _, p := range findPaths(jsonStr, "enum") {
|
|
arr := gjson.Get(jsonStr, p)
|
|
if !arr.IsArray() {
|
|
continue
|
|
}
|
|
|
|
var stringVals []string
|
|
for _, item := range arr.Array() {
|
|
stringVals = append(stringVals, item.String())
|
|
}
|
|
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), p, stringVals)
|
|
jsonStr = string(updated)
|
|
if forceStringType {
|
|
parentPath := trimSuffix(p, ".enum")
|
|
updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "string")
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// Antigravity does not enforce enum on function arguments and ignores boolean response enums.
|
|
// Preserve the advisory values in description, but do not leave an unenforced constraint in the
|
|
// schema contract. Response enums for string, number, and integer remain native constraints.
|
|
func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
|
|
for _, path := range findPaths(jsonStr, "enum") {
|
|
parentPath := trimSuffix(path, ".enum")
|
|
shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean")
|
|
if !shouldDrop {
|
|
continue
|
|
}
|
|
enum := gjson.Get(jsonStr, path)
|
|
if enum.IsArray() && len(enum.Array()) == 1 {
|
|
jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String())
|
|
}
|
|
jsonStr, _ = sjson.Delete(jsonStr, path)
|
|
}
|
|
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", "uniqueItems", "format",
|
|
"default", "examples", // Claude rejects these in VALIDATED mode
|
|
}
|
|
|
|
func constraintKeywords(options jsonSchemaCleanOptions) []string {
|
|
keywords := append([]string(nil), unsupportedConstraints...)
|
|
if options.antigravitySemantics {
|
|
keywords = append(keywords, "minimum", "maximum", "multipleOf")
|
|
}
|
|
return keywords
|
|
}
|
|
|
|
func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
|
|
constraints := constraintKeywords(options)
|
|
pathsByField := findPathsByFields(jsonStr, constraints)
|
|
for _, key := range constraints {
|
|
for _, p := range pathsByField[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 moveNotToDescription(jsonStr string) string {
|
|
for _, path := range findPaths(jsonStr, "not") {
|
|
value := gjson.Get(jsonStr, path)
|
|
if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
|
|
continue
|
|
}
|
|
jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw)
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
func mergeConditionals(jsonStr string) string {
|
|
pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
|
|
var paths []string
|
|
for _, key := range []string{"then", "else"} {
|
|
for _, p := range pathsByField[key] {
|
|
parentPath := trimSuffix(p, "."+key)
|
|
if isPropertyDefinition(parentPath) {
|
|
continue
|
|
}
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
sortByDepth(paths)
|
|
|
|
for _, p := range paths {
|
|
props := gjson.Get(jsonStr, joinPath(p, "properties"))
|
|
if !props.IsObject() {
|
|
continue
|
|
}
|
|
var parentPath string
|
|
if strings.HasSuffix(p, ".then") {
|
|
parentPath = trimSuffix(p, ".then")
|
|
} else if strings.HasSuffix(p, ".else") {
|
|
parentPath = trimSuffix(p, ".else")
|
|
} else if p == "then" || p == "else" {
|
|
parentPath = ""
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
props.ForEach(func(key, value gjson.Result) bool {
|
|
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
|
|
if !gjson.Get(jsonStr, destPath).Exists() {
|
|
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))
|
|
jsonStr = string(updated)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
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 !item.IsObject() {
|
|
continue
|
|
}
|
|
item.ForEach(func(key, value gjson.Result) bool {
|
|
field := key.String()
|
|
switch field {
|
|
case "required":
|
|
if !value.IsArray() {
|
|
return true
|
|
}
|
|
reqPath := joinPath(parentPath, "required")
|
|
current := getStrings(jsonStr, reqPath)
|
|
for _, required := range value.Array() {
|
|
if name := required.String(); !contains(current, name) {
|
|
current = append(current, name)
|
|
}
|
|
}
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
|
|
jsonStr = string(updated)
|
|
case "if", "then", "else", "allOf":
|
|
// Conditional applicability cannot be represented by the upstream schema.
|
|
default:
|
|
destination := joinPath(parentPath, escapeGJSONPathKey(field))
|
|
jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing
|
|
// definition. A parent schema is the canonical definition; allOf and conditional branches may
|
|
// enrich gaps in it, but can never replace it with a narrower branch shell.
|
|
func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string {
|
|
existing := gjson.Get(jsonStr, destination)
|
|
if !existing.Exists() {
|
|
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw))
|
|
return string(updated)
|
|
}
|
|
if !existing.IsObject() || !incoming.IsObject() {
|
|
return jsonStr
|
|
}
|
|
incoming.ForEach(func(key, value gjson.Result) bool {
|
|
child := joinPath(destination, escapeGJSONPathKey(key.String()))
|
|
jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value)
|
|
return true
|
|
})
|
|
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
|
|
hasNull := false
|
|
for _, item := range items {
|
|
if item.Get("type").String() == "null" {
|
|
hasNull = true
|
|
break
|
|
}
|
|
}
|
|
if hasNull && items[bestIdx].Get("type").String() != "null" {
|
|
updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
|
|
selected = string(updated)
|
|
}
|
|
|
|
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, preserveNativeNullable bool) 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]
|
|
}
|
|
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), p, firstType)
|
|
jsonStr = string(updated)
|
|
|
|
parentPath := trimSuffix(p, ".type")
|
|
if len(nonNullTypes) > 1 {
|
|
hint := "Accepts: " + strings.Join(nonNullTypes, " | ")
|
|
jsonStr = appendHint(jsonStr, parentPath, hint)
|
|
}
|
|
|
|
if hasNull {
|
|
if preserveNativeNullable {
|
|
updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
|
|
jsonStr = string(updated)
|
|
jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
|
|
continue
|
|
}
|
|
|
|
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)
|
|
jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
|
|
}
|
|
}
|
|
}
|
|
|
|
for objectPath, fields := range nullableFields {
|
|
reqPath := joinPath(objectPath, "required")
|
|
req := gjson.Get(jsonStr, reqPath)
|
|
if !req.IsArray() {
|
|
continue
|
|
}
|
|
|
|
var filtered []string
|
|
for _, required := range req.Array() {
|
|
if !contains(fields, required.String()) {
|
|
filtered = append(filtered, required.String())
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
|
} else {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
|
|
keywords := append(constraintKeywords(options),
|
|
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
|
|
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
|
|
"if", "then", "else",
|
|
"$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", // Schema metadata fields unsupported by Gemini
|
|
)
|
|
if options.antigravitySemantics {
|
|
keywords = append(keywords, "not")
|
|
}
|
|
|
|
deletePaths := make([]string, 0)
|
|
pathsByField := findPathsByFields(jsonStr, keywords)
|
|
for _, key := range keywords {
|
|
for _, p := range pathsByField[key] {
|
|
if isPropertyDefinition(trimSuffix(p, "."+key)) {
|
|
continue
|
|
}
|
|
if options.preserveAdditionalPropertiesFalse && key == "additionalProperties" {
|
|
if gjson.Get(jsonStr, p).Type == gjson.False {
|
|
continue
|
|
}
|
|
}
|
|
deletePaths = append(deletePaths, p)
|
|
}
|
|
}
|
|
sortByDepth(deletePaths)
|
|
for _, p := range deletePaths {
|
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
|
}
|
|
// Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API
|
|
jsonStr = removeExtensionFields(jsonStr)
|
|
return jsonStr
|
|
}
|
|
|
|
// removeExtensionFields removes all x-* extension fields from the JSON schema.
|
|
// These are OpenAPI/JSON Schema extension fields that Google APIs don't recognize.
|
|
func removeExtensionFields(jsonStr string) string {
|
|
var paths []string
|
|
walkForExtensions(gjson.Parse(jsonStr), "", &paths)
|
|
// walkForExtensions returns paths in a way that deeper paths are added before their ancestors
|
|
// when they are not deleted wholesale, but since we skip children of deleted x-* nodes,
|
|
// any collected path is safe to delete. We still use DeleteBytes for efficiency.
|
|
|
|
b := []byte(jsonStr)
|
|
for _, p := range paths {
|
|
b, _ = sjson.DeleteBytes(b, p)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func walkForExtensions(value gjson.Result, path string, paths *[]string) {
|
|
if value.IsArray() {
|
|
arr := value.Array()
|
|
for i := len(arr) - 1; i >= 0; i-- {
|
|
walkForExtensions(arr[i], joinPath(path, strconv.Itoa(i)), paths)
|
|
}
|
|
return
|
|
}
|
|
|
|
if value.IsObject() {
|
|
value.ForEach(func(key, val gjson.Result) bool {
|
|
keyStr := key.String()
|
|
safeKey := escapeGJSONPathKey(keyStr)
|
|
childPath := joinPath(path, safeKey)
|
|
|
|
// If it's an extension field, we delete it and don't need to look at its children.
|
|
if strings.HasPrefix(keyStr, "x-") && !isPropertyDefinition(path) {
|
|
*paths = append(*paths, childPath)
|
|
return true
|
|
}
|
|
|
|
walkForExtensions(val, childPath, paths)
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), p, valid)
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
}
|
|
return jsonStr
|
|
}
|
|
|
|
// addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas.
|
|
// Claude VALIDATED mode requires at least one required 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)
|
|
reqPath := joinPath(parentPath, "required")
|
|
reqVal := gjson.Get(jsonStr, reqPath)
|
|
hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0
|
|
|
|
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")
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reasonPath+".type", "string")
|
|
jsonStr = string(updated)
|
|
updated, _ = sjson.SetBytes([]byte(jsonStr), reasonPath+".description", placeholderReasonDescription)
|
|
jsonStr = string(updated)
|
|
|
|
// Add to required array
|
|
updated, _ = sjson.SetBytes([]byte(jsonStr), reqPath, []string{"reason"})
|
|
jsonStr = string(updated)
|
|
continue
|
|
}
|
|
|
|
// If schema has properties but none are required, add a minimal placeholder.
|
|
if propsVal.IsObject() && !hasRequiredProperties {
|
|
// DO NOT add placeholder if it's a top-level schema (parentPath is empty)
|
|
// or if we've already added a placeholder reason above.
|
|
if parentPath == "" {
|
|
continue
|
|
}
|
|
placeholderPath := joinPath(propsPath, "_")
|
|
if !gjson.Get(jsonStr, placeholderPath).Exists() {
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), placeholderPath+".type", "boolean")
|
|
jsonStr = string(updated)
|
|
}
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, []string{"_"})
|
|
jsonStr = string(updated)
|
|
}
|
|
}
|
|
|
|
return jsonStr
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func findPaths(jsonStr, field string) []string {
|
|
var paths []string
|
|
Walk(gjson.Parse(jsonStr), "", field, &paths)
|
|
return paths
|
|
}
|
|
|
|
func findPathsByFields(jsonStr string, fields []string) map[string][]string {
|
|
set := make(map[string]struct{}, len(fields))
|
|
for _, field := range fields {
|
|
set[field] = struct{}{}
|
|
}
|
|
paths := make(map[string][]string, len(set))
|
|
walkForFields(gjson.Parse(jsonStr), "", set, paths)
|
|
return paths
|
|
}
|
|
|
|
func walkForFields(value gjson.Result, path string, fields map[string]struct{}, paths map[string][]string) {
|
|
switch value.Type {
|
|
case gjson.JSON:
|
|
value.ForEach(func(key, val gjson.Result) bool {
|
|
keyStr := key.String()
|
|
safeKey := escapeGJSONPathKey(keyStr)
|
|
|
|
var childPath string
|
|
if path == "" {
|
|
childPath = safeKey
|
|
} else {
|
|
childPath = path + "." + safeKey
|
|
}
|
|
|
|
if _, ok := fields[keyStr]; ok {
|
|
paths[keyStr] = append(paths[keyStr], childPath)
|
|
}
|
|
|
|
walkForFields(val, childPath, fields, paths)
|
|
return true
|
|
})
|
|
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
|
|
// Terminal types - no further traversal needed
|
|
}
|
|
}
|
|
|
|
func sortByDepth(paths []string) {
|
|
sort.SliceStable(paths, func(i, j int) bool {
|
|
return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(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.SetRawBytes([]byte(jsonStr), path, []byte(value))
|
|
return string(result)
|
|
}
|
|
|
|
// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
|
|
// subschemas. A key directly under one of them is a name, never a schema keyword.
|
|
var schemaNameMapKeywords = map[string]struct{}{
|
|
"properties": {},
|
|
"patternProperties": {},
|
|
"dependentSchemas": {},
|
|
"$defs": {},
|
|
"definitions": {},
|
|
}
|
|
|
|
// isPropertyDefinition reports whether path points at a map whose keys are names chosen by the
|
|
// tool author, so a key spelled like a schema keyword there must be preserved.
|
|
//
|
|
// A trailing ".properties" is not enough to tell: a tool may declare a property named
|
|
// "properties", and the schema for that property then sits at a path ending in ".properties" while
|
|
// being an ordinary schema node. Classifying it as a name map skipped every cleaning pass inside
|
|
// it, so unsupported keywords such as "propertyNames" reached the private Gemini backend, which
|
|
// rejects unknown fields with a 400.
|
|
//
|
|
// Each name-map keyword at the end of the path therefore flips the answer, because the node it
|
|
// names is a map only when its own parent is a schema: "properties" is a map,
|
|
// "properties.properties" the schema of a property named "properties", and
|
|
// "properties.properties.properties" that schema's own map. Only the trailing run matters, so any
|
|
// prefix the caller nests the schema under is ignored.
|
|
func isPropertyDefinition(path string) bool {
|
|
segments := splitGJSONPath(path)
|
|
trailing := 0
|
|
for i := len(segments) - 1; i >= 0; i-- {
|
|
if _, ok := schemaNameMapKeywords[unescapeGJSONPathKey(segments[i])]; !ok {
|
|
break
|
|
}
|
|
trailing++
|
|
}
|
|
return trailing%2 == 1
|
|
}
|
|
|
|
func descriptionPath(parentPath string) string {
|
|
if parentPath == "" || parentPath == "@this" {
|
|
return "description"
|
|
}
|
|
return parentPath + ".description"
|
|
}
|
|
|
|
// mergeHint combines an existing description with a hint. Cleaning is not always a single pass:
|
|
// a schema may be cleaned by a translator and again by an executor, so an already-present hint is
|
|
// kept as-is instead of being appended a second time.
|
|
func mergeHint(existing, hint string) string {
|
|
if existing == "" {
|
|
return hint
|
|
}
|
|
// A hint added to an empty description is stored bare and later hints are appended after it, so
|
|
// the bare form may sit alone, lead the description, or appear parenthesised further along.
|
|
if existing == hint ||
|
|
strings.HasPrefix(existing, hint+" (") ||
|
|
strings.Contains(existing, fmt.Sprintf("(%s)", hint)) {
|
|
return existing
|
|
}
|
|
return fmt.Sprintf("%s (%s)", existing, hint)
|
|
}
|
|
|
|
func appendHint(jsonStr, parentPath, hint string) string {
|
|
descPath := parentPath + ".description"
|
|
if parentPath == "" || parentPath == "@this" {
|
|
descPath = "description"
|
|
}
|
|
merged := mergeHint(gjson.Get(jsonStr, descPath).String(), hint)
|
|
updated, _ := sjson.SetBytes([]byte(jsonStr), descPath, merged)
|
|
jsonStr = string(updated)
|
|
return jsonStr
|
|
}
|
|
|
|
func appendHintRaw(jsonRaw, hint string) string {
|
|
merged := mergeHint(gjson.Get(jsonRaw, "description").String(), hint)
|
|
updated, _ := sjson.SetBytes([]byte(jsonRaw), "description", merged)
|
|
jsonRaw = string(updated)
|
|
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 {
|
|
if strings.IndexAny(key, ".*?") == -1 {
|
|
return key
|
|
}
|
|
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 == "":
|
|
updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", parentDesc)
|
|
return string(updated)
|
|
case childDesc == parentDesc:
|
|
return schemaRaw
|
|
default:
|
|
combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc)
|
|
updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", combined)
|
|
return string(updated)
|
|
}
|
|
}
|