diff --git a/pkg/model/provider/openai/schema.go b/pkg/model/provider/openai/schema.go index 28149ab3b..f7db79e3a 100644 --- a/pkg/model/provider/openai/schema.go +++ b/pkg/model/provider/openai/schema.go @@ -55,12 +55,42 @@ func walkSchema(schema map[string]any, fn func(map[string]any)) { // makeAllRequired makes all object properties "required" throughout the schema, // because that's what the OpenAI Response API demands. // Properties that were not originally required are made nullable. +// Also ensures all object-type schemas have additionalProperties: false. func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters { if schema == nil { schema = map[string]any{"type": "object", "properties": map[string]any{}} } walkSchema(schema, func(node map[string]any) { + // Check if this node is an object type (either "object" or ["object", ...]) + isObject := false + if typeVal, ok := node["type"]; ok { + switch t := typeVal.(type) { + case string: + isObject = t == "object" + case []any: + for _, v := range t { + if s, ok := v.(string); ok && s == "object" { + isObject = true + break + } + } + case []string: + isObject = slices.Contains(t, "object") + } + } + + // All object types must have additionalProperties: false for OpenAI Responses API strict mode + // But only set it if additionalProperties is not already defined as an object schema + if isObject { + if addProps, exists := node["additionalProperties"]; !exists || addProps == nil || addProps == true { + node["additionalProperties"] = false + } + // If additionalProperties is already set to false or is an object schema (map[string]any), + // leave it as is - the object schema case will be walked separately + } + + // If the node has explicit properties, make them all required properties, ok := node["properties"].(map[string]any) if !ok { return @@ -88,7 +118,6 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters } node["required"] = newRequired - node["additionalProperties"] = false }) return schema diff --git a/pkg/model/provider/openai/schema_test.go b/pkg/model/provider/openai/schema_test.go index 65562b774..d9331aac7 100644 --- a/pkg/model/provider/openai/schema_test.go +++ b/pkg/model/provider/openai/schema_test.go @@ -333,6 +333,45 @@ func TestRemoveFormatFields_NoProperties(t *testing.T) { assert.Equal(t, schema, updated) } +func TestMakeAllRequired_TypeArrayWithObject(t *testing.T) { + // Reproduces the user_prompt tool schema where a property has + // type: ["object", "null"] with nested properties. OpenAI requires + // these nested properties to also have additionalProperties: false. + schema := shared.FunctionParameters{ + "type": "object", + "properties": map[string]any{ + "schema": map[string]any{ + "type": []string{"object", "null"}, + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "age": map[string]any{"type": "number"}, + }, + "required": []any{"name"}, + }, + }, + "required": []any{"schema"}, + } + + updated := makeAllRequired(schema) + + // Top-level should have additionalProperties: false + assert.Equal(t, false, updated["additionalProperties"]) + + // The schema property should also have additionalProperties: false + schemaProps := updated["properties"].(map[string]any)["schema"].(map[string]any) + assert.Equal(t, false, schemaProps["additionalProperties"]) + + // All properties in schema should be required + schemaRequired := schemaProps["required"].([]any) + assert.Len(t, schemaRequired, 2) + assert.Contains(t, schemaRequired, "name") + assert.Contains(t, schemaRequired, "age") + + // age was not originally required, so its type should be nullable + age := schemaProps["properties"].(map[string]any)["age"].(map[string]any) + assert.Equal(t, []string{"number", "null"}, age["type"]) +} + func TestFixSchemaArrayItems(t *testing.T) { schema := `{ "properties": {