mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-28 02:21:59 +08:00
fix(schema): preserve multi-branch anyOf/oneOf unions during schema sanitization
- Retain multi-branch `anyOf` and `oneOf` unions instead of flattening them into a single branch. - Normalize `oneOf` to `anyOf` on native Gemini/Antigravity schema carriers while preserving `oneOf` when standard constraints are allowed. - Materialize required-only union branches using parent object properties. - Strip null and boolean `false` branches from unions, applying `nullable: true` and description hints where appropriate. Closes: #3662
This commit is contained in:
@@ -275,6 +275,15 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
|
||||
payload := `{"request":{
|
||||
"tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
|
||||
"choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
|
||||
"item":{"anyOf":[
|
||||
{"type":"string"},
|
||||
{"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},
|
||||
{"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}
|
||||
]},
|
||||
"variant":{"oneOf":[
|
||||
{"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},
|
||||
{"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}
|
||||
]},
|
||||
"level":{"type":"number","enum":[1,2]}
|
||||
}}}]}],
|
||||
"generationConfig":{"responseSchema":{"type":"object","properties":{
|
||||
@@ -304,7 +313,22 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
|
||||
|
||||
toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
|
||||
if toolSchema.Get("properties.choice.anyOf").Exists() {
|
||||
t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)
|
||||
t.Errorf("tool nullable single-branch anyOf union was not flattened: %s", toolSchema.Raw)
|
||||
}
|
||||
if gotBranches := len(toolSchema.Get("properties.item.anyOf").Array()); gotBranches != 3 {
|
||||
t.Errorf("tool multi-branch anyOf count = %d, want 3: %s", gotBranches, toolSchema.Raw)
|
||||
}
|
||||
if !toolSchema.Get("properties.item.anyOf.2.properties.audio").Exists() {
|
||||
t.Errorf("tool multi-branch anyOf lost audio branch: %s", toolSchema.Raw)
|
||||
}
|
||||
if toolSchema.Get("properties.variant.oneOf").Exists() {
|
||||
t.Errorf("tool multi-branch oneOf should be converted to anyOf for parameters Schema carrier: %s", toolSchema.Raw)
|
||||
}
|
||||
if gotVariantBranches := len(toolSchema.Get("properties.variant.anyOf").Array()); gotVariantBranches != 2 {
|
||||
t.Errorf("tool multi-branch variant.anyOf count = %d, want 2: %s", gotVariantBranches, toolSchema.Raw)
|
||||
}
|
||||
if !toolSchema.Get("properties.variant.anyOf.1.properties.audio").Exists() {
|
||||
t.Errorf("tool multi-branch variant.anyOf lost audio branch: %s", toolSchema.Raw)
|
||||
}
|
||||
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
|
||||
t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
|
||||
|
||||
@@ -71,7 +71,7 @@ func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool)
|
||||
// additionalProperties: false (which Antigravity natively enforces for response schemas).
|
||||
// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
|
||||
// - Flattened: allOf merged into properties/required.
|
||||
// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
|
||||
// - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with nullable:true.
|
||||
// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
|
||||
// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
|
||||
// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
|
||||
@@ -136,7 +136,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
jsonStr = mergeConditionals(jsonStr)
|
||||
jsonStr = mergeAllOf(jsonStr)
|
||||
if options.flattenUnions {
|
||||
jsonStr = flattenAnyOfOneOf(jsonStr)
|
||||
jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints)
|
||||
}
|
||||
jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
|
||||
|
||||
@@ -1121,80 +1121,233 @@ func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func flattenAnyOfOneOf(jsonStr string) string {
|
||||
for _, key := range []string{"anyOf", "oneOf"} {
|
||||
paths := findPaths(jsonStr, key)
|
||||
sortByDepth(paths)
|
||||
func isRequiredOnlyUnionBranch(item gjson.Result) bool {
|
||||
if !item.IsObject() || !item.Get("required").Exists() {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} {
|
||||
if item.Get(key).Exists() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
arr := gjson.Get(jsonStr, p)
|
||||
if !arr.IsArray() || len(arr.Array()) == 0 {
|
||||
func materializeRequiredOnlyBranch(item, parentProps gjson.Result) (gjson.Result, bool) {
|
||||
if !parentProps.IsObject() {
|
||||
return gjson.Result{}, false
|
||||
}
|
||||
reqs := getStrings(item.Raw, "required")
|
||||
if len(reqs) == 0 {
|
||||
return gjson.Result{}, false
|
||||
}
|
||||
raw := item.Raw
|
||||
for _, reqName := range reqs {
|
||||
escaped := escapeGJSONPathKey(reqName)
|
||||
propVal := parentProps.Get(escaped)
|
||||
if !propVal.Exists() {
|
||||
return gjson.Result{}, false
|
||||
}
|
||||
updated, errSet := sjson.SetRawBytes([]byte(raw), "properties."+escaped, []byte(propVal.Raw))
|
||||
if errSet == nil {
|
||||
raw = string(updated)
|
||||
}
|
||||
}
|
||||
if updated, errSet := sjson.SetBytes([]byte(raw), "type", "object"); errSet == nil {
|
||||
raw = string(updated)
|
||||
}
|
||||
return gjson.Parse(raw), true
|
||||
}
|
||||
|
||||
func isNullOnlyBranch(item gjson.Result) bool {
|
||||
if item.Get("properties").Exists() || item.Get("items").Exists() {
|
||||
return false
|
||||
}
|
||||
t := item.Get("type")
|
||||
if t.Type == gjson.String {
|
||||
return t.String() == "null"
|
||||
}
|
||||
if t.IsArray() && len(t.Array()) > 0 {
|
||||
for _, elem := range t.Array() {
|
||||
if elem.String() != "null" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func partitionUnionBranches(items []gjson.Result, parentProps gjson.Result) (nonNullItems []gjson.Result, hasNull, hasFilteredBranch bool) {
|
||||
for _, item := range items {
|
||||
if item.Type == gjson.True {
|
||||
nonNullItems = append(nonNullItems, gjson.Parse("{}"))
|
||||
hasFilteredBranch = true
|
||||
continue
|
||||
}
|
||||
if !item.IsObject() {
|
||||
hasFilteredBranch = true
|
||||
continue
|
||||
}
|
||||
if isNullOnlyBranch(item) {
|
||||
hasNull = true
|
||||
continue
|
||||
}
|
||||
if isRequiredOnlyUnionBranch(item) {
|
||||
if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok {
|
||||
nonNullItems = append(nonNullItems, materialized)
|
||||
hasFilteredBranch = true
|
||||
continue
|
||||
}
|
||||
hasFilteredBranch = true
|
||||
continue
|
||||
}
|
||||
nonNullItems = append(nonNullItems, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
parentPath := trimSuffix(p, "."+key)
|
||||
parent := gjson.Get(jsonStr, parentPath)
|
||||
if parentPath == "" {
|
||||
parent = gjson.Parse(jsonStr)
|
||||
func flattenAnyOfOneOf(jsonStr string, preserveOneOf bool) string {
|
||||
pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"})
|
||||
var paths []string
|
||||
for _, key := range []string{"oneOf", "anyOf"} {
|
||||
paths = append(paths, pathsByField[key]...)
|
||||
}
|
||||
sortByDepth(paths)
|
||||
|
||||
for _, p := range paths {
|
||||
key := "anyOf"
|
||||
if p == "oneOf" || strings.HasSuffix(p, ".oneOf") {
|
||||
key = "oneOf"
|
||||
}
|
||||
|
||||
arr := gjson.Get(jsonStr, p)
|
||||
if !arr.IsArray() || len(arr.Array()) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parentPath := trimSuffix(p, "."+key)
|
||||
if isPropertyDefinition(parentPath) {
|
||||
continue
|
||||
}
|
||||
parent := gjson.Get(jsonStr, parentPath)
|
||||
if parentPath == "" {
|
||||
parent = gjson.Parse(jsonStr)
|
||||
}
|
||||
|
||||
// When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf),
|
||||
// protobuf Schema has only a single anyOf field and cannot represent a second union
|
||||
// conjunction structurally. Preserve anyOf as the primary union and move the sibling
|
||||
// oneOf constraint to a description hint instead of concatenating it into anyOf.
|
||||
if key == "oneOf" && !preserveOneOf && parent.Get("anyOf").IsArray() {
|
||||
jsonStr = appendHint(jsonStr, parentPath, "oneOf: "+arr.Raw)
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
continue
|
||||
}
|
||||
|
||||
items := arr.Array()
|
||||
parentProps := parent.Get("properties")
|
||||
nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps)
|
||||
|
||||
// Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost.
|
||||
// On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because
|
||||
// the protobuf Schema message supports anyOf but rejects oneOf.
|
||||
if len(nonNullItems) >= 2 {
|
||||
targetKey := key
|
||||
if key == "oneOf" && !preserveOneOf {
|
||||
targetKey = "anyOf"
|
||||
}
|
||||
targetPath := joinPath(parentPath, targetKey)
|
||||
|
||||
items := arr.Array()
|
||||
|
||||
// If the parent already defines properties (e.g. an object schema with anyOf/oneOf constraints),
|
||||
// do not replace the parent with a single branch. Instead, merge any branch properties
|
||||
// into the parent and delete the union keyword.
|
||||
if parentProps := parent.Get("properties"); parentProps.IsObject() {
|
||||
hasNull := false
|
||||
for _, item := range items {
|
||||
if item.Get("type").String() == "null" {
|
||||
hasNull = true
|
||||
}
|
||||
if branchProps := item.Get("properties"); branchProps.IsObject() {
|
||||
branchProps.ForEach(func(propKey, propVal gjson.Result) bool {
|
||||
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
|
||||
jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal)
|
||||
return true
|
||||
})
|
||||
}
|
||||
if targetKey != key || hasNull || hasFilteredBranch {
|
||||
rawBranches := make([]json.RawMessage, 0, len(nonNullItems))
|
||||
for _, item := range nonNullItems {
|
||||
rawBranches = append(rawBranches, json.RawMessage(item.Raw))
|
||||
}
|
||||
if hasNull {
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
|
||||
updated, errSet := sjson.SetBytes([]byte(jsonStr), targetPath, rawBranches)
|
||||
if errSet == nil {
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
if parent.Get("type").String() == "" {
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object")
|
||||
if targetKey != key {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
}
|
||||
}
|
||||
if hasNull {
|
||||
updated, errSet := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
|
||||
if errSet == nil {
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If the parent already defines properties (e.g. an object schema with unmaterializable
|
||||
// union constraints or a single-branch union), do not replace the parent with a single branch.
|
||||
// Instead, merge any branch properties into the parent and delete the union keyword.
|
||||
if parentProps.IsObject() {
|
||||
for _, item := range nonNullItems {
|
||||
if branchProps := item.Get("properties"); branchProps.IsObject() {
|
||||
branchProps.ForEach(func(propKey, propVal gjson.Result) bool {
|
||||
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
|
||||
jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
if hasNull {
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
if parent.Get("type").String() == "" {
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object")
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(nonNullItems) == 0 {
|
||||
if !hasNull {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
continue
|
||||
}
|
||||
|
||||
parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
|
||||
bestIdx, allTypes := selectBest(items)
|
||||
selected := items[bestIdx].Raw
|
||||
hasNull := false
|
||||
for _, item := range items {
|
||||
if item.Get("type").String() == "null" {
|
||||
hasNull = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasNull && items[bestIdx].Get("type").String() != "null" {
|
||||
updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
|
||||
selected = string(updated)
|
||||
}
|
||||
|
||||
if parentDesc != "" {
|
||||
selected := `{"type":"null"}`
|
||||
if parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); parentDesc != "" {
|
||||
selected = mergeDescriptionRaw(selected, parentDesc)
|
||||
}
|
||||
|
||||
if len(allTypes) > 1 {
|
||||
hint := "Accepts: " + strings.Join(allTypes, " | ")
|
||||
selected = appendHintRaw(selected, hint)
|
||||
}
|
||||
|
||||
jsonStr = setRawAt(jsonStr, parentPath, selected)
|
||||
continue
|
||||
}
|
||||
|
||||
candidateItems := items
|
||||
if hasFilteredBranch {
|
||||
candidateItems = nonNullItems
|
||||
if hasNull {
|
||||
candidateItems = append(append([]gjson.Result(nil), nonNullItems...), gjson.Parse(`{"type":"null"}`))
|
||||
}
|
||||
}
|
||||
|
||||
parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
|
||||
_, allTypes := selectBest(candidateItems)
|
||||
selected := nonNullItems[0].Raw
|
||||
if hasNull {
|
||||
updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
|
||||
selected = string(updated)
|
||||
}
|
||||
|
||||
if parentDesc != "" {
|
||||
selected = mergeDescriptionRaw(selected, parentDesc)
|
||||
}
|
||||
|
||||
if len(allTypes) > 1 {
|
||||
hint := "Accepts: " + strings.Join(allTypes, " | ")
|
||||
selected = appendHintRaw(selected, hint)
|
||||
} else if hasNull {
|
||||
selected = appendHintRaw(selected, "(nullable)")
|
||||
}
|
||||
|
||||
jsonStr = setRawAt(jsonStr, parentPath, selected)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
@@ -1460,6 +1613,9 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
|
||||
reqPath := joinPath(parentPath, "required")
|
||||
reqVal := gjson.Get(jsonStr, reqPath)
|
||||
hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0
|
||||
if gjson.Get(jsonStr, joinPath(parentPath, "anyOf")).IsArray() || gjson.Get(jsonStr, joinPath(parentPath, "oneOf")).IsArray() {
|
||||
continue
|
||||
}
|
||||
|
||||
needsPlaceholder := false
|
||||
if !propsVal.Exists() {
|
||||
|
||||
@@ -158,8 +158,10 @@ func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "string",
|
||||
"description": "Accepts: string | integer"
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -445,14 +447,22 @@ func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "(nullable)"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
if !strings.Contains(result, "Accepts:") {
|
||||
t.Errorf("Expected alternative types hint, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "string") || !strings.Contains(result, "integer") {
|
||||
t.Errorf("Expected all alternative types in hint, got: %s", result)
|
||||
}
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) {
|
||||
@@ -549,7 +559,34 @@ func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
||||
input := `{
|
||||
nullableInput := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"description": "Parent desc",
|
||||
"anyOf": [
|
||||
{ "type": "string", "description": "Child desc" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
expectedNullable := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Parent desc (Child desc) (Accepts: string | null)"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
resultNullable := CleanJSONSchemaForAntigravity(nullableInput)
|
||||
compareJSON(t, expectedNullable, resultNullable)
|
||||
|
||||
multiBranchInput := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
@@ -562,18 +599,8 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t
|
||||
}
|
||||
}`
|
||||
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "string",
|
||||
"description": "Parent desc (Child desc) (Accepts: string | integer)"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
resultMulti := CleanJSONSchemaForAntigravity(multiBranchInput)
|
||||
compareJSON(t, multiBranchInput, resultMulti)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
|
||||
@@ -2280,7 +2307,8 @@ func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T)
|
||||
}
|
||||
|
||||
// TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches tests issue 5219 #2:
|
||||
// anyOf with required-only branches should not overwrite the parent object's type and properties.
|
||||
// anyOf with required-only branches should not overwrite the parent object's type and properties,
|
||||
// and should materialize valid typed object branches from parent properties so the union is preserved.
|
||||
func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
@@ -2304,8 +2332,15 @@ func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *test
|
||||
if !parsed.Get("properties.left").Exists() || !parsed.Get("properties.right").Exists() {
|
||||
t.Fatalf("properties were wiped out; cleaned: %s", got)
|
||||
}
|
||||
if parsed.Get("anyOf").Exists() {
|
||||
t.Fatalf("anyOf was not removed; cleaned: %s", got)
|
||||
branches := parsed.Get("anyOf").Array()
|
||||
if len(branches) != 2 {
|
||||
t.Fatalf("anyOf branch count = %d, want 2; cleaned: %s", len(branches), got)
|
||||
}
|
||||
if branches[0].Get("type").String() != "object" || !branches[0].Get("properties.left").Exists() || branches[0].Get("required.0").String() != "left" {
|
||||
t.Fatalf("anyOf[0] was not materialized into valid object branch: %s", branches[0].Raw)
|
||||
}
|
||||
if branches[1].Get("type").String() != "object" || !branches[1].Get("properties.right").Exists() || branches[1].Get("required.0").String() != "right" {
|
||||
t.Fatalf("anyOf[1] was not materialized into valid object branch: %s", branches[1].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3135,3 +3170,357 @@ func TestCleanJSONSchema_RemovesDependentKeywords_Issue6539(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662 covers Issue #3662:
|
||||
// Multi-branch anyOf/oneOf unions (such as string | image object | audio object) must not be
|
||||
// flattened to a single selected branch, which previously caused non-first object branches (e.g. audio)
|
||||
// to disappear from the cleaned schema.
|
||||
func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" }
|
||||
},
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": { "type": "string" }
|
||||
},
|
||||
"required": ["audio"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["item"]
|
||||
}`
|
||||
|
||||
cleaners := map[string]func(string) string{
|
||||
"Antigravity": CleanJSONSchemaForAntigravity,
|
||||
"AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
|
||||
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
|
||||
"Gemini": CleanJSONSchemaForGemini,
|
||||
"GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
|
||||
}
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := clean(input)
|
||||
compareJSON(t, input, got)
|
||||
|
||||
// Verify idempotency when cleaned a second time
|
||||
gotSecond := clean(got)
|
||||
compareJSON(t, got, gotSecond)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NullableMultiBranchUnionStripsNullBranchAndSetsNullable", func(t *testing.T) {
|
||||
nullableUnion := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
},
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["item"]
|
||||
}`
|
||||
|
||||
agCleaned := gjson.Parse(CleanJSONSchemaForAntigravity(nullableUnion))
|
||||
branches := agCleaned.Get("properties.item.anyOf").Array()
|
||||
if len(branches) != 3 {
|
||||
t.Fatalf("Antigravity anyOf branch count = %d, want 3: %s", len(branches), agCleaned.Raw)
|
||||
}
|
||||
if !agCleaned.Get("properties.item.nullable").Bool() {
|
||||
t.Fatalf("Antigravity properties.item.nullable = false, want true: %s", agCleaned.Raw)
|
||||
}
|
||||
if !agCleaned.Get("properties.item.anyOf.2.properties.audio").Exists() {
|
||||
t.Fatalf("Antigravity lost audio branch: %s", agCleaned.Raw)
|
||||
}
|
||||
|
||||
geminiCleaned := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(nullableUnion))
|
||||
if len(geminiCleaned.Get("properties.item.anyOf").Array()) != 3 {
|
||||
t.Fatalf("GeminiJSONSchema anyOf branch count = %d, want 3: %s", len(geminiCleaned.Get("properties.item.anyOf").Array()), geminiCleaned.Raw)
|
||||
}
|
||||
if geminiCleaned.Get("properties.item.nullable").Exists() {
|
||||
t.Fatalf("GeminiJSONSchema should not keep nullable keyword: %s", geminiCleaned.Raw)
|
||||
}
|
||||
if !strings.Contains(geminiCleaned.Get("properties.item.description").String(), "(nullable)") {
|
||||
t.Fatalf("GeminiJSONSchema missing (nullable) hint: %s", geminiCleaned.Raw)
|
||||
}
|
||||
|
||||
// Nested nullable union in both [null, nestedUnion] and [nestedUnion, null] orders
|
||||
// must unwrap to the nested union with nullable metadata and remain idempotent.
|
||||
for _, nestedNullable := range []string{
|
||||
`{"type":"object","properties":{"item":{"anyOf":[{"type":"null"},{"anyOf":[{"type":"string"},{"type":"integer"}]}]}}}`,
|
||||
`{"type":"object","properties":{"item":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"integer"}]},{"type":"null"}]}}}`,
|
||||
} {
|
||||
expectedNested := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "(nullable)"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cleanedOnce := CleanJSONSchemaForAntigravity(nestedNullable)
|
||||
compareJSON(t, expectedNested, cleanedOnce)
|
||||
cleanedTwice := CleanJSONSchemaForAntigravity(cleanedOnce)
|
||||
compareJSON(t, expectedNested, cleanedTwice)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OneOfNormalizedToAnyOfOnNativeSchemaCarriers", func(t *testing.T) {
|
||||
oneOfInput := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["item"]
|
||||
}`
|
||||
|
||||
for name, clean := range map[string]func(string) string{
|
||||
"Antigravity": CleanJSONSchemaForAntigravity,
|
||||
"AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
|
||||
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
|
||||
"Gemini": CleanJSONSchemaForGemini,
|
||||
} {
|
||||
parsed := gjson.Parse(clean(oneOfInput))
|
||||
if parsed.Get("properties.item.oneOf").Exists() {
|
||||
t.Fatalf("[%s] oneOf survived on native Schema carrier: %s", name, parsed.Raw)
|
||||
}
|
||||
if got := len(parsed.Get("properties.item.anyOf").Array()); got != 3 {
|
||||
t.Fatalf("[%s] anyOf branch count = %d, want 3: %s", name, got, parsed.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// parametersJsonSchema carrier preserves standard JSON Schema oneOf
|
||||
jsonSchemaParsed := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(oneOfInput))
|
||||
if got := len(jsonSchemaParsed.Get("properties.item.oneOf").Array()); got != 3 {
|
||||
t.Fatalf("[GeminiJSONSchema] oneOf branch count = %d, want 3: %s", got, jsonSchemaParsed.Raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PreservesUnionAndBranchRequiredWhenParentHasProperties", func(t *testing.T) {
|
||||
parentPropsUnion := `{
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := clean(parentPropsUnion)
|
||||
compareJSON(t, parentPropsUnion, got)
|
||||
})
|
||||
}
|
||||
|
||||
// Mixed union with a required-only branch materializes the required-only branch from parent properties
|
||||
// so that the alternative branch (e.g. text) is preserved in anyOf.
|
||||
mixedRequiredOnlyBranch := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"audio": { "type": "string" },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
},
|
||||
{
|
||||
"required": ["text"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
expectedMixedRequiredOnly := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"audio": { "type": "string" },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "text": { "type": "string" } },
|
||||
"required": ["text"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
compareJSON(t, expectedMixedRequiredOnly, CleanJSONSchemaForAntigravity(mixedRequiredOnlyBranch))
|
||||
|
||||
// Pure required-only union branches on an object with parent properties are materialized into valid object branches.
|
||||
pureRequiredOnlyUnion := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"audio": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["image"] },
|
||||
{ "required": ["audio"] }
|
||||
]
|
||||
}`
|
||||
expectedPureRequiredOnly := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"audio": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "image": { "type": "string" } },
|
||||
"required": ["image"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "audio": { "type": "string" } },
|
||||
"required": ["audio"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
compareJSON(t, expectedPureRequiredOnly, CleanJSONSchemaForAntigravity(pureRequiredOnlyUnion))
|
||||
})
|
||||
|
||||
t.Run("BooleanUnionBranchesAndSiblingAnyOfOneOf", func(t *testing.T) {
|
||||
// Boolean false subschema branches in anyOf/oneOf are unsatisfiable and must be stripped
|
||||
// instead of leaking raw boolean false into protobuf Schema anyOf.
|
||||
falseSingleBranch := `{"type":"object","properties":{"value":{"anyOf":[false,{"type":"string"}]}}}`
|
||||
expectedFalseSingle := `{"type":"object","properties":{"value":{"type":"string"}}}`
|
||||
compareJSON(t, expectedFalseSingle, CleanJSONSchemaForAntigravity(falseSingleBranch))
|
||||
|
||||
falseMultiBranch := `{"type":"object","properties":{"value":{"oneOf":[false,{"type":"string"},{"type":"integer"}]}}}`
|
||||
expectedFalseMulti := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"}]}}}`
|
||||
compareJSON(t, expectedFalseMulti, CleanJSONSchemaForAntigravity(falseMultiBranch))
|
||||
|
||||
// Boolean true subschema branch in anyOf/oneOf is normalized to an unconstrained {} branch and preserved.
|
||||
trueMultiBranch := `{"type":"object","properties":{"value":{"anyOf":[true,{"type":"string"},{"type":"integer"}]}}}`
|
||||
expectedTrueMulti := `{"type":"object","properties":{"value":{"anyOf":[{},{"type":"string"},{"type":"integer"}]}}}`
|
||||
compareJSON(t, expectedTrueMulti, CleanJSONSchemaForAntigravityTool(trueMultiBranch, false))
|
||||
|
||||
// When a node declares both anyOf and oneOf, native Schema carriers keep anyOf without concatenating
|
||||
// oneOf into it and record oneOf as a description hint, while GeminiJSONSchema preserves both.
|
||||
siblingUnions := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
],
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "boolean" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
for name, clean := range map[string]func(string) string{
|
||||
"Antigravity": CleanJSONSchemaForAntigravity,
|
||||
"AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
|
||||
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
|
||||
"Gemini": CleanJSONSchemaForGemini,
|
||||
} {
|
||||
parsed := gjson.Parse(clean(siblingUnions))
|
||||
if parsed.Get("properties.value.oneOf").Exists() {
|
||||
t.Fatalf("[%s] sibling oneOf survived on native Schema carrier: %s", name, parsed.Raw)
|
||||
}
|
||||
if got := len(parsed.Get("properties.value.anyOf").Array()); got != 2 {
|
||||
t.Fatalf("[%s] anyOf branch count = %d, want 2 (must not concatenate oneOf): %s", name, got, parsed.Raw)
|
||||
}
|
||||
if !strings.Contains(parsed.Get("properties.value.description").String(), "oneOf:") {
|
||||
t.Fatalf("[%s] missing oneOf description hint: %s", name, parsed.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
jsonSchemaSibling := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(siblingUnions))
|
||||
if len(jsonSchemaSibling.Get("properties.value.anyOf").Array()) != 2 || len(jsonSchemaSibling.Get("properties.value.oneOf").Array()) != 2 {
|
||||
t.Fatalf("[GeminiJSONSchema] expected both anyOf and oneOf to be preserved: %s", jsonSchemaSibling.Raw)
|
||||
}
|
||||
|
||||
// Null-only unions (anyOf:[null], oneOf:[null], anyOf:[false, null]) must not panic across any cleaner.
|
||||
for _, nullOnlyInput := range []string{
|
||||
`{"type":"object","properties":{"value":{"anyOf":[{"type":"null"}]}}}`,
|
||||
`{"type":"object","properties":{"value":{"oneOf":[{"type":"null"},{"type":"null"}]}}}`,
|
||||
`{"type":"object","properties":{"value":{"anyOf":[false,{"type":"null"}],"description":"Null only"}}}`,
|
||||
} {
|
||||
for name, clean := range cleaners {
|
||||
got := clean(nullOnlyInput)
|
||||
if !gjson.Valid(got) {
|
||||
t.Fatalf("[%s] invalid JSON for null-only union %s: %s", name, nullOnlyInput, got)
|
||||
}
|
||||
if gjson.Get(got, "properties.value.type").String() != "null" {
|
||||
t.Fatalf("[%s] expected properties.value.type = null, got: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user