mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-26 17:41:33 +08:00
feat(util): inline local schema references in Gemini cleaners and preserve literal values
- Expand local `$ref` definitions before stripping schema definition containers to prevent nested field loss. - Preserve schema-looking keys inside literal data keywords (`default`, `example`, `examples`, `const`, `enum`). - Add comprehensive test coverage for schema inlining, literal preservation, and wrapped Antigravity tool schemas. Closes: #6732
This commit is contained in:
@@ -134,10 +134,9 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools)
|
||||
jsonStr = normalizeMalformedSchemaObjects(jsonStr, options.addMissingArrayItems)
|
||||
|
||||
// Phase 1: Convert and add hints
|
||||
if options.antigravitySemantics {
|
||||
jsonStr = inlineLocalRefs(jsonStr)
|
||||
}
|
||||
// Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local
|
||||
// references must be expanded here for every route; otherwise nested fields are lost.
|
||||
jsonStr = inlineLocalRefsForClean(jsonStr)
|
||||
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
|
||||
jsonStr = convertConstToEnum(jsonStr)
|
||||
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
|
||||
@@ -171,8 +170,8 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
jsonStr = removeKeywords(jsonStr, []string{"title"})
|
||||
}
|
||||
jsonStr = cleanupRequiredFields(jsonStr)
|
||||
jsonStr = sanitizeArrayItems(jsonStr)
|
||||
jsonStr = sanitizeObjectProperties(jsonStr)
|
||||
jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems)
|
||||
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||
if options.addPlaceholder {
|
||||
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
||||
@@ -206,10 +205,13 @@ func sanitizeObjectProperties(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array".
|
||||
// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array", and
|
||||
// (when addMissing is set) that array-typed nodes still missing "items" after reference
|
||||
// inlining receive the placeholder Phase 0 adds. Phase 0 cannot add it before inlining: on a
|
||||
// $ref node the placeholder would clobber the referenced definition's real items.
|
||||
// Gemini's protobuf validator enforces a strict field predicate on items ($type == Type.ARRAY);
|
||||
// if type is missing, it is inferred as array; if type is explicitly a non-array, items is removed.
|
||||
func sanitizeArrayItems(jsonStr string) string {
|
||||
func sanitizeArrayItems(jsonStr string, addMissing bool) string {
|
||||
paths := findPaths(jsonStr, "items")
|
||||
sortByDepth(paths)
|
||||
for _, p := range paths {
|
||||
@@ -226,6 +228,23 @@ func sanitizeArrayItems(jsonStr string) string {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
}
|
||||
}
|
||||
|
||||
if !addMissing {
|
||||
return jsonStr
|
||||
}
|
||||
typePaths := findPaths(jsonStr, "type")
|
||||
sortByDepth(typePaths)
|
||||
for _, p := range typePaths {
|
||||
parentPath := trimSuffix(p, ".type")
|
||||
if isPropertyDefinition(parentPath) || !isArrayDeclaredType(gjson.Get(jsonStr, p).Value()) {
|
||||
continue
|
||||
}
|
||||
if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() || gjson.Get(jsonStr, joinPath(parentPath, "$ref")).Exists() {
|
||||
continue
|
||||
}
|
||||
updated, _ := sjson.SetRawBytes([]byte(jsonStr), joinPath(parentPath, "items"), []byte(`{"type":"string"}`))
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
@@ -533,8 +552,10 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin
|
||||
}
|
||||
|
||||
// Gemini and Antigravity reject tool array schemas without an items definition,
|
||||
// and reject tool schemas with items whose type is not ARRAY.
|
||||
if addMissingArrayItems {
|
||||
// and reject tool schemas with items whose type is not ARRAY. A node still holding an
|
||||
// unresolved $ref gets neither a placeholder items nor a synthesized type: both would become
|
||||
// sibling keywords during inlining and clobber the referenced definition's real values.
|
||||
if addMissingArrayItems && clone["$ref"] == nil {
|
||||
if isArrayDeclaredType(clone["type"]) {
|
||||
if _, hasItems := clone["items"]; !hasItems {
|
||||
clone["items"] = map[string]any{"type": "string"}
|
||||
@@ -734,17 +755,34 @@ func mergeStringSlices(existing, promoted []string) []string {
|
||||
return res
|
||||
}
|
||||
|
||||
// InlineLocalRefs resolves JSON Pointer references against the original schema before definition
|
||||
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
|
||||
// InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
|
||||
// receives its own copy as long as the expansion budget lasts, sibling keywords override the
|
||||
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
|
||||
func InlineLocalRefs(jsonStr string) string {
|
||||
return inlineLocalRefs(jsonStr)
|
||||
}
|
||||
|
||||
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
|
||||
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
|
||||
// maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local
|
||||
// reference expansion. Shared definitions fan out once per reference, so a small input can expand
|
||||
// exponentially; expansions past the budget degrade to a typed hint like cycles do.
|
||||
const maxInlinedSchemaBytes = 256 * 1024
|
||||
|
||||
// maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a
|
||||
// large shared description cannot be repeated per reference and defeat the expansion budget.
|
||||
const maxFallbackDescriptionBytes = 512
|
||||
|
||||
// inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
|
||||
// receives its own copy as long as the expansion budget lasts, sibling keywords override the
|
||||
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
|
||||
func inlineLocalRefs(jsonStr string) string {
|
||||
return inlineLocalRefsRooted(jsonStr, nil, false)
|
||||
}
|
||||
|
||||
// inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes
|
||||
// receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase
|
||||
// 0); references inside it must then resolve against the inner schema, not the carrier, or every
|
||||
// lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte.
|
||||
func inlineLocalRefsForClean(jsonStr string) string {
|
||||
if !strings.Contains(jsonStr, `"$ref"`) {
|
||||
return jsonStr
|
||||
}
|
||||
@@ -756,7 +794,43 @@ func inlineLocalRefs(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
resolved := resolveLocalRefs(root, root, make(map[string]bool))
|
||||
resolveRoot := root
|
||||
wrapped := false
|
||||
if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 {
|
||||
if inner, okInner := rootMap["schema"].(map[string]any); okInner {
|
||||
resolveRoot = inner
|
||||
wrapped = true
|
||||
}
|
||||
}
|
||||
if !wrapped {
|
||||
return inlineLocalRefsRooted(jsonStr, root, true)
|
||||
}
|
||||
|
||||
state := newRefResolveState()
|
||||
resolved := resolveSchemaRoot(resolveRoot, state, true)
|
||||
out, err := json.Marshal(map[string]any{"schema": resolved})
|
||||
if err != nil {
|
||||
return jsonStr
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string {
|
||||
if !strings.Contains(jsonStr, `"$ref"`) {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
root := parsedRoot
|
||||
if root == nil {
|
||||
decoder := json.NewDecoder(strings.NewReader(jsonStr))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
state := newRefResolveState()
|
||||
resolved := resolveSchemaRoot(root, state, forClean)
|
||||
out, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
return jsonStr
|
||||
@@ -764,43 +838,366 @@ func inlineLocalRefs(jsonStr string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func resolveLocalRefs(root, value any, active map[string]bool) any {
|
||||
// refResolveState carries the remaining expansion budget of one inlining pass. Every reference
|
||||
// charges the size of the target it copies (nested references charge their own targets), so
|
||||
// shared-definition fan-out stops once the budget is spent while ordinary reuse still expands.
|
||||
type refResolveState struct {
|
||||
budget int
|
||||
}
|
||||
|
||||
func newRefResolveState() *refResolveState {
|
||||
return &refResolveState{budget: maxInlinedSchemaBytes}
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in a deterministic order, so budget allocation never depends on
|
||||
// Go map iteration order.
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// resolveSchemaRoot resolves references in the schema body before resolving definition
|
||||
// containers. Definitions only matter through references, so walking them first would spend the
|
||||
// expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while
|
||||
// the fields that are actually kept degraded to hints. For the public InlineLocalRefs the
|
||||
// containers are kept, so they are still resolved afterwards with whatever budget remains; when
|
||||
// cleaning they are dropped here altogether (references still resolve against the original
|
||||
// root), so their bytes never reach the intermediate JSON at all.
|
||||
func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any {
|
||||
rootMap, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean)
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(rootMap))
|
||||
for _, key := range sortedKeys(rootMap) {
|
||||
item := rootMap[key]
|
||||
switch key {
|
||||
case "$defs", "definitions":
|
||||
if !forClean {
|
||||
out[key] = nil // placeholder, resolved after the schema body below
|
||||
}
|
||||
case "$ref":
|
||||
out[key] = item
|
||||
default:
|
||||
out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean)
|
||||
}
|
||||
}
|
||||
|
||||
// A root-level reference expands into the whole referenced schema; its siblings were resolved
|
||||
// above and must override the target, mirroring resolveLocalRefs.
|
||||
if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") {
|
||||
if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget {
|
||||
if size := targetExpansionSize(target, forClean); size <= state.budget {
|
||||
state.budget -= size
|
||||
active := map[string]bool{ref: true}
|
||||
if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap {
|
||||
merged := make(map[string]any, len(targetMap)+len(out))
|
||||
for _, key := range sortedKeys(targetMap) {
|
||||
merged[key] = targetMap[key]
|
||||
}
|
||||
for _, key := range sortedKeys(out) {
|
||||
if key != "$ref" {
|
||||
merged[key] = out[key]
|
||||
}
|
||||
}
|
||||
out = merged
|
||||
} else {
|
||||
out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
|
||||
}
|
||||
} else {
|
||||
out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
|
||||
}
|
||||
delete(out, "$ref")
|
||||
}
|
||||
}
|
||||
|
||||
if !forClean {
|
||||
for _, key := range []string{"$defs", "definitions"} {
|
||||
if item, exists := rootMap[key]; exists {
|
||||
out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// targetExpansionSize estimates what copying a reference target into the output costs. When
|
||||
// cleaning, definitions nested inside the target are deleted later and never copied, so they are
|
||||
// excluded; otherwise a small target carrying a large unused definitions block would be degraded
|
||||
// purely because of material that is about to be thrown away. Name-map and literal context is
|
||||
// threaded so properties named like a container keyword, and data inside literal values, still
|
||||
// count in full.
|
||||
func targetExpansionSize(value any, forClean bool) int {
|
||||
if !forClean {
|
||||
return jsonValueSize(value)
|
||||
}
|
||||
return cleanExpansionSize(value, false)
|
||||
}
|
||||
|
||||
func cleanExpansionSize(value any, inNameMap bool) int {
|
||||
switch node := value.(type) {
|
||||
case nil:
|
||||
return 4
|
||||
case bool:
|
||||
return 5
|
||||
case json.Number:
|
||||
return len(node.String())
|
||||
case string:
|
||||
return jsonStringEncodedSize(node)
|
||||
case []any:
|
||||
size := 2
|
||||
for _, item := range node {
|
||||
size += cleanExpansionSize(item, false) + 1
|
||||
if size > maxInlinedSchemaBytes+1 {
|
||||
return maxInlinedSchemaBytes + 1
|
||||
}
|
||||
}
|
||||
return size
|
||||
case map[string]any:
|
||||
size := 2
|
||||
for key, item := range node {
|
||||
if !inNameMap && (key == "$defs" || key == "definitions") {
|
||||
continue
|
||||
}
|
||||
child := item
|
||||
childInNameMap := false
|
||||
if !inNameMap {
|
||||
if isLiteralValueKeyword(key) {
|
||||
size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1
|
||||
if size > maxInlinedSchemaBytes+1 {
|
||||
return maxInlinedSchemaBytes + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
|
||||
childInNameMap = true
|
||||
}
|
||||
}
|
||||
size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1
|
||||
if size > maxInlinedSchemaBytes+1 {
|
||||
return maxInlinedSchemaBytes + 1
|
||||
}
|
||||
}
|
||||
return size
|
||||
default:
|
||||
return 16
|
||||
}
|
||||
}
|
||||
|
||||
// jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit
|
||||
// it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control
|
||||
// characters). It is used to bound ref expansion, so it must not under-count what serialization
|
||||
// will actually produce.
|
||||
func jsonValueSize(value any) int {
|
||||
switch node := value.(type) {
|
||||
case nil:
|
||||
return 4
|
||||
case bool:
|
||||
return 5
|
||||
case json.Number:
|
||||
return len(node.String())
|
||||
case string:
|
||||
return jsonStringEncodedSize(node)
|
||||
case []any:
|
||||
size := 2
|
||||
for _, item := range node {
|
||||
size += jsonValueSize(item) + 1
|
||||
}
|
||||
return size
|
||||
case map[string]any:
|
||||
size := 2
|
||||
for key, item := range node {
|
||||
size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1
|
||||
}
|
||||
return size
|
||||
default:
|
||||
return 16
|
||||
}
|
||||
}
|
||||
|
||||
// jsonStringEncodedSize returns the encoded length of one JSON string including quotes,
|
||||
// mirroring encoding/json's default HTML escaping.
|
||||
func jsonStringEncodedSize(s string) int {
|
||||
size := 2
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c < 0x20:
|
||||
if c == '\n' || c == '\r' || c == '\t' {
|
||||
size += 2
|
||||
} else {
|
||||
size += 6
|
||||
}
|
||||
case c == '"' || c == '\\':
|
||||
size += 2
|
||||
case c == '<' || c == '>' || c == '&':
|
||||
size += 6
|
||||
case c < 0x80:
|
||||
size++
|
||||
case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9):
|
||||
size += 6
|
||||
i += 2
|
||||
default:
|
||||
size++
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must
|
||||
// copy their values verbatim: keys inside them that happen to look like schema keywords are data.
|
||||
var literalValueKeywords = map[string]struct{}{
|
||||
"default": {},
|
||||
"example": {},
|
||||
"examples": {},
|
||||
"const": {},
|
||||
"enum": {},
|
||||
}
|
||||
|
||||
func isLiteralValueKeyword(key string) bool {
|
||||
_, ok := literalValueKeywords[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any {
|
||||
switch node := value.(type) {
|
||||
case []any:
|
||||
out := make([]any, len(node))
|
||||
for i, item := range node {
|
||||
out[i] = resolveLocalRefs(root, item, active)
|
||||
out[i] = resolveLocalRefs(root, item, active, state, false, forClean)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
ref, hasRef := node["$ref"].(string)
|
||||
if hasRef && strings.HasPrefix(ref, "#/") {
|
||||
if target, ok := resolveJSONPointer(root, ref); ok {
|
||||
if active[ref] {
|
||||
return cyclicRefFallback(node, target, ref)
|
||||
}
|
||||
active[ref] = true
|
||||
resolvedTarget := resolveLocalRefs(root, target, active)
|
||||
delete(active, ref)
|
||||
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
|
||||
out := make(map[string]any, len(targetMap)+len(node))
|
||||
for key, item := range targetMap {
|
||||
out[key] = item
|
||||
if !inNameMap {
|
||||
ref, hasRef := node["$ref"].(string)
|
||||
if hasRef && strings.HasPrefix(ref, "#/") {
|
||||
if target, ok := resolveJSONPointer(root, ref); ok {
|
||||
if active[ref] {
|
||||
return resolveRefFallback(root, node, target, ref, active, state, forClean)
|
||||
}
|
||||
for key, item := range node {
|
||||
if key == "$ref" {
|
||||
continue
|
||||
// Charge the stored target size and let nested references charge their own
|
||||
// costs during recursion. The total copied material stays bounded, while an
|
||||
// over-budget chain still expands its affordable outer levels and only the
|
||||
// deepest references degrade to hints.
|
||||
if size := targetExpansionSize(target, forClean); size > state.budget {
|
||||
return resolveRefFallback(root, node, target, ref, active, state, forClean)
|
||||
} else {
|
||||
state.budget -= size
|
||||
}
|
||||
active[ref] = true
|
||||
resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean)
|
||||
delete(active, ref)
|
||||
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
|
||||
out := make(map[string]any, len(targetMap)+len(node))
|
||||
for _, key := range sortedKeys(targetMap) {
|
||||
if forClean && (key == "$defs" || key == "definitions") {
|
||||
continue // dropped with every other definitions container when cleaning
|
||||
}
|
||||
out[key] = targetMap[key]
|
||||
}
|
||||
out[key] = resolveLocalRefs(root, item, active)
|
||||
for _, key := range sortedKeys(node) {
|
||||
if key == "$ref" {
|
||||
continue
|
||||
}
|
||||
if forClean && (key == "$defs" || key == "definitions") {
|
||||
continue
|
||||
}
|
||||
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(node))
|
||||
for _, key := range sortedKeys(node) {
|
||||
if forClean && !inNameMap && (key == "$defs" || key == "definitions") {
|
||||
continue // dropped with every other definitions container when cleaning
|
||||
}
|
||||
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to
|
||||
// a typed hint, but still resolves the sibling subschemas on the same node so their own references
|
||||
// are not left dangling behind the degraded one. Literal values stay verbatim through
|
||||
// resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so
|
||||
// resolved siblings cannot clobber the hint.
|
||||
func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any {
|
||||
out := cyclicRefFallback(node, target, ref)
|
||||
// The target description copied into the fallback is expansion output like any other; charge
|
||||
// it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large
|
||||
// number of degraded references cannot multiply copied descriptions without bound. Cyclic
|
||||
// fallbacks omit over-long descriptions entirely, and omitted content is not charged.
|
||||
if targetMap, okMap := target.(map[string]any); okMap {
|
||||
if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes {
|
||||
if _, hasOwn := node["description"]; !hasOwn {
|
||||
cost := jsonStringEncodedSize(description)
|
||||
if cost > state.budget {
|
||||
out["description"] = "See: " + refName(ref)
|
||||
} else {
|
||||
state.budget -= cost
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range sortedKeys(node) {
|
||||
if key == "$ref" || key == "description" {
|
||||
continue
|
||||
}
|
||||
if forClean && (key == "$defs" || key == "definitions") {
|
||||
delete(out, key)
|
||||
continue
|
||||
}
|
||||
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value
|
||||
// is client data and is deep-copied without resolving refs inside it. Under a name map
|
||||
// ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen
|
||||
// property name whose value is a subschema; name-map entries are therefore resolved directly
|
||||
// here so such property names keep their schema semantics. Nested definition containers outside
|
||||
// the root still resolve through this path; when cleaning, callers skip them entirely instead.
|
||||
func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any {
|
||||
if isLiteralValueKeyword(key) {
|
||||
return deepCopyJSONValue(item)
|
||||
}
|
||||
if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
|
||||
if nameMap, ok := item.(map[string]any); ok {
|
||||
out := make(map[string]any, len(nameMap))
|
||||
for _, name := range sortedKeys(nameMap) {
|
||||
out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return resolveLocalRefs(root, item, active, state, false, forClean)
|
||||
}
|
||||
|
||||
func deepCopyJSONValue(value any) any {
|
||||
switch node := value.(type) {
|
||||
case []any:
|
||||
out := make([]any, len(node))
|
||||
for i, item := range node {
|
||||
out[i] = deepCopyJSONValue(item)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(node))
|
||||
for key, item := range node {
|
||||
out[key] = resolveLocalRefs(root, item, active)
|
||||
out[key] = deepCopyJSONValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
@@ -837,6 +1234,11 @@ func cyclicRefFallback(node map[string]any, target any, ref string) map[string]a
|
||||
if targetMap, ok := target.(map[string]any); ok {
|
||||
for _, key := range []string{"type", "nullable", "description"} {
|
||||
if value, exists := targetMap[key]; exists {
|
||||
if key == "description" {
|
||||
if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
@@ -870,10 +1272,12 @@ func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
|
||||
sortByDepth(paths)
|
||||
|
||||
for _, p := range paths {
|
||||
parentPath := trimSuffix(p, ".$ref")
|
||||
if isPropertyDefinition(parentPath) {
|
||||
continue // a property literally named "$ref", not a reference keyword
|
||||
}
|
||||
refVal := gjson.Get(jsonStr, p).String()
|
||||
defName := refName(refVal)
|
||||
|
||||
parentPath := trimSuffix(p, ".$ref")
|
||||
hint := fmt.Sprintf("See: %s", defName)
|
||||
if !preserveSiblings {
|
||||
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
|
||||
@@ -1582,6 +1986,11 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Literal values are client data; x-* keys inside them are not schema extensions.
|
||||
if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
|
||||
return true
|
||||
}
|
||||
|
||||
walkForExtensions(val, childPath, paths)
|
||||
return true
|
||||
})
|
||||
@@ -1698,7 +2107,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
|
||||
|
||||
func findPaths(jsonStr, field string) []string {
|
||||
var paths []string
|
||||
Walk(gjson.Parse(jsonStr), "", field, &paths)
|
||||
WalkSchema(gjson.Parse(jsonStr), "", field, &paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -1730,6 +2139,12 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
|
||||
paths[keyStr] = append(paths[keyStr], childPath)
|
||||
}
|
||||
|
||||
// Literal values are client data; schema-looking keys inside them are not keywords.
|
||||
// Under a name map the key is a property name instead, so keep descending there.
|
||||
if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
|
||||
return true
|
||||
}
|
||||
|
||||
walkForFields(val, childPath, fields, paths)
|
||||
return true
|
||||
})
|
||||
@@ -1767,11 +2182,14 @@ func setRawAt(jsonStr, path, value string) string {
|
||||
}
|
||||
|
||||
// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
|
||||
// subschemas. A key directly under one of them is a name, never a schema keyword.
|
||||
// subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07
|
||||
// "dependencies" container qualifies for object (schema) values; array values are property-name
|
||||
// lists and are never walked as maps by the checks that use this set.
|
||||
var schemaNameMapKeywords = map[string]struct{}{
|
||||
"properties": {},
|
||||
"patternProperties": {},
|
||||
"dependentSchemas": {},
|
||||
"dependencies": {},
|
||||
"$defs": {},
|
||||
"definitions": {},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -3793,3 +3794,502 @@ func TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699(t *testing.T)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_GeminiCleanersInlineLocalDefs verifies that the Gemini cleaners expand local
|
||||
// $defs references before the definition container is stripped, so nested fields survive.
|
||||
func TestIssue6732_GeminiCleanersInlineLocalDefs(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
|
||||
|
||||
for name, clean := range map[string]func(string) string{
|
||||
"gemini": CleanJSONSchemaForGemini,
|
||||
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
|
||||
} {
|
||||
got := clean(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("%s: inlined properties.person.properties.name.type = %q, want %q: %s", name, gotType, "string", got)
|
||||
}
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Errorf("%s: $defs should be removed after inlining: %s", name, got)
|
||||
}
|
||||
if strings.Contains(got, "See: Person") {
|
||||
t.Errorf("%s: resolvable local ref degraded to a hint: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_GeminiCleanersInlinePropertyReuse verifies that a Zod-style #/properties/...
|
||||
// reference is expanded by the Gemini cleaners instead of becoming a description hint.
|
||||
func TestIssue6732_GeminiCleanersInlinePropertyReuse(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"billing":{"type":"object","properties":{"zip":{"type":"string"}}},"shipping":{"$ref":"#/properties/billing"}}}`
|
||||
|
||||
for name, clean := range map[string]func(string) string{
|
||||
"gemini": CleanJSONSchemaForGemini,
|
||||
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
|
||||
} {
|
||||
got := clean(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.shipping.properties.zip.type").String(); gotType != "string" {
|
||||
t.Errorf("%s: properties.shipping.properties.zip.type = %q, want %q: %s", name, gotType, "string", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_GeminiCleanersReuseSameDefinitionTwice verifies that two properties referencing
|
||||
// the same definition both keep the referenced fields; bounding expansion must not degrade
|
||||
// ordinary reuse of a shared definition to a hint.
|
||||
func TestIssue6732_GeminiCleanersReuseSameDefinitionTwice(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"billing":{"$ref":"#/$defs/Address"},"shipping":{"$ref":"#/$defs/Address"}},"$defs":{"Address":{"type":"object","properties":{"zip":{"type":"string"}}}}}`
|
||||
|
||||
for name, clean := range map[string]func(string) string{
|
||||
"gemini": CleanJSONSchemaForGemini,
|
||||
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
|
||||
} {
|
||||
got := clean(input)
|
||||
parsed := gjson.Parse(got)
|
||||
for _, field := range []string{"billing", "shipping"} {
|
||||
if gotType := parsed.Get("properties." + field + ".properties.zip.type").String(); gotType != "string" {
|
||||
t.Errorf("%s: properties.%s.properties.zip.type = %q, want %q: %s", name, field, gotType, "string", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot verifies that a schema wrapped in the
|
||||
// single-key {"schema": ...} carrier used by the Antigravity executor resolves local refs
|
||||
// against the inner schema rather than the carrier.
|
||||
func TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot(t *testing.T) {
|
||||
inner := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
|
||||
wrapped := `{"schema":` + inner + `}`
|
||||
|
||||
got := CleanJSONSchemaForAntigravityTool(wrapped, false)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("schema.properties.person.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("wrapped schema.properties.person.properties.name.type = %q, want %q: %s", gotType, "string", got)
|
||||
}
|
||||
if parsed.Get("schema.$defs").Exists() {
|
||||
t.Errorf("wrapped $defs should be removed after inlining: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "See: Person") {
|
||||
t.Errorf("wrapped resolvable local ref degraded to a hint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute verifies that the parametersJsonSchema
|
||||
// route forwards default values verbatim even when they contain schema-looking keys.
|
||||
func TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"report":{"type":"object","default":{"title":"Report","format":"pdf","id":7,"x-owner":"ops"}}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
def := gjson.Parse(got).Get("properties.report.default")
|
||||
if !def.Exists() {
|
||||
t.Fatalf("default literal missing: %s", got)
|
||||
}
|
||||
if def.Get("title").String() != "Report" || def.Get("format").String() != "pdf" || def.Get("id").Int() != 7 || def.Get("x-owner").String() != "ops" {
|
||||
t.Errorf("default literal was rewritten, got %s: %s", def.Raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_ExampleLiteralsPreserved verifies that example/examples values keep
|
||||
// schema-looking keys on the parametersJsonSchema route.
|
||||
func TestIssue6732_ExampleLiteralsPreserved(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"a":{"type":"string","example":{"title":"Hi","x-owner":"ops","id":3},"examples":[{"title":"Hi2","x-owner":"ops","id":4}]}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
example := parsed.Get("properties.a.example")
|
||||
if example.Get("title").String() != "Hi" || example.Get("x-owner").String() != "ops" || example.Get("id").Int() != 3 {
|
||||
t.Errorf("example literal was rewritten, got %s: %s", example.Raw, got)
|
||||
}
|
||||
exampleItem := parsed.Get("properties.a.examples.0")
|
||||
if exampleItem.Get("title").String() != "Hi2" || exampleItem.Get("x-owner").String() != "ops" || exampleItem.Get("id").Int() != 4 {
|
||||
t.Errorf("examples literal was rewritten, got %s: %s", exampleItem.Raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_InlineLocalRefsLeavesLiteralRefs verifies that the inliner does not resolve
|
||||
// {"$ref": ...} objects that appear as client data inside a default value.
|
||||
func TestIssue6732_InlineLocalRefsLeavesLiteralRefs(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"a":{"type":"object","default":{"$ref":"#/$defs/Person"}}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
|
||||
|
||||
got := InlineLocalRefs(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if ref := parsed.Get("properties.a.default.$ref").String(); ref != "#/$defs/Person" {
|
||||
t.Errorf("literal $ref inside default = %q, want it preserved verbatim: %s", ref, got)
|
||||
}
|
||||
if parsed.Get("properties.a.default.properties").Exists() {
|
||||
t.Errorf("literal $ref inside default was expanded: %s", got)
|
||||
}
|
||||
if !parsed.Get("$defs.Person.properties.name").Exists() {
|
||||
t.Errorf("InlineLocalRefs must not strip definitions: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas verifies that properties literally
|
||||
// named "default" and "type" keep their schema semantics: their own keywords are still cleaned
|
||||
// and their own $ref is still inlined.
|
||||
func TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"default":{"$ref":"#/$defs/Person"},"type":{"type":"string","title":"Kind"}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.default.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("property named \"default\" ref was not inlined, name type = %q: %s", gotType, got)
|
||||
}
|
||||
if parsed.Get("properties.type.title").Exists() {
|
||||
t.Errorf("property named \"type\" schema title should still be cleaned: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_ExpansionBudgetBoundsSharedDefs verifies that inlining a schema whose shared
|
||||
// definitions fan out exponentially stays bounded instead of expanding without limit, while the
|
||||
// affordable outer levels are still expanded.
|
||||
func TestIssue6732_ExpansionBudgetBoundsSharedDefs(t *testing.T) {
|
||||
input := geminiSchemaDiamondInput()
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
if len(got) >= 512*1024 {
|
||||
t.Errorf("diamond schema output = %d bytes, want < %d (input %d bytes)", len(got), 512*1024, len(input))
|
||||
}
|
||||
parsed := gjson.Parse(got)
|
||||
if !parsed.Get("properties.leaf").Exists() {
|
||||
t.Errorf("diamond schema lost the top-level property: %s", got)
|
||||
}
|
||||
if !parsed.Get("properties.leaf.properties.a.properties.a").Exists() {
|
||||
t.Errorf("diamond schema outer levels should still be expanded: %s", got)
|
||||
}
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Errorf("diamond schema should not retain $defs: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget verifies that definitions the
|
||||
// cleaners are about to delete anyway neither consume the expansion budget nor make the kept
|
||||
// fields depend on map iteration order.
|
||||
func TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"},"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}`)
|
||||
for i := range 13 {
|
||||
target := "leaf0"
|
||||
if i > 0 {
|
||||
target = "D" + strconv.Itoa(i-1)
|
||||
}
|
||||
sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
|
||||
}
|
||||
sb.WriteString(`}}`)
|
||||
input := sb.String()
|
||||
|
||||
first := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
for run := range 30 {
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
if got != first {
|
||||
t.Fatalf("run %d: output is not deterministic:\nfirst: %s\ngot: %s", run, first, got)
|
||||
}
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
|
||||
t.Fatalf("run %d: used definition was not inlined, name type = %q: %s", run, gotType, got)
|
||||
}
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Fatalf("run %d: $defs should be removed: %s", run, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions verifies that references degraded by
|
||||
// the expansion budget do not each copy a large target description, which would defeat the bound.
|
||||
func TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"type":"object","properties":{`)
|
||||
for i := range 200 {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/Big"}`)
|
||||
}
|
||||
sb.WriteString(`},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`)
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
|
||||
if len(got) >= 512*1024 {
|
||||
t.Errorf("shared large-description output = %d bytes, want < %d", len(got), 512*1024)
|
||||
}
|
||||
parsed := gjson.Parse(got)
|
||||
if !parsed.Get("properties.p0").Exists() || !parsed.Get("properties.p199").Exists() {
|
||||
t.Errorf("shared large-description schema lost properties: %s", got)
|
||||
}
|
||||
if gotType := parsed.Get("properties.p0.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("first affordable expansion should keep its fields, name type = %q: %s", gotType, got)
|
||||
}
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Errorf("$defs should be removed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget verifies that a self-contained
|
||||
// reference graph inside a nested definitions container (which cleaning deletes at every level)
|
||||
// neither spends the expansion budget nor starves kept fields.
|
||||
func TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"},"a":{"type":"object","$defs":{"Leaf":{"type":"string","description":"` + strings.Repeat("x", 1024) + `"}`)
|
||||
for i := range 256 {
|
||||
sb.WriteString(`,"U` + strconv.Itoa(i) + `":{"$ref":"#/properties/a/$defs/Leaf"}`)
|
||||
}
|
||||
sb.WriteString(`}}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`)
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("nested unused definitions starved the kept field, name type = %q: %s", gotType, got)
|
||||
}
|
||||
if parsed.Get("properties.a.$defs").Exists() || parsed.Get("$defs").Exists() {
|
||||
t.Errorf("nested and root $defs should be removed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_RefSiblingNameMapReferencesStillExpand verifies that references inside a $ref
|
||||
// node's own name-map siblings expand when the reference target itself expands.
|
||||
func TestIssue6732_RefSiblingNameMapReferencesStillExpand(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Base","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Base":{"type":"object"},"S":{"type":"string"}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
|
||||
t.Errorf("ref sibling name-map reference lost its type, got %q: %s", gotType, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate verifies that the cleaning
|
||||
// inliner's intermediate output does not serialize definition containers (root or carried inside
|
||||
// expanded targets), so repeated references to a target holding a huge unused definitions block
|
||||
// cannot multiply that block in the intermediate JSON.
|
||||
func TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate(t *testing.T) {
|
||||
person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
|
||||
input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Person"},"p1":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`
|
||||
|
||||
got := inlineLocalRefsForClean(input)
|
||||
if len(got) >= 64*1024 {
|
||||
t.Errorf("clean inlining intermediate = %d bytes, want < %d", len(got), 64*1024)
|
||||
}
|
||||
parsed := gjson.Parse(got)
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Errorf("intermediate should not carry root $defs: %s", got)
|
||||
}
|
||||
if parsed.Get("properties.p0.$defs").Exists() {
|
||||
t.Errorf("intermediate should not carry target-nested $defs: %s", got)
|
||||
}
|
||||
if gotType := parsed.Get("properties.p1.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("second expansion lost its fields, name type = %q: %s", gotType, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems verifies that the placeholder items
|
||||
// Phase 0 adds to a typed but unresolved $ref node does not clobber the items of the referenced
|
||||
// definition during inlining.
|
||||
func TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Rows"}},"$defs":{"Rows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.rows.items.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("array ref target items were clobbered, name type = %q: %s", gotType, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_BudgetFallbackStillResolvesSiblings verifies that when a reference target is too
|
||||
// large to expand, sibling subschemas on the same node are still resolved instead of being copied
|
||||
// verbatim with their own references left dangling.
|
||||
func TestIssue6732_BudgetFallbackStillResolvesSiblings(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Big","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 280*1024) + `"},"S":{"type":"string"}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
|
||||
t.Errorf("sibling reference after budget fallback lost its type, got %q: %s", gotType, got)
|
||||
}
|
||||
if strings.Contains(got, `"$ref"`) {
|
||||
t.Errorf("cleaned schema left a dangling reference: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics verifies that definitions whose
|
||||
// names look like schema keywords are treated as schemas by the public inliner: a definition named
|
||||
// "properties" keeps its literal default intact, and a definition named "default" still expands.
|
||||
func TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics(t *testing.T) {
|
||||
input := `{"$defs":{"S":{"type":"string"},"properties":{"type":"object","default":{"$ref":"#/$defs/S"}},"default":{"type":"object","properties":{"x":{"$ref":"#/$defs/S"}}}}}`
|
||||
|
||||
got := InlineLocalRefs(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if ref := parsed.Get("$defs.properties.default.$ref").String(); ref != "#/$defs/S" {
|
||||
t.Errorf("literal default inside definition named \"properties\" was expanded, $ref = %q: %s", ref, got)
|
||||
}
|
||||
if gotType := parsed.Get("$defs.default.properties.x.type").String(); gotType != "string" {
|
||||
t.Errorf("definition named \"default\" was not resolved as a schema, x type = %q: %s", gotType, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_TargetNestedDefinitionsExemptFromBudget verifies that unused definitions nested
|
||||
// inside a referenced target (which cleaning deletes) do not inflate the target's budget charge.
|
||||
func TestIssue6732_TargetNestedDefinitionsExemptFromBudget(t *testing.T) {
|
||||
person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
|
||||
cases := map[string]string{
|
||||
"propertyRef": `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`,
|
||||
"rootRef": `{"$ref":"#/$defs/Person","$defs":{"Person":` + person + `}}`,
|
||||
}
|
||||
for caseName, input := range cases {
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
namePath := "properties.person.properties.name.type"
|
||||
if caseName == "rootRef" {
|
||||
namePath = "properties.name.type"
|
||||
}
|
||||
if gotType := parsed.Get(namePath).String(); gotType != "string" {
|
||||
t.Errorf("%s: %s = %q, want %q: %s", caseName, namePath, gotType, "string", got)
|
||||
}
|
||||
if parsed.Get("$defs").Exists() {
|
||||
t.Errorf("%s: $defs should be removed: %s", caseName, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder verifies that a typed array whose
|
||||
// referenced definition carries no items still ends up with the placeholder items Phase 0 would
|
||||
// have added, and that cleaning twice is stable.
|
||||
func TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Common"}},"$defs":{"Common":{"description":"Rows"}}}`
|
||||
|
||||
first := CleanJSONSchemaForAntigravityTool(input, false)
|
||||
parsed := gjson.Parse(first)
|
||||
if gotType := parsed.Get("properties.rows.items.type").String(); gotType != "string" {
|
||||
t.Errorf("array ref without target items missing placeholder items, got %q: %s", gotType, first)
|
||||
}
|
||||
if second := CleanJSONSchemaForAntigravityTool(first, false); second != first {
|
||||
t.Errorf("cleaning is not idempotent for array refs:\nfirst: %s\nsecond: %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_SmallTargetFanOutStaysBounded verifies that thousands of references to a small
|
||||
// target with a near-limit description cannot multiply the description without bound through a
|
||||
// mix of expansions and fallback copies.
|
||||
func TestIssue6732_SmallTargetFanOutStaysBounded(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"type":"object","properties":{`)
|
||||
for i := range 2000 {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/S"}`)
|
||||
}
|
||||
sb.WriteString(`},"$defs":{"S":{"type":"string","description":"` + strings.Repeat("x", 512) + `"}}}`)
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
|
||||
if len(got) >= 512*1024 {
|
||||
t.Errorf("small-target fan-out output = %d bytes, want < %d", len(got), 512*1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_EscapedDescriptionFanOutStaysBounded verifies that budget accounting covers
|
||||
// HTML-escaped string growth during JSON encoding.
|
||||
func TestIssue6732_EscapedDescriptionFanOutStaysBounded(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Big"},"p1":{"$ref":"#/$defs/Big"},"p2":{"$ref":"#/$defs/Big"},"p3":{"$ref":"#/$defs/Big"},"p4":{"$ref":"#/$defs/Big"},"p5":{"$ref":"#/$defs/Big"},"p6":{"$ref":"#/$defs/Big"},"p7":{"$ref":"#/$defs/Big"}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("<", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
if len(got) >= 512*1024 {
|
||||
t.Errorf("escaped-description fan-out output = %d bytes, want < %d", len(got), 512*1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_DependenciesNameMapKeepsSchemaSemantics verifies that the Draft-07 dependencies
|
||||
// container behaves as a name map: a dependency keyed "default" holds a schema whose references
|
||||
// still expand in the public inliner.
|
||||
func TestIssue6732_DependenciesNameMapKeepsSchemaSemantics(t *testing.T) {
|
||||
input := `{"type":"object","dependencies":{"default":{"properties":{"x":{"$ref":"#/definitions/S"}}}},"definitions":{"S":{"type":"string"}}}`
|
||||
|
||||
got := InlineLocalRefs(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("dependencies.default.properties.x.type").String(); gotType != "string" {
|
||||
t.Errorf("dependency schema named \"default\" was not resolved, x type = %q: %s", gotType, got)
|
||||
}
|
||||
if parsed.Get("dependencies.default.properties.x.$ref").Exists() {
|
||||
t.Errorf("dependency schema reference was left dangling: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_OmittedFallbackDescriptionNotCharged verifies that a target description the
|
||||
// fallback omits (because it exceeds the copy limit) is not charged to the expansion budget, so
|
||||
// a following small reference still expands.
|
||||
func TestIssue6732_OmittedFallbackDescriptionNotCharged(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/A"},"b":{"$ref":"#/$defs/B"}},"$defs":{"A":{"type":"string","description":"` + strings.Repeat("x", 262142) + `"},"B":{"type":"object","properties":{"name":{"type":"string"}}}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get("properties.b.properties.name.type").String(); gotType != "string" {
|
||||
t.Errorf("small reference after omitted fallback description lost its fields, name type = %q: %s", gotType, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget verifies that the type Phase 0 would
|
||||
// infer from a ref node's sibling items does not clobber the referenced definition's object
|
||||
// type, and that cleaning the result again is stable.
|
||||
func TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget(t *testing.T) {
|
||||
input := `{"$ref":"#/$defs/O","items":{"type":"integer"},"$defs":{"O":{"type":"object","properties":{"x":{"type":"string"}}}}}`
|
||||
|
||||
first := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(first)
|
||||
if gotType := parsed.Get("type").String(); gotType != "object" {
|
||||
t.Errorf("root ref target type = %q, want %q: %s", gotType, "object", first)
|
||||
}
|
||||
if parsed.Get("items").Exists() {
|
||||
t.Errorf("object schema should not carry items: %s", first)
|
||||
}
|
||||
if gotType := parsed.Get("properties.x.type").String(); gotType != "string" {
|
||||
t.Errorf("target property lost its type, got %q: %s", gotType, first)
|
||||
}
|
||||
if second := CleanJSONSchemaForGeminiJSONSchema(first); second != first {
|
||||
t.Errorf("cleaning is not idempotent:\nfirst: %s\nsecond: %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_PropertyNamedRefRemainsProperty verifies that a property literally named "$ref"
|
||||
// is treated as a property, not as a reference keyword, by the cleaner.
|
||||
func TestIssue6732_PropertyNamedRefRemainsProperty(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"$ref":{"type":"string"}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get(`properties.\$ref.type`).String(); gotType != "string" {
|
||||
t.Errorf("property named $ref lost its schema, type = %q: %s", gotType, got)
|
||||
}
|
||||
if strings.Contains(got, "See:") {
|
||||
t.Errorf("property named $ref was converted into a hint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssue6732_PropertyNamedConstRemainsProperty verifies that a property literally named
|
||||
// "const" holding a schema object is not converted into an enum constraint on its parent.
|
||||
func TestIssue6732_PropertyNamedConstRemainsProperty(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"const":{"type":"string"}}}`
|
||||
|
||||
got := CleanJSONSchemaForGeminiJSONSchema(input)
|
||||
parsed := gjson.Parse(got)
|
||||
if gotType := parsed.Get(`properties.const.type`).String(); gotType != "string" {
|
||||
t.Errorf("property named const lost its schema, type = %q: %s", gotType, got)
|
||||
}
|
||||
if parsed.Get("enum").Exists() {
|
||||
t.Errorf("property named const fabricates a root enum: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// geminiSchemaDiamondInput builds a schema referencing D12, where each Di is an object with two
|
||||
// properties referencing Di-1 and D0 references a leaf definition twice. Fully expanding it
|
||||
// produces 2^13 leaf copies.
|
||||
func geminiSchemaDiamondInput() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"type":"object","properties":{"leaf":{"$ref":"#/$defs/D12"}},"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"}`)
|
||||
for i := range 13 {
|
||||
target := "leaf0"
|
||||
if i > 0 {
|
||||
target = "D" + strconv.Itoa(i-1)
|
||||
}
|
||||
sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
|
||||
}
|
||||
sb.WriteString(`}}`)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@ import (
|
||||
// The function works recursively, building dot-notation paths to each occurrence
|
||||
// of the specified field throughout the JSON structure.
|
||||
func Walk(value gjson.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 gjson.Result, path, field string, paths *[]string) {
|
||||
walk(value, path, field, paths, true)
|
||||
}
|
||||
|
||||
func walk(value gjson.Result, path, field string, paths *[]string, schemaAware bool) {
|
||||
switch value.Type {
|
||||
case gjson.JSON:
|
||||
// For JSON objects and arrays, iterate through each child
|
||||
@@ -49,7 +62,10 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
|
||||
if keyStr == field {
|
||||
*paths = append(*paths, childPath)
|
||||
}
|
||||
Walk(val, childPath, field, paths)
|
||||
if schemaAware && isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
|
||||
return true
|
||||
}
|
||||
walk(val, childPath, field, paths, schemaAware)
|
||||
return true
|
||||
})
|
||||
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
|
||||
|
||||
Reference in New Issue
Block a user