#4731·higress

[ai-proxy] Tool and content fields are dropped when converting between OpenAI, Claude and Gemini

Author: Android-Login-AnalysisCreated Sep 19, 2026Updated Sep 29, 2026
  • 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

go
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

go
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

go
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:

  • strict onto the Claude tool, which has the field, and onto Gemini functionCallingConfig as VALIDATED
  • document and file content as the target's own file or inline-data block
  • role: developer kept when the target is OpenAI
  • the allowed_tools subset 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_choice onto Gemini functionCallingConfig (NONE, ANY, allowedFunctionNames)
  • input_audio onto Gemini inlineData
  • the allowed_tools required mode onto Claude {"type": "any"}
  • Claude strict onto OpenAI function.strict
  • the tool schema into Gemini parametersJsonSchema Case 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.

  1. Configure ai-proxy with an OpenAI-compatible client route and a Claude provider.
  2. Send this request:
bash
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":{}}}}]}'
  1. 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