Skip to content

Commit bf23f5b

Browse files
authored
feat: add support for multiple types in Gemini structured outputs properties (#1243)
## Summary Added support for multiple types in JSON schema properties for Gemini and Anthropic structured outputs, allowing for proper handling of union types like `["string", "integer"]` in schema definitions. ## Changes - Added schema normalization for Gemini to handle properties with multiple types - Implemented conversion of union types to `anyOf` constructs for Gemini when needed - Maintained array type notation for nullable types (`["string", "null"]`) which Gemini supports natively - Added comprehensive test cases for structured output conversion with various schema patterns - Updated documentation to reflect the changes in schema handling ## Type of change - [x] Feature - [x] Bug fix ## Affected areas - [x] Core (Go) - [x] Providers/Integrations - [x] Docs ## How to test ```sh # Run the new tests for structured output conversion go test ./core/providers/gemini -run TestStructuredOutputConversion go test ./core/providers/gemini -run TestResponsesStructuredOutputConversion # Run all Gemini tests go test ./core/providers/gemini/... ``` ## Breaking changes - [x] No ## Related issues Extends the schema normalization support previously added for Anthropic to Gemini provider. ## Security considerations No security implications as this only affects schema transformation logic. ## Checklist - [x] I added/updated tests where appropriate - [x] I updated documentation where needed - [x] I verified builds succeed (Go and UI)
2 parents e323426 + c56dde9 commit bf23f5b

File tree

6 files changed

+556
-84
lines changed

6 files changed

+556
-84
lines changed

core/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
- feat: Add schema normalization for Anthropic to handle enum fields with multiple types like ["string", "integer"]
1+
- feat: added support for multiple types in gemini and anthropic structured outputs properties
22
- fix: ensure request ID is consistently set in context before PreHooks are executed

core/providers/gemini/gemini_test.go

Lines changed: 359 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestGemini(t *testing.T) {
2727

2828
testConfig := testutil.ComprehensiveTestConfig{
2929
Provider: schemas.Gemini,
30-
ChatModel: "gemini-2.0-flash",
30+
ChatModel: "gemini-2.5-flash",
3131
VisionModel: "gemini-2.0-flash",
3232
EmbeddingModel: "text-embedding-004",
3333
TranscriptionModel: "gemini-2.5-flash",
@@ -288,6 +288,364 @@ func TestBifrostToGeminiToolConversion(t *testing.T) {
288288
}
289289
}
290290

291+
// TestStructuredOutputConversion tests that response_format with json_schema is properly converted to Gemini's responseJsonSchema
292+
func TestStructuredOutputConversion(t *testing.T) {
293+
tests := []struct {
294+
name string
295+
input *schemas.BifrostChatRequest
296+
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
297+
}{
298+
{
299+
name: "JSONSchemaWithUnionTypes_ConvertedToAnyOf",
300+
input: &schemas.BifrostChatRequest{
301+
Model: "gemini-2.5-pro",
302+
Input: []schemas.ChatMessage{
303+
{
304+
Role: schemas.ChatMessageRoleUser,
305+
Content: &schemas.ChatMessageContent{
306+
ContentStr: schemas.Ptr("Extract information: User ID is 12345, Status is \"active\""),
307+
},
308+
},
309+
},
310+
Params: &schemas.ChatParameters{
311+
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
312+
"type": "json_schema",
313+
"json_schema": map[string]interface{}{
314+
"name": "UserInfo",
315+
"schema": map[string]interface{}{
316+
"type": "object",
317+
"properties": map[string]interface{}{
318+
"user_id": map[string]interface{}{
319+
"type": []interface{}{"string", "integer"},
320+
"description": "User ID as string or integer",
321+
},
322+
"status": map[string]interface{}{
323+
"type": "string",
324+
"enum": []interface{}{"active", "inactive"},
325+
},
326+
},
327+
"required": []interface{}{"user_id", "status"},
328+
"additionalProperties": false,
329+
},
330+
},
331+
}),
332+
},
333+
},
334+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
335+
// Verify ResponseMIMEType is set
336+
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType, "responseMimeType should be application/json")
337+
338+
// Verify ResponseJSONSchema is set
339+
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema, "responseJsonSchema should be set")
340+
341+
// Validate the schema structure
342+
schemaMap, ok := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
343+
require.True(t, ok, "ResponseJSONSchema should be a map")
344+
345+
// Check properties
346+
properties, ok := schemaMap["properties"].(map[string]interface{})
347+
require.True(t, ok, "properties should be a map")
348+
349+
// Validate user_id property - should be converted to anyOf
350+
userID, ok := properties["user_id"].(map[string]interface{})
351+
require.True(t, ok, "user_id should exist in properties")
352+
353+
// user_id should have anyOf instead of type array
354+
anyOf, hasAnyOf := userID["anyOf"]
355+
assert.True(t, hasAnyOf, "user_id should have anyOf for union types")
356+
357+
anyOfSlice, ok := anyOf.([]interface{})
358+
require.True(t, ok, "anyOf should be a slice")
359+
require.Len(t, anyOfSlice, 2, "anyOf should have 2 branches for string and integer")
360+
361+
// Verify the anyOf branches
362+
stringBranch := anyOfSlice[0].(map[string]interface{})
363+
assert.Equal(t, "string", stringBranch["type"])
364+
365+
integerBranch := anyOfSlice[1].(map[string]interface{})
366+
assert.Equal(t, "integer", integerBranch["type"])
367+
368+
// Validate status property - should remain unchanged
369+
status, ok := properties["status"].(map[string]interface{})
370+
require.True(t, ok, "status should exist in properties")
371+
assert.Equal(t, "string", status["type"])
372+
enum := status["enum"].([]interface{})
373+
assert.Len(t, enum, 2)
374+
},
375+
},
376+
{
377+
name: "JSONSchemaWithNullableType_KeptAsArray",
378+
input: &schemas.BifrostChatRequest{
379+
Model: "gemini-2.5-pro",
380+
Input: []schemas.ChatMessage{
381+
{
382+
Role: schemas.ChatMessageRoleUser,
383+
Content: &schemas.ChatMessageContent{
384+
ContentStr: schemas.Ptr("Extract nullable field"),
385+
},
386+
},
387+
},
388+
Params: &schemas.ChatParameters{
389+
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
390+
"type": "json_schema",
391+
"json_schema": map[string]interface{}{
392+
"name": "NullableData",
393+
"schema": map[string]interface{}{
394+
"type": "object",
395+
"properties": map[string]interface{}{
396+
"name": map[string]interface{}{
397+
"type": []interface{}{"string", "null"},
398+
},
399+
},
400+
},
401+
},
402+
}),
403+
},
404+
},
405+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
406+
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
407+
properties := schemaMap["properties"].(map[string]interface{})
408+
name := properties["name"].(map[string]interface{})
409+
410+
// Nullable types should be kept as array (Gemini supports this)
411+
typeVal := name["type"]
412+
typeSlice, ok := typeVal.([]interface{})
413+
require.True(t, ok, "type should remain as array for nullable types")
414+
require.Len(t, typeSlice, 2)
415+
assert.Contains(t, typeSlice, "string")
416+
assert.Contains(t, typeSlice, "null")
417+
},
418+
},
419+
{
420+
name: "JSONSchemaComplex",
421+
input: &schemas.BifrostChatRequest{
422+
Model: "gemini-2.5-pro",
423+
Input: []schemas.ChatMessage{
424+
{
425+
Role: schemas.ChatMessageRoleUser,
426+
Content: &schemas.ChatMessageContent{
427+
ContentStr: schemas.Ptr("Extract nested data"),
428+
},
429+
},
430+
},
431+
Params: &schemas.ChatParameters{
432+
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
433+
"type": "json_schema",
434+
"json_schema": map[string]interface{}{
435+
"name": "ComplexData",
436+
"schema": map[string]interface{}{
437+
"type": "object",
438+
"properties": map[string]interface{}{
439+
"items": map[string]interface{}{
440+
"type": "array",
441+
"items": map[string]interface{}{
442+
"type": "object",
443+
"properties": map[string]interface{}{
444+
"id": map[string]interface{}{
445+
"type": "integer",
446+
},
447+
"name": map[string]interface{}{
448+
"type": "string",
449+
},
450+
},
451+
"required": []interface{}{"id", "name"},
452+
},
453+
},
454+
},
455+
"required": []interface{}{"items"},
456+
},
457+
},
458+
}),
459+
},
460+
},
461+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
462+
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
463+
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema)
464+
465+
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
466+
properties := schemaMap["properties"].(map[string]interface{})
467+
items := properties["items"].(map[string]interface{})
468+
469+
// Validate array items
470+
assert.Equal(t, "array", items["type"])
471+
itemsSchema := items["items"].(map[string]interface{})
472+
assert.Equal(t, "object", itemsSchema["type"])
473+
474+
// Validate nested properties
475+
nestedProps := itemsSchema["properties"].(map[string]interface{})
476+
assert.Contains(t, nestedProps, "id")
477+
assert.Contains(t, nestedProps, "name")
478+
},
479+
},
480+
{
481+
name: "JSONObjectFormat",
482+
input: &schemas.BifrostChatRequest{
483+
Model: "gemini-2.5-pro",
484+
Input: []schemas.ChatMessage{
485+
{
486+
Role: schemas.ChatMessageRoleUser,
487+
Content: &schemas.ChatMessageContent{
488+
ContentStr: schemas.Ptr("Return JSON"),
489+
},
490+
},
491+
},
492+
Params: &schemas.ChatParameters{
493+
ResponseFormat: schemas.Ptr[interface{}](map[string]interface{}{
494+
"type": "json_object",
495+
}),
496+
},
497+
},
498+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
499+
// json_object should only set ResponseMIMEType without schema
500+
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
501+
assert.Nil(t, result.GenerationConfig.ResponseJSONSchema)
502+
assert.Nil(t, result.GenerationConfig.ResponseSchema)
503+
},
504+
},
505+
}
506+
507+
for _, tt := range tests {
508+
t.Run(tt.name, func(t *testing.T) {
509+
result := gemini.ToGeminiChatCompletionRequest(tt.input)
510+
require.NotNil(t, result, "Conversion should not return nil")
511+
tt.validate(t, result)
512+
})
513+
}
514+
}
515+
516+
// TestResponsesStructuredOutputConversion tests that Responses API text config with union types is properly handled
517+
func TestResponsesStructuredOutputConversion(t *testing.T) {
518+
tests := []struct {
519+
name string
520+
input *schemas.BifrostResponsesRequest
521+
validate func(t *testing.T, result *gemini.GeminiGenerationRequest)
522+
}{
523+
{
524+
name: "ResponsesAPI_UnionTypes_ConvertedToAnyOf",
525+
input: &schemas.BifrostResponsesRequest{
526+
Provider: schemas.Gemini,
527+
Model: "gemini-2.5-pro",
528+
Input: []schemas.ResponsesMessage{
529+
{
530+
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
531+
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
532+
Content: &schemas.ResponsesMessageContent{
533+
ContentStr: schemas.Ptr("Extract info with union types"),
534+
},
535+
},
536+
},
537+
Params: &schemas.ResponsesParameters{
538+
Text: &schemas.ResponsesTextConfig{
539+
Format: &schemas.ResponsesTextConfigFormat{
540+
Type: "json_schema",
541+
Name: schemas.Ptr("UserInfo"),
542+
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
543+
Type: schemas.Ptr("object"),
544+
Properties: &map[string]interface{}{
545+
"user_id": map[string]interface{}{
546+
"type": []interface{}{"string", "integer"},
547+
"description": "User ID as string or integer",
548+
},
549+
"status": map[string]interface{}{
550+
"type": "string",
551+
"enum": []interface{}{"active", "inactive"},
552+
},
553+
},
554+
Required: []string{"user_id", "status"},
555+
AdditionalProperties: schemas.Ptr(false),
556+
},
557+
},
558+
},
559+
},
560+
},
561+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
562+
// Verify ResponseMIMEType is set
563+
assert.Equal(t, "application/json", result.GenerationConfig.ResponseMIMEType)
564+
assert.NotNil(t, result.GenerationConfig.ResponseJSONSchema)
565+
566+
// Validate the schema structure
567+
schemaMap, ok := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
568+
require.True(t, ok, "ResponseJSONSchema should be a map")
569+
570+
properties, ok := schemaMap["properties"].(map[string]interface{})
571+
require.True(t, ok, "properties should be a map")
572+
573+
// Validate user_id property - should be converted to anyOf
574+
userID, ok := properties["user_id"].(map[string]interface{})
575+
require.True(t, ok, "user_id should exist in properties")
576+
577+
// user_id should have anyOf instead of type array
578+
anyOf, hasAnyOf := userID["anyOf"]
579+
assert.True(t, hasAnyOf, "user_id should have anyOf for union types in Responses API")
580+
581+
anyOfSlice, ok := anyOf.([]interface{})
582+
require.True(t, ok, "anyOf should be a slice")
583+
require.Len(t, anyOfSlice, 2, "anyOf should have 2 branches for string and integer")
584+
585+
// Verify the anyOf branches
586+
stringBranch := anyOfSlice[0].(map[string]interface{})
587+
assert.Equal(t, "string", stringBranch["type"])
588+
589+
integerBranch := anyOfSlice[1].(map[string]interface{})
590+
assert.Equal(t, "integer", integerBranch["type"])
591+
},
592+
},
593+
{
594+
name: "ResponsesAPI_NullableType_KeptAsArray",
595+
input: &schemas.BifrostResponsesRequest{
596+
Provider: schemas.Gemini,
597+
Model: "gemini-2.5-pro",
598+
Input: []schemas.ResponsesMessage{
599+
{
600+
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
601+
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
602+
Content: &schemas.ResponsesMessageContent{
603+
ContentStr: schemas.Ptr("Extract nullable field"),
604+
},
605+
},
606+
},
607+
Params: &schemas.ResponsesParameters{
608+
Text: &schemas.ResponsesTextConfig{
609+
Format: &schemas.ResponsesTextConfigFormat{
610+
Type: "json_schema",
611+
Name: schemas.Ptr("NullableData"),
612+
JSONSchema: &schemas.ResponsesTextConfigFormatJSONSchema{
613+
Type: schemas.Ptr("object"),
614+
Properties: &map[string]interface{}{
615+
"name": map[string]interface{}{
616+
"type": []interface{}{"string", "null"},
617+
},
618+
},
619+
},
620+
},
621+
},
622+
},
623+
},
624+
validate: func(t *testing.T, result *gemini.GeminiGenerationRequest) {
625+
schemaMap := result.GenerationConfig.ResponseJSONSchema.(map[string]interface{})
626+
properties := schemaMap["properties"].(map[string]interface{})
627+
name := properties["name"].(map[string]interface{})
628+
629+
// Nullable types should be kept as array (Gemini supports this)
630+
typeVal := name["type"]
631+
typeSlice, ok := typeVal.([]interface{})
632+
require.True(t, ok, "type should remain as array for nullable types in Responses API")
633+
require.Len(t, typeSlice, 2)
634+
assert.Contains(t, typeSlice, "string")
635+
assert.Contains(t, typeSlice, "null")
636+
},
637+
},
638+
}
639+
640+
for _, tt := range tests {
641+
t.Run(tt.name, func(t *testing.T) {
642+
result := gemini.ToGeminiResponsesRequest(tt.input)
643+
require.NotNil(t, result, "Responses API conversion should not return nil")
644+
tt.validate(t, result)
645+
})
646+
}
647+
}
648+
291649
// TestBifrostResponsesToGeminiToolConversion tests the conversion of tools from Bifrost Responses API to Gemini format
292650
func TestBifrostResponsesToGeminiToolConversion(t *testing.T) {
293651
tests := []struct {

0 commit comments

Comments
 (0)