Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/providers/anthropic/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif
}
}

if anthropicTool.InputSchema != nil {
anthropicTool.InputSchema = anthropicTool.InputSchema.Normalized()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if tool.CacheControl != nil {
anthropicTool.CacheControl = tool.CacheControl
}
Expand Down
10 changes: 10 additions & 0 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -4539,6 +4539,16 @@ func convertBifrostToolToAnthropic(model string, tool *schemas.ResponsesTool) *A
}
}

// Normalize tool schema key ordering to ensure deterministic serialization.
// Clients (e.g. Claude Agent SDK) may send non-deterministic property orderings
// across turns, which breaks Anthropic's prefix-based prompt caching since tool
// definitions are part of the serialized request prefix.
// Normalized() returns a shallow copy with sorted key slices, so the
// caller-owned tool.ResponsesToolFunction.Parameters is never mutated.
if anthropicTool.InputSchema != nil {
anthropicTool.InputSchema = anthropicTool.InputSchema.Normalized()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if tool.CacheControl != nil {
anthropicTool.CacheControl = tool.CacheControl
}
Expand Down
39 changes: 23 additions & 16 deletions core/providers/anthropic/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func getRequestBodyForResponses(ctx *schemas.BifrostContext, request *schemas.Bi
delete(requestBody, field)
}
}
jsonBody, err = sonic.Marshal(requestBody)
jsonBody, err = providerUtils.MarshalSorted(requestBody)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply deterministic re-marshal in the exclude-fields branch too.

Line 119 fixes raw-body determinism, but Line 174 still re-marshals a map[string]interface{} via sonic.Marshal after deletions. That branch can still destabilize key order when excludeFields is non-empty, weakening cache-key reuse.

Suggested patch
@@
-				// Re-marshal the map
-				jsonBody, err = sonic.Marshal(jsonMap)
+				// Re-marshal deterministically to preserve stable cache keys
+				jsonBody, err = providerUtils.MarshalSorted(jsonMap)
 				if err != nil {
 					return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/providers/anthropic/utils.go` at line 119, The branch that handles
excludeFields re-marshals the modified map with sonic.Marshal which can produce
non-deterministic key order; replace that sonic.Marshal call with
providerUtils.MarshalSorted (the same deterministic marshaller used where
jsonBody is set) so that after you delete keys from requestBody the final
jsonBody is produced via providerUtils.MarshalSorted(requestBody), and
propagate/handle the returned error similarly to the other branch (ensure
variables like requestBody, excludeFields, jsonBody and the error are used
consistently).

if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
}
Expand Down Expand Up @@ -160,21 +160,13 @@ func getRequestBodyForResponses(ctx *schemas.BifrostContext, request *schemas.Bi
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
}
}
} else {
// Remove excluded fields
if len(excludeFields) > 0 {
var jsonMap map[string]interface{}
if err := sonic.Unmarshal(jsonBody, &jsonMap); err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
}
for _, field := range excludeFields {
delete(jsonMap, field)
}
// Re-marshal the map
jsonBody, err = sonic.Marshal(jsonMap)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
}
} else if len(excludeFields) > 0 {
// Remove top-level keys while preserving nested JSON byte ordering
// (avoids roundtrip through map[string]interface{} which loses key order
// in nested objects like tool input_schema).
jsonBody, err = excludeTopLevelJSONKeys(jsonBody, excludeFields)
if err != nil {
return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err, providerName)
}
}
}
Expand Down Expand Up @@ -1646,3 +1638,18 @@ func IsClaudeCodeRequest(ctx *schemas.BifrostContext) bool {
}
return false
}

// excludeTopLevelJSONKeys removes the specified top-level keys from a JSON
// object without parsing nested values. Nested bytes are preserved as-is,
// which keeps the original key ordering within tool schemas and other
// deeply nested structures.
func excludeTopLevelJSONKeys(data []byte, keys []string) ([]byte, error) {
var raw map[string]json.RawMessage
if err := sonic.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("anthropic: unmarshalling for field exclusion: %w", err)
}
for _, k := range keys {
delete(raw, k)
}
return sonic.Marshal(raw)
}
53 changes: 53 additions & 0 deletions core/schemas/chatcompletions.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,59 @@ func (t *ToolFunctionParameters) UnmarshalJSON(data []byte) error {
return nil
}

// Normalized returns a shallow copy of the ToolFunctionParameters with all
// OrderedMap keys sorted using JSON Schema priority ordering (type,
// description, properties, required first, then alphabetically). The copy
// shares primitive values with the original but has independent key slices,
// so sorting does not mutate the caller's data.
//
// The captured keyOrder is cleared so the struct field declaration order is
// used for the top-level keys. This produces deterministic JSON serialization
// regardless of the client's original key ordering, which is critical for
// Anthropic's prefix-based prompt caching.
func (t *ToolFunctionParameters) Normalized() *ToolFunctionParameters {
if t == nil {
return nil
}
out := *t
out.keyOrder = JSONKeyOrder{}
out.Properties = t.Properties.SortedCopy()
out.Defs = t.Defs.SortedCopy()
out.Definitions = t.Definitions.SortedCopy()
out.Items = t.Items.SortedCopy()
if len(t.AnyOf) > 0 {
out.AnyOf = make([]OrderedMap, len(t.AnyOf))
for i := range t.AnyOf {
if cp := t.AnyOf[i].SortedCopy(); cp != nil {
out.AnyOf[i] = *cp
}
}
}
if len(t.OneOf) > 0 {
out.OneOf = make([]OrderedMap, len(t.OneOf))
for i := range t.OneOf {
if cp := t.OneOf[i].SortedCopy(); cp != nil {
out.OneOf[i] = *cp
}
}
}
if len(t.AllOf) > 0 {
out.AllOf = make([]OrderedMap, len(t.AllOf))
for i := range t.AllOf {
if cp := t.AllOf[i].SortedCopy(); cp != nil {
out.AllOf[i] = *cp
}
}
}
if t.AdditionalProperties != nil && t.AdditionalProperties.AdditionalPropertiesMap != nil {
out.AdditionalProperties = &AdditionalPropertiesStruct{
AdditionalPropertiesBool: t.AdditionalProperties.AdditionalPropertiesBool,
AdditionalPropertiesMap: t.AdditionalProperties.AdditionalPropertiesMap.SortedCopy(),
}
}
return &out
}
Comment on lines +422 to +463

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify `Normalized()` does not currently normalize `Default`.
rg -n 'type ToolFunctionParameters struct|Default  interface|func \(t \*ToolFunctionParameters\) Normalized|out\.Default' core/schemas/chatcompletions.go -A80 -B20

Repository: maximhq/bifrost

Length of output: 9719


🏁 Script executed:

#!/bin/bash
# Find where Normalized() is called
rg -n '\.Normalized\(\)' core/ --type go

Repository: maximhq/bifrost

Length of output: 279


🏁 Script executed:

#!/bin/bash
# Check if there are any examples or tests that use Default with map/array values
rg -n 'Default.*:=' core/schemas/ -A2 -B2 --type go | head -50

Repository: maximhq/bifrost

Length of output: 302


🏁 Script executed:

#!/bin/bash
# Look at OrderedMap and understand how SortedCopy works
rg -n 'func.*SortedCopy|type OrderedMap' core/schemas/ --type go -A5 | head -80

Repository: maximhq/bifrost

Length of output: 1155


🏁 Script executed:

#!/bin/bash
# Look at the full SortedCopy implementation
rg -n 'func.*SortedCopy' core/schemas/orderedmap.go -A30

Repository: maximhq/bifrost

Length of output: 912


🏁 Script executed:

#!/bin/bash
# Check if Default field is ever populated with maps in tests or examples
rg -n '"default"' core/schemas/chatcompletions.go -B2 -A2

Repository: maximhq/bifrost

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Look at how OrderedMapFromMap works
rg -n 'func OrderedMapFromMap' core/schemas/ --type go -A20

Repository: maximhq/bifrost

Length of output: 1181


🏁 Script executed:

#!/bin/bash
# Check the Anthropic provider usage to understand the context
rg -n 'InputSchema.*Normalized' core/providers/anthropic/ -B5 -A5 --type go

Repository: maximhq/bifrost

Length of output: 1699


🏁 Script executed:

#!/bin/bash
# Get the complete SortedCopy implementation
sed -n '376,430p' core/schemas/orderedmap.go

Repository: maximhq/bifrost

Length of output: 1358


🏁 Script executed:

#!/bin/bash
# Look for actual usage of Default field with structured values in tests
rg -n 'Default.*map\[|Default.*\[\]|Default.*{' core/ --type go | head -20

Repository: maximhq/bifrost

Length of output: 200


🏁 Script executed:

#!/bin/bash
# Check JSON Schema specs - Default field can be any JSON value
# Let's see if there are any existing helpers for normalizing arbitrary values
rg -n 'func normalize' core/schemas/ --type go

Repository: maximhq/bifrost

Length of output: 405


🏁 Script executed:

#!/bin/bash
# Check the normalizeForSortedMarshal function - it may already handle this
rg -n 'func normalizeForSortedMarshal' core/schemas/ -A20 --type go

Repository: maximhq/bifrost

Length of output: 2385


🏁 Script executed:

#!/bin/bash
# Look for actual test cases that use Default with complex values
rg -n '"default"' core/schemas/chatcompletions_test.go -B3 -A3

Repository: maximhq/bifrost

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Check if there's a stack of related PRs or if this is localized
git log --oneline -20

Repository: maximhq/bifrost

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Check what the recent commit actually changed
git show --stat 92aa3ab2 | head -50

Repository: maximhq/bifrost

Length of output: 2875


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that populate Default with map or array values
find core -name "*.go" -type f -exec grep -l "Default" {} \; | xargs grep -l "map\|OrderedMap" | head -5

Repository: maximhq/bifrost

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Look at the actual diff of the recent commit to understand what was normalized
git show 92aa3ab2 core/schemas/chatcompletions.go 2>/dev/null | head -100

Repository: maximhq/bifrost

Length of output: 4471


🏁 Script executed:

#!/bin/bash
# Check if Default is actually used with complex values anywhere
rg -n 'Default:' core/ --type go -B2 -A2 | head -40

Repository: maximhq/bifrost

Length of output: 2478


🏁 Script executed:

#!/bin/bash
# Confirm the patch would compile and check if similar patterns are already tested
rg -n 'sortedCopySlice\|normalizeForSortedMarshal' core/schemas/ --type go -B2 -A10 | head -60

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if there's any test that verifies Normalized() determinism
rg -n 'Normalized.*deterministic\|deterministic.*Normalized' core/ --type go -B3 -A3

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify that OrderedMapFromMap + SortedCopy pattern is sound
rg -n 'OrderedMapFromMap.*SortedCopy' core/ --type go

Repository: maximhq/bifrost

Length of output: 223


Normalize Default when it is object/array-shaped.

Normalized() currently leaves Default untouched (shallow copy only). If default contains maps, serialization can still vary by key order, so normalized schemas are not fully deterministic.

Suggested patch
 func (t *ToolFunctionParameters) Normalized() *ToolFunctionParameters {
 	if t == nil {
 		return nil
 	}
 	out := *t
 	out.keyOrder = JSONKeyOrder{}
 	out.Properties = t.Properties.SortedCopy()
 	out.Defs = t.Defs.SortedCopy()
 	out.Definitions = t.Definitions.SortedCopy()
 	out.Items = t.Items.SortedCopy()
@@
 	if t.AdditionalProperties != nil && t.AdditionalProperties.AdditionalPropertiesMap != nil {
 		out.AdditionalProperties = &AdditionalPropertiesStruct{
 			AdditionalPropertiesBool: t.AdditionalProperties.AdditionalPropertiesBool,
 			AdditionalPropertiesMap:  t.AdditionalProperties.AdditionalPropertiesMap.SortedCopy(),
 		}
 	}
+	out.Default = normalizeDeterministicSchemaValue(t.Default)
 	return &out
 }
+
+func normalizeDeterministicSchemaValue(v interface{}) interface{} {
+	switch x := v.(type) {
+	case *OrderedMap:
+		return x.SortedCopy()
+	case map[string]interface{}:
+		return OrderedMapFromMap(x).SortedCopy()
+	case []interface{}:
+		out := make([]interface{}, len(x))
+		for i := range x {
+			out[i] = normalizeDeterministicSchemaValue(x[i])
+		}
+		return out
+	default:
+		return v
+	}
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (t *ToolFunctionParameters) Normalized() *ToolFunctionParameters {
if t == nil {
return nil
}
out := *t
out.keyOrder = JSONKeyOrder{}
out.Properties = t.Properties.SortedCopy()
out.Defs = t.Defs.SortedCopy()
out.Definitions = t.Definitions.SortedCopy()
out.Items = t.Items.SortedCopy()
if len(t.AnyOf) > 0 {
out.AnyOf = make([]OrderedMap, len(t.AnyOf))
for i := range t.AnyOf {
if cp := t.AnyOf[i].SortedCopy(); cp != nil {
out.AnyOf[i] = *cp
}
}
}
if len(t.OneOf) > 0 {
out.OneOf = make([]OrderedMap, len(t.OneOf))
for i := range t.OneOf {
if cp := t.OneOf[i].SortedCopy(); cp != nil {
out.OneOf[i] = *cp
}
}
}
if len(t.AllOf) > 0 {
out.AllOf = make([]OrderedMap, len(t.AllOf))
for i := range t.AllOf {
if cp := t.AllOf[i].SortedCopy(); cp != nil {
out.AllOf[i] = *cp
}
}
}
if t.AdditionalProperties != nil && t.AdditionalProperties.AdditionalPropertiesMap != nil {
out.AdditionalProperties = &AdditionalPropertiesStruct{
AdditionalPropertiesBool: t.AdditionalProperties.AdditionalPropertiesBool,
AdditionalPropertiesMap: t.AdditionalProperties.AdditionalPropertiesMap.SortedCopy(),
}
}
return &out
}
func (t *ToolFunctionParameters) Normalized() *ToolFunctionParameters {
if t == nil {
return nil
}
out := *t
out.keyOrder = JSONKeyOrder{}
out.Properties = t.Properties.SortedCopy()
out.Defs = t.Defs.SortedCopy()
out.Definitions = t.Definitions.SortedCopy()
out.Items = t.Items.SortedCopy()
if len(t.AnyOf) > 0 {
out.AnyOf = make([]OrderedMap, len(t.AnyOf))
for i := range t.AnyOf {
if cp := t.AnyOf[i].SortedCopy(); cp != nil {
out.AnyOf[i] = *cp
}
}
}
if len(t.OneOf) > 0 {
out.OneOf = make([]OrderedMap, len(t.OneOf))
for i := range t.OneOf {
if cp := t.OneOf[i].SortedCopy(); cp != nil {
out.OneOf[i] = *cp
}
}
}
if len(t.AllOf) > 0 {
out.AllOf = make([]OrderedMap, len(t.AllOf))
for i := range t.AllOf {
if cp := t.AllOf[i].SortedCopy(); cp != nil {
out.AllOf[i] = *cp
}
}
}
if t.AdditionalProperties != nil && t.AdditionalProperties.AdditionalPropertiesMap != nil {
out.AdditionalProperties = &AdditionalPropertiesStruct{
AdditionalPropertiesBool: t.AdditionalProperties.AdditionalPropertiesBool,
AdditionalPropertiesMap: t.AdditionalProperties.AdditionalPropertiesMap.SortedCopy(),
}
}
out.Default = normalizeDeterministicSchemaValue(t.Default)
return &out
}
func normalizeDeterministicSchemaValue(v interface{}) interface{} {
switch x := v.(type) {
case *OrderedMap:
return x.SortedCopy()
case map[string]interface{}:
return OrderedMapFromMap(x).SortedCopy()
case []interface{}:
out := make([]interface{}, len(x))
for i := range x {
out[i] = normalizeDeterministicSchemaValue(x[i])
}
return out
default:
return v
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/schemas/chatcompletions.go` around lines 422 - 463, Normalized()
currently only shallow-copies ToolFunctionParameters.Default which leaves
embedded maps/arrays with non-deterministic key/item order; update
ToolFunctionParameters.Normalized to detect when Default is object-shaped (e.g.,
an OrderedMap or map-like value) or array-shaped and produce a normalized copy:
for object-shaped defaults call SortedCopy (or the equivalent
OrderedMap.SortedCopy) and assign the result to out.Default, and for
array-shaped defaults iterate items and SortedCopy any map-like elements into a
new slice before assigning to out.Default so serialized defaults become
deterministic; modify the Normalized method in ToolFunctionParameters to perform
these transformations alongside the existing Properties/Defs/Items handling.


type AdditionalPropertiesStruct struct {
AdditionalPropertiesBool *bool
AdditionalPropertiesMap *OrderedMap
Expand Down
127 changes: 127 additions & 0 deletions core/schemas/orderedmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,133 @@ func (om *OrderedMap) UnmarshalJSON(data []byte) error {
return nil
}

// jsonSchemaPriority maps JSON Schema keywords to their preferred
// serialization position. Keys present in this map are emitted first
// (in the given order), followed by all remaining keys alphabetically.
// This matches the optimal ordering for LLM tool schemas: the model
// sees type and description before properties, constraints, etc.
var jsonSchemaPriority = map[string]int{
"type": 0,
"description": 1,
"properties": 2,
"required": 3,
}

// SortKeys sorts the keys of this OrderedMap using JSON Schema priority
// ordering (type, description, properties, required first), with remaining
// keys sorted alphabetically. Nested *OrderedMap values are also sorted
// recursively.
func (om *OrderedMap) SortKeys() {
if om == nil || len(om.keys) == 0 {
return
}
sort.Slice(om.keys, func(i, j int) bool {
pi, okI := jsonSchemaPriority[om.keys[i]]
pj, okJ := jsonSchemaPriority[om.keys[j]]
switch {
case okI && okJ:
return pi < pj
case okI:
return true
case okJ:
return false
default:
return om.keys[i] < om.keys[j]
}
})
for k, v := range om.values {
switch nested := v.(type) {
case *OrderedMap:
nested.SortKeys()
case map[string]interface{}:
converted := OrderedMapFromMap(nested)
converted.SortKeys()
om.values[k] = converted
case []interface{}:
sortOrderedMapsInSlice(nested)
}
}
}

func sortOrderedMapsInSlice(s []interface{}) {
for i, item := range s {
switch v := item.(type) {
case *OrderedMap:
v.SortKeys()
case map[string]interface{}:
converted := OrderedMapFromMap(v)
converted.SortKeys()
s[i] = converted
case []interface{}:
sortOrderedMapsInSlice(v)
}
}
}

// SortedCopy returns a new OrderedMap with keys sorted using JSON Schema
// priority ordering. Nested *OrderedMap values are recursively copied and
// sorted. Primitive values (strings, numbers, bools) are shared, not cloned.
// This is much cheaper than a full JSON marshal/unmarshal Clone because it
// only allocates new key slices and value maps.
func (om *OrderedMap) SortedCopy() *OrderedMap {
if om == nil {
return nil
}
if len(om.keys) == 0 {
return &OrderedMap{values: make(map[string]interface{})}
}

newKeys := make([]string, len(om.keys))
copy(newKeys, om.keys)
sort.Slice(newKeys, func(i, j int) bool {
pi, okI := jsonSchemaPriority[newKeys[i]]
pj, okJ := jsonSchemaPriority[newKeys[j]]
switch {
case okI && okJ:
return pi < pj
case okI:
return true
case okJ:
return false
default:
return newKeys[i] < newKeys[j]
}
})

newValues := make(map[string]interface{}, len(om.values))
for k, v := range om.values {
switch nested := v.(type) {
case *OrderedMap:
newValues[k] = nested.SortedCopy()
case map[string]interface{}:
newValues[k] = OrderedMapFromMap(nested).SortedCopy()
case []interface{}:
newValues[k] = sortedCopySlice(nested)
default:
newValues[k] = v
}
}

return &OrderedMap{keys: newKeys, values: newValues}
}

func sortedCopySlice(s []interface{}) []interface{} {
out := make([]interface{}, len(s))
for i, item := range s {
switch v := item.(type) {
case *OrderedMap:
out[i] = v.SortedCopy()
case map[string]interface{}:
out[i] = OrderedMapFromMap(v).SortedCopy()
case []interface{}:
out[i] = sortedCopySlice(v)
default:
out[i] = item
}
}
return out
}

// decodeOrderedValue reads a single JSON value from the decoder.
// Objects are decoded as *OrderedMap (preserving key order).
// Arrays are decoded as []interface{} with each element recursively decoded.
Expand Down
80 changes: 80 additions & 0 deletions core/schemas/orderedmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,86 @@ func TestOrderedMap_UnmarshalThenMarshalPreservesOrder(t *testing.T) {
assert.Equal(t, input, string(output))
}

func TestOrderedMap_SortKeys_PlainMapValues(t *testing.T) {
om := NewOrderedMapFromPairs(
KV("properties", map[string]interface{}{
"z_field": map[string]interface{}{
"type": "string",
"description": "last field",
},
"a_field": map[string]interface{}{
"type": "number",
"description": "first field",
},
}),
KV("type", "object"),
)

om.SortKeys()

assert.Equal(t, []string{"type", "properties"}, om.Keys(), "top-level keys should be schema-sorted")

props, ok := om.Get("properties")
require.True(t, ok)
propsOM, ok := props.(*OrderedMap)
require.True(t, ok, "plain map should be converted to *OrderedMap, got %T", props)

assert.Equal(t, []string{"a_field", "z_field"}, propsOM.Keys(), "nested plain map keys should be sorted")

zField, ok := propsOM.Get("z_field")
require.True(t, ok)
zFieldOM, ok := zField.(*OrderedMap)
require.True(t, ok, "deeply nested plain map should be converted to *OrderedMap, got %T", zField)
assert.Equal(t, []string{"type", "description"}, zFieldOM.Keys(), "deeply nested keys should be schema-sorted")
}

func TestOrderedMap_SortKeys_PlainMapInSlice(t *testing.T) {
om := NewOrderedMapFromPairs(
KV("anyOf", []interface{}{
map[string]interface{}{
"description": "first option",
"type": "string",
},
}),
)

om.SortKeys()

anyOf, ok := om.Get("anyOf")
require.True(t, ok)
slice, ok := anyOf.([]interface{})
require.True(t, ok)
require.Len(t, slice, 1)

elemOM, ok := slice[0].(*OrderedMap)
require.True(t, ok, "plain map in slice should be converted to *OrderedMap, got %T", slice[0])
assert.Equal(t, []string{"type", "description"}, elemOM.Keys())
}

func TestOrderedMap_SortedCopy_PlainMapValues(t *testing.T) {
om := NewOrderedMapFromPairs(
KV("properties", map[string]interface{}{
"z_field": "string",
"a_field": "number",
}),
KV("type", "object"),
)

sorted := om.SortedCopy()

assert.Equal(t, []string{"type", "properties"}, sorted.Keys())

props, ok := sorted.Get("properties")
require.True(t, ok)
propsOM, ok := props.(*OrderedMap)
require.True(t, ok, "plain map should be converted to *OrderedMap in SortedCopy, got %T", props)
assert.Equal(t, []string{"a_field", "z_field"}, propsOM.Keys())

origProps, _ := om.Get("properties")
_, isMap := origProps.(map[string]interface{})
assert.True(t, isMap, "original should be unmodified (still a plain map)")
}

func TestOrderedMap_EmptyArray(t *testing.T) {
input := `{"items":[],"name":"test"}`

Expand Down