Skip to content

Commit 77656d5

Browse files
authored
fix(go/plugins/googlegenai): fixed nullable in array type in JSON schema (#5162)
1 parent f5fac91 commit 77656d5

2 files changed

Lines changed: 250 additions & 17 deletions

File tree

go/plugins/googlegenai/schema.go

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,35 @@ func toGeminiSchema(originalSchema map[string]any, genkitSchema map[string]any)
3232
return toGeminiSchema(originalSchema, s)
3333
}
3434

35-
// Handle "anyOf" subschemas by finding the first valid schema definition
35+
// Handle "anyOf" subschemas by finding the first valid schema definition.
36+
// JSON unmarshal produces []any, but schemas built directly in Go code may
37+
// use []map[string]any, so accept both. Recurse into each subschema so that
38+
// non-trivial forms (e.g. {"type": ["string", "null"]} or nested $ref) are
39+
// recognized, not just the simple {"type": "string"} case.
3640
if v, ok := genkitSchema["anyOf"]; ok {
37-
if anyOfList, isList := v.([]map[string]any); isList {
38-
for _, subSchema := range anyOfList {
39-
if subSchemaType, hasType := subSchema["type"]; hasType {
40-
if typeStr, isString := subSchemaType.(string); isString && typeStr != "null" {
41-
if title, ok := genkitSchema["title"]; ok {
42-
subSchema["title"] = title
43-
}
44-
if description, ok := genkitSchema["description"]; ok {
45-
subSchema["description"] = description
46-
}
47-
// Found a schema like: {"type": "string"}
48-
return toGeminiSchema(originalSchema, subSchema)
49-
}
41+
var anyOfList []map[string]any
42+
switch list := v.(type) {
43+
case []map[string]any:
44+
anyOfList = list
45+
case []any:
46+
for _, item := range list {
47+
if m, isMap := item.(map[string]any); isMap {
48+
anyOfList = append(anyOfList, m)
5049
}
5150
}
5251
}
52+
for _, subSchema := range anyOfList {
53+
if title, ok := genkitSchema["title"]; ok {
54+
subSchema["title"] = title
55+
}
56+
if description, ok := genkitSchema["description"]; ok {
57+
subSchema["description"] = description
58+
}
59+
res, err := toGeminiSchema(originalSchema, subSchema)
60+
if err == nil && res != nil && res.Type != "" {
61+
return res, nil
62+
}
63+
}
5364
}
5465

5566
schema := &genai.Schema{}
@@ -58,12 +69,43 @@ func toGeminiSchema(originalSchema map[string]any, genkitSchema map[string]any)
5869
return nil, fmt.Errorf("schema is missing the 'type' field: %#v", genkitSchema)
5970
}
6071

61-
typeStr, ok := typeVal.(string)
62-
if !ok {
72+
// JSON Schema 2020-12 allows "type" to be an array of types, e.g.
73+
// ["string", "null"] to denote a nullable string. Normalize to a single
74+
// concrete type plus the Nullable flag when "null" is present. JSON
75+
// unmarshal produces []any, but schemas built directly in Go code may use
76+
// []string, so accept both.
77+
var typeStr string
78+
var typeList []string
79+
switch tv := typeVal.(type) {
80+
case string:
81+
typeStr = tv
82+
case []any:
83+
for _, t := range tv {
84+
s, isString := t.(string)
85+
if !isString {
86+
return nil, fmt.Errorf("schema 'type' array contains non-string element of type %T", t)
87+
}
88+
typeList = append(typeList, s)
89+
}
90+
case []string:
91+
typeList = tv
92+
default:
6393
return nil, fmt.Errorf("schema 'type' field is not a string, but %T", typeVal)
6494
}
95+
for _, s := range typeList {
96+
if s == "null" {
97+
schema.Nullable = genai.Ptr(true)
98+
continue
99+
}
100+
if typeStr != "" {
101+
return nil, fmt.Errorf("schema 'type' array contains multiple non-null types: %v", typeList)
102+
}
103+
typeStr = s
104+
}
65105

66106
switch typeStr {
107+
case "":
108+
// Only "null" was specified (or similar); leave Type unset and rely on Nullable.
67109
case "string":
68110
schema.Type = genai.TypeString
69111
case "float64", "number":
@@ -77,7 +119,7 @@ func toGeminiSchema(originalSchema map[string]any, genkitSchema map[string]any)
77119
case "array":
78120
schema.Type = genai.TypeArray
79121
default:
80-
return nil, fmt.Errorf("schema type %q not allowed", genkitSchema["type"])
122+
return nil, fmt.Errorf("schema type %q not allowed", typeStr)
81123
}
82124
if v, ok := genkitSchema["required"]; ok {
83125
schema.Required = castToStringArray(v)
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright 2025 Google LLC
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package googlegenai
5+
6+
import (
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
"google.golang.org/genai"
11+
)
12+
13+
func TestToGeminiSchema(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
genkitSchema map[string]any
17+
want *genai.Schema
18+
wantErr bool
19+
}{
20+
{
21+
name: "string type",
22+
genkitSchema: map[string]any{
23+
"type": "string",
24+
},
25+
want: &genai.Schema{
26+
Type: genai.TypeString,
27+
},
28+
},
29+
{
30+
name: "array type [string, null]",
31+
genkitSchema: map[string]any{
32+
"type": []any{"string", "null"},
33+
},
34+
want: &genai.Schema{
35+
Type: genai.TypeString,
36+
Nullable: genai.Ptr(true),
37+
},
38+
},
39+
{
40+
name: "array type [null, integer]",
41+
genkitSchema: map[string]any{
42+
"type": []any{"null", "integer"},
43+
},
44+
want: &genai.Schema{
45+
Type: genai.TypeInteger,
46+
Nullable: genai.Ptr(true),
47+
},
48+
},
49+
{
50+
name: "array type [null]",
51+
genkitSchema: map[string]any{
52+
"type": []any{"null"},
53+
},
54+
want: &genai.Schema{
55+
Nullable: genai.Ptr(true),
56+
},
57+
},
58+
{
59+
name: "array type [array, null] with items",
60+
genkitSchema: map[string]any{
61+
"type": []any{"array", "null"},
62+
"items": map[string]any{
63+
"type": "number",
64+
},
65+
},
66+
want: &genai.Schema{
67+
Type: genai.TypeArray,
68+
Nullable: genai.Ptr(true),
69+
Items: &genai.Schema{
70+
Type: genai.TypeNumber,
71+
},
72+
},
73+
},
74+
{
75+
name: "object with array-typed property (MCP reverse_list style)",
76+
genkitSchema: map[string]any{
77+
"type": "object",
78+
"properties": map[string]any{
79+
"numbers": map[string]any{
80+
"type": []any{"array", "null"},
81+
"items": map[string]any{
82+
"type": "number",
83+
},
84+
"description": "List of numbers to reverse",
85+
},
86+
},
87+
"required": []any{"numbers"},
88+
},
89+
want: &genai.Schema{
90+
Type: genai.TypeObject,
91+
Properties: map[string]*genai.Schema{
92+
"numbers": {
93+
Type: genai.TypeArray,
94+
Nullable: genai.Ptr(true),
95+
Items: &genai.Schema{
96+
Type: genai.TypeNumber,
97+
},
98+
Description: "List of numbers to reverse",
99+
},
100+
},
101+
Required: []string{"numbers"},
102+
},
103+
},
104+
{
105+
name: "anyOf with []any (as decoded by json.Unmarshal)",
106+
genkitSchema: map[string]any{
107+
"anyOf": []any{
108+
map[string]any{"type": "string"},
109+
map[string]any{"type": "null"},
110+
},
111+
"title": "Domain",
112+
"description": "A domain",
113+
},
114+
want: &genai.Schema{
115+
Type: genai.TypeString,
116+
Title: "Domain",
117+
Description: "A domain",
118+
},
119+
},
120+
{
121+
name: "anyOf with []map[string]any",
122+
genkitSchema: map[string]any{
123+
"anyOf": []map[string]any{
124+
{"type": "string"},
125+
{"type": "null"},
126+
},
127+
"title": "Domain",
128+
"description": "A domain",
129+
},
130+
want: &genai.Schema{
131+
Type: genai.TypeString,
132+
Title: "Domain",
133+
Description: "A domain",
134+
},
135+
},
136+
{
137+
name: "anyOf with subschema using array type",
138+
genkitSchema: map[string]any{
139+
"anyOf": []any{
140+
map[string]any{"type": []any{"string", "null"}},
141+
},
142+
"title": "MaybeString",
143+
},
144+
want: &genai.Schema{
145+
Type: genai.TypeString,
146+
Nullable: genai.Ptr(true),
147+
Title: "MaybeString",
148+
},
149+
},
150+
{
151+
name: "type as []string",
152+
genkitSchema: map[string]any{
153+
"type": []string{"string", "null"},
154+
},
155+
want: &genai.Schema{
156+
Type: genai.TypeString,
157+
Nullable: genai.Ptr(true),
158+
},
159+
},
160+
{
161+
name: "unsupported type string",
162+
genkitSchema: map[string]any{
163+
"type": "bogus",
164+
},
165+
wantErr: true,
166+
},
167+
{
168+
name: "type is wrong shape (int)",
169+
genkitSchema: map[string]any{
170+
"type": 42,
171+
},
172+
wantErr: true,
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
got, err := toGeminiSchema(tt.genkitSchema, tt.genkitSchema)
179+
if (err != nil) != tt.wantErr {
180+
t.Errorf("toGeminiSchema() error = %v, wantErr %v", err, tt.wantErr)
181+
return
182+
}
183+
if tt.wantErr {
184+
return
185+
}
186+
if diff := cmp.Diff(tt.want, got); diff != "" {
187+
t.Errorf("toGeminiSchema() mismatch (-want +got):\n%s", diff)
188+
}
189+
})
190+
}
191+
}

0 commit comments

Comments
 (0)