Skip to content

fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion#13674

Merged
nic-6443 merged 7 commits into
apache:masterfrom
nic-6443:fix/anthropic-messages-conversion
Jul 9, 2026
Merged

fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion#13674
nic-6443 merged 7 commits into
apache:masterfrom
nic-6443:fix/anthropic-messages-conversion

Conversation

@nic-6443

@nic-6443 nic-6443 commented Jul 8, 2026

Copy link
Copy Markdown
Member

Description

ai-proxy / ai-proxy-multi reject a perfectly legal Anthropic request when a user message carries tool_result blocks alongside anything else. The converter emitted the leftover text before the tool messages:

assistant(tool_calls) -> user(text) -> tool -> tool -> tool

OpenAI-compatible providers require every tool message to immediately follow the assistant message holding the matching tool_calls, so they reject this with An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.

Anthropic explicitly allows tool_result and text in the same user message (the tool_result blocks just have to come first), and Claude Code produces exactly that shape whenever a system reminder or a queued prompt rides along with the tool results. Once such a turn is in the history, every later request in that session carries it — so the route starts returning 400 and never recovers until the conversation is thrown away.

This emits the tool messages first and moves the leftover content into a message after them. The same branch also silently dropped image/document blocks sitting next to a tool_result; those are now preserved in the trailing message.

Verified by running the converter over a real 143-message Claude Code request body: before the change the converted message list violates the tool-message adjacency rule exactly once (at the turn where a system reminder rides along with three parallel tool results); after the change there are zero violations. Replaying a slice of that body through the plugin against a live OpenAI-compatible provider reproduces the 400 before the fix and returns 200 after it.

Bringing the rest of the converter back to the reference behavior

LiteLLM's Anthropic adapter is the de-facto reference implementation that clients speaking the Anthropic Messages protocol are built against; the ordering fix above is the same one it shipped in BerriAI/litellm#12473. Feeding the same 47 request bodies through both implementations turned up more places where this converter had drifted, all fixed here:

  • tool_use blocks in the conversation history never went through the tool-name sanitizer, so a tool could be declared to the upstream as my_tool_x while the history kept calling it my tool!x. Both paths now share one pure mapping.
  • Tool names longer than 64 chars were cut to 64, which makes two long names that share a prefix collide — a real shape for MCP tools, whose names are mcp__<server>__<tool>. They are now rewritten as <55-char prefix>_<8 hex chars of sha256(name)>, byte-for-byte what the reference adapter produces, and distinct for any two rewritten names. Names carrying characters outside [a-zA-Z0-9_-] still get those replaced (OpenAI rejects them outright), and now also get the hash suffix, so a sanitized name can never take a name another tool owns verbatim.
  • thinking.budget_tokens bucketed into reasoning_effort at 4096/16384. The buckets are now 1024/2048/4096 → minimal/low/medium/high, and a missing budget_tokens counts as a zero budget instead of defaulting to medium.
  • thinking: {"type": "adaptive"} was ignored, so no reasoning_effort reached the upstream at all. It now resolves through output_config.effort, forwarded verbatim, falling back to medium when no effort is given.
  • Structured outputs were read from output_config.json_schema / output_format.json_schema. Neither key exists — the schema lives at output_format.schema (beta) or output_config.format.schema (GA) — so response_format was never produced. It is now read from the right place and wrapped in the named json_schema object the OpenAI side expects, with strict: true and the schema normalized as strict mode requires (additionalProperties: false on every object, every property in required).
  • A user message now keeps its content blocks as an array even when there is a single text block, and an assistant message concatenates its text blocks into a string. Both used to depend on the block count.
  • setmetatable(t, core.json.empty_array_mt) is a no-op — core.json exports array_mt, not empty_array_mt — so an empty Lua table encoded as {}. The strict-mode normalizer hit this for an object that declares no properties, sending "required": {} upstream. Both sites now take core.json.array_mt, including message_start's empty content array in the response converter, which has been shipping "content": {} instead of [].
  • An adaptive thinking block with output_config.effort = "" forwarded the empty string as reasoning_effort; it now falls back to medium, as in the reference adapter. An output_format carrying an empty schema (schema = {}) no longer produces a response_format.

Of those 51 bodies, 45 now convert to a byte-identical OpenAI request (up from 15). Two of the remaining six differ only in serialization: arguments is encoded without spaces after :/,, and the required array of a strict schema is sorted, because Lua tables carry no insertion order. The other four are intentional: thinking / redacted_thinking blocks are dropped rather than forwarded as thinking_blocks (a field OpenAI Chat Completions has no place for), a message left with no content becomes content: "" rather than being omitted, and tool names get the character sanitization described above. The hash suffix keeps two rewritten names apart; it does not make collisions impossible, and neither does the reference adapter's.

Behavior changes worth calling out

  • response_format is now actually emitted for structured-output requests — it never was, because the schema was read from a key that does not exist. Note that some OpenAI-compatible upstreams do not implement response_format: {"type": "json_schema"} at all (DeepSeek answers This response_format type is unavailable now), so a request that used to be silently stripped of its schema will now surface the upstream's error.
  • reasoning_effort is now emitted for adaptive thinking. output_config.effort alone, with no thinking block, still emits nothing — sending reasoning_effort to a non-reasoning upstream would be a regression.
  • A given budget_tokens can map to a different reasoning_effort than before, and minimal becomes reachable. It is only reachable from a request the Anthropic API itself rejects (thinking.budget_tokens below 1024, or thinking: {"type": "enabled"} with no budget at all), since the plugin does not validate thinking before converting it. Such a request used to be converted to low; it now becomes minimal, which an upstream whose reasoning_effort enum lacks that value will reject (DeepSeek answers unknown variant \minimal``).
  • output_config is no longer treated as an alias of output_format; the two fields mean different things. Neither output_config = {type = "json_object"} nor output_format = {type = "json_object"} produces a response_format any more. Neither shape exists in the Anthropic API — output_config has no type key at all, and output_format only carries json_schema — so nothing that talks the Anthropic protocol can be relying on them.

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (If not, please discuss on the APISIX mailing list first)

…ersion

When a user message carried both `tool_result` blocks and other content
(text or media), the converter emitted the text before the `tool` messages.
OpenAI-compatible providers require every `tool` message to immediately
follow the assistant message holding the matching `tool_calls`, so the
upstream rejected the request with "An assistant message with 'tool_calls'
must be followed by tool messages responding to each 'tool_call_id'". This
shape is legal in the Anthropic Messages API and is what Claude Code sends
whenever a system reminder or a queued prompt rides along with tool results,
so once such a turn entered the history every subsequent request failed.

Emit the `tool` messages first and the remaining content as a following
message. Media blocks alongside `tool_result` were dropped entirely; they
are now kept in that trailing message.

Also fixes three other conversion gaps found while auditing the same path:

- `tool_use` blocks in the conversation history kept their original name
  while the `tools` array was sanitized, so a tool could be declared as
  `my_tool_x` and called as `my tool!x` in the same request. Tool name
  mapping now runs before the messages and is shared by both.
- `thinking: {"type": "adaptive"}` produced no `reasoning_effort`. It now
  maps through `output_config.effort`, clamping `xhigh`/`max` to `high`
  since Chat Completions has no such level.
- Structured outputs were read from `output_config.json_schema` /
  `output_format.json_schema`, neither of which exists. The schema lives in
  `output_config.format.schema` (GA) or `output_format.schema` (beta), and
  the OpenAI side needs it wrapped in a named `json_schema` object.

Signed-off-by: Nic <qianyong@api7.ai>
Copilot AI review requested due to automatic review settings July 8, 2026 09:24
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. bug Something isn't working labels Jul 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes several correctness gaps in the Anthropic Messages → OpenAI Chat request converter used by ai-proxy / ai-proxy-multi, primarily ensuring OpenAI tool-message adjacency requirements are met while preserving trailing non-tool content and properly emitting structured-output and adaptive-thinking fields.

Changes:

  • Reorders conversion output so tool messages immediately follow the assistant message containing tool_calls, with any trailing user content emitted after the tool messages (and preserves media blocks in that trailing message).
  • Unifies tool-name sanitization/mapping so both declared tools and historical tool_use blocks use consistent OpenAI-compatible names, enabling correct name restoration on responses.
  • Fixes missing/incorrect field conversions: emits reasoning_effort for adaptive thinking and emits response_format from the correct structured-output schema locations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
t/plugin/ai-proxy-anthropic.t Updates and adds regression tests for tool-message ordering, media preservation, adaptive thinking → reasoning_effort, and structured outputs → response_format.
apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua Adjusts request conversion logic for tool-name mapping, tool-result ordering, adaptive thinking effort mapping, and structured-output schema extraction/wrapping.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua Outdated
nic-6443 added 6 commits July 8, 2026 17:34
A tool whose name needs sanitizing could take a name that another tool
already owns verbatim (e.g. "fo o" sanitizes to "fo_o", which is also a
literal tool name). Both were then declared to the upstream under the same
function name, and the response converter restored that shared name to the
wrong original tool.

Reserve the already-valid tool names before sanitizing, and run the collision
suffix against every name handed to the upstream rather than only against the
sanitized ones.

Signed-off-by: Nic <qianyong@api7.ai>
…tputs

- `output_config.effort` defaults to "high" on the Anthropic side ("equivalent
  to not setting the parameter"), so adaptive thinking without an explicit
  effort must map to "high", not "medium". An unrecognised level falls back to
  the same default instead of silently downgrading.
- Stop forcing `strict = true` on the generated `response_format`. Anthropic
  accepts optional properties and keywords such as `minLength` and `format`,
  all of which OpenAI rejects under strict schema adherence, so a schema that
  is valid for Anthropic would have been rejected by the upstream.
- `output_config.format` (GA) now takes precedence over the beta
  `output_format`, and an `output_format` of an unrelated shape no longer
  shadows it.
- Do not reserve OpenAI names for Anthropic built-in tools, which are dropped
  before they reach the upstream.
- Restore the `p.type == "text"` guard when concatenating the content that
  trails a tool_result, matching the tool_calls branch.

Signed-off-by: Nic <qianyong@api7.ai>
Chat Completions accepts none/minimal/low/medium/high/xhigh for
reasoning_effort, so `xhigh` has a direct counterpart and must not be
downgraded to `high`. Only Anthropic's `max` lacks an equivalent; it now
clamps to `xhigh`, the highest level OpenAI accepts.

Signed-off-by: Nic <qianyong@api7.ai>
… adapter

An earlier revision of this branch second-guessed the Anthropic adapter that
Anthropic-protocol clients are actually built against, and drifted from it.
Restore that behavior:

- `output_config.effort` is forwarded verbatim. Chat Completions accepts the
  same labels (none/minimal/low/medium/high/xhigh), so mapping them onto a
  smaller set only threw information away.
- Adaptive thinking with no explicit effort falls back to `medium`.
- `response_format.json_schema` carries `strict = true` again. Strict mode is
  what makes the schema binding, and it is safe because the schema is first
  normalized the way strict mode demands: `additionalProperties: false` on
  every object and every property listed in `required`, recursing through
  `items`, `anyOf`/`oneOf`/`allOf` and `$defs`/`definitions`. The client's
  schema is deep-copied first so the incoming body is never mutated.
- The top-level `output_format` takes precedence over `output_config.format`
  again, falling back to it only when absent or empty.

Signed-off-by: Nic <qianyong@api7.ai>
The Anthropic → OpenAI converter drifted from the reference adapter in
several places. Bring the remaining ones back in line:

- Tool names longer than 64 chars are now rewritten as
  <55-char prefix>_<8 hex chars of sha256(name)> instead of being cut at
  64. Truncation alone made two long names that share a prefix collide;
  the previous fix for that -- a numeric suffix handed out from a table of
  already-used names -- is no longer needed and is removed. Names carrying
  characters outside [a-zA-Z0-9_-] still get those replaced, and now also
  get the hash suffix, so a sanitized name can never take a name that
  another tool owns verbatim.

- thinking.budget_tokens buckets into reasoning_effort at 1024 / 2048 /
  4096 (minimal / low / medium / high). A missing budget_tokens counts as
  zero rather than defaulting to medium.

- A user message keeps its content blocks as an array, even when there is
  a single text block; an assistant message concatenates its text blocks
  into a string. Both previously depended on the block count.

- output_format = {type = "json_object"} no longer maps to a
  response_format. It is not an Anthropic output format, so a client can
  never send it, and json_schema is the only shape that carries a schema.

Signed-off-by: Nic <qianyong@api7.ai>
`core.json` has no `empty_array_mt`, so `setmetatable(t, core.json.empty_array_mt)`
was `setmetatable(t, nil)` and an empty Lua table encoded as `{}`. A strict schema
whose object declares no properties therefore reached the upstream as
`"required": {}`, which is not an array and is rejected.

`core.json.array_mt` is the metatable that exists. Both sites take it: the
`required` array added by the strict-mode normalizer, and `message_start`'s empty
`content` array in the response converter, which shipped `"content": {}` for as
long as it has been there.

While in the same code:

- An adaptive `thinking` block with `output_config.effort = ""` forwarded the
  empty string as `reasoning_effort`; it now falls back to `medium`.
- `output_format = {type = "json_schema", schema = {}}` produced a
  `response_format` carrying a schema with nothing in it; an empty schema now
  means no `response_format`, as with a missing one.
- `tool_choice` naming a tool that is not declared no longer records a name
  mapping for a tool the upstream cannot call.
- The two schema-keyword lists are hoisted out of the recursion.

Signed-off-by: Nic <qianyong@api7.ai>
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jul 8, 2026

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@nic-6443 nic-6443 merged commit a28d9bb into apache:master Jul 9, 2026
17 checks passed
@nic-6443 nic-6443 deleted the fix/anthropic-messages-conversion branch July 9, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants