// Package util provides utility functions for the CLI Proxy API server. // It includes helper functions for JSON manipulation, proxy configuration, // and other common operations used across the application. package util import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "sort" "strconv" "strings" "github.com/MoeForge/moego/ojson" log "github.com/sirupsen/logrus" ) // Walk recursively traverses a JSON structure to find all occurrences of a specific field. // It builds paths to each occurrence and adds them to the provided paths slice. // // Parameters: // - value: The ojson.Result object to traverse // - path: The current path in the JSON structure (empty string for root) // - field: The field name to search for // - paths: Pointer to a slice where found paths will be stored // // The function works recursively, building dot-notation paths to each occurrence // of the specified field throughout the JSON structure. func Walk(value ojson.Result, path, field string, paths *[]string) { walk(value, path, field, paths, false) } // WalkSchema is Walk for JSON Schema documents: it records the field wherever a schema keyword // may appear, but does not descend into literal values (default, example, examples, const, enum), // whose contents are client data rather than subschemas. The literal keyword itself is still // recorded when it is the searched field, and under a property-name map a key spelled like one is // a property name whose subschema is still walked. func WalkSchema(value ojson.Result, path, field string, paths *[]string) { walk(value, path, field, paths, true) } func walk(value ojson.Result, path, field string, paths *[]string, schemaAware bool) { switch value.Type { case ojson.JSON: // For JSON objects and arrays, iterate through each child value.ForEach(func(key, val ojson.Result) bool { var childPath string // Escape special characters for gjson/sjson path syntax // . -> \. // * -> \* // ? -> \? keyStr := key.String() safeKey := escapeGJSONPathKey(keyStr) if path == "" { childPath = safeKey } else { childPath = path + "." + safeKey } if keyStr == field { *paths = append(*paths, childPath) } if schemaAware && isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { return true } walk(val, childPath, field, paths, schemaAware) return true }) case ojson.String, ojson.Number, ojson.True, ojson.False, ojson.Null: // Terminal types - no further traversal needed } } // RenameKey renames a key in a JSON string by moving its value to a new key path // and then deleting the old key path. // // Parameters: // - jsonStr: The JSON string to modify // - oldKeyPath: The dot-notation path to the key that should be renamed // - newKeyPath: The dot-notation path where the value should be moved to // // Returns: // - string: The modified JSON string with the key renamed // - error: An error if the operation fails // // The function performs the rename in two steps: // 1. Sets the value at the new key path // 2. Deletes the old key path func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) { value := ojson.Get(jsonStr, oldKeyPath) if !value.Exists() { return "", fmt.Errorf("old key '%s' does not exist", oldKeyPath) } doc := ojson.NewDocString(jsonStr) if errSet := doc.SetRaw(newKeyPath, value.Raw); errSet != nil { return "", fmt.Errorf("failed to set new key '%s': %w", newKeyPath, errSet) } if errDelete := doc.Delete(oldKeyPath); errDelete != nil { return "", fmt.Errorf("failed to delete old key '%s': %w", oldKeyPath, errDelete) } return doc.String(), nil } // FixJSON converts non-standard JSON that uses single quotes for strings into // RFC 8259-compliant JSON by converting those single-quoted strings to // double-quoted strings with proper escaping. // // Examples: // // {'a': 1, 'b': '2'} => {"a": 1, "b": "2"} // {"t": 'He said "hi"'} => {"t": "He said \"hi\""} // // Rules: // - Existing double-quoted JSON strings are preserved as-is. // - Single-quoted strings are converted to double-quoted strings. // - Inside converted strings, any double quote is escaped (\"). // - Common backslash escapes (\n, \r, \t, \b, \f, \\) are preserved. // - \' inside single-quoted strings becomes a literal ' in the output (no // escaping needed inside double quotes). // - Unicode escapes (\uXXXX) inside single-quoted strings are forwarded. // - The function does not attempt to fix other non-JSON features beyond quotes. func FixJSON(input string) string { var out bytes.Buffer inDouble := false inSingle := false escaped := false // applies within the current string state // Helper to write a rune, escaping double quotes when inside a converted // single-quoted string (which becomes a double-quoted string in output). writeConverted := func(r rune) { if r == '"' { out.WriteByte('\\') out.WriteByte('"') return } out.WriteRune(r) } runes := []rune(input) for i := 0; i < len(runes); i++ { r := runes[i] if inDouble { out.WriteRune(r) if escaped { // end of escape sequence in a standard JSON string escaped = false continue } if r == '\\' { escaped = true continue } if r == '"' { inDouble = false } continue } if inSingle { if escaped { // Handle common escape sequences after a backslash within a // single-quoted string escaped = false switch r { case 'n', 'r', 't', 'b', 'f', '/', '"': // Keep the backslash and the character (except for '"' which // rarely appears, but if it does, keep as \" to remain valid) out.WriteByte('\\') out.WriteRune(r) case '\\': out.WriteByte('\\') out.WriteByte('\\') case '\'': // \' inside single-quoted becomes a literal ' out.WriteRune('\'') case 'u': // Forward \uXXXX if possible out.WriteByte('\\') out.WriteByte('u') // Copy up to next 4 hex digits if present for k := 0; k < 4 && i+1 < len(runes); k++ { peek := runes[i+1] // simple hex check if (peek >= '0' && peek <= '9') || (peek >= 'a' && peek <= 'f') || (peek >= 'A' && peek <= 'F') { out.WriteRune(peek) i++ } else { break } } default: // Unknown escape: preserve the backslash and the char out.WriteByte('\\') out.WriteRune(r) } continue } if r == '\\' { // start escape sequence escaped = true continue } if r == '\'' { // end of single-quoted string out.WriteByte('"') inSingle = false continue } // regular char inside converted string; escape double quotes writeConverted(r) continue } // Outside any string if r == '"' { inDouble = true out.WriteRune(r) continue } if r == '\'' { // start of non-standard single-quoted string inSingle = true out.WriteByte('"') continue } out.WriteRune(r) } // If input ended while still inside a single-quoted string, close it to // produce the best-effort valid JSON. if inSingle { out.WriteByte('"') } return out.String() } func CanonicalToolName(name string) string { canonical := strings.TrimSpace(name) canonical = strings.TrimLeft(canonical, "_") return strings.ToLower(canonical) } // ToolNameMapFromClaudeRequest returns a canonical-name -> original-name map extracted from a Claude request. // It is used to restore exact tool name casing for clients that require strict tool name matching (e.g. Claude Code). func ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { return toolNameMapFromClaudeRequest(rawJSON, false) } // UnambiguousToolNameMapFromClaudeRequest precomputes the legacy Claude mapper's // collision policy so response chunks only need MapToolName, not the request body. // Ambiguous canonical keys are stored with an empty value, so the returned map is // only meaningful with MapToolName (which keeps the upstream name for empty // values); do not index it directly. func UnambiguousToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { return toolNameMapFromClaudeRequest(rawJSON, true) } func claudePayloadMayContainToolAddition(rawJSON []byte) bool { return bytes.Contains(rawJSON, []byte("tool_addition")) || bytes.Contains(rawJSON, []byte(`\u`)) || bytes.Contains(rawJSON, []byte(`\U`)) } func forEachClaudeInlineToolAdditionDefinition(rawJSON []byte, fn func(def ojson.Result)) { if !claudePayloadMayContainToolAddition(rawJSON) { return } messages := ojson.GetBytes(rawJSON, "messages") if !messages.IsArray() { return } messages.ForEach(func(_, message ojson.Result) bool { if message.Get("role").String() != "system" { return true } content := message.Get("content") if !content.IsArray() { return true } content.ForEach(func(_, part ojson.Result) bool { if part.Get("type").String() != "tool_addition" { return true } toolSpec := part.Get("tool") if toolSpec.Get("type").String() == "tool_definition" || toolSpec.Get("definition").IsObject() { if def := toolSpec.Get("definition"); def.IsObject() { fn(def) } } return true }) return true }) } func toolNameMapFromClaudeRequest(rawJSON []byte, rejectAmbiguous bool) map[string]string { if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) { return nil } tools := ojson.GetBytes(rawJSON, "tools") hasTools := tools.Exists() && tools.IsArray() hasToolAddition := claudePayloadMayContainToolAddition(rawJSON) if !hasTools && !hasToolAddition { return nil } var out map[string]string recordTool := func(tool ojson.Result) { name := strings.Clone(strings.TrimSpace(tool.Get("name").String())) if name == "" { name = strings.Clone(strings.TrimSpace(tool.Get("function.name").String())) } if name == "" { return } key := strings.Clone(CanonicalToolName(name)) if key == "" { return } if out == nil { out = make(map[string]string, 4) } if previous, exists := out[key]; !exists { out[key] = name } else if rejectAmbiguous && previous != name { // An empty value preserves the upstream name for every spelling of // an ambiguous canonical key, including exact declared names. out[key] = "" } } if hasTools { tools.ForEach(func(_, tool ojson.Result) bool { recordTool(tool) return true }) } if hasToolAddition { forEachClaudeInlineToolAdditionDefinition(rawJSON, recordTool) } if len(out) == 0 { return nil } return out } func MapToolName(toolNameMap map[string]string, name string) string { if name == "" || toolNameMap == nil { return name } if mapped, ok := toolNameMap[CanonicalToolName(name)]; ok && mapped != "" { return mapped } return name } // MapClaudeToolName restores the client-facing tool name while preferring an exact // declared name over canonical (case/leading-underscore-insensitive) restoration. // It re-scans the request body on every call; hot paths should precompute with // UnambiguousToolNameMapFromClaudeRequest and use MapToolName instead. // This prevents distinct tools such as "Read" and "read" from being collapsed to // the first declaration when the upstream already returned an exact name. func MapClaudeToolName(toolNameMap map[string]string, originalRequestRawJSON []byte, name string) string { if name == "" { return name } if len(originalRequestRawJSON) > 0 && ojson.ValidBytes(originalRequestRawJSON) { tools := ojson.GetBytes(originalRequestRawJSON, "tools") hasTools := tools.IsArray() hasToolAddition := claudePayloadMayContainToolAddition(originalRequestRawJSON) if hasTools || hasToolAddition { canonical := CanonicalToolName(name) canonicalNames := map[string]bool{} exactMatch := false inspectTool := func(tool ojson.Result) { declared := strings.TrimSpace(tool.Get("name").String()) if declared == "" { declared = strings.TrimSpace(tool.Get("function.name").String()) } if declared == name { exactMatch = true return } if declared != "" && CanonicalToolName(declared) == canonical { canonicalNames[declared] = true } } if hasTools { for _, tool := range tools.Array() { inspectTool(tool) if exactMatch { return name } } } if hasToolAddition { forEachClaudeInlineToolAdditionDefinition(originalRequestRawJSON, inspectTool) if exactMatch { return name } } if len(canonicalNames) > 1 { return name } } } return MapToolName(toolNameMap, name) } // SanitizedFunctionNameMap builds an original-name → sanitized-name map from request tools. // Exact duplicate names share a mapping. Distinct names that sanitize to the same value receive // deterministic hash suffixes so every declaration remains addressable within the 64-byte limit. func SanitizedFunctionNameMap(rawJSON []byte) map[string]string { names := functionNamesFromRequest(rawJSON) if len(names) == 0 { return nil } uniqueNames := make(map[string]struct{}, len(names)) baseCounts := make(map[string]int, len(names)) for _, name := range names { if name == "" { continue } if _, exists := uniqueNames[name]; exists { continue } uniqueNames[name] = struct{}{} baseCounts[SanitizeFunctionName(name)]++ } sortedNames := make([]string, 0, len(uniqueNames)) for name := range uniqueNames { sortedNames = append(sortedNames, name) } sort.Strings(sortedNames) out := make(map[string]string, len(sortedNames)) used := make(map[string]string, len(sortedNames)) for _, name := range sortedNames { base := SanitizeFunctionName(name) mapped := base _, baseUsed := used[base] if baseCounts[base] > 1 || baseUsed { mapped = disambiguateSanitizedFunctionName(base, name, used) } out[name] = mapped used[mapped] = name } if len(out) == 0 { return nil } return out } // MapSanitizedFunctionName returns the request-specific sanitized name when available. func MapSanitizedFunctionName(nameMap map[string]string, name string) string { if mapped := nameMap[name]; mapped != "" { return mapped } return SanitizeFunctionName(name) } // DisambiguatedToolNameMap builds a sanitized-name → original-name map using the // same collision-aware mapping as SanitizedFunctionNameMap. func DisambiguatedToolNameMap(rawJSON []byte) map[string]string { forward := SanitizedFunctionNameMap(rawJSON) if len(forward) == 0 { return nil } out := make(map[string]string, len(forward)) for original, sanitized := range forward { if sanitized != original { out[sanitized] = original } } if len(out) == 0 { return nil } return out } // SanitizedToolNameMap builds the legacy sanitized-name → original-name map from // top-level Claude-style tools. Collision-aware translators should use // DisambiguatedToolNameMap instead. func SanitizedToolNameMap(rawJSON []byte) map[string]string { if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) { return nil } tools := ojson.GetBytes(rawJSON, "tools") if !tools.IsArray() { return nil } out := make(map[string]string) tools.ForEach(func(_, tool ojson.Result) bool { name := strings.Clone(strings.TrimSpace(tool.Get("name").String())) if name == "" { return true } sanitized := strings.Clone(SanitizeFunctionName(name)) if sanitized == name { return true } if existing, exists := out[sanitized]; !exists { out[sanitized] = name } else { log.Warnf("sanitized tool name collision: %q and %q both map to %q, keeping first", existing, name, sanitized) } return true }) if len(out) == 0 { return nil } return out } func functionNamesFromRequest(rawJSON []byte) []string { if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) { return nil } tools := ojson.GetBytes(rawJSON, "tools") if !tools.IsArray() { return nil } names := make([]string, 0, len(tools.Array())) var collectTool func(ojson.Result) collectDeclarations := func(declarations ojson.Result) { if !declarations.IsArray() { return } declarations.ForEach(func(_, declaration ojson.Result) bool { if name := declaration.Get("name").String(); name != "" { names = append(names, strings.Clone(name)) } return true }) } collectTool = func(tool ojson.Result) { if nestedTools := tool.Get("tools"); nestedTools.IsArray() { nestedTools.ForEach(func(_, nestedTool ojson.Result) bool { collectTool(nestedTool) return true }) return } hasDeclarations := false if declarations := tool.Get("functionDeclarations"); declarations.IsArray() { collectDeclarations(declarations) hasDeclarations = true } if declarations := tool.Get("function_declarations"); declarations.IsArray() { collectDeclarations(declarations) hasDeclarations = true } if hasDeclarations { return } if name := tool.Get("function.name").String(); name != "" { names = append(names, strings.Clone(name)) return } if name := tool.Get("name").String(); name != "" { names = append(names, strings.Clone(name)) } } tools.ForEach(func(_, tool ojson.Result) bool { collectTool(tool) return true }) return names } func disambiguateSanitizedFunctionName(base, original string, used map[string]string) string { for attempt := 0; ; attempt++ { digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", original, attempt))) suffix := "_" + hex.EncodeToString(digest[:6]) prefix := base if maxPrefix := 64 - len(suffix); len(prefix) > maxPrefix { prefix = prefix[:maxPrefix] } candidate := prefix + suffix if _, exists := used[candidate]; !exists { return candidate } } } // DeduplicateFunctionDeclarations removes duplicate named declarations while preserving order. func DeduplicateFunctionDeclarations(raw []byte) []byte { result := ParseGJSONBytesNoCopy(raw) if !result.IsArray() { return raw } seen := make(map[string]struct{}, len(result.Array())) parts := make([]string, 0, len(result.Array())) for _, declaration := range result.Array() { name := declaration.Get("name").String() if name != "" { if _, exists := seen[name]; exists { continue } seen[name] = struct{}{} } parts = append(parts, declaration.Raw) } return []byte("[" + strings.Join(parts, ",") + "]") } // RestoreSanitizedToolName looks up a sanitized function name in the provided map // and returns the original client-facing name. If no mapping exists, it returns // the sanitized name unchanged. func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName string) string { if sanitizedName == "" || toolNameMap == nil { return sanitizedName } if original, ok := toolNameMap[sanitizedName]; ok { return original } return sanitizedName } type claudeObjectSchemaInfo struct { properties map[string][]ojson.Result required map[string]bool hasProperties bool allowsDynamic bool forbidsDynamic bool hasPatternProperties bool complex bool unsafeComplex bool declaresObject bool branchInfos []claudeObjectSchemaInfo } func claudeSchemaAllowsDynamic(schema ojson.Result) bool { // patternProperties permit matching keys even when additionalProperties is false // (the latter only restricts keys matching no pattern), so pattern permission is // never cancelled by an additionalProperties: false sibling. if schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0 { return true } additionalProperties := schema.Get("additionalProperties") if additionalProperties.Exists() && additionalProperties.Type != ojson.False { return true } return false } func claudeSchemaHasPatternProperties(schema ojson.Result) bool { return schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0 } func claudeSchemaForbidsDynamic(schema ojson.Result) bool { additionalProperties := schema.Get("additionalProperties") return additionalProperties.Exists() && additionalProperties.Type == ojson.False } func claudeResolveLocalRef(root ojson.Result, ref string) (ojson.Result, bool) { if !strings.HasPrefix(ref, "#/") { return ojson.Result{}, false } current := root for _, segment := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~") next, ok := current.Map()[segment] if !ok { if current.IsArray() { index, errAtoi := strconv.Atoi(segment) if errAtoi != nil || index < 0 || index >= len(current.Array()) { return ojson.Result{}, false } next = current.Array()[index] } else { return ojson.Result{}, false } } current = next } return current, true } func analyzeClaudeObjectSchema(schema ojson.Result, root ojson.Result) claudeObjectSchemaInfo { return analyzeClaudeObjectSchemaDepth(schema, root, 0, map[string]bool{}) } func claudeSchemaDeclaresObject(schema ojson.Result) bool { typeValue := schema.Get("type") if typeValue.String() == "object" { return true } if typeValue.IsArray() { for _, item := range typeValue.Array() { if item.String() == "object" { return true } } } return false } func claudeUnionBranches(schema ojson.Result) ([]ojson.Result, bool) { var branches []ojson.Result seen := map[string]bool{} for _, keyword := range []string{"anyOf", "oneOf"} { union := schema.Get(keyword) if !union.Exists() { continue } if !union.IsArray() { return nil, false } for _, branch := range union.Array() { branchType := branch.Get("type") if branchType.String() == "null" { continue } if branchType.IsArray() && len(branchType.Array()) == 1 && branchType.Array()[0].String() == "null" { continue } if seen[branch.Raw] { continue } seen[branch.Raw] = true branches = append(branches, branch) } } return branches, true } var claudeAnalyzedSchemaKeywords = map[string]bool{ "type": true, "description": true, "title": true, "properties": true, "required": true, "items": true, "additionalProperties": true, "patternProperties": true, "anyOf": true, "oneOf": true, "allOf": true, "$ref": true, "$defs": true, "definitions": true, "nullable": true, "minLength": true, "maxLength": true, "minimum": true, "maximum": true, "exclusiveMinimum": true, "exclusiveMaximum": true, "multipleOf": true, "pattern": true, "format": true, "default": true, "examples": true, "minItems": true, "maxItems": true, "maxProperties": true, "$schema": true, "$id": true, "id": true, "$comment": true, "deprecated": true, "readOnly": true, "writeOnly": true, "example": true, "contentEncoding": true, "contentMediaType": true, } func claudeSchemaHasUnanalyzedKeyword(schema ojson.Result) bool { for key := range schema.Map() { if strings.HasPrefix(key, "x-") { continue } if !claudeAnalyzedSchemaKeywords[key] { return true } } return false } func analyzeClaudeObjectSchemaDepth(schema ojson.Result, root ojson.Result, depth int, visitedRefs map[string]bool) claudeObjectSchemaInfo { info := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}, required: map[string]bool{}} if depth > 16 || !schema.Exists() || !schema.IsObject() { info.complex = true return info } if claudeSchemaDeclaresObject(schema) { info.declaresObject = true } if claudeSchemaHasUnanalyzedKeyword(schema) { // Any keyword outside the explicitly analyzed set (enum, const, contains, // conditionals, unevaluated*, $dynamicRef, vendor applicators, ...) may // constrain legal values in ways this analyzer does not model. Such schemas // are unsafe-complex (unlike multi-branch unions, whose branches are // collected): no destructive cleaning may run on them at all, even when a // union keyword is also present. info.complex = true info.unsafeComplex = true return info } if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() { branches, okBranches := claudeUnionBranches(schema) if !okBranches || len(branches) == 0 { info.complex = true return info } if len(branches) > 1 { // Multi-branch unions stay complex so this level never normalizes arguments, // but collect every branch's declared properties so child recursion and // placeholder protection still see the full original declaration. The // per-branch infos are kept separately because union branches are // alternatives: their dynamic-property policies must not be combined as // simultaneous constraints. for _, branch := range branches { branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) info.branchInfos = append(info.branchInfos, branchInfo) mergeClaudeObjectSchemaInfo(&info, branchInfo) } if claudeSchemaAllowsDynamic(schema) { info.allowsDynamic = true } if claudeSchemaForbidsDynamic(schema) { info.forbidsDynamic = true } if claudeSchemaHasPatternProperties(schema) { info.hasPatternProperties = true } for _, requiredName := range schema.Get("required").Array() { info.required[requiredName.String()] = true } props := schema.Get("properties") if props.IsObject() { info.hasProperties = true info.declaresObject = true for key, value := range props.Map() { info.properties[key] = appendUniqueSchema(info.properties[key], value) } } // Sibling $ref/allOf applicators still constrain this node alongside the // union; collect them too or their declared child properties would be // invisible to protection and child recursion. if ref := schema.Get("$ref"); ref.Exists() { refValue := ref.String() if !visitedRefs[refValue] { if target, ok := claudeResolveLocalRef(root, refValue); ok { visitedRefs[refValue] = true resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs) delete(visitedRefs, refValue) mergeClaudeObjectSchemaInfo(&info, resolved) } } } if allOf := schema.Get("allOf"); allOf.IsArray() { for _, branch := range allOf.Array() { branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) mergeClaudeObjectSchemaInfo(&info, branchInfo) } } info.complex = true return info } branchInfo := analyzeClaudeObjectSchemaDepth(branches[0], root, depth+1, visitedRefs) mergeClaudeObjectSchemaInfo(&info, branchInfo) } if ref := schema.Get("$ref"); ref.Exists() { refValue := ref.String() if visitedRefs[refValue] { info.complex = true return info } target, ok := claudeResolveLocalRef(root, refValue) if !ok { info.complex = true return info } visitedRefs[refValue] = true resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs) delete(visitedRefs, refValue) mergeClaudeObjectSchemaInfo(&info, resolved) } if allOf := schema.Get("allOf"); allOf.IsArray() { for _, branch := range allOf.Array() { branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) mergeClaudeObjectSchemaInfo(&info, branchInfo) } } else if allOf.Exists() { info.complex = true } if claudeSchemaAllowsDynamic(schema) { info.allowsDynamic = true } if claudeSchemaForbidsDynamic(schema) { info.forbidsDynamic = true } if claudeSchemaHasPatternProperties(schema) { info.hasPatternProperties = true } props := schema.Get("properties") if props.Exists() { if !props.IsObject() { info.complex = true } else { info.hasProperties = true info.declaresObject = true for key, value := range props.Map() { info.properties[key] = appendUniqueSchema(info.properties[key], value) } } } for _, requiredName := range schema.Get("required").Array() { info.required[requiredName.String()] = true } return info } func appendUniqueSchema(schemas []ojson.Result, value ojson.Result) []ojson.Result { for _, existing := range schemas { if existing.Raw == value.Raw { return schemas } } return append(schemas, value) } func mergeClaudeObjectSchemaInfo(dst *claudeObjectSchemaInfo, src claudeObjectSchemaInfo) { if dst.properties == nil { dst.properties = map[string][]ojson.Result{} } if dst.required == nil { dst.required = map[string]bool{} } if src.hasProperties { dst.hasProperties = true for key, values := range src.properties { for _, value := range values { dst.properties[key] = appendUniqueSchema(dst.properties[key], value) } } } for name := range src.required { dst.required[name] = true } dst.branchInfos = append(dst.branchInfos, src.branchInfos...) dst.allowsDynamic = dst.allowsDynamic || src.allowsDynamic dst.forbidsDynamic = dst.forbidsDynamic || src.forbidsDynamic dst.hasPatternProperties = dst.hasPatternProperties || src.hasPatternProperties dst.complex = dst.complex || src.complex dst.unsafeComplex = dst.unsafeComplex || src.unsafeComplex dst.declaresObject = dst.declaresObject || src.declaresObject } func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (ojson.Result, ojson.Result, bool) { if toolName == "" || len(originalRequestRawJSON) == 0 || !ojson.ValidBytes(originalRequestRawJSON) { return ojson.Result{}, ojson.Result{}, false } root := ojson.ParseBytes(originalRequestRawJSON) tools := root.Get("tools") if !tools.IsArray() { return ojson.Result{}, root, false } type candidate struct { name string schema ojson.Result found bool } var exact []candidate canonicalToolName := CanonicalToolName(toolName) canonicalMatches := map[string][]candidate{} for _, tool := range tools.Array() { name := strings.TrimSpace(tool.Get("name").String()) if name == "" { name = strings.TrimSpace(tool.Get("function.name").String()) } if name == "" { continue } schema := tool.Get("input_schema") if !schema.Exists() { schema = tool.Get("parameters") } if !schema.Exists() { schema = tool.Get("function.parameters") } c := candidate{name: name, schema: schema, found: true} if name == toolName { exact = append(exact, c) } if CanonicalToolName(name) == canonicalToolName { canonicalMatches[name] = append(canonicalMatches[name], c) } } if len(exact) == 1 { return exact[0].schema, root, true } if len(exact) > 1 { firstRaw := exact[0].schema.Raw identical := true for _, other := range exact[1:] { if other.schema.Raw != firstRaw { identical = false break } } if identical { return exact[0].schema, root, true } return ojson.Result{}, root, false } if len(canonicalMatches) == 1 { for _, matches := range canonicalMatches { if len(matches) == 1 { return matches[0].schema, root, true } firstRaw := matches[0].schema.Raw identical := true for _, other := range matches[1:] { if other.schema.Raw != firstRaw { identical = false break } } if identical { return matches[0].schema, root, true } } } return ojson.Result{}, root, false } // ClaudeToolIsParameterless reports whether the tool was declared in the original // Claude request with a missing schema or an explicitly closed empty object schema // (additionalProperties: false). Schemas that allow dynamic additional or pattern // properties, including objects that omit additionalProperties, are not parameterless. func ClaudeToolIsParameterless(toolName string, originalRequestRawJSON []byte) bool { schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName) if !found { return false } if !schema.Exists() { return true } _ = root info := analyzeClaudeObjectSchema(schema, schema) if info.complex || claudeInfoAnyBranchAllowsDynamic(info) || !info.declaresObject || !info.forbidsDynamic { return false } return len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) } func resolveClaudeArrayItemsSchema(schema ojson.Result, root ojson.Result, depth int, visitedRefs map[string]bool) ([]ojson.Result, bool) { if depth > 16 || !schema.Exists() || !schema.IsObject() { return nil, false } var items []ojson.Result deterministic := true collect := func(candidate ojson.Result) { items = appendUniqueSchema(items, candidate) } if ref := schema.Get("$ref"); ref.Exists() { refValue := ref.String() if visitedRefs[refValue] { return nil, false } target, ok := claudeResolveLocalRef(root, refValue) if !ok { return nil, false } visitedRefs[refValue] = true targetItems, targetOK := resolveClaudeArrayItemsSchema(target, root, depth+1, visitedRefs) delete(visitedRefs, refValue) if !targetOK { deterministic = false } else { for _, item := range targetItems { collect(item) } } } if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() { branches, okBranches := claudeUnionBranches(schema) if !okBranches || len(branches) != 1 { deterministic = false } else { branchItems, branchOK := resolveClaudeArrayItemsSchema(branches[0], root, depth+1, visitedRefs) if !branchOK { deterministic = false } else { for _, item := range branchItems { collect(item) } } } } if allOf := schema.Get("allOf"); allOf.IsArray() { for _, branch := range allOf.Array() { branchItems, branchOK := resolveClaudeArrayItemsSchema(branch, root, depth+1, visitedRefs) if !branchOK { deterministic = false continue } for _, item := range branchItems { collect(item) } } } else if allOf.Exists() { deterministic = false } if directItems := schema.Get("items"); directItems.Exists() { collect(directItems) } return items, deterministic } func claudeDeclaredPropertyPlaceholders(info claudeObjectSchemaInfo) (bool, bool) { _, reasonDeclared := info.properties["reason"] _, underscoreDeclared := info.properties["_"] return reasonDeclared, underscoreDeclared } func claudeArraySchemaUnsafe(schema ojson.Result, root ojson.Result) bool { return analyzeClaudeObjectSchema(schema, root).unsafeComplex } func cleanClaudeToolArray(argsJSON string, schemas []ojson.Result, root ojson.Result, path string) string { for _, schema := range schemas { if claudeArraySchemaUnsafe(schema, root) { // Value-constraining keywords on the array itself (e.g. enum of whole // arrays) are not analyzed; keep the array untouched. return argsJSON } } var itemSchemas []ojson.Result deterministic := true for _, schema := range schemas { resolvedItems, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{}) if !okItems { deterministic = false continue } if len(resolvedItems) == 0 { // A candidate without an items schema (e.g. free-form "{}") accepts // arbitrary elements; it must stay in the candidate set so element // cleaning sees the free alternative instead of only strict siblings. itemSchemas = appendUniqueSchema(itemSchemas, schema) continue } for _, item := range resolvedItems { itemSchemas = appendUniqueSchema(itemSchemas, item) } } if !deterministic || len(itemSchemas) == 0 { return argsJSON } cleaned := argsJSON elementCount := len(ojson.Get(cleaned, path).Array()) for index := 0; index < elementCount; index++ { elementPath := joinPath(path, strconv.Itoa(index)) element := ojson.Get(cleaned, elementPath) if element.IsObject() { cleaned = cleanClaudeToolObject(cleaned, itemSchemas, root, elementPath, false) } else if element.IsArray() { cleaned = cleanClaudeToolArray(cleaned, itemSchemas, root, elementPath) } } return cleaned } func cleanClaudeToolObject(argsJSON string, schemas []ojson.Result, root ojson.Result, path string, topLevel bool) string { argsResult := ojson.Get(argsJSON, path) if path == "" { argsResult = ojson.Parse(argsJSON) } if !argsResult.IsObject() || len(schemas) == 0 { return argsJSON } merged := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}} for _, schema := range schemas { mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(schema, root)) } if merged.complex || !merged.declaresObject { return argsJSON } isEmptySchema := len(merged.required) == 0 && (!merged.hasProperties || len(merged.properties) == 0) allSchemasDeclareObject := true for _, schema := range schemas { if !analyzeClaudeObjectSchema(schema, root).declaresObject { allSchemasDeclareObject = false break } } if isEmptySchema && allSchemasDeclareObject && merged.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(merged) && !claudeOriginalsAnyBranchAllowsDynamic(schemas, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, argsResult) { if len(argsResult.Map()) == 0 { return argsJSON } if topLevel { return "{}" } updated, _ := ojson.SetRaw(argsJSON, path, "{}") return updated } keys := make([]string, 0, len(merged.properties)) for key := range merged.properties { keys = append(keys, key) } sort.Strings(keys) cleaned := argsJSON for _, key := range keys { propertySchemas := merged.properties[key] childPath := joinPath(path, escapeGJSONPathKey(key)) child := ojson.Get(cleaned, childPath) if !child.Exists() { continue } currentForBranch := ojson.Parse(cleaned) if path != "" { currentForBranch = ojson.Get(cleaned, path) } if claudeParentBranchAcceptsChild(merged, key, currentForBranch) || claudeOriginalsMayPermitChild(schemas, root, key, currentForBranch) { continue } if child.IsObject() { cleaned = cleanClaudeToolObject(cleaned, propertySchemas, root, childPath, false) } else if child.IsArray() { cleaned = cleanClaudeToolArray(cleaned, propertySchemas, root, childPath) } } if claudeInfoAnyBranchAllowsDynamic(merged) { return cleaned } current := ojson.Parse(cleaned) if path != "" { current = ojson.Get(cleaned, path) } if !current.IsObject() { return cleaned } reasonDeclared, underscoreDeclared := claudeDeclaredPropertyPlaceholders(merged) doc := ojson.NewDocString(cleaned) docModified := false for _, placeholder := range []string{"reason", "_"} { if placeholder == "reason" && reasonDeclared { continue } if placeholder == "_" && underscoreDeclared { continue } if merged.required[placeholder] { continue } if claudeInfoAnyBranchAllowsDynamic(merged) || claudeOriginalsAnyBranchAllowsDynamic(schemas, root) { continue } if claudeParentBranchAcceptsChild(merged, placeholder, current) { continue } if claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, current) { continue } if _, ok := current.Map()[placeholder]; ok { placeholderPath := joinPath(path, placeholder) if doc.Delete(placeholderPath) == nil { docModified = true } } } if docModified { cleaned = doc.String() } parsedCleaned := ojson.Parse(cleaned) if path != "" { parsedCleaned = ojson.Get(cleaned, path) } if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" { return "{}" } return cleaned } func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName string, originalRequestRawJSON []byte) []ojson.Result { if toolName == "" || len(translatedRequestRawJSON) == 0 || !ojson.ValidBytes(translatedRequestRawJSON) { return nil } root := ojson.ParseBytes(translatedRequestRawJSON) type namedSchema struct { name string schema ojson.Result } var named []namedSchema collectNamed := func(tools ojson.Result) { if !tools.IsArray() { return } for _, toolGroup := range tools.Array() { decls := toolGroup.Get("functionDeclarations") if !decls.IsArray() { continue } for _, decl := range decls.Array() { name := strings.TrimSpace(decl.Get("name").String()) if name == "" { continue } schema := decl.Get("parametersJsonSchema") if !schema.Exists() { schema = decl.Get("parameters") } if schema.Exists() && schema.IsObject() { named = append(named, namedSchema{name: name, schema: schema}) } } } } collectNamed(root.Get("tools")) collectNamed(root.Get("request.tools")) if len(named) == 0 { return nil } canonicalToolName := CanonicalToolName(toolName) sanitizedToolName := SanitizeFunctionName(toolName) // Request translation renames colliding tool names via the request-wide // disambiguated map (hash suffixes); the response name is the restored // client name, so match the actually-sent name when it can be derived. sentName := "" if forward := SanitizedFunctionNameMap(originalRequestRawJSON); forward != nil { sentName = forward[toolName] } var exact []ojson.Result canonicalByName := map[string][]ojson.Result{} for _, entry := range named { if entry.name == toolName || (sentName != "" && entry.name == sentName) { exact = append(exact, entry.schema) } if CanonicalToolName(entry.name) == canonicalToolName || entry.name == sanitizedToolName || SanitizeFunctionName(entry.name) == sanitizedToolName { canonicalByName[entry.name] = append(canonicalByName[entry.name], entry.schema) } } if len(exact) > 0 { return exact } if len(canonicalByName) == 1 { for _, schemas := range canonicalByName { return schemas } } return nil } func claudeOriginalDeclaresPropertyDeep(schema ojson.Result, root ojson.Result, property string, depth int, visitedRefs map[string]bool) bool { if depth > 16 || !schema.Exists() || !schema.IsObject() { return false } if ref := schema.Get("$ref"); ref.Exists() { refValue := ref.String() if !visitedRefs[refValue] { if target, ok := claudeResolveLocalRef(root, refValue); ok { visitedRefs[refValue] = true declared := claudeOriginalDeclaresPropertyDeep(target, root, property, depth+1, visitedRefs) delete(visitedRefs, refValue) if declared { return true } } } } if _, ok := schema.Get("properties").Map()[property]; ok { return true } for _, requiredName := range schema.Get("required").Array() { if requiredName.String() == property { return true } } for _, keyword := range []string{"allOf", "anyOf", "oneOf"} { for _, branch := range schema.Get(keyword).Array() { if claudeOriginalDeclaresPropertyDeep(branch, root, property, depth+1, visitedRefs) { return true } } } return false } func claudeInfoAnyBranchAllowsDynamic(info claudeObjectSchemaInfo) bool { if info.hasPatternProperties { return true } if len(info.branchInfos) == 0 { return info.allowsDynamic && !info.forbidsDynamic } for _, branch := range info.branchInfos { if claudeInfoAnyBranchAllowsDynamic(branch) { return true } } return info.allowsDynamic && !info.forbidsDynamic } func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current ojson.Result) bool { // patternProperties permit matching keys regardless of additionalProperties. if info.hasPatternProperties { return true } // A branch that does not explicitly forbid additional properties accepts the // key by JSON Schema default. To avoid treating every unconstrained sibling // branch as permission (which would mask genuine placeholder cleaning), this // implicit permission only counts when the current object matches the branch // by carrying every property the branch requires. if !info.forbidsDynamic { matches := true for requiredName := range info.required { if _, ok := current.Map()[requiredName]; !ok { matches = false break } } if matches { return true } } if info.allowsDynamic && !info.forbidsDynamic { return true } for _, branch := range info.branchInfos { _, branchDeclaresKey := branch.properties[key] if branchDeclaresKey { // This branch constrains the key with its own explicit schema, // which is already part of the collected candidates. continue } if claudeBranchMayAccept(branch, key, current) { return true } } return false } func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key string, current ojson.Result) bool { if len(originalInfo.branchInfos) == 0 { return false } for _, branch := range originalInfo.branchInfos { _, branchDeclaresKey := branch.properties[key] if branchDeclaresKey { continue } if claudeBranchMayAccept(branch, key, current) { return true } } return false } func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, current ojson.Result) bool { if _, declaresKey := info.properties[key]; declaresKey { return false } if info.hasPatternProperties { return true } if info.forbidsDynamic { return false } for requiredName := range info.required { if _, ok := current.Map()[requiredName]; !ok { return false } } return true } func claudeOriginalsMayPermitChild(originals []ojson.Result, root ojson.Result, key string, current ojson.Result) bool { if len(originals) <= 1 { return false } for _, original := range originals { info := analyzeClaudeObjectSchema(original, root) if info.complex { continue } if claudeCandidateMayPermitChild(info, key, current) { return true } } return false } func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []ojson.Result, root ojson.Result, current ojson.Result) bool { if len(originals) <= 1 { return false } for _, original := range originals { info := analyzeClaudeObjectSchema(original, root) if info.complex || info.forbidsDynamic { continue } matches := true for requiredName := range info.required { if _, ok := current.Map()[requiredName]; !ok { matches = false break } } if matches { return true } } return false } func claudeOriginalsAnyBranchAllowsDynamic(originals []ojson.Result, root ojson.Result) bool { for _, original := range originals { if info := analyzeClaudeObjectSchema(original, root); claudeInfoAnyBranchAllowsDynamic(info) { return true } } return false } func mergedClaudeOriginalInfo(originals []ojson.Result, root ojson.Result) claudeObjectSchemaInfo { merged := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}} for _, original := range originals { mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(original, root)) } return merged } func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchemas []ojson.Result, originals []ojson.Result, root ojson.Result, path string, topLevel bool) string { current := ojson.Parse(argsJSON) if path != "" { current = ojson.Get(argsJSON, path) } if !current.IsObject() || len(translatedSchemas) == 0 || len(originals) == 0 { return argsJSON } for _, original := range originals { if !original.IsObject() { return argsJSON } } originalInfo := mergedClaudeOriginalInfo(originals, root) if originalInfo.unsafeComplex { return argsJSON } if originalInfo.complex { // Multi-branch unions in the original cannot be analyzed declaratively; fall through // and rely on the translated schema, which records the actually-sent branches. hasUnion := false for _, original := range originals { if original.Get("anyOf").IsArray() || original.Get("oneOf").IsArray() { hasUnion = true } } if !hasUnion { return argsJSON } } cleaned := argsJSON // Recurse into declared children using matching translated child schemas. if originalInfo.hasProperties { keys := make([]string, 0, len(originalInfo.properties)) for key := range originalInfo.properties { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { childPath := joinPath(path, escapeGJSONPathKey(key)) child := ojson.Get(cleaned, childPath) if !child.Exists() { continue } var translatedChildren []ojson.Result for _, translated := range translatedSchemas { translatedInfo := analyzeClaudeObjectSchema(translated, translated) for _, childSchema := range translatedInfo.properties[key] { translatedChildren = appendUniqueSchema(translatedChildren, childSchema) } } if len(translatedChildren) == 0 { continue } originalChildren := originalInfo.properties[key] currentForBranch := ojson.Parse(cleaned) if path != "" { currentForBranch = ojson.Get(cleaned, path) } if claudeParentBranchAcceptsChild(originalInfo, key, currentForBranch) || claudeOriginalsMayPermitChild(originals, root, key, currentForBranch) { // Another alternative branch/candidate may accept this key without an // explicit property schema (dynamic, pattern, or implicit-open matched // permission); the collected explicit candidates do not constrain it // then, so skip destructive recursion into this subtree. continue } if child.IsObject() && len(originalChildren) > 0 { cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedChildren, originalChildren, root, childPath, false) } else if child.IsArray() && len(originalChildren) > 0 { cleaned = cleanClaudeTranslatedArray(cleaned, translatedChildren, originalChildren, root, childPath) } } } currentAfterChildren := ojson.Parse(cleaned) if path != "" { currentAfterChildren = ojson.Get(cleaned, path) } if !currentAfterChildren.IsObject() { return cleaned } var placeholdersToDelete []string for _, placeholder := range []string{"reason", "_"} { if _, declared := originalInfo.properties[placeholder]; declared { continue } if originalInfo.required[placeholder] { continue } if claudeInfoAnyBranchAllowsDynamic(originalInfo) || claudeOriginalsAnyBranchAllowsDynamic(originals, root) { // Some alternative branch (or candidate schema) accepts dynamic keys, so // this value may be legitimate user data under that branch even though // another branch received an injected placeholder. Never delete it. continue } if claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentAfterChildren) { // Alternative candidate child schemas extracted from different union // branches: one candidate that the current object matches accepts the // key implicitly, so the value may be legitimate under that candidate. continue } if claudeParentBranchAcceptsChild(originalInfo, placeholder, currentAfterChildren) { // An alternative branch that the current object matches (it carries all // of the branch's required keys) accepts this key by implicit-open or // pattern permission, so the value may be legitimate under that branch. continue } declaredDeep := false for _, original := range originals { if claudeOriginalDeclaresPropertyDeep(original, root, placeholder, 0, map[string]bool{}) { declaredDeep = true break } } if declaredDeep { continue } if _, ok := currentAfterChildren.Map()[placeholder]; !ok { continue } injected := false for _, translated := range translatedSchemas { translatedInfo := analyzeClaudeObjectSchema(translated, translated) placeholderSchemas, declaredTranslated := translatedInfo.properties[placeholder] if !declaredTranslated { continue } if placeholder == "reason" { for _, placeholderSchema := range placeholderSchemas { required := false for _, req := range translated.Get("required").Array() { if req.String() == "reason" { required = true } } if required || placeholderSchema.Get("description").String() == placeholderReasonDescription { injected = true } } // Also check required on union branches flattened into the analyzed info. if !injected { for _, req := range translated.Get("required").Array() { if req.String() == "reason" { injected = true } } } } else { for _, req := range translated.Get("required").Array() { if req.String() == "_" { injected = true } } // The "_" placeholder is also marked required by addEmptySchemaPlaceholder; // when branch flattening drops required, its boolean type is the fallback signal // only for nested objects. At the top level, the Antigravity executor wraps tool // schemas before its second sanitization pass, which can also inject "_" after // translation; that post-translation injection is handled below by re-running // the same schema cleaning on the translated schema. if !injected && !topLevel { for _, placeholderSchema := range placeholderSchemas { if placeholderSchema.Get("type").String() == "boolean" { injected = true } } } } } // Union branches: analyzeClaudeObjectSchema only resolves single-branch unions, // so also scan raw union branches. A required placeholder alone does not prove // injection (the original may legitimately require it in a branch, which the // deep-declaration check above already excluded); here the original declares the // field nowhere, so a required occurrence in a sent branch is the injected one. if !injected { for _, translated := range translatedSchemas { for _, keyword := range []string{"anyOf", "oneOf"} { for _, branch := range translated.Get(keyword).Array() { branchInfo := analyzeClaudeObjectSchema(branch, branch) if _, declaredBranch := branchInfo.properties[placeholder]; !declaredBranch { continue } for _, req := range branch.Get("required").Array() { if req.String() == placeholder { injected = true } } } } } } // The Antigravity executor re-sanitizes tool schemas after translation (wrapping // them so top-level objects become nested), which can inject placeholders that the // translated request seen here does not yet contain. Re-running the same cleaning // on the translated schema reproduces that final sent schema; if the placeholder // appears there as required, it was injected post-translation. This simulation is // only applied to closed objects (additionalProperties: false): open objects may // legitimately carry the key as user data on routes without executor injection, // and deleting based on a simulated injection would lose real arguments there. if !injected && topLevel && originalInfo.forbidsDynamic { for _, translated := range translatedSchemas { // Mirror the executor's cleanNestedSchema wrapping: the schema is nested // under a wrapper key before cleaning, so its top-level object counts as // nested and can receive the "_" placeholder. wrapped, errWrap := ojson.SetRaw("{}", "schema", translated.Raw) if errWrap != nil { continue } recleaned := ojson.Get(CleanJSONSchemaForAntigravity(wrapped), "schema") if !recleaned.Exists() { continue } recleanedInfo := analyzeClaudeObjectSchema(recleaned, recleaned) if _, declaredRecleaned := recleanedInfo.properties[placeholder]; !declaredRecleaned { continue } for _, req := range recleaned.Get("required").Array() { if req.String() == placeholder { injected = true } } } } if injected { placeholdersToDelete = append(placeholdersToDelete, joinPath(path, placeholder)) } } doc := ojson.NewDocString(cleaned) docModified := false for _, placeholderPath := range placeholdersToDelete { if doc.Delete(placeholderPath) == nil { docModified = true } } // Closed parameterless objects: if the original declared no properties and // explicitly forbids additional properties (additionalProperties: false) without // any candidate or union branch permitting dynamic data, normalize to "{}". // Implicitly open objects (additionalProperties omitted) keep non-placeholder // arguments after proven injected placeholders have been removed above. allCandidatesDeclareObject := true for _, original := range originals { if !analyzeClaudeObjectSchema(original, root).declaresObject { allCandidatesDeclareObject = false break } } currentForNormalize := doc.Get(path) if path == "" { if docModified { cleaned = doc.String() docModified = false } currentForNormalize = ojson.Parse(cleaned) } if !originalInfo.complex && allCandidatesDeclareObject && originalInfo.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(originalInfo) && !claudeOriginalsAnyBranchAllowsDynamic(originals, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentForNormalize) && originalInfo.declaresObject && len(originalInfo.required) == 0 && (!originalInfo.hasProperties || len(originalInfo.properties) == 0) { if topLevel { return "{}" } if doc.SetRaw(path, "{}") == nil { return doc.String() } return cleaned } if docModified { cleaned = doc.String() } parsedCleaned := ojson.Parse(cleaned) if path != "" { parsedCleaned = ojson.Get(cleaned, path) } if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" { return "{}" } _ = root return cleaned } func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []ojson.Result, originalArraySchemas []ojson.Result, root ojson.Result, path string) string { for _, schema := range originalArraySchemas { if claudeArraySchemaUnsafe(schema, root) { return argsJSON } } var translatedItems []ojson.Result var originalItems []ojson.Result deterministic := true for _, schema := range translatedArraySchemas { items, okItems := resolveClaudeArrayItemsSchema(schema, schema, 0, map[string]bool{}) if !okItems { deterministic = false } for _, item := range items { translatedItems = appendUniqueSchema(translatedItems, item) } } for _, schema := range originalArraySchemas { items, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{}) if !okItems { deterministic = false } if okItems && len(items) == 0 { originalItems = appendUniqueSchema(originalItems, schema) continue } for _, item := range items { originalItems = appendUniqueSchema(originalItems, item) } } if !deterministic || len(translatedItems) == 0 || len(originalItems) == 0 { return argsJSON } cleaned := argsJSON elementCount := len(ojson.Get(cleaned, path).Array()) for index := 0; index < elementCount; index++ { elementPath := joinPath(path, strconv.Itoa(index)) element := ojson.Get(cleaned, elementPath) if element.IsObject() { cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedItems, originalItems, root, elementPath, false) } else if element.IsArray() { cleaned = cleanClaudeTranslatedArray(cleaned, translatedItems, originalItems, root, elementPath) } } return cleaned } // CleanClaudeToolArgumentsWithTranslated strips placeholder arguments ("reason" or "_") // only when the translated (actually-sent) tool schema shows that the placeholder was // injected for that object (present in the translated schema with the placeholder // description or marked required, while absent from the original declaration). When no // translated schema is available it falls back to the original-schema inference used by // CleanClaudeToolArguments. Parameterless tools are still normalized to "{}" because a // strict Claude client rejects any argument for them. func CleanClaudeToolArgumentsWithTranslated(rawArgs string, toolName string, originalRequestRawJSON []byte, translatedRequestRawJSON []byte) string { if translatedSchemas := findTranslatedClaudeToolSchemas(translatedRequestRawJSON, toolName, originalRequestRawJSON); len(translatedSchemas) > 0 { rawArgs = strings.TrimSpace(rawArgs) if rawArgs == "" || rawArgs == "{}" { return "{}" } if !ojson.Valid(rawArgs) || !ojson.Parse(rawArgs).IsObject() { return rawArgs } schema, _, found := findClaudeToolSchema(originalRequestRawJSON, toolName) if !found { return rawArgs } if !schema.Exists() { return "{}" } return cleanClaudeToolArgumentsByTranslatedSchema(rawArgs, translatedSchemas, []ojson.Result{schema}, schema, "", true) } return CleanClaudeToolArguments(rawArgs, toolName, originalRequestRawJSON) } // CleanClaudeToolArguments strips placeholder arguments ("reason" or "_") injected during // schema normalization for empty object schemas when returning responses to Claude clients. // If the tool was declared with empty or missing properties and additionalProperties: false, // the arguments are normalized to "{}". Placeholder fields are also stripped recursively from declared nested object // properties when they were not declared there. Free-form nested schemas that do not declare // an object type or properties are preserved because no placeholder is injected for them. // Tools that legitimately declared those fields or use union schemas that cannot be resolved // deterministically are left untouched. Deterministic allOf, single-branch unions, and local // $ref schemas are resolved within the tool schema scope, matching request translation. func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRawJSON []byte) string { rawArgs = strings.TrimSpace(rawArgs) if rawArgs == "" || rawArgs == "{}" { return "{}" } if !ojson.Valid(rawArgs) { return rawArgs } schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName) if !found { return rawArgs } if !schema.Exists() { return "{}" } argsResult := ojson.Parse(rawArgs) if !argsResult.IsObject() { return rawArgs } _ = root info := analyzeClaudeObjectSchema(schema, schema) if info.complex || !info.declaresObject { return rawArgs } if len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) && info.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(info) { return "{}" } return cleanClaudeToolObject(rawArgs, []ojson.Result{schema}, schema, "", true) }