mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #7077 from ggllzz04/fix/antigravity-tuple-array-schema
fix(antigravity): handle prefixItems and ensure array schema has valid items fallback
This commit is contained in:
@@ -119,14 +119,32 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
// 0. [NEW] 合并 allOf
|
||||
mergeAllOf(schemaMap)
|
||||
|
||||
// 0.5 [FIX] 处理 Draft 2020-12 的 prefixItems (元组定义)
|
||||
if prefixItemsVal, hasPrefix := schemaMap["prefixItems"]; hasPrefix {
|
||||
if prefixItems, ok := prefixItemsVal.([]any); ok && len(prefixItems) > 0 {
|
||||
itemsVal, hasItems := schemaMap["items"]
|
||||
_, itemsIsBool := itemsVal.(bool)
|
||||
// 如果没有 items,或者 items 为 nil,或者 items 是布尔值(如 Draft 2020-12 中表示元组闭合的 items: false),
|
||||
// 用选出的最佳元组项填充 items,以满足 Gemini 对 array.items 必须为合法 Schema 对象的规范。
|
||||
if !hasItems || itemsVal == nil || itemsIsBool {
|
||||
best := extractBestSchemaFromUnion(prefixItems)
|
||||
if best == nil {
|
||||
best = map[string]any{"type": "string"}
|
||||
}
|
||||
schemaMap["items"] = best
|
||||
}
|
||||
}
|
||||
// 无论 prefixItems 是否为空数组,都彻底删除 prefixItems 关键字以防 Gemini 400
|
||||
delete(schemaMap, "prefixItems")
|
||||
}
|
||||
|
||||
// 1. [CRITICAL] 深度递归处理子项
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||
for _, v := range props {
|
||||
cleanJSONSchemaRecursive(v)
|
||||
}
|
||||
// Go 中不需要像 Rust 那样显式处理 nullable_keys remove required,
|
||||
// 因为我们在子项处理中会正确设置 type 和 description
|
||||
} else if items, ok := schemaMap["items"]; ok {
|
||||
}
|
||||
if items, ok := schemaMap["items"]; ok {
|
||||
// [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。
|
||||
if itemsArr, ok := items.([]any); ok {
|
||||
// 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。
|
||||
@@ -141,8 +159,9 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
} else {
|
||||
cleanJSONSchemaRecursive(items)
|
||||
}
|
||||
} else {
|
||||
// 遍历所有值递归
|
||||
}
|
||||
// 如果既无 properties 也无 items,遍历其他 map/slice 子项递归
|
||||
if !hasKey(schemaMap, "properties") && !hasKey(schemaMap, "items") {
|
||||
for _, v := range schemaMap {
|
||||
if _, isMap := v.(map[string]any); isMap {
|
||||
cleanJSONSchemaRecursive(v)
|
||||
@@ -205,6 +224,18 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
schemaMap[k] = deepCopy(v)
|
||||
}
|
||||
}
|
||||
// 清除已合并的 union 关键字
|
||||
delete(schemaMap, "anyOf")
|
||||
delete(schemaMap, "oneOf")
|
||||
// 对合并进来的新属性和 items 进行深度递归清洗,确保合并进来的子结构(如 prefixItems)也能被处理
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||
for _, pv := range props {
|
||||
cleanJSONSchemaRecursive(pv)
|
||||
}
|
||||
}
|
||||
if items, ok := schemaMap["items"]; ok {
|
||||
cleanJSONSchemaRecursive(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,14 +245,37 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
hasKey(schemaMap, "properties") ||
|
||||
hasKey(schemaMap, "items") ||
|
||||
hasKey(schemaMap, "enum") ||
|
||||
hasKey(schemaMap, "const") ||
|
||||
hasKey(schemaMap, "anyOf") ||
|
||||
hasKey(schemaMap, "oneOf") ||
|
||||
hasKey(schemaMap, "allOf")
|
||||
|
||||
if looksLikeSchema {
|
||||
// 4. [ROBUST] 约束迁移
|
||||
// 4. [ROBUST] 约束迁移与 const 规范化
|
||||
migrateConstraints(schemaMap)
|
||||
|
||||
// 规范化 const 关键字 (Draft 6+ 转换为 Gemini 兼容的 enum: [const])
|
||||
if constVal, exists := schemaMap["const"]; exists {
|
||||
if _, hasEnum := schemaMap["enum"]; !hasEnum {
|
||||
schemaMap["enum"] = []any{constVal}
|
||||
}
|
||||
if _, hasType := schemaMap["type"]; !hasType {
|
||||
switch constVal.(type) {
|
||||
case string:
|
||||
schemaMap["type"] = "string"
|
||||
case int, int32, int64:
|
||||
schemaMap["type"] = "integer"
|
||||
case float32, float64:
|
||||
schemaMap["type"] = "number"
|
||||
case bool:
|
||||
schemaMap["type"] = "boolean"
|
||||
default:
|
||||
schemaMap["type"] = "string"
|
||||
}
|
||||
}
|
||||
delete(schemaMap, "const")
|
||||
}
|
||||
|
||||
// 5. [CRITICAL] 白名单过滤
|
||||
allowedFields := map[string]bool{
|
||||
"type": true,
|
||||
@@ -238,43 +292,7 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. [SAFETY] 处理空 Object
|
||||
if t, _ := schemaMap["type"].(string); t == "object" {
|
||||
hasProps := false
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
|
||||
hasProps = true
|
||||
}
|
||||
if !hasProps {
|
||||
schemaMap["properties"] = map[string]any{
|
||||
"reason": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Reason for calling this tool",
|
||||
},
|
||||
}
|
||||
schemaMap["required"] = []any{"reason"}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. [SAFETY] Required 字段对齐
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||
if req, ok := schemaMap["required"].([]any); ok {
|
||||
var validReq []any
|
||||
for _, r := range req {
|
||||
if rStr, ok := r.(string); ok {
|
||||
if _, exists := props[rStr]; exists {
|
||||
validReq = append(validReq, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(validReq) > 0 {
|
||||
schemaMap["required"] = validReq
|
||||
} else {
|
||||
delete(schemaMap, "required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 处理 type 字段 (Lowercase + Nullable 提取)
|
||||
// 6. 处理 type 字段 (Lowercase + Nullable 提取)
|
||||
isEffectivelyNullable := false
|
||||
if typeVal, exists := schemaMap["type"]; exists {
|
||||
var selectedType string
|
||||
@@ -305,16 +323,86 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
}
|
||||
schemaMap["type"] = selectedType
|
||||
} else {
|
||||
// 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist)
|
||||
// 如果没有 type,但有 properties,补一个
|
||||
// 如果没有 type,但有 properties/items/enum,推断并补全
|
||||
if hasKey(schemaMap, "properties") {
|
||||
schemaMap["type"] = "object"
|
||||
} else if hasKey(schemaMap, "items") {
|
||||
schemaMap["type"] = "array"
|
||||
} else if enumArr, ok := schemaMap["enum"].([]any); ok && len(enumArr) > 0 {
|
||||
// [FIX] 针对仅含 enum 的 schema,按其元素类型推断标量类型,避免被误判为 object 并注入 reason 属性
|
||||
switch enumArr[0].(type) {
|
||||
case int, int32, int64:
|
||||
schemaMap["type"] = "integer"
|
||||
case float32, float64:
|
||||
schemaMap["type"] = "number"
|
||||
case bool:
|
||||
schemaMap["type"] = "boolean"
|
||||
default:
|
||||
schemaMap["type"] = "string"
|
||||
}
|
||||
} else if hasKey(schemaMap, "enum") {
|
||||
schemaMap["type"] = "string"
|
||||
} else {
|
||||
// 默认为 string ? or object? Gemini 通常需要明确 type
|
||||
schemaMap["type"] = "object"
|
||||
}
|
||||
}
|
||||
|
||||
// 7. [SAFETY] 处理空 Object
|
||||
if t, _ := schemaMap["type"].(string); t == "object" {
|
||||
hasProps := false
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
|
||||
hasProps = true
|
||||
}
|
||||
if !hasProps {
|
||||
schemaMap["properties"] = map[string]any{
|
||||
"reason": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Reason for calling this tool",
|
||||
},
|
||||
}
|
||||
schemaMap["required"] = []any{"reason"}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. [SAFETY] 处理缺失 items 的 Array (Gemini 严苛要求 array 必须声明 items Schema)
|
||||
if t, _ := schemaMap["type"].(string); t == "array" {
|
||||
itemsVal, hasItems := schemaMap["items"]
|
||||
needDefaultItems := false
|
||||
if !hasItems || itemsVal == nil {
|
||||
needDefaultItems = true
|
||||
} else if itemsMap, ok := itemsVal.(map[string]any); ok {
|
||||
if len(itemsMap) == 0 {
|
||||
needDefaultItems = true
|
||||
}
|
||||
} else if _, isBool := itemsVal.(bool); isBool {
|
||||
needDefaultItems = true
|
||||
}
|
||||
if needDefaultItems {
|
||||
schemaMap["items"] = map[string]any{
|
||||
"type": "string",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. [SAFETY] Required 字段对齐
|
||||
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||
if req, ok := schemaMap["required"].([]any); ok {
|
||||
var validReq []any
|
||||
for _, r := range req {
|
||||
if rStr, ok := r.(string); ok {
|
||||
if _, exists := props[rStr]; exists {
|
||||
validReq = append(validReq, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(validReq) > 0 {
|
||||
schemaMap["required"] = validReq
|
||||
} else {
|
||||
delete(schemaMap, "required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isEffectivelyNullable {
|
||||
desc, _ := schemaMap["description"].(string)
|
||||
if !strings.Contains(desc, "nullable") {
|
||||
@@ -491,6 +579,9 @@ func scoreSchemaOption(val any) int {
|
||||
if hasKey(m, "items") || typeStr == "array" {
|
||||
return 2
|
||||
}
|
||||
if hasKey(m, "enum") || hasKey(m, "const") {
|
||||
return 1
|
||||
}
|
||||
if typeStr != "" && typeStr != "null" {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCleanJSONSchema_ArrayPrefixItems(t *testing.T) {
|
||||
// 模拟 Claude Code 2.1 Artifact 工具的 query.where 参数 Schema (Draft 2020-12 prefixItems 元组)
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"where": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"prefixItems": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{"type": "string", "enum": []any{"==", "!=", ">", "<"}},
|
||||
map[string]any{},
|
||||
},
|
||||
},
|
||||
"maxItems": float64(10),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
query, ok := cleaned["properties"].(map[string]any)["query"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
where, ok := query["properties"].(map[string]any)["where"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "array", where["type"])
|
||||
|
||||
whereItems, ok := where["items"].(map[string]any)
|
||||
require.True(t, ok, "where.items must be an object")
|
||||
assert.Equal(t, "array", whereItems["type"])
|
||||
// prefixItems 应在 whereItems 中被彻底移除
|
||||
assert.Nil(t, whereItems["prefixItems"])
|
||||
|
||||
// 关键验证:where.items.items 必须存在且有效,不能缺失导致 Gemini 400
|
||||
innerItems, ok := whereItems["items"].(map[string]any)
|
||||
require.True(t, ok, "where.items.items must be an object")
|
||||
assert.Equal(t, "string", innerItems["type"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_ArrayMissingItemsFallback(t *testing.T) {
|
||||
// 针对任何缺少 items 的 array,必须兜底注入 items: {type: string}
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
},
|
||||
"nested_empty_array": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
tags, ok := props["tags"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
tagsItems, ok := tags["items"].(map[string]any)
|
||||
require.True(t, ok, "tags.items must be an object")
|
||||
assert.Equal(t, "string", tagsItems["type"])
|
||||
|
||||
nested, ok := props["nested_empty_array"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
nestedItems, ok := nested["items"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "array", nestedItems["type"])
|
||||
nestedInnerItems, ok := nestedItems["items"].(map[string]any)
|
||||
require.True(t, ok, "nested items.items must be an object")
|
||||
assert.Equal(t, "string", nestedInnerItems["type"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_ArrayExistingItemsPreserved(t *testing.T) {
|
||||
// 正常的 array items 不受影响
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"numbers": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
numbers, ok := props["numbers"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "array", numbers["type"])
|
||||
items, ok := numbers["items"].(map[string]any)
|
||||
require.True(t, ok, "numbers.items must be an object")
|
||||
assert.Equal(t, "integer", items["type"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_EnumOnlySchemaInTuple(t *testing.T) {
|
||||
// 测试 enum-only schema 不会被误判为 object,也不应被注入 reason 属性
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"choice": map[string]any{
|
||||
"enum": []any{"option_a", "option_b"},
|
||||
},
|
||||
"tuple_with_enum": map[string]any{
|
||||
"type": "array",
|
||||
"prefixItems": []any{
|
||||
map[string]any{
|
||||
"enum": []any{"read", "write"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
choice, ok := props["choice"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "string", choice["type"])
|
||||
assert.Nil(t, choice["properties"], "enum-only schema must NOT be treated as object with reason property")
|
||||
assert.Equal(t, []any{"option_a", "option_b"}, choice["enum"])
|
||||
|
||||
tupleArray, ok := props["tuple_with_enum"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
tupleItems, ok := tupleArray["items"].(map[string]any)
|
||||
require.True(t, ok, "tuple_with_enum.items must be an object")
|
||||
assert.Equal(t, "string", tupleItems["type"])
|
||||
assert.Nil(t, tupleItems["properties"])
|
||||
assert.Equal(t, []any{"read", "write"}, tupleItems["enum"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_ConstKeywordConversion(t *testing.T) {
|
||||
// 测试 const 关键字自动转换为 Gemini 兼容的 enum: [const] 且具有合法 type
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"const": "ping",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
action, ok := props["action"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, action["const"], "const keyword must be removed")
|
||||
assert.Equal(t, "string", action["type"])
|
||||
assert.Equal(t, []any{"ping"}, action["enum"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_AnyOfNestedPrefixItems(t *testing.T) {
|
||||
// 测试 anyOf 分支合并进来的嵌套 prefixItems 也被完整深度清洗
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"status": map[string]any{"type": "string"},
|
||||
},
|
||||
"anyOf": []any{
|
||||
map[string]any{
|
||||
"properties": map[string]any{
|
||||
"extra_tuple": map[string]any{
|
||||
"type": "array",
|
||||
"prefixItems": []any{
|
||||
map[string]any{"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, props["extra_tuple"])
|
||||
extraTuple, ok := props["extra_tuple"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, extraTuple["prefixItems"], "nested prefixItems from anyOf merge must be cleaned")
|
||||
extraItems, ok := extraTuple["items"].(map[string]any)
|
||||
require.True(t, ok, "extra_tuple.items must be an object")
|
||||
assert.Equal(t, "integer", extraItems["type"])
|
||||
}
|
||||
|
||||
func TestCleanJSONSchema_EmptyPrefixItems(t *testing.T) {
|
||||
// 验证空 prefixItems: [] 也被安全删除,不遗留非法关键字
|
||||
input := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"empty_tuple": map[string]any{
|
||||
"type": "array",
|
||||
"prefixItems": []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaned := CleanJSONSchema(input)
|
||||
require.NotNil(t, cleaned)
|
||||
|
||||
props, ok := cleaned["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
emptyTuple, ok := props["empty_tuple"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, emptyTuple["prefixItems"], "empty prefixItems array must be removed")
|
||||
emptyTupleItems, ok := emptyTuple["items"].(map[string]any)
|
||||
require.True(t, ok, "empty_tuple.items must be an object")
|
||||
assert.Equal(t, "string", emptyTupleItems["type"])
|
||||
}
|
||||
Reference in New Issue
Block a user