diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index b45af7dd..770d02ce 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -246,10 +246,18 @@ func buildCompletionParams(model string, messages []*message.Message, opts []age switch frmt.Kind { case "json": if schema := frmt.Schema; schema != nil { + wireSchema := schema + if frmt.Strict { + var err error + wireSchema, err = strictSchemaToMap(schema) + if err != nil { + return openai.ChatCompletionNewParams{}, fmt.Errorf("failed to convert response format schema (type %T) to JSON format: %w", schema, err) + } + } params.ResponseFormat.OfJSONSchema = &shared.ResponseFormatJSONSchemaParam{ JSONSchema: shared.ResponseFormatJSONSchemaJSONSchemaParam{ Name: frmt.Name, - Schema: schema, + Schema: wireSchema, }, } if desc := frmt.Description; desc != "" { diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 3ab5a353..0de37f53 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/agenttest" "github.com/microsoft/agent-framework-go/internal/messagetest" "github.com/microsoft/agent-framework-go/message" @@ -109,6 +110,42 @@ func TestChatRequestIncludesAgentFrameworkUserAgent(t *testing.T) { } } +func TestChatResponseFormatSchemaConvertsJSONSchema(t *testing.T) { + type payload struct { + Name string `json:"name"` + Nickname string `json:"nickname,omitempty"` + } + format, err := jsonformat.For[payload]() + if err != nil { + t.Fatal(err) + } + + const input = ` + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "response_format":{"type":"json_schema","json_schema":{"name":"payload","schema":{"properties":{"name":{"type":"string"},"nickname":{"type":"string"}},"type":"object","required":["name","nickname"],"additionalProperties":false},"strict":true}} + } + ` + const output = ` + { + "id":"chatcmpl-test", + "object":"chat.completion", + "created":1727888631, + "model":"gpt-4o-mini", + "choices":[{"index":0,"message":{"role":"assistant","content":"{\"name\":\"Ada\",\"nickname\":\"A\"}"},"finish_reason":"stop"}] + } + ` + + server := newTestServer(t, input, output) + defer server.Close() + + a := newTestClient(server) + if _, err := a.RunText(t.Context(), "hello", agent.WithResponseFormat(format)).Collect(); err != nil { + t.Fatalf("error = %v", err) + } +} + func TestChatConfigInstructions_NonStreaming(t *testing.T) { const input = ` { diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index d0de5c79..f308e4d4 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -262,7 +262,13 @@ func responsesBuildCompletionParams(config AgentConfig, messages []*message.Mess switch frmt.Kind { case "json": if schema := frmt.Schema; schema != nil { - schemaMap, err := schemaToMap(schema) + var schemaMap map[string]any + var err error + if frmt.Strict { + schemaMap, err = strictSchemaToMap(schema) + } else { + schemaMap, err = schemaToMap(schema) + } if err != nil { return responses.ResponseNewParams{}, fmt.Errorf("failed to convert response format schema (type %T) to JSON format: %w", schema, err) } diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index f563204a..770deb47 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -2643,7 +2643,8 @@ data: {"type":"response.completed","sequence_number":2,"response":{"id":"resp_00 func TestResponsesResponseFormatSchemaConvertsJSONSchema(t *testing.T) { type payload struct { - Name string `json:"name"` + Name string `json:"name"` + Nickname string `json:"nickname,omitempty"` } format, err := jsonformat.For[payload]() if err != nil { @@ -2654,7 +2655,7 @@ func TestResponsesResponseFormatSchemaConvertsJSONSchema(t *testing.T) { { "model":"gpt-4o-mini", "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}], - "text":{"format":{"type":"json_schema","name":"payload","schema":{"properties":{"name":{"type":"string"}},"type":"object","required":["name"],"additionalProperties":false},"strict":true}} + "text":{"format":{"type":"json_schema","name":"payload","schema":{"properties":{"name":{"type":"string"},"nickname":{"type":"string"}},"type":"object","required":["name","nickname"],"additionalProperties":false},"strict":true}} } ` @@ -2676,6 +2677,18 @@ func TestResponsesResponseFormatSchemaConvertsJSONSchema(t *testing.T) { if _, err := a.RunText(t.Context(), "hello", agent.WithResponseFormat(format)).Collect(); err != nil { t.Fatalf("error = %v", err) } + + localFormat, err := jsonformat.FromResponseFormat(format) + if err != nil { + t.Fatal(err) + } + data, err := localFormat.Marshal(payload{Name: "Ada"}) + if err != nil { + t.Fatalf("local Marshal() rejected omitted optional property: %v", err) + } + if got, want := string(data), `{"name":"Ada"}`; got != want { + t.Fatalf("local Marshal() = %s, want %s", got, want) + } } func TestResponsesStreamingResponseWithQueuedUpdate_HandlesCorrectly(t *testing.T) { diff --git a/provider/openaiprovider/strict_schema.go b/provider/openaiprovider/strict_schema.go new file mode 100644 index 00000000..cfa79a20 --- /dev/null +++ b/provider/openaiprovider/strict_schema.go @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft. All rights reserved. + +package openaiprovider + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" +) + +const strictSchemaTransformCacheLimit = 256 + +var strictSchemaCache strictSchemaTransformCache + +var unsupportedStrictSchemaKeywords = [...]string{ + "$anchor", + "$dynamicAnchor", + "$dynamicRef", + "$recursiveAnchor", + "$recursiveRef", + "allOf", + "contains", + "contentEncoding", + "contentMediaType", + "contentSchema", + "dependentRequired", + "dependentSchemas", + "dependencies", + "else", + "if", + "maxContains", + "maxProperties", + "minContains", + "minProperties", + "not", + "patternProperties", + "prefixItems", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + "uniqueItems", +} + +type strictSchemaTransformCache struct { + mu sync.RWMutex + entries map[[sha256.Size]byte][]byte +} + +func strictSchemaToMap(schema any) (map[string]any, error) { + return strictSchemaCache.transform(schema) +} + +func (c *strictSchemaTransformCache) transform(schema any) (map[string]any, error) { + source, err := json.Marshal(schema) + if err != nil { + return nil, err + } + key := sha256.Sum256(source) + if transformed, ok := c.load(key); ok { + return decodeStrictSchemaMap(transformed) + } + + var schemaValue any + if err := json.Unmarshal(source, &schemaValue); err != nil { + return nil, err + } + schemaMap, ok := schemaValue.(map[string]any) + if !ok { + return nil, strictSchemaError(nil, "root schema must have type object") + } + if err := validateStrictSchemaRoot(schemaMap); err != nil { + return nil, err + } + if err := transformStrictSchemaObject(schemaMap, nil); err != nil { + return nil, err + } + transformed, err := json.Marshal(schemaMap) + if err != nil { + return nil, err + } + return decodeStrictSchemaMap(c.store(key, transformed)) +} + +func (c *strictSchemaTransformCache) load(key [sha256.Size]byte) ([]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + value, ok := c.entries[key] + return value, ok +} + +func (c *strictSchemaTransformCache) store(key [sha256.Size]byte, value []byte) []byte { + c.mu.Lock() + defer c.mu.Unlock() + if cached, ok := c.entries[key]; ok { + return cached + } + if c.entries == nil { + c.entries = make(map[[sha256.Size]byte][]byte) + } + if len(c.entries) >= strictSchemaTransformCacheLimit { + for entry := range c.entries { + delete(c.entries, entry) + break + } + } + c.entries[key] = value + return value +} + +func decodeStrictSchemaMap(data []byte) (map[string]any, error) { + var schema map[string]any + if err := json.Unmarshal(data, &schema); err != nil { + return nil, err + } + return schema, nil +} + +func transformStrictSchema(value any, path []string) (any, error) { + switch schema := value.(type) { + case bool: + return nil, strictSchemaError(path, "boolean schemas are not supported") + case map[string]any: + if err := transformStrictSchemaObject(schema, path); err != nil { + return nil, err + } + return schema, nil + default: + return nil, strictSchemaError(path, "schema must be an object or boolean") + } +} + +func transformStrictSchemaObject(schema map[string]any, path []string) error { + if err := validateStrictSchemaNode(schema, path); err != nil { + return err + } + properties, hasProperties, err := strictSchemaProperties(schema, path) + if err != nil { + return err + } + + if required, ok := schema["required"]; ok { + requiredNames, ok := required.([]any) + if !ok { + return strictSchemaError(path, "required must be an array of property names") + } + for _, value := range requiredNames { + name, ok := value.(string) + if !ok { + return strictSchemaError(path, "required must contain only property names") + } + if _, ok := properties[name]; !ok { + return strictSchemaError(path, "required property %q is not declared in properties", name) + } + } + } + + if additionalProperties, ok := schema["additionalProperties"]; ok { + closed, isBoolean := additionalProperties.(bool) + if !isBoolean || closed { + return strictSchemaError(path, "additionalProperties must be false") + } + } + if strictSchemaHasType(schema, "object") && (!hasProperties || len(properties) == 0) { + if _, ok := schema["additionalProperties"]; !ok { + return strictSchemaError(path, "object schema must declare properties or set additionalProperties to false") + } + } + + for name, property := range properties { + transformed, err := transformStrictSchema(property, strictSchemaPath(path, "properties", name)) + if err != nil { + return err + } + properties[name] = transformed + } + if item, ok := schema["items"]; ok { + transformed, err := transformStrictSchema(item, strictSchemaPath(path, "items")) + if err != nil { + return err + } + schema["items"] = transformed + } + for _, keyword := range []string{"anyOf", "oneOf"} { + value, ok := schema[keyword] + if !ok { + continue + } + subschemas, ok := value.([]any) + if !ok { + return strictSchemaError(append(path, keyword), "must be an array of schemas") + } + for index, subschema := range subschemas { + transformed, err := transformStrictSchema(subschema, strictSchemaPath(path, keyword, fmt.Sprintf("[%d]", index))) + if err != nil { + return err + } + subschemas[index] = transformed + } + } + for _, keyword := range []string{"$defs", "definitions"} { + value, ok := schema[keyword] + if !ok { + continue + } + definitions, ok := value.(map[string]any) + if !ok { + return strictSchemaError(append(path, keyword), "must be an object of schemas") + } + for name, definition := range definitions { + transformed, err := transformStrictSchema(definition, strictSchemaPath(path, keyword, name)) + if err != nil { + return err + } + definitions[name] = transformed + } + } + + if hasProperties { + if _, ok := schema["additionalProperties"]; !ok { + schema["additionalProperties"] = false + } + + required := make([]any, 0, len(properties)) + seen := make(map[string]bool, len(properties)) + if original, ok := schema["required"].([]any); ok { + for _, value := range original { + name := value.(string) + if !seen[name] { + required = append(required, name) + seen[name] = true + } + } + } + missing := make([]string, 0, len(properties)-len(required)) + for name := range properties { + if !seen[name] { + missing = append(missing, name) + } + } + sort.Strings(missing) + for _, name := range missing { + required = append(required, name) + } + schema["required"] = required + } + + return cleanStrictSchemaNode(schema, path) +} + +func cleanStrictSchemaNode(schema map[string]any, path []string) error { + if defaultValue, ok := schema["default"]; ok { + encoded, err := json.Marshal(defaultValue) + if err != nil { + return strictSchemaError(path, "encoding default: %v", err) + } + defaultDescription := "Default value: " + string(encoded) + if description, ok := schema["description"]; ok && description != nil { + descriptionText, ok := description.(string) + if !ok { + return strictSchemaError(path, "description must be a string") + } + schema["description"] = descriptionText + " (" + defaultDescription + ")" + } else { + schema["description"] = defaultDescription + } + delete(schema, "default") + } + return nil +} + +func validateStrictSchemaRoot(schema map[string]any) error { + switch schemaType := schema["type"].(type) { + case string: + if schemaType != "object" { + return strictSchemaError(nil, "root schema must have type object") + } + case []any: + if len(schemaType) != 1 || schemaType[0] != "object" { + return strictSchemaError(nil, "root schema must have type object") + } + schema["type"] = "object" + default: + return strictSchemaError(nil, "root schema must have type object") + } + if _, ok := schema["anyOf"]; ok { + return strictSchemaError(nil, "root schema must not use anyOf") + } + return nil +} + +func validateStrictSchemaNode(schema map[string]any, path []string) error { + for _, keyword := range unsupportedStrictSchemaKeywords { + if _, ok := schema[keyword]; ok { + return strictSchemaError(path, "unsupported keyword %q", keyword) + } + } + return nil +} + +func strictSchemaProperties(schema map[string]any, path []string) (map[string]any, bool, error) { + value, ok := schema["properties"] + if !ok { + return nil, false, nil + } + properties, ok := value.(map[string]any) + if !ok { + return nil, false, strictSchemaError(path, "properties must be an object") + } + return properties, true, nil +} + +func strictSchemaHasType(schema map[string]any, want string) bool { + switch value := schema["type"].(type) { + case string: + return value == want + case []any: + for _, item := range value { + if item == want { + return true + } + } + } + return false +} + +func strictSchemaPath(path []string, elements ...string) []string { + result := make([]string, 0, len(path)+len(elements)) + result = append(result, path...) + return append(result, elements...) +} + +func strictSchemaError(path []string, format string, args ...any) error { + location := "" + if len(path) > 0 { + location = strings.Join(path, "/") + } + return fmt.Errorf("strict JSON schema at %s: %s", location, fmt.Sprintf(format, args...)) +} diff --git a/provider/openaiprovider/strict_schema_test.go b/provider/openaiprovider/strict_schema_test.go new file mode 100644 index 00000000..fe24fca5 --- /dev/null +++ b/provider/openaiprovider/strict_schema_test.go @@ -0,0 +1,490 @@ +// Copyright (c) Microsoft. All rights reserved. + +package openaiprovider + +import ( + "reflect" + "strconv" + "strings" + "sync" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/format/jsonformat" +) + +func TestStrictSchemaToMapTransformsCloneRecursively(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": []any{"string", "null"}}, + "items": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{"type": "string"}, + }, + }, + }, + }, + "required": []any{"name"}, + "$defs": map[string]any{ + "details": map[string]any{ + "type": "object", + "properties": map[string]any{ + "enabled": map[string]any{"type": "boolean"}, + }, + }, + }, + } + original := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": []any{"string", "null"}}, + "items": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{"type": "string"}, + }, + }, + }, + }, + "required": []any{"name"}, + "$defs": map[string]any{ + "details": map[string]any{ + "type": "object", + "properties": map[string]any{ + "enabled": map[string]any{"type": "boolean"}, + }, + }, + }, + } + + strict, err := strictSchemaToMap(schema) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(schema, original) { + t.Fatalf("source schema was mutated:\ngot %#v\nwant %#v", schema, original) + } + if got, want := strict["required"], []any{"name", "items"}; !reflect.DeepEqual(got, want) { + t.Errorf("root required = %#v, want %#v", got, want) + } + name := strict["properties"].(map[string]any)["name"].(map[string]any) + if got, want := name["type"], []any{"string", "null"}; !reflect.DeepEqual(got, want) { + t.Errorf("required nullable type = %#v, want %#v", got, want) + } + items := strict["properties"].(map[string]any)["items"].(map[string]any)["items"].(map[string]any) + if got, want := items["required"], []any{"value"}; !reflect.DeepEqual(got, want) { + t.Errorf("array item required = %#v, want %#v", got, want) + } + if got := items["additionalProperties"]; got != false { + t.Errorf("array item additionalProperties = %#v, want false", got) + } + details := strict["$defs"].(map[string]any)["details"].(map[string]any) + if got, want := details["required"], []any{"enabled"}; !reflect.DeepEqual(got, want) { + t.Errorf("definition required = %#v, want %#v", got, want) + } +} + +func TestStrictSchemaToMapPreservesSupportedOpenAIKeywords(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + "minLength": float64(1), + "maxLength": float64(10), + "pattern": "^[a-z]+$", + "format": "email", + }, + "number": map[string]any{ + "type": "number", + "minimum": float64(1), + "maximum": float64(10), + "multipleOf": float64(2), + }, + "items": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "minItems": float64(1), + "maxItems": float64(10), + }, + }, + } + + strict, err := strictSchemaToMap(schema) + if err != nil { + t.Fatal(err) + } + + properties := strict["properties"].(map[string]any) + for name, want := range schema["properties"].(map[string]any) { + got := properties[name].(map[string]any) + for keyword, wantValue := range want.(map[string]any) { + if gotValue := got[keyword]; !reflect.DeepEqual(gotValue, wantValue) { + t.Errorf("properties/%s/%s = %#v, want %#v", name, keyword, gotValue, wantValue) + } + } + } +} + +func TestStrictSchemaToMapMovesDefaultToDescription(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "type": "string", + "description": "A value", + "default": "fallback", + }, + }, + } + + strict, err := strictSchemaToMap(schema) + if err != nil { + t.Fatal(err) + } + + value := strict["properties"].(map[string]any)["value"].(map[string]any) + if got, want := value["description"], `A value (Default value: "fallback")`; got != want { + t.Errorf("description = %#v, want %#v", got, want) + } + if _, ok := value["default"]; ok { + t.Error("strict schema retained default") + } +} + +func TestStrictSchemaToMapRejectsUnsupportedOpenAIKeywords(t *testing.T) { + tests := map[string]any{ + "allOf": []any{map[string]any{"type": "string"}}, + "contains": map[string]any{"type": "string"}, + "contentEncoding": "base64", + "contentMediaType": "text/plain", + "dependentRequired": map[string]any{"value": []any{"other"}}, + "dependentSchemas": map[string]any{"value": map[string]any{"type": "string"}}, + "else": map[string]any{"type": "string"}, + "if": map[string]any{"type": "string"}, + "maxContains": float64(2), + "maxProperties": float64(2), + "minContains": float64(1), + "minProperties": float64(1), + "not": map[string]any{"type": "string"}, + "patternProperties": map[string]any{"^x-": map[string]any{"type": "string"}}, + "prefixItems": []any{map[string]any{"type": "string"}}, + "propertyNames": map[string]any{"type": "string"}, + "then": map[string]any{"type": "string"}, + "unevaluatedItems": false, + "unevaluatedProperties": false, + "uniqueItems": true, + } + for keyword, value := range tests { + t.Run(keyword, func(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "type": keywordType(keyword), + keyword: value, + }, + }, + } + + _, err := strictSchemaToMap(schema) + if err == nil || !strings.Contains(err.Error(), `properties/value: unsupported keyword "`+keyword+`"`) { + t.Fatalf("strictSchemaToMap() error = %v, want unsupported-keyword error", err) + } + }) + } +} + +func keywordType(keyword string) string { + switch keyword { + case "contains", "maxContains", "minContains", "prefixItems", "unevaluatedItems", "uniqueItems": + return "array" + case "dependentRequired", "dependentSchemas", "maxProperties", "minProperties", "patternProperties", "propertyNames", "unevaluatedProperties": + return "object" + default: + return "string" + } +} + +func TestStrictSchemaToMapRejectsBooleanSchemas(t *testing.T) { + for _, value := range []bool{true, false} { + t.Run(strconv.FormatBool(value), func(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": value, + }, + } + + _, err := strictSchemaToMap(schema) + if err == nil || !strings.Contains(err.Error(), "properties/value: boolean schemas are not supported") { + t.Fatalf("strictSchemaToMap() error = %v, want boolean-schema error", err) + } + }) + } +} + +func TestStrictSchemaTransformCacheReturnsIndependentMaps(t *testing.T) { + cache := strictSchemaTransformCache{} + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + } + + first, err := cache.transform(schema) + if err != nil { + t.Fatal(err) + } + first["required"] = []any{"corrupted"} + + second, err := cache.transform(schema) + if err != nil { + t.Fatal(err) + } + if got, want := second["required"], []any{"name"}; !reflect.DeepEqual(got, want) { + t.Fatalf("cached required = %#v, want independent %#v", got, want) + } + if got := len(cache.entries); got != 1 { + t.Fatalf("cache entries = %d, want 1", got) + } + + schema["properties"].(map[string]any)["age"] = map[string]any{"type": "integer"} + third, err := cache.transform(schema) + if err != nil { + t.Fatal(err) + } + if got, want := third["required"], []any{"age", "name"}; !reflect.DeepEqual(got, want) { + t.Fatalf("required after source mutation = %#v, want %#v", got, want) + } + if got := len(cache.entries); got != 2 { + t.Fatalf("cache entries after source mutation = %d, want 2", got) + } +} + +func TestStrictSchemaTransformCacheIsBounded(t *testing.T) { + cache := strictSchemaTransformCache{} + for index := 0; index < strictSchemaTransformCacheLimit+1; index++ { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + "description": strconv.Itoa(index), + } + if _, err := cache.transform(schema); err != nil { + t.Fatal(err) + } + } + if got := len(cache.entries); got != strictSchemaTransformCacheLimit { + t.Fatalf("cache entries = %d, want %d", got, strictSchemaTransformCacheLimit) + } +} + +func TestStrictSchemaTransformCacheSupportsConcurrentCalls(t *testing.T) { + cache := strictSchemaTransformCache{} + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + } + + const callCount = 32 + results := make(chan map[string]any, callCount) + errors := make(chan error, callCount) + var waitGroup sync.WaitGroup + for range callCount { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + result, err := cache.transform(schema) + if err != nil { + errors <- err + return + } + results <- result + }() + } + waitGroup.Wait() + close(results) + close(errors) + + for err := range errors { + t.Errorf("transform() error = %v", err) + } + for result := range results { + if got, want := result["required"], []any{"name"}; !reflect.DeepEqual(got, want) { + t.Errorf("required = %#v, want %#v", got, want) + } + } + if got := len(cache.entries); got != 1 { + t.Fatalf("cache entries = %d, want 1", got) + } +} + +func TestStrictSchemaToMapRejectsOpenMap(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{ + "type": "object", + "additionalProperties": map[string]any{"type": "string"}, + }, + }, + } + + _, err := strictSchemaToMap(schema) + if err == nil || !strings.Contains(err.Error(), "properties/tags: additionalProperties must be false") { + t.Fatalf("strictSchemaToMap() error = %v, want open-map error with path", err) + } +} + +func TestStrictSchemaToMapRejectsInferredMap(t *testing.T) { + type payload struct { + Tags map[string]string `json:"tags"` + } + format, err := jsonformat.For[payload]() + if err != nil { + t.Fatal(err) + } + + _, err = strictSchemaToMap(format.Schema) + if err == nil || !strings.Contains(err.Error(), "properties/tags: additionalProperties must be false") { + t.Fatalf("strictSchemaToMap() error = %v, want inferred-map error with path", err) + } +} + +func TestStrictSchemaToMapRejectsImplicitlyOpenObject(t *testing.T) { + _, err := strictSchemaToMap(map[string]any{"type": "object"}) + if err == nil || !strings.Contains(err.Error(), ": object schema must declare properties or set additionalProperties to false") { + t.Fatalf("strictSchemaToMap() error = %v, want implicitly-open-object error", err) + } +} + +func TestStrictSchemaToMapRejectsEmptyPropertiesOpenObject(t *testing.T) { + _, err := strictSchemaToMap(map[string]any{ + "type": "object", + "properties": map[string]any{}, + }) + if err == nil || !strings.Contains(err.Error(), ": object schema must declare properties or set additionalProperties to false") { + t.Fatalf("strictSchemaToMap() error = %v, want empty open-object error", err) + } +} + +func TestStrictSchemaToMapRejectsNonObjectRoot(t *testing.T) { + stringFormat, err := jsonformat.For[string]() + if err != nil { + t.Fatal(err) + } + arrayFormat, err := jsonformat.For[[]string]() + if err != nil { + t.Fatal(err) + } + + tests := map[string]any{ + "string": stringFormat.Schema, + "array": arrayFormat.Schema, + "unconstrained": jsonformat.Any().Schema, + "nothing": jsonformat.Nothing().Schema, + "boolean": true, + } + for name, schema := range tests { + t.Run(name, func(t *testing.T) { + _, err := strictSchemaToMap(schema) + if err == nil || !strings.Contains(err.Error(), ": root schema must have type object") { + t.Fatalf("strictSchemaToMap() error = %v, want root-object error", err) + } + }) + } +} + +func TestStrictSchemaToMapRejectsRootAnyOf(t *testing.T) { + _, err := strictSchemaToMap(map[string]any{ + "type": "object", + "properties": map[string]any{}, + "additionalProperties": false, + "anyOf": []any{ + map[string]any{"type": "object", "additionalProperties": false}, + }, + }) + if err == nil || !strings.Contains(err.Error(), ": root schema must not use anyOf") { + t.Fatalf("strictSchemaToMap() error = %v, want root-anyOf error", err) + } +} + +func TestStrictSchemaToMapRejectsUndeclaredRequiredProperty(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + "required": []any{"name", "ghost"}, + } + + _, err := strictSchemaToMap(schema) + if err == nil || !strings.Contains(err.Error(), `: required property "ghost" is not declared in properties`) { + t.Fatalf("strictSchemaToMap() error = %v, want undeclared-property error", err) + } +} + +func TestStrictSchemaToMapPreservesRequiredNullableLocalValidation(t *testing.T) { + type payload struct { + Name *string `json:"name"` + Email *string `json:"email,omitempty"` + } + format, err := jsonformat.For[payload]() + if err != nil { + t.Fatal(err) + } + + strict, err := strictSchemaToMap(format.Schema) + if err != nil { + t.Fatal(err) + } + if got, want := strict["required"], []any{"name", "email"}; !reflect.DeepEqual(got, want) { + t.Fatalf("strict required = %#v, want %#v", got, want) + } + + local, err := jsonformat.FromResponseFormat(format) + if err != nil { + t.Fatal(err) + } + var value payload + if err := local.Unmarshal([]byte(`{"email":null}`), &value); err == nil { + t.Fatal("local Unmarshal() accepted a missing required nullable property") + } +} + +func TestResponseBuildersRejectOpenMapOnlyInStrictMode(t *testing.T) { + type payload struct { + Tags map[string]string `json:"tags"` + } + format, err := jsonformat.For[payload]() + if err != nil { + t.Fatal(err) + } + + strictOptions := []agent.Option{agent.WithResponseFormat(format)} + if _, err := buildCompletionParams("test-model", nil, strictOptions); err == nil || !strings.Contains(err.Error(), "properties/tags: additionalProperties must be false") { + t.Fatalf("buildCompletionParams() error = %v, want strict map error", err) + } + if _, err := responsesBuildCompletionParams(AgentConfig{Model: "test-model"}, nil, strictOptions); err == nil || !strings.Contains(err.Error(), "properties/tags: additionalProperties must be false") { + t.Fatalf("responsesBuildCompletionParams() error = %v, want strict map error", err) + } + + format.Strict = false + nonStrictOptions := []agent.Option{agent.WithResponseFormat(format)} + if _, err := buildCompletionParams("test-model", nil, nonStrictOptions); err != nil { + t.Fatalf("buildCompletionParams() rejected non-strict map: %v", err) + } + if _, err := responsesBuildCompletionParams(AgentConfig{Model: "test-model"}, nil, nonStrictOptions); err != nil { + t.Fatalf("responsesBuildCompletionParams() rejected non-strict map: %v", err) + } +}