fix(ai-proxy): correct Anthropic Messages to OpenAI Chat request conversion#13674
Merged
nic-6443 merged 7 commits intoJul 9, 2026
Merged
Conversation
…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>
There was a problem hiding this comment.
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
toolmessages immediately follow theassistantmessage containingtool_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
toolsand historicaltool_useblocks use consistent OpenAI-compatible names, enabling correct name restoration on responses. - Fixes missing/incorrect field conversions: emits
reasoning_effortfor adaptive thinking and emitsresponse_formatfrom 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.
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>
AlinsRan
approved these changes
Jul 9, 2026
shreemaan-abhishek
approved these changes
Jul 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
ai-proxy/ai-proxy-multireject a perfectly legal Anthropic request when ausermessage carriestool_resultblocks alongside anything else. The converter emitted the leftover text before thetoolmessages:OpenAI-compatible providers require every
toolmessage to immediately follow the assistant message holding the matchingtool_calls, so they reject this withAn assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.Anthropic explicitly allows
tool_resultandtextin the same user message (thetool_resultblocks 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
toolmessages first and moves the leftover content into a message after them. The same branch also silently droppedimage/documentblocks sitting next to atool_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_useblocks in the conversation history never went through the tool-name sanitizer, so a tool could be declared to the upstream asmy_tool_xwhile the history kept calling itmy tool!x. Both paths now share one pure mapping.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_tokensbucketed intoreasoning_effortat 4096/16384. The buckets are now 1024/2048/4096 →minimal/low/medium/high, and a missingbudget_tokenscounts as a zero budget instead of defaulting tomedium.thinking: {"type": "adaptive"}was ignored, so noreasoning_effortreached the upstream at all. It now resolves throughoutput_config.effort, forwarded verbatim, falling back tomediumwhen no effort is given.output_config.json_schema/output_format.json_schema. Neither key exists — the schema lives atoutput_format.schema(beta) oroutput_config.format.schema(GA) — soresponse_formatwas never produced. It is now read from the right place and wrapped in the namedjson_schemaobject the OpenAI side expects, withstrict: trueand the schema normalized as strict mode requires (additionalProperties: falseon every object, every property inrequired).usermessage now keeps its content blocks as an array even when there is a singletextblock, and anassistantmessage concatenates itstextblocks into a string. Both used to depend on the block count.setmetatable(t, core.json.empty_array_mt)is a no-op —core.jsonexportsarray_mt, notempty_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 takecore.json.array_mt, includingmessage_start's emptycontentarray in the response converter, which has been shipping"content": {}instead of[].thinkingblock withoutput_config.effort = ""forwarded the empty string asreasoning_effort; it now falls back tomedium, as in the reference adapter. Anoutput_formatcarrying an empty schema (schema = {}) no longer produces aresponse_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:
argumentsis encoded without spaces after:/,, and therequiredarray of a strict schema is sorted, because Lua tables carry no insertion order. The other four are intentional:thinking/redacted_thinkingblocks are dropped rather than forwarded asthinking_blocks(a field OpenAI Chat Completions has no place for), a message left with no content becomescontent: ""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_formatis 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 implementresponse_format: {"type": "json_schema"}at all (DeepSeek answersThis 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_effortis now emitted for adaptive thinking.output_config.effortalone, with nothinkingblock, still emits nothing — sendingreasoning_effortto a non-reasoning upstream would be a regression.budget_tokenscan map to a differentreasoning_effortthan before, andminimalbecomes reachable. It is only reachable from a request the Anthropic API itself rejects (thinking.budget_tokensbelow 1024, orthinking: {"type": "enabled"}with no budget at all), since the plugin does not validatethinkingbefore converting it. Such a request used to be converted tolow; it now becomesminimal, which an upstream whosereasoning_effortenum lacks that value will reject (DeepSeek answersunknown variant \minimal``).output_configis no longer treated as an alias ofoutput_format; the two fields mean different things. Neitheroutput_config = {type = "json_object"}noroutput_format = {type = "json_object"}produces aresponse_formatany more. Neither shape exists in the Anthropic API —output_confighas notypekey at all, andoutput_formatonly carriesjson_schema— so nothing that talks the Anthropic protocol can be relying on them.Checklist