Skip to content

Commit 6711ce3

Browse files
authored
fix(gemini): accept numeric schema integer constraints (#3994)
* fix(gemini): accept numeric schema integer constraints Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) * fix(gemini): address schema review feedback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) * fix(gemini): simplify schema integer parsing
1 parent a1e0933 commit 6711ce3

2 files changed

Lines changed: 246 additions & 4 deletions

File tree

core/providers/gemini/gemini_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"testing"
1010

11+
"github.com/bytedance/sonic"
1112
"github.com/maximhq/bifrost/core/internal/llmtests"
1213
"github.com/maximhq/bifrost/core/providers/gemini"
1314
"github.com/stretchr/testify/assert"
@@ -432,6 +433,180 @@ func TestThoughtSignatureBypassSentinelRoundTripsThroughJSON(t *testing.T) {
432433
assert.Equal(t, []byte("skip_thought_signature_validator"), decoded.ThoughtSignature)
433434
}
434435

436+
func TestGeminiGenerationRequestUnmarshalAcceptsSchemaIntegerConstraints(t *testing.T) {
437+
tests := []struct {
438+
name string
439+
body string
440+
}{
441+
{
442+
name: "numeric constraints",
443+
body: `{
444+
"contents": [{"role": "user", "parts": [{"text": "Search docs"}]}],
445+
"tools": [{
446+
"functionDeclarations": [{
447+
"name": "exa_web_search_exa",
448+
"description": "Search project docs",
449+
"parameters": {
450+
"type": "object",
451+
"minProperties": 1,
452+
"maxProperties": 3,
453+
"properties": {
454+
"query": {"type": "string", "description": "Search query", "minLength": 1, "maxLength": 100},
455+
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5}
456+
},
457+
"required": ["query"]
458+
}
459+
}]
460+
}]
461+
}`,
462+
},
463+
{
464+
name: "quoted constraints",
465+
body: `{
466+
"contents": [{"role": "user", "parts": [{"text": "Search docs"}]}],
467+
"tools": [{
468+
"functionDeclarations": [{
469+
"name": "exa_web_search_exa",
470+
"description": "Search project docs",
471+
"parameters": {
472+
"type": "object",
473+
"minProperties": "1",
474+
"maxProperties": "3",
475+
"properties": {
476+
"query": {"type": "string", "description": "Search query", "minLength": "1", "maxLength": "100"},
477+
"tags": {"type": "array", "items": {"type": "string"}, "minItems": "1", "maxItems": "5"}
478+
},
479+
"required": ["query"]
480+
}
481+
}]
482+
}]
483+
}`,
484+
},
485+
}
486+
487+
for _, tt := range tests {
488+
t.Run(tt.name, func(t *testing.T) {
489+
var req gemini.GeminiGenerationRequest
490+
require.NoError(t, sonic.Unmarshal([]byte(tt.body), &req))
491+
require.Len(t, req.Tools, 1)
492+
require.Len(t, req.Tools[0].FunctionDeclarations, 1)
493+
494+
params := req.Tools[0].FunctionDeclarations[0].Parameters
495+
require.NotNil(t, params)
496+
require.NotNil(t, params.MinProperties)
497+
require.NotNil(t, params.MaxProperties)
498+
assert.Equal(t, int64(1), *params.MinProperties)
499+
assert.Equal(t, int64(3), *params.MaxProperties)
500+
501+
query := params.Properties["query"]
502+
require.NotNil(t, query)
503+
require.NotNil(t, query.MinLength)
504+
require.NotNil(t, query.MaxLength)
505+
assert.Equal(t, int64(1), *query.MinLength)
506+
assert.Equal(t, int64(100), *query.MaxLength)
507+
508+
tags := params.Properties["tags"]
509+
require.NotNil(t, tags)
510+
require.NotNil(t, tags.MinItems)
511+
require.NotNil(t, tags.MaxItems)
512+
assert.Equal(t, int64(1), *tags.MinItems)
513+
assert.Equal(t, int64(5), *tags.MaxItems)
514+
})
515+
}
516+
517+
t.Run("invalid string constraint", func(t *testing.T) {
518+
var req gemini.GeminiGenerationRequest
519+
err := sonic.Unmarshal([]byte(`{
520+
"tools": [{
521+
"functionDeclarations": [{
522+
"name": "exa_web_search_exa",
523+
"parameters": {
524+
"type": "object",
525+
"properties": {
526+
"query": {"type": "string", "minLength": "many"}
527+
}
528+
}
529+
}]
530+
}]
531+
}`), &req)
532+
require.Error(t, err)
533+
assert.Contains(t, err.Error(), "invalid schema integer constraint")
534+
})
535+
536+
t.Run("max int64 numeric constraint", func(t *testing.T) {
537+
var req gemini.GeminiGenerationRequest
538+
require.NoError(t, sonic.Unmarshal([]byte(`{
539+
"tools": [{
540+
"functionDeclarations": [{
541+
"name": "exa_web_search_exa",
542+
"parameters": {
543+
"type": "object",
544+
"properties": {
545+
"query": {"type": "string", "maxLength": 9223372036854775807}
546+
}
547+
}
548+
}]
549+
}]
550+
}`), &req))
551+
552+
query := req.Tools[0].FunctionDeclarations[0].Parameters.Properties["query"]
553+
require.NotNil(t, query.MaxLength)
554+
assert.Equal(t, int64(9223372036854775807), *query.MaxLength)
555+
})
556+
557+
t.Run("null constraint remains unset", func(t *testing.T) {
558+
var req gemini.GeminiGenerationRequest
559+
require.NoError(t, sonic.Unmarshal([]byte(`{
560+
"tools": [{
561+
"functionDeclarations": [{
562+
"name": "exa_web_search_exa",
563+
"parameters": {
564+
"type": "object",
565+
"properties": {
566+
"query": {"type": "string", "minLength": null}
567+
}
568+
}
569+
}]
570+
}]
571+
}`), &req))
572+
573+
query := req.Tools[0].FunctionDeclarations[0].Parameters.Properties["query"]
574+
assert.Nil(t, query.MinLength)
575+
})
576+
577+
invalidConstraints := []struct {
578+
name string
579+
constraint string
580+
}{
581+
{name: "float", constraint: `1.5`},
582+
{name: "bool", constraint: `true`},
583+
{name: "object", constraint: `{}`},
584+
{name: "array", constraint: `[]`},
585+
{name: "overflow string", constraint: `"9223372036854775808"`},
586+
}
587+
588+
for _, tt := range invalidConstraints {
589+
t.Run("invalid "+tt.name+" constraint", func(t *testing.T) {
590+
var req gemini.GeminiGenerationRequest
591+
err := sonic.Unmarshal([]byte(`{
592+
"tools": [{
593+
"functionDeclarations": [{
594+
"name": "exa_web_search_exa",
595+
"parameters": {
596+
"type": "object",
597+
"properties": {
598+
"query": {"type": "string", "minLength": `+tt.constraint+`}
599+
}
600+
}
601+
}]
602+
}]
603+
}`), &req)
604+
require.Error(t, err)
605+
assert.Contains(t, err.Error(), "invalid schema integer constraint")
606+
})
607+
}
608+
}
609+
435610
// parseToolParams parses fd.ParametersJSONSchema (raw JSON Schema passthrough) into a
436611
// map for assertions. All tool conversion paths now use ParametersJSONSchema; fd.Parameters
437612
// is always nil.

core/providers/gemini/types.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -830,10 +830,10 @@ const (
830830
type TrafficType string
831831

832832
const (
833-
TrafficTypeUnspecified TrafficType = "TRAFFIC_TYPE_UNSPECIFIED"
834-
TrafficTypeOnDemand TrafficType = "ON_DEMAND"
835-
TrafficTypeOnDemandPriority TrafficType = "ON_DEMAND_PRIORITY"
836-
TrafficTypeOnDemandFlex TrafficType = "ON_DEMAND_FLEX"
833+
TrafficTypeUnspecified TrafficType = "TRAFFIC_TYPE_UNSPECIFIED"
834+
TrafficTypeOnDemand TrafficType = "ON_DEMAND"
835+
TrafficTypeOnDemandPriority TrafficType = "ON_DEMAND_PRIORITY"
836+
TrafficTypeOnDemandFlex TrafficType = "ON_DEMAND_FLEX"
837837
TrafficTypeProvisionedThroughput TrafficType = "PROVISIONED_THROUGHPUT"
838838
)
839839

@@ -997,6 +997,73 @@ type Schema struct {
997997
Type Type `json:"type,omitempty"`
998998
}
999999

1000+
type flexibleSchemaInt64 int64
1001+
1002+
var schemaIntegerJSON = sonic.Config{UseInt64: true}.Froze()
1003+
1004+
func (i *flexibleSchemaInt64) UnmarshalJSON(data []byte) error {
1005+
var value any
1006+
if err := schemaIntegerJSON.Unmarshal(data, &value); err != nil {
1007+
return fmt.Errorf("invalid schema integer constraint: %w", err)
1008+
}
1009+
1010+
switch typedValue := value.(type) {
1011+
case nil:
1012+
return nil
1013+
case int64:
1014+
*i = flexibleSchemaInt64(typedValue)
1015+
return nil
1016+
case string:
1017+
parsed, err := strconv.ParseInt(typedValue, 10, 64)
1018+
if err != nil {
1019+
return fmt.Errorf("invalid schema integer constraint %q: %w", typedValue, err)
1020+
}
1021+
*i = flexibleSchemaInt64(parsed)
1022+
return nil
1023+
default:
1024+
return fmt.Errorf("invalid schema integer constraint %v", typedValue)
1025+
}
1026+
}
1027+
1028+
func schemaInt64Ptr(value *flexibleSchemaInt64) *int64 {
1029+
if value == nil {
1030+
return nil
1031+
}
1032+
out := int64(*value)
1033+
return &out
1034+
}
1035+
1036+
// UnmarshalJSON accepts both the quoted integer format emitted by this struct's
1037+
// JSON tags and standard numeric JSON Schema constraints used by SDKs.
1038+
func (s *Schema) UnmarshalJSON(data []byte) error {
1039+
type schemaAlias Schema
1040+
type schemaWithFlexibleConstraints struct {
1041+
*schemaAlias
1042+
MaxItems *flexibleSchemaInt64 `json:"maxItems,omitempty"`
1043+
MaxLength *flexibleSchemaInt64 `json:"maxLength,omitempty"`
1044+
MaxProperties *flexibleSchemaInt64 `json:"maxProperties,omitempty"`
1045+
MinItems *flexibleSchemaInt64 `json:"minItems,omitempty"`
1046+
MinLength *flexibleSchemaInt64 `json:"minLength,omitempty"`
1047+
MinProperties *flexibleSchemaInt64 `json:"minProperties,omitempty"`
1048+
}
1049+
1050+
var aux schemaAlias
1051+
withConstraints := schemaWithFlexibleConstraints{schemaAlias: &aux}
1052+
if err := sonic.Unmarshal(data, &withConstraints); err != nil {
1053+
return err
1054+
}
1055+
1056+
*s = Schema(aux)
1057+
s.MaxItems = schemaInt64Ptr(withConstraints.MaxItems)
1058+
s.MaxLength = schemaInt64Ptr(withConstraints.MaxLength)
1059+
s.MaxProperties = schemaInt64Ptr(withConstraints.MaxProperties)
1060+
s.MinItems = schemaInt64Ptr(withConstraints.MinItems)
1061+
s.MinLength = schemaInt64Ptr(withConstraints.MinLength)
1062+
s.MinProperties = schemaInt64Ptr(withConstraints.MinProperties)
1063+
1064+
return nil
1065+
}
1066+
10001067
// Type represents the type of the data.
10011068
type Type string
10021069

0 commit comments

Comments
 (0)