[ai-proxy] Tool and content fields are dropped when converting between OpenAI, Claude and Gemini
- I have searched the issues of this repository and believe that this is not a duplicate.
Closest existing issue is #4214, [ai-proxy] Claude→OpenAI conversion leaves a non-leading system message. Same plugin, different defect. A title search for tool_choice in this
repository returns nothing.
Ⅰ. Issue Description
Nine fields a caller sets are lost when ai-proxy converts a request between protocols.
Five are fields the target protocol can carry, so they should survive. Four have no target
equivalent, and the problem there is that the request is sent anyway with no notice.
The forwarded request is wrong in three different ways depending on the case: the field is missing, the message is emptied, or a malformed block is produced.
Ⅱ. Describe what happened
| # | route | caller sent | forwarded |
|---|---|---|---|
| 1 | OpenAI to Gemini | tool strict: true |
declaration with no strict |
| 2 | Claude to OpenAI | document block with base64 PDF |
{"role": "user"} with no content key at all |
| 3 | OpenAI to Gemini | file part with file_id |
"parts": [] |
| 4 | OpenAI to Claude | file part with file_data |
the whole OpenAI body sent to /v1/messages unchanged, with no max_tokens |
| 5 | OpenAI to Claude | file part with file_id |
{"type":"file","source":{"type":"url","file_id":"..."}} |
| 6 | OpenAI to Claude | role: developer |
{"messages": null, "system": "..."} |
| 7 | OpenAI to Claude | tool_choice.allowed_tools |
{"type": "allowed_tools"}, subset gone |
| 8 | OpenAI to Gemini | named tool_choice plus parallel_tool_calls: false |
no tool configuration at all |
| 9 | OpenAI to Claude | input_audio part |
"content": [] |
| 10 | OpenAI to Gemini | tool_choice none, required or an allowed_tools subset |
no tool configuration |
| 11 | OpenAI to Gemini | input_audio part |
"parts": [] |
| 12 | OpenAI to Claude | allowed_tools with mode: "required" |
{"type": "allowed_tools"}, required mode gone |
| 13 | Claude to OpenAI | container_upload block |
{"role": "user"} with no content key |
| 14 | Claude to OpenAI | tool strict: true |
function with no strict |
| 15 | OpenAI to Gemini | const or additionalProperties in a tool schema |
schema sent in parameters, which defines neither |
| 16 | Claude to Gemini | any request with tools or non-text blocks | tools become {"name": ""}, image and container_upload blocks dropped |
Case 5 is the clearest single defect. The forwarded block declares source.type as url
but carries no url field, only file_id. That shape is not valid on either side.
Case 6 sets messages to null, not to an empty array.
Case 2 produces a message object with no content key.
No exception trace. Every one of these returns a normal response, which is the problem. The caller has no way to learn that the field was dropped.
Root cause, verified at faccaad:
Case 1. plugins/wasm-go/extensions/ai-proxy/provider/model.go:157
type function struct {
Description string `json:"description,omitempty"`
Name string `json:"name"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}No Strict member, so neither the Claude nor the Gemini builder can carry it.
Case 6. provider/provider.go:232
developerRoleSupportedProviders = map[string]bool{
providerTypeAzure: true,
}Only Azure is listed, so convertDeveloperRoleToSystem runs for OpenAI and Anthropic
targets as well. The OpenAI target accepts role: developer directly.
Case 7. provider/claude.go:38
type claudeToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"`
}No field for the allowed-tools subset, so only the bare type is forwarded.
Case 8. provider/gemini.go:327. geminiGenerationContentRequest has Contents,
SystemInstruction, SafetySettings, GenerationConfig and Tools, and no tool
configuration field. A grep for toolConfig or functionCallingConfig in gemini.go
returns zero.
Cases 2, 3, 5 and 9 are missing branches in the content builders. The Claude builder at
claude.go handles thinking, redacted_thinking, tool_use and a few others, and the
OpenAI parser recognises input_audio and file, but the target builders have no matching
case for them.
Case 4. provider/model.go:481. ParseContent reads subObj["file_id"].(string), which fails for a part that has only file_data. The Claude request is then never built and the original body is forwarded. The builder does have a file case (claude.go:677).
Case 10. Same cause as case 8. buildGeminiChatRequest never reads request.ToolChoice (provider/gemini.go:444).
Case 11. provider/gemini.go:471. The part switch handles only text and image_url. Other types are logged at debug level and skipped.
Case 12. Same cause as case 7 (provider/claude.go:744).
Case 13. Same cause as case 2. convertContentArray has no container_upload case (provider/claude_to_openai.go:1084).
Case 14. provider/claude_to_openai.go:283 builds the OpenAI tool from name, description and input schema only. function (case 1) has no Strict member.
Case 15. provider/gemini.go:444 appends the OpenAI function as is, so the JSON Schema lands in parameters. parametersJsonSchema takes JSON Schema and carries both keywords.
Case 16. main.go:254 marks a /v1/messages request for conversion, but geminiProvider.OnRequestBody (provider/gemini.go:98) does not call handleRequestBody, the only caller of the Claude to OpenAI converter. provider/gemini.go:176 then reads the Claude body as an OpenAI request.
Cases 4 and 10 to 16 checked at e86e9e9.
Ⅲ. Describe what you expected to happen
For the five cases where the target can carry the field, forward it:
strictonto the Claude tool, which has the field, and onto GeminifunctionCallingConfigasVALIDATEDdocumentandfilecontent as the target's own file or inline-data blockrole: developerkept when the target is OpenAI- the
allowed_toolssubset onto a Claude named choice when it holds one tool
For the four where the target genuinely cannot carry it, reject the request with a message naming the field. Sending a weakened request and returning success is the part that causes trouble.
Separately, and regardless of any conversion work: a content part that cannot be converted
should not empty its message or set messages to null, and case 5 should not emit a
block whose declared source type does not match its contents.
add to the list of fields to forward
tool_choiceonto GeminifunctionCallingConfig(NONE,ANY,allowedFunctionNames)input_audioonto GeminiinlineData- the
allowed_toolsrequired mode onto Claude{"type": "any"} - Claude
strictonto OpenAIfunction.strict - the tool schema into Gemini
parametersJsonSchemaCase 16 needs the Claude body converted before the Gemini builder runs. For case 4, a part that cannot be parsed should fail the request, not send the original body.
Ⅳ. How to reproduce it (as minimally and precisely as possible)
The upstream below is a recording endpoint that stores the exact request bytes it receives and returns a fixed valid response, so the forwarded requests above are quoted verbatim.
- Configure
ai-proxywith an OpenAI-compatible client route and a Claude provider. - Send this request:
curl -X POST http://<gateway>/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"<claude-model>","messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"tool_a","description":"t","strict":true,
"parameters":{"type":"object","properties":{}}}}]}'- Read the request the gateway sent upstream. The tool has no
strict.
For case 5, send "content":[{"type":"file","file":{"file_id":"file-example"}}] on the same
route. For case 6, send a message with "role":"developer". For case 9, send
"content":[{"type":"input_audio","input_audio":{"format":"wav","data":"UklGRiQ..."}}].
For the Gemini cases, point the same requests at a Gemini provider.
Ⅵ. Environment:
- Higress version:
faccaad - OS: Linux x86_64
- Others: plugin
plugins/wasm-go/extensions/ai-proxy, requests sent with cURL
Source: higress-group/higress