Skip to content

fix(anthropic): deterministic tool schema serialization for prompt caching - #2082

Merged
akshaydeo merged 1 commit into
maximhq:mainfrom
Edward-Upton:ed/fix-anthropic-cache-determinism
Mar 15, 2026
Merged

akshaydeo merged 1 commit into
maximhq:mainfrom
Edward-Upton:ed/fix-anthropic-cache-determinism

Conversation

@Edward-Upton

@Edward-Upton Edward-Upton commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Anthropic's prompt caching is prefix-based — it derives a cache key from the serialized request. When clients send tool definitions with JSON object fields (e.g. properties, $defs), the key ordering within those objects can vary non-deterministically between turns.

Because tool definitions are part of the request prefix, this means the cache key changes every turn, causing repeated cache writes instead of cache reads. In practice, only the system prompt portion (~16k tokens) was ever cache-hit; the full tool schema (~25k tokens) was re-written on every single LLM call.

Impact

We measured this across multiple production agent runs (80+ LLM turns each). After the fix:

Model Cost Before Cost After Savings
Claude Sonnet 4.6 ~$15.80 ~$1.65 ~90%
Claude Opus 4.6 ~$9.55 ~$2.71 ~72%

Cache read tokens went from plateauing early to growing correctly with each turn, confirming the prefix cache is working as intended.

Fix

Ensures all tool schema JSON is serialized with deterministic, LLM-optimal key ordering across all code paths:

  1. OrderedMap.SortKeys() — sorts keys using JSON Schema priority ordering (type, description, properties, required first, then remaining keys alphabetically). This ensures LLMs see type information before descriptions and constraints in nested tool schemas.

  2. OrderedMap.SortedCopy() — returns a new OrderedMap tree with sorted keys, sharing primitive values with the original. This is much cheaper than a full Marshal/Unmarshal clone since it only allocates new key slices and value maps.

  3. ToolFunctionParameters.Normalized() — returns a shallow struct copy with all nested OrderedMaps sorted via SortedCopy(). Replaces the old Clone() + NormalizeKeyOrder() pattern, avoiding a full JSON roundtrip per tool per request.

  4. Chat Completions + Responses API — both Anthropic converters call Normalized() on tool input schemas before serialization.

  5. excludeTopLevelJSONKeys() — the Responses API excludeFields path now uses map[string]json.RawMessage instead of map[string]interface{}, preserving nested byte ordering instead of re-sorting everything alphabetically.

  6. Passthrough mode — uses MarshalSorted for deterministic map key output when forwarding raw request bodies.

Test plan

  • Verified core/ module compiles
  • Tested with production agent runs across Sonnet, Opus, and Haiku — cache read tokens accumulate correctly, cache writes drop to near-zero after first turn
  • Existing provider tests pass (make test-core PROVIDER=anthropic)

@CLAassistant

CLAassistant commented Mar 15, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Request payloads now preserve deterministic JSON key ordering for more consistent outputs.
  • Bug Fixes
    • Tool input schemas are normalized and serialized deterministically to ensure stable formatting and avoid nondeterministic caching behavior.
    • Schema handling avoids in-place mutations, preventing unexpected side effects.
  • Refactor
    • Top-level field-exclusion logic updated to preserve nested ordering when removing keys.
  • Tests
    • Added tests validating ordered-map sorting and copy behavior for nested maps and slices.

Walkthrough

Normalize and deterministically sort JSON Schema key ordering across Anthropic provider flows: tool InputSchema values are shallow-normalized, request payloads are marshaled with a sorted routine, top-level keys can be excluded without reordering nested content, and OrderedMap/ToolFunctionParameters gain sorting utilities.

Changes

Cohort / File(s) Summary
Anthropic provider conversion
core/providers/anthropic/chat.go, core/providers/anthropic/responses.go
Apply ToolFunctionParameters.Normalized() to non-nil InputSchema during tool conversion for deterministic schema ordering.
Anthropic request utilities
core/providers/anthropic/utils.go
Use providerUtils.MarshalSorted(requestBody) for stable key ordering; add excludeTopLevelJSONKeys(data []byte, keys []string) and switch top-level-key exclusion to preserve nested byte order when removing fields.
Tool schema ordering
core/schemas/chatcompletions.go
Add func (t *ToolFunctionParameters) Normalized() *ToolFunctionParameters returning a shallow copy with OrderedMap fields replaced by sorted copies and top-level keyOrder cleared.
OrderedMap sorting utilities & tests
core/schemas/orderedmap.go, core/schemas/orderedmap_test.go
Introduce jsonSchemaPriority, SortKeys() (in-place) and SortedCopy() (non-mutating) with recursive sorting for nested maps/slices; add tests for plain-map and slice scenarios validating sorting behavior.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nudged the keys in tidy rows,

I sorted maps where wild order grows,
I copied schemas, peeked inside,
Now JSON hums with steady stride. 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: fixing deterministic tool schema serialization for Anthropic's prompt caching to resolve cache key variations.
Description check ✅ Passed The description is comprehensive and well-structured, covering the problem, impact with metrics, detailed fix description, and test plan, though provider tests remain to be executed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@akshaydeo

Copy link
Copy Markdown
Contributor

❤️ for this @Edward-Upton - @Pratham-Mishra04 was going to look into this but thank you - ill review this in a bit and get in

@akshaydeo

Copy link
Copy Markdown
Contributor

one quick question I have here @Edward-Upton - We have noticed that, specifically with OpenAI - order of the keys (not sorted/unsorted but for example where is the required field for JSON response schema) matters a lot on how much model hallucinates.

Apparently JSON parser in models is serialized parser so it does not get entire schema and considers that for response generation. Anything you observed with anthropic?

@Edward-Upton

Copy link
Copy Markdown
Contributor Author

one quick question I have here @Edward-Upton - We have noticed that, specifically with OpenAI - order of the keys (not sorted/unsorted but for example where is the required field for JSON response schema) matters a lot on how much model hallucinates.

Apparently JSON parser in models is serialized parser so it does not get entire schema and considers that for response generation. Anything you observed with anthropic?

Haven't noticed, but potentially not ran enough evals of this comparing with vs without. It was so expensive to run evals without this fix that I'm not sure we will even be able to compare. Is there any public information about ordering of keys we could try to replicate?

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
core/schemas/chatcompletions.go (1)

417-419: Consider a nil guard in NormalizeKeyOrder.

If this utility is ever called on a nil receiver, it will panic at Line 418. A tiny guard makes it safer for reuse.

Suggested patch
 func (t *ToolFunctionParameters) NormalizeKeyOrder() {
+	if t == nil {
+		return
+	}
 	t.keyOrder = JSONKeyOrder{}
 	t.Properties.SortKeys()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/schemas/chatcompletions.go` around lines 417 - 419, Add a nil receiver
guard at the start of ToolFunctionParameters.NormalizeKeyOrder to avoid panics
when the method is called on a nil receiver: check if t == nil and return early;
also defensively ensure t.Properties is non-nil before calling
t.Properties.SortKeys() (and still reset t.keyOrder = JSONKeyOrder{} only when t
is non-nil). This touches the method ToolFunctionParameters.NormalizeKeyOrder
and the fields keyOrder and Properties and the JSONKeyOrder type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/providers/anthropic/chat.go`:
- Around line 137-139: The conversion in ToAnthropicChatRequest mutates the
original schema via anthropicTool.InputSchema.NormalizeKeyOrder(), which can
modify nested structures referenced from bifrostReq.Params.Tools[...] and causes
side effects; instead, deep-copy anthropicTool.InputSchema before calling
NormalizeKeyOrder so the converter remains pure—locate ToAnthropicChatRequest
and where it accesses anthropicTool.InputSchema, create a deep copy of the
schema object (preserving maps/slices) and call NormalizeKeyOrder on that copy,
then use the copied/normalized schema in the constructed Anthropic request,
ensuring bifrostReq.Params.Tools and the original anthropicTool remain
unchanged.

In `@core/providers/anthropic/responses.go`:
- Around line 4546-4548: The code calls
anthropicTool.InputSchema.NormalizeKeyOrder() in-place which can mutate a shared
pointer (e.g., tool.ResponsesToolFunction.Parameters) and leak state; replace
this by deep-copying the schema before normalizing: create a new schema instance
(clone/copy) from anthropicTool.InputSchema (or from
tool.ResponsesToolFunction.Parameters if that's the shared source), assign that
copy back to anthropicTool.InputSchema, then call NormalizeKeyOrder() on the
copy so the original caller-owned schema is not modified. Ensure the copy is a
full/deep copy of the schema structure to avoid shared references.

In `@core/providers/anthropic/utils.go`:
- 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).

---

Nitpick comments:
In `@core/schemas/chatcompletions.go`:
- Around line 417-419: Add a nil receiver guard at the start of
ToolFunctionParameters.NormalizeKeyOrder to avoid panics when the method is
called on a nil receiver: check if t == nil and return early; also defensively
ensure t.Properties is non-nil before calling t.Properties.SortKeys() (and still
reset t.keyOrder = JSONKeyOrder{} only when t is non-nil). This touches the
method ToolFunctionParameters.NormalizeKeyOrder and the fields keyOrder and
Properties and the JSONKeyOrder type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f43517c4-a3a6-4944-951e-59191cf649cd

📥 Commits

Reviewing files that changed from the base of the PR and between 6a6eaec and 45ade14.

📒 Files selected for processing (5)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/schemas/orderedmap.go

Comment thread core/providers/anthropic/chat.go
Comment thread core/providers/anthropic/responses.go
}
}
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).

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/providers/anthropic/chat.go`:
- Around line 1091-1097: deepCopyToolFunctionParameters is using encoding/json's
json.Marshal and json.Unmarshal (variables p and copied of type
*schemas.ToolFunctionParameters); replace those calls with
github.com/bytedance/sonic's sonic.Marshal and sonic.Unmarshal for performance
in the hot path, and update imports to remove encoding/json and ensure sonic is
imported; keep the same error handling and return values while marshaling p with
sonic.Marshal and unmarshaling into copied with sonic.Unmarshal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bc92db0-4f1a-4acd-8e71-f4e81caa2776

📥 Commits

Reviewing files that changed from the base of the PR and between 45ade14 and c50d745.

📒 Files selected for processing (5)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/schemas/orderedmap.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/providers/anthropic/responses.go

Comment thread core/providers/anthropic/chat.go Outdated
@Edward-Upton
Edward-Upton force-pushed the ed/fix-anthropic-cache-determinism branch from c50d745 to c9a1b09 Compare March 15, 2026 07:50

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/schemas/orderedmap.go`:
- Around line 342-349: The loop only recurses into *OrderedMap and
[]interface{}, so nested plain map[string]interface{} values (e.g., produced by
OrderedMapFromMap) are left unsorted; update the recursion to also detect
map[string]interface{} (and slices containing map[string]interface{}) and either
convert them to OrderedMap and call SortKeys() or call a helper that sorts those
plain maps in-place; specifically extend the cases in the loop over om.values
and in sortOrderedMapsInSlice to handle map[string]interface{} (and
[]map[string]interface{} / []interface{} elements that are maps) by invoking
OrderedMapFromMap(...) followed by SortKeys() or by delegating to a new
recursive function that sorts plain maps, and apply the same change to the other
occurrences of the same pattern (the other loops that currently only check
*OrderedMap and []interface{}).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d0612799-578d-469d-8e3f-41ba50fbd081

📥 Commits

Reviewing files that changed from the base of the PR and between c50d745 and c9a1b09.

📒 Files selected for processing (5)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/schemas/orderedmap.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/providers/anthropic/utils.go
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go

Comment thread core/schemas/orderedmap.go Outdated
@Edward-Upton
Edward-Upton force-pushed the ed/fix-anthropic-cache-determinism branch from c9a1b09 to 4c2eebe Compare March 15, 2026 08:19

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
core/providers/anthropic/utils.go (1)

1641-1654: Use sonic instead of encoding/json for marshaling/unmarshaling.

The function correctly preserves nested byte ordering via json.RawMessage, but uses encoding/json for marshal/unmarshal operations. Since this is called in the request body construction path, it should use sonic for performance per coding guidelines. The json.RawMessage type can remain from encoding/json as it's just a type alias for []byte.

♻️ Proposed refactor to use sonic
 // 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 := json.Unmarshal(data, &raw); err != nil {
+	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 json.Marshal(raw)
+	return sonic.Marshal(raw)
 }

As per coding guidelines: "JSON marshaling in hot paths must use github.com/bytedance/sonic for performance, not encoding/json"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/providers/anthropic/utils.go` around lines 1641 - 1654, The
excludeTopLevelJSONKeys function currently uses encoding/json for
Unmarshal/Marshal; switch to github.com/bytedance/sonic's Unmarshal and Marshal
for performance: replace json.Unmarshal(data, &raw) with sonic.Unmarshal(data,
&raw) and json.Marshal(raw) with sonic.Marshal(raw), keep using json.RawMessage
as the byte alias, and update imports to include sonic while removing direct
encoding/json Marshal/Unmarshal usages; ensure error wrapping and return
semantics remain identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@core/providers/anthropic/utils.go`:
- Around line 1641-1654: The excludeTopLevelJSONKeys function currently uses
encoding/json for Unmarshal/Marshal; switch to github.com/bytedance/sonic's
Unmarshal and Marshal for performance: replace json.Unmarshal(data, &raw) with
sonic.Unmarshal(data, &raw) and json.Marshal(raw) with sonic.Marshal(raw), keep
using json.RawMessage as the byte alias, and update imports to include sonic
while removing direct encoding/json Marshal/Unmarshal usages; ensure error
wrapping and return semantics remain identical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b05347dd-4398-445f-a025-e530c024fdf2

📥 Commits

Reviewing files that changed from the base of the PR and between c9a1b09 and 4c2eebe.

📒 Files selected for processing (6)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/schemas/orderedmap.go
  • core/schemas/orderedmap_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go

…ching

Anthropic's prompt caching is prefix-based — when clients send tool
definitions with non-deterministic JSON key ordering across turns, the
cache key changes every turn, causing repeated cache writes instead of
reads.

Changes:
- Add OrderedMap.SortKeys() with JSON Schema priority ordering (type,
  description, properties, required first, then alphabetically) so LLMs
  see type information before descriptions and constraints.
- Add OrderedMap.SortedCopy() which returns a new tree with sorted keys
  sharing primitive values — avoids expensive Marshal/Unmarshal cloning.
- Add ToolFunctionParameters.Normalized() that returns a shallow copy
  with all nested OrderedMaps sorted via SortedCopy, replacing the old
  Clone() + NormalizeKeyOrder() pattern.
- Call Normalized() in both Chat Completions and Responses API Anthropic
  converters before serialization.
- Replace map[string]interface{} roundtrip in excludeFields path with
  map[string]json.RawMessage to preserve nested byte ordering.
- Passthrough mode uses MarshalSorted for deterministic map key output.

Made-with: Cursor
@Edward-Upton
Edward-Upton force-pushed the ed/fix-anthropic-cache-determinism branch from 4c2eebe to 92aa3ab Compare March 15, 2026 08:32

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
core/providers/anthropic/utils.go (1)

1646-1655: ⚠️ Potential issue | 🟠 Major

Use deterministic marshal after top-level key exclusion.

Line 1654 uses sonic.Marshal(raw) on a Go map, so top-level key order can still vary between requests and weaken cache-key reuse when excludeFields is active.

Suggested patch
 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)
+	// Keep nested RawMessage bytes intact, but sort top-level keys deterministically.
+	return providerUtils.MarshalSorted(raw)
 }
#!/bin/bash
# Verify current implementation in excludeTopLevelJSONKeys still uses non-deterministic marshal.
rg -n 'func excludeTopLevelJSONKeys|return sonic.Marshal|MarshalSorted' core/providers/anthropic/utils.go -A5 -B5
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/providers/anthropic/utils.go` around lines 1646 - 1655, The function
excludeTopLevelJSONKeys currently marshals the map with sonic.Marshal which can
produce non-deterministic top-level key ordering; update excludeTopLevelJSONKeys
to produce a deterministic JSON encoding by using a stable serializer (e.g.,
sonic.MarshalSorted or otherwise sorting the top-level keys before marshaling)
so the returned byte slice is stable across runs when keys are excluded; locate
the function excludeTopLevelJSONKeys and replace the sonic.Marshal(raw) call
with a deterministic marshal call (MarshalSorted or manual key-sorting +
marshal) and preserve the existing error wrapping behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/schemas/chatcompletions.go`:
- Around line 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.

---

Duplicate comments:
In `@core/providers/anthropic/utils.go`:
- Around line 1646-1655: The function excludeTopLevelJSONKeys currently marshals
the map with sonic.Marshal which can produce non-deterministic top-level key
ordering; update excludeTopLevelJSONKeys to produce a deterministic JSON
encoding by using a stable serializer (e.g., sonic.MarshalSorted or otherwise
sorting the top-level keys before marshaling) so the returned byte slice is
stable across runs when keys are excluded; locate the function
excludeTopLevelJSONKeys and replace the sonic.Marshal(raw) call with a
deterministic marshal call (MarshalSorted or manual key-sorting + marshal) and
preserve the existing error wrapping behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c0e7be63-3783-45f9-b9a4-bb33cb04aa09

📥 Commits

Reviewing files that changed from the base of the PR and between 4c2eebe and 92aa3ab.

📒 Files selected for processing (6)
  • core/providers/anthropic/chat.go
  • core/providers/anthropic/responses.go
  • core/providers/anthropic/utils.go
  • core/schemas/chatcompletions.go
  • core/schemas/orderedmap.go
  • core/schemas/orderedmap_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/providers/anthropic/chat.go
  • core/schemas/orderedmap_test.go
  • core/providers/anthropic/responses.go

Comment on lines +422 to +463
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
}

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.

@akshaydeo
akshaydeo merged commit 35e178d into maximhq:main Mar 15, 2026
2 of 3 checks passed
occcat pushed a commit to occcat/bifrost that referenced this pull request Sep 2, 2026
…ching (maximhq#2082)

Anthropic's prompt caching is prefix-based — when clients send tool
definitions with non-deterministic JSON key ordering across turns, the
cache key changes every turn, causing repeated cache writes instead of
reads.

Changes:
- Add OrderedMap.SortKeys() with JSON Schema priority ordering (type,
  description, properties, required first, then alphabetically) so LLMs
  see type information before descriptions and constraints.
- Add OrderedMap.SortedCopy() which returns a new tree with sorted keys
  sharing primitive values — avoids expensive Marshal/Unmarshal cloning.
- Add ToolFunctionParameters.Normalized() that returns a shallow copy
  with all nested OrderedMaps sorted via SortedCopy, replacing the old
  Clone() + NormalizeKeyOrder() pattern.
- Call Normalized() in both Chat Completions and Responses API Anthropic
  converters before serialization.
- Replace map[string]interface{} roundtrip in excludeFields path with
  map[string]json.RawMessage to preserve nested byte ordering.
- Passthrough mode uses MarshalSorted for deterministic map key output.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants