Ctrl K
ComunidadGuías…

internal/util/translator.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)
}