mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-08 08:40:44 +08:00
fix(gemini): enforce array type for schema nodes declaring items
- Infer `array` type or remove `items` on non-array nodes to satisfy Gemini schema validation - Prioritize `array` type when flattening union types for properties declaring `items` - Infer `type: array` during tool schema normalization when `items` is defined without an explicit type Closes: #6011
This commit is contained in:
@@ -152,6 +152,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
jsonStr = removeKeywords(jsonStr, []string{"title"})
|
||||
}
|
||||
jsonStr = cleanupRequiredFields(jsonStr)
|
||||
jsonStr = sanitizeArrayItems(jsonStr)
|
||||
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||
if options.addPlaceholder {
|
||||
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
||||
@@ -160,6 +161,29 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array".
|
||||
// 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 {
|
||||
paths := findPaths(jsonStr, "items")
|
||||
sortByDepth(paths)
|
||||
for _, p := range paths {
|
||||
parentPath := trimSuffix(p, ".items")
|
||||
if isPropertyDefinition(parentPath) {
|
||||
continue
|
||||
}
|
||||
typePath := joinPath(parentPath, "type")
|
||||
t := gjson.Get(jsonStr, typePath).String()
|
||||
if t == "" {
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), typePath, "array")
|
||||
jsonStr = string(updated)
|
||||
} else if t != "array" {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
}
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// removeKeywords removes all occurrences of specified keywords from the JSON schema.
|
||||
func removeKeywords(jsonStr string, keywords []string) string {
|
||||
deletePaths := make([]string, 0)
|
||||
@@ -459,11 +483,19 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini and Antigravity reject tool array schemas without an items definition.
|
||||
if addMissingArrayItems && isArrayDeclaredType(clone["type"]) {
|
||||
if _, hasItems := clone["items"]; !hasItems {
|
||||
clone["items"] = map[string]any{"type": "string"}
|
||||
modified = true
|
||||
// Gemini and Antigravity reject tool array schemas without an items definition,
|
||||
// and reject tool schemas with items whose type is not ARRAY.
|
||||
if addMissingArrayItems {
|
||||
if isArrayDeclaredType(clone["type"]) {
|
||||
if _, hasItems := clone["items"]; !hasItems {
|
||||
clone["items"] = map[string]any{"type": "string"}
|
||||
modified = true
|
||||
}
|
||||
} else if _, hasItems := clone["items"]; hasItems {
|
||||
if clone["type"] == nil || clone["type"] == "" {
|
||||
clone["type"] = "array"
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,15 +1217,23 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
parentPath := trimSuffix(p, ".type")
|
||||
|
||||
firstType := "string"
|
||||
if len(nonNullTypes) > 0 {
|
||||
firstType = nonNullTypes[0]
|
||||
if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") {
|
||||
firstType = "array"
|
||||
} else {
|
||||
firstType = nonNullTypes[0]
|
||||
}
|
||||
}
|
||||
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), p, firstType)
|
||||
jsonStr = string(updated)
|
||||
|
||||
parentPath := trimSuffix(p, ".type")
|
||||
if firstType != "array" && gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, joinPath(parentPath, "items"))
|
||||
}
|
||||
if len(nonNullTypes) > 1 {
|
||||
hint := "Accepts: " + strings.Join(nonNullTypes, " | ")
|
||||
jsonStr = appendHint(jsonStr, parentPath, hint)
|
||||
|
||||
@@ -2729,3 +2729,193 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesSchemaValuedAdditionalPrope
|
||||
t.Fatalf("legacy CleanJSONSchemaForGemini should strip additionalProperties schema: %s", legacyCleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011 covers Issue #6011:
|
||||
// Gemini rejects tool schemas where a property declares "items" but its type is not "array"
|
||||
// (e.g. type is omitted or flattened to a non-array type), returning:
|
||||
// "field predicate failed: $type == Type.ARRAY".
|
||||
func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
|
||||
// Case 1: property declares "items" but omits "type": "array" (relying on JSON Schema inference)
|
||||
// Reproduces the error path reported in Issue #6011: properties.revision_reasons.items.properties.evidence_reference
|
||||
inputMissingType := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"revision_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"evidence_reference": {
|
||||
"description": "references to evidence",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
cleaners := map[string]func(string) string{
|
||||
"gemini": CleanJSONSchemaForGemini,
|
||||
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
|
||||
"antigravity": CleanJSONSchemaForAntigravity,
|
||||
"antigravityLegacy": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
|
||||
}
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_missing_type", func(t *testing.T) {
|
||||
cleaned := clean(inputMissingType)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference")
|
||||
if !target.Exists() {
|
||||
t.Fatalf("evidence_reference property missing in %s: %s", name, cleaned)
|
||||
}
|
||||
if gotType := target.Get("type").String(); gotType != "array" {
|
||||
t.Fatalf("%s: evidence_reference.type = %q, want 'array'; cleaned schema: %s", name, gotType, cleaned)
|
||||
}
|
||||
if gotItemsType := target.Get("items.type").String(); gotItemsType != "string" {
|
||||
t.Fatalf("%s: evidence_reference.items.type = %q, want 'string'; cleaned schema: %s", name, gotItemsType, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 2: property declares union type: ["string", "array"] with items
|
||||
inputUnionType := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"revision_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"evidence_reference": {
|
||||
"type": ["string", "array"],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_union_type_with_items", func(t *testing.T) {
|
||||
cleaned := clean(inputUnionType)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference")
|
||||
if gotType := target.Get("type").String(); gotType != "array" {
|
||||
t.Fatalf("%s: evidence_reference.type = %q, want 'array' when items is defined; cleaned schema: %s", name, gotType, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 3: property has explicit non-array type (e.g. "string") with extraneous items
|
||||
inputNonArrayWithItems := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": { "key": { "type": "string" } },
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_non_array_strips_items", func(t *testing.T) {
|
||||
cleaned := clean(inputNonArrayWithItems)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
if parsed.Get("properties.label.items").Exists() {
|
||||
t.Fatalf("%s: properties.label should not retain items on string type: %s", name, cleaned)
|
||||
}
|
||||
if parsed.Get("properties.config.items").Exists() {
|
||||
t.Fatalf("%s: properties.config should not retain items on object type: %s", name, cleaned)
|
||||
}
|
||||
if parsed.Get("properties.label.type").String() != "string" {
|
||||
t.Fatalf("%s: properties.label.type should remain string: %s", name, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 4: property literally named "items" should be preserved and not treated as array items
|
||||
inputPropertyNamedItems := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "a field named items"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_property_named_items_preserved", func(t *testing.T) {
|
||||
cleaned := clean(inputPropertyNamedItems)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
itemProp := parsed.Get("properties.items")
|
||||
if !itemProp.Exists() {
|
||||
t.Fatalf("%s: property named 'items' was removed: %s", name, cleaned)
|
||||
}
|
||||
if itemProp.Get("type").String() != "string" {
|
||||
t.Fatalf("%s: property named 'items' type changed: %s", name, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 5: Root array with items missing type
|
||||
inputRootArrayMissingType := `{
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}`
|
||||
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_root_array_missing_type", func(t *testing.T) {
|
||||
cleaned := clean(inputRootArrayMissingType)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
if parsed.Get("type").String() != "array" {
|
||||
t.Fatalf("%s: root schema type should be inferred as 'array': %s", name, cleaned)
|
||||
}
|
||||
if parsed.Get("items.type").String() != "string" {
|
||||
t.Fatalf("%s: root schema items.type should remain 'string': %s", name, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 6: Cleaning is idempotent across all cleaners
|
||||
for name, clean := range cleaners {
|
||||
t.Run(name+"_idempotent", func(t *testing.T) {
|
||||
cleaned1 := clean(inputMissingType)
|
||||
cleaned2 := clean(cleaned1)
|
||||
if cleaned1 != cleaned2 {
|
||||
t.Fatalf("%s: cleaner is not idempotent.\nFirst:\n%s\nSecond:\n%s", name, cleaned1, cleaned2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Case 7: Response schema regression check
|
||||
t.Run("antigravity_response_schema", func(t *testing.T) {
|
||||
// Response schema declaring items without type infers array type
|
||||
respInput := `{"type":"object","properties":{"names":{"items":{"type":"string"}}}}`
|
||||
cleaned := CleanJSONSchemaForAntigravityResponse(respInput)
|
||||
parsed := gjson.Parse(cleaned)
|
||||
if parsed.Get("properties.names.type").String() != "array" {
|
||||
t.Fatalf("response schema should infer type 'array' when items is defined: %s", cleaned)
|
||||
}
|
||||
// Response array missing items still remains untouched
|
||||
respNoItems := `{"type":"object","properties":{"values":{"type":"array"}}}`
|
||||
cleanedNoItems := CleanJSONSchemaForAntigravityResponse(respNoItems)
|
||||
parsedNoItems := gjson.Parse(cleanedNoItems)
|
||||
if parsedNoItems.Get("properties.values.items").Exists() {
|
||||
t.Fatalf("response array missing items should not gain placeholder items: %s", cleanedNoItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user