diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md index 6128bce84..e3d4b98a9 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README.md +++ b/plugins/wasm-go/extensions/ai-proxy/README.md @@ -9,10 +9,21 @@ description: AI 代理插件配置参考 `AI 代理`插件实现了基于 OpenAI API 契约的 AI 代理功能。目前支持 OpenAI、Azure OpenAI、月之暗面(Moonshot)和通义千问等 AI 服务提供商。 -> **注意:** +**🚀 自动协议兼容 (Auto Protocol Compatibility)** + +插件现在支持**自动协议检测**,无需配置即可同时兼容 OpenAI 和 Claude 两种协议格式: + +- **OpenAI 协议**: 请求路径 `/v1/chat/completions`,使用标准的 OpenAI Messages API 格式 +- **Claude 协议**: 请求路径 `/v1/messages`,使用 Anthropic Claude Messages API 格式 +- **智能转换**: 自动检测请求协议,如果目标供应商不原生支持该协议,则自动进行协议转换 +- **零配置**: 用户无需设置 `protocol` 字段,插件自动处理 + +> **协议支持说明:** > 请求路径后缀匹配 `/v1/chat/completions` 时,对应文生文场景,会用 OpenAI 的文生文协议解析请求 Body,再转换为对应 LLM 厂商的文生文协议 +> 请求路径后缀匹配 `/v1/messages` 时,对应 Claude 文生文场景,会自动检测供应商能力:如果支持原生 Claude 协议则直接转发,否则先转换为 OpenAI 协议再转发给供应商 + > 请求路径后缀匹配 `/v1/embeddings` 时,对应文本向量场景,会用 OpenAI 的文本向量协议解析请求 Body,再转换为对应 LLM 厂商的文本向量协议 ## 运行属性 @@ -937,19 +948,40 @@ provider: } ``` -### 使用 OpenAI 协议代理 Claude 服务 +### 使用自动协议兼容功能 + +插件现在支持自动协议检测,可以同时处理 OpenAI 和 Claude 两种协议格式的请求。 **配置信息** ```yaml provider: - type: claude + type: claude # 原生支持 Claude 协议的供应商 apiTokens: - 'YOUR_CLAUDE_API_TOKEN' version: '2023-06-01' ``` -**请求示例** +**OpenAI 协议请求示例** + +URL: `http://your-domain/v1/chat/completions` + +```json +{ + "model": "claude-3-opus-20240229", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好,你是谁?" + } + ] +} +``` + +**Claude 协议请求示例** + +URL: `http://your-domain/v1/messages` ```json { @@ -966,6 +998,8 @@ provider: **响应示例** +两种协议格式的请求都会返回相应格式的响应: + ```json { "id": "msg_01Jt3GzyjuzymnxmZERJguLK", @@ -990,6 +1024,39 @@ provider: } ``` +### 使用智能协议转换 + +当目标供应商不原生支持 Claude 协议时,插件会自动进行协议转换: + +**配置信息** + +```yaml +provider: + type: qwen # 不原生支持 Claude 协议,会自动转换 + apiTokens: + - 'YOUR_QWEN_API_TOKEN' + modelMapping: + 'claude-3-opus-20240229': 'qwen-max' + '*': 'qwen-turbo' +``` + +**Claude 协议请求** + +URL: `http://your-domain/v1/messages` (自动转换为 OpenAI 协议调用供应商) + +```json +{ + "model": "claude-3-opus-20240229", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好,你是谁?" + } + ] +} +``` + ### 使用 OpenAI 协议代理混元服务 **配置信息** diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md index 1297ccd60..ecf0d6b18 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md +++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md @@ -8,10 +8,21 @@ description: Reference for configuring the AI Proxy plugin The `AI Proxy` plugin implements AI proxy functionality based on the OpenAI API contract. It currently supports AI service providers such as OpenAI, Azure OpenAI, Moonshot, and Qwen. -> **Note:** +**🚀 Auto Protocol Compatibility** + +The plugin now supports **automatic protocol detection**, allowing seamless compatibility with both OpenAI and Claude protocol formats without configuration: + +- **OpenAI Protocol**: Request path `/v1/chat/completions`, using standard OpenAI Messages API format +- **Claude Protocol**: Request path `/v1/messages`, using Anthropic Claude Messages API format +- **Intelligent Conversion**: Automatically detects request protocol and performs conversion if the target provider doesn't natively support it +- **Zero Configuration**: No need to set `protocol` field, the plugin handles everything automatically + +> **Protocol Support:** > When the request path suffix matches `/v1/chat/completions`, it corresponds to text-to-text scenarios. The request body will be parsed using OpenAI's text-to-text protocol and then converted to the corresponding LLM vendor's text-to-text protocol. +> When the request path suffix matches `/v1/messages`, it corresponds to Claude text-to-text scenarios. The plugin automatically detects provider capabilities: if native Claude protocol is supported, requests are forwarded directly; otherwise, they are converted to OpenAI protocol first. + > When the request path suffix matches `/v1/embeddings`, it corresponds to text vector scenarios. The request body will be parsed using OpenAI's text vector protocol and then converted to the corresponding LLM vendor's text vector protocol. ## Execution Properties @@ -35,7 +46,7 @@ Plugin execution priority: `100` | `apiTokens` | array of string | Optional | - | Tokens used for authentication when accessing AI services. If multiple tokens are configured, the plugin randomly selects one for each request. Some service providers only support configuring a single token. | | `timeout` | number | Optional | - | Timeout for accessing AI services, in milliseconds. The default value is 120000, which equals 2 minutes. Only used when retrieving context data. Won't affect the request forwarded to the LLM upstream. | | `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.
1. Supports prefix matching. For example, "gpt-3-\*" matches all model names starting with “gpt-3-”;
2. Supports using "\*" as a key for a general fallback mapping;
3. If the mapped target name is an empty string "", the original model name is preserved. | -| `protocol` | string | Optional | - | API contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the raw interface contract of the target service provider) | +| `protocol` | string | Optional | - | API contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the raw interface contract of the target service provider). **Note: Auto protocol detection is now supported, no need to configure this field to support both OpenAI and Claude protocols** | | `context` | object | Optional | - | Configuration for AI conversation context information | | `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests | | `subPath` | string | Optional | - | If subPath is configured, the prefix will be removed from the request path before further processing. | @@ -883,19 +894,40 @@ provider: } ``` -### Using OpenAI Protocol Proxy for Claude Service +### Using Auto Protocol Compatibility + +The plugin now supports automatic protocol detection, capable of handling both OpenAI and Claude protocol format requests simultaneously. **Configuration Information** ```yaml provider: - type: claude + type: claude # Provider with native Claude protocol support apiTokens: - "YOUR_CLAUDE_API_TOKEN" version: "2023-06-01" ``` -**Example Request** +**OpenAI Protocol Request Example** + +URL: `http://your-domain/v1/chat/completions` + +```json +{ + "model": "claude-3-opus-20240229", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Hello, who are you?" + } + ] +} +``` + +**Claude Protocol Request Example** + +URL: `http://your-domain/v1/messages` ```json { @@ -912,6 +944,8 @@ provider: **Example Response** +Both protocol formats will return responses in their respective formats: + ```json { "id": "msg_01Jt3GzyjuzymnxmZERJguLK", @@ -936,6 +970,39 @@ provider: } ``` +### Using Intelligent Protocol Conversion + +When the target provider doesn't natively support Claude protocol, the plugin automatically performs protocol conversion: + +**Configuration Information** + +```yaml +provider: + type: qwen # Doesn't natively support Claude protocol, auto-conversion applied + apiTokens: + - "YOUR_QWEN_API_TOKEN" + modelMapping: + 'claude-3-opus-20240229': 'qwen-max' + '*': 'qwen-turbo' +``` + +**Claude Protocol Request** + +URL: `http://your-domain/v1/messages` (automatically converted to OpenAI protocol for provider) + +```json +{ + "model": "claude-3-opus-20240229", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Hello, who are you?" + } + ] +} +``` + ### Using OpenAI Protocol Proxy for Hunyuan Service **Configuration Information** diff --git a/plugins/wasm-go/extensions/ai-proxy/main.go b/plugins/wasm-go/extensions/ai-proxy/main.go index 512eda775..7429788ac 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main.go +++ b/plugins/wasm-go/extensions/ai-proxy/main.go @@ -194,6 +194,23 @@ func onHttpRequestHeader(ctx wrapper.HttpContext, pluginConfig config.PluginConf } } + // Auto-detect protocol based on request path and handle conversion if needed + // If request is Claude format (/v1/messages) but provider doesn't support it natively, + // convert to OpenAI format (/v1/chat/completions) + if apiName == provider.ApiNameAnthropicMessages && !providerConfig.IsSupportedAPI(provider.ApiNameAnthropicMessages) { + // Provider doesn't support Claude protocol natively, convert to OpenAI format + newPath := strings.Replace(path.Path, provider.PathAnthropicMessages, provider.PathOpenAIChatCompletions, 1) + _ = proxywasm.ReplaceHttpRequestHeader(":path", newPath) + // Update apiName to match the new path + apiName = provider.ApiNameChatCompletion + // Mark that we need to convert response back to Claude format + ctx.SetContext("needClaudeResponseConversion", true) + log.Debugf("[Auto Protocol] Claude request detected, provider doesn't support natively, converted path from %s to %s, apiName: %s", path.Path, newPath, apiName) + } else if apiName == provider.ApiNameAnthropicMessages { + // Provider supports Claude protocol natively, no conversion needed + log.Debugf("[Auto Protocol] Claude request detected, provider supports natively, keeping original path: %s, apiName: %s", path.Path, apiName) + } + if contentType, _ := proxywasm.GetHttpRequestHeader(util.HeaderContentType); contentType != "" && !strings.Contains(contentType, util.MimeTypeApplicationJson) { ctx.DontReadRequestBody() log.Debugf("[onHttpRequestHeader] unsupported content type: %s, will not process the request body", contentType) @@ -354,6 +371,18 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin apiName, _ := ctx.GetContext(provider.CtxKeyApiName).(provider.ApiName) modifiedChunk, err := handler.OnStreamingResponseBody(ctx, apiName, chunk, isLastChunk) if err == nil && modifiedChunk != nil { + // Check if we need to convert OpenAI stream response back to Claude format + // Only convert if we did the forward conversion (provider doesn't support Claude natively) + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeConversion { + converter := &provider.ClaudeToOpenAIConverter{} + claudeChunk, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, modifiedChunk) + if err != nil { + log.Errorf("failed to convert streaming response to claude format: %v", err) + return modifiedChunk + } + return claudeChunk + } return modifiedChunk } return chunk @@ -388,7 +417,23 @@ func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.Plugin } } } - return []byte(responseBuilder.String()) + + result := []byte(responseBuilder.String()) + + // Check if we need to convert OpenAI stream response back to Claude format + // Only convert if we did the forward conversion (provider doesn't support Claude natively) + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeConversion { + converter := &provider.ClaudeToOpenAIConverter{} + claudeChunk, err := converter.ConvertOpenAIStreamResponseToClaude(ctx, result) + if err != nil { + log.Errorf("failed to convert streaming event response to claude format: %v", err) + return result + } + return claudeChunk + } + + return result } return chunk } @@ -410,6 +455,19 @@ func onHttpResponseBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfi _ = util.ErrorHandler("ai-proxy.proc_resp_body_failed", fmt.Errorf("failed to process response body: %v", err)) return types.ActionContinue } + + // Check if we need to convert OpenAI response back to Claude format + // Only convert if we did the forward conversion (provider doesn't support Claude natively) + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeConversion { + converter := &provider.ClaudeToOpenAIConverter{} + body, err = converter.ConvertOpenAIResponseToClaude(ctx, body) + if err != nil { + _ = util.ErrorHandler("ai-proxy.convert_resp_to_claude_failed", fmt.Errorf("failed to convert response to claude format: %v", err)) + return types.ActionContinue + } + } + if err = provider.ReplaceResponseBody(body); err != nil { _ = util.ErrorHandler("ai-proxy.replace_resp_body_failed", fmt.Errorf("failed to replace response body: %v", err)) } diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go new file mode 100644 index 000000000..30a814dfa --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go @@ -0,0 +1,271 @@ +package provider + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/higress-group/wasm-go/pkg/log" + "github.com/higress-group/wasm-go/pkg/wrapper" +) + +// ClaudeToOpenAIConverter converts Claude protocol requests to OpenAI protocol +type ClaudeToOpenAIConverter struct{} + +// ConvertClaudeRequestToOpenAI converts a Claude chat completion request to OpenAI format +func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]byte, error) { + var claudeRequest claudeTextGenRequest + if err := json.Unmarshal(body, &claudeRequest); err != nil { + return nil, fmt.Errorf("unable to unmarshal claude request: %v", err) + } + + // Convert Claude request to OpenAI format + openaiRequest := chatCompletionRequest{ + Model: claudeRequest.Model, + Stream: claudeRequest.Stream, + Temperature: claudeRequest.Temperature, + TopP: claudeRequest.TopP, + MaxTokens: claudeRequest.MaxTokens, + Stop: claudeRequest.StopSequences, + } + + // Convert messages from Claude format to OpenAI format + for _, claudeMsg := range claudeRequest.Messages { + openaiMsg := chatMessage{ + Role: claudeMsg.Role, + } + + // Handle different content types + switch content := claudeMsg.Content.(type) { + case string: + // Simple text content + openaiMsg.Content = content + case []claudeChatMessageContent: + // Multi-modal content + var openaiContents []chatMessageContent + for _, claudeContent := range content { + switch claudeContent.Type { + case "text": + openaiContents = append(openaiContents, chatMessageContent{ + Type: contentTypeText, + Text: claudeContent.Text, + }) + case "image": + if claudeContent.Source != nil { + if claudeContent.Source.Type == "base64" { + // Convert base64 image to OpenAI format + dataUrl := fmt.Sprintf("data:%s;base64,%s", claudeContent.Source.MediaType, claudeContent.Source.Data) + openaiContents = append(openaiContents, chatMessageContent{ + Type: contentTypeImageUrl, + ImageUrl: &chatMessageContentImageUrl{ + Url: dataUrl, + }, + }) + } else if claudeContent.Source.Type == "url" { + openaiContents = append(openaiContents, chatMessageContent{ + Type: contentTypeImageUrl, + ImageUrl: &chatMessageContentImageUrl{ + Url: claudeContent.Source.Url, + }, + }) + } + } + } + } + openaiMsg.Content = openaiContents + } + + openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg) + } + + // Handle system message - Claude has separate system field + if claudeRequest.System != "" { + systemMsg := chatMessage{ + Role: roleSystem, + Content: claudeRequest.System, + } + // Insert system message at the beginning + openaiRequest.Messages = append([]chatMessage{systemMsg}, openaiRequest.Messages...) + } + + // Convert tools if present + for _, claudeTool := range claudeRequest.Tools { + openaiTool := tool{ + Type: "function", + Function: function{ + Name: claudeTool.Name, + Description: claudeTool.Description, + Parameters: claudeTool.InputSchema, + }, + } + openaiRequest.Tools = append(openaiRequest.Tools, openaiTool) + } + + // Convert tool choice if present + if claudeRequest.ToolChoice != nil { + if claudeRequest.ToolChoice.Type == "tool" && claudeRequest.ToolChoice.Name != "" { + openaiRequest.ToolChoice = &toolChoice{ + Type: "function", + Function: function{ + Name: claudeRequest.ToolChoice.Name, + }, + } + } else { + // For other types like "auto", "none", etc. + openaiRequest.ToolChoice = claudeRequest.ToolChoice.Type + } + + // Handle parallel tool calls + openaiRequest.ParallelToolCalls = !claudeRequest.ToolChoice.DisableParallelToolUse + } + + return json.Marshal(openaiRequest) +} + +// ConvertOpenAIResponseToClaude converts an OpenAI response back to Claude format +func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.HttpContext, body []byte) ([]byte, error) { + var openaiResponse chatCompletionResponse + if err := json.Unmarshal(body, &openaiResponse); err != nil { + return nil, fmt.Errorf("unable to unmarshal openai response: %v", err) + } + + // Convert OpenAI response to Claude format + claudeResponse := claudeTextGenResponse{ + Id: openaiResponse.Id, + Type: "message", + Role: "assistant", + Model: openaiResponse.Model, + Usage: claudeTextGenUsage{ + InputTokens: openaiResponse.Usage.PromptTokens, + OutputTokens: openaiResponse.Usage.CompletionTokens, + }, + } + + // Convert the first choice content + if len(openaiResponse.Choices) > 0 { + choice := openaiResponse.Choices[0] + if choice.Message != nil { + content := claudeTextGenContent{ + Type: "text", + Text: choice.Message.StringContent(), + } + claudeResponse.Content = []claudeTextGenContent{content} + } + + // Convert finish reason + if choice.FinishReason != nil { + claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason) + claudeResponse.StopReason = &claudeFinishReason + } + } + + return json.Marshal(claudeResponse) +} + +// ConvertOpenAIStreamResponseToClaude converts OpenAI streaming response to Claude format +func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrapper.HttpContext, chunk []byte) ([]byte, error) { + // For streaming responses, we need to handle the Server-Sent Events format + lines := strings.Split(string(chunk), "\n") + var result strings.Builder + + for _, line := range lines { + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + + // Skip [DONE] messages + if data == "[DONE]" { + continue + } + + var openaiStreamResponse chatCompletionResponse + if err := json.Unmarshal([]byte(data), &openaiStreamResponse); err != nil { + log.Errorf("unable to unmarshal openai stream response: %v", err) + continue + } + + // Convert to Claude streaming format + claudeStreamResponse := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse) + if claudeStreamResponse != nil { + responseData, err := json.Marshal(claudeStreamResponse) + if err != nil { + log.Errorf("unable to marshal claude stream response: %v", err) + continue + } + result.WriteString(fmt.Sprintf("data: %s\n\n", responseData)) + } + } + } + + return []byte(result.String()), nil +} + +// buildClaudeStreamResponse builds a Claude streaming response from OpenAI streaming response +func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpContext, openaiResponse *chatCompletionResponse) *claudeTextGenStreamResponse { + if len(openaiResponse.Choices) == 0 { + return nil + } + + choice := openaiResponse.Choices[0] + + // Determine the response type based on the content + if choice.Delta != nil && choice.Delta.Content != "" { + // Content delta + if deltaContent, ok := choice.Delta.Content.(string); ok { + return &claudeTextGenStreamResponse{ + Type: "content_block_delta", + Index: choice.Index, + Delta: &claudeTextGenDelta{ + Type: "text_delta", + Text: deltaContent, + }, + } + } + } else if choice.FinishReason != nil { + // Message completed + claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason) + return &claudeTextGenStreamResponse{ + Type: "message_delta", + Index: choice.Index, + Delta: &claudeTextGenDelta{ + Type: "message_delta", + StopReason: &claudeFinishReason, + }, + Usage: &claudeTextGenUsage{ + InputTokens: openaiResponse.Usage.PromptTokens, + OutputTokens: openaiResponse.Usage.CompletionTokens, + }, + } + } else if choice.Delta != nil && choice.Delta.Role != "" { + // Message start + return &claudeTextGenStreamResponse{ + Type: "message_start", + Index: choice.Index, + Message: &claudeTextGenResponse{ + Id: openaiResponse.Id, + Type: "message", + Role: "assistant", + Model: openaiResponse.Model, + Usage: claudeTextGenUsage{ + InputTokens: openaiResponse.Usage.PromptTokens, + OutputTokens: 0, + }, + }, + } + } + + return nil +} + +// openAIFinishReasonToClaude converts OpenAI finish reason to Claude format +func openAIFinishReasonToClaude(reason string) string { + switch reason { + case finishReasonStop: + return "end_turn" + case finishReasonLength: + return "max_tokens" + case finishReasonToolCall: + return "tool_use" + default: + return reason + } +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index faff0647d..00a887c74 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "bytes" "errors" + "fmt" "math/rand" "net/http" "path" @@ -522,10 +523,9 @@ func (c *ProviderConfig) FromJson(json gjson.Result) { c.reasoningContentMode = strings.ToLower(c.reasoningContentMode) switch c.reasoningContentMode { case reasoningBehaviorPassThrough, reasoningBehaviorIgnore, reasoningBehaviorConcat: - break + // valid values, no action needed default: c.reasoningContentMode = reasoningBehaviorPassThrough - break } } @@ -832,6 +832,10 @@ func (c *ProviderConfig) isSupportedAPI(apiName ApiName) bool { return exist } +func (c *ProviderConfig) IsSupportedAPI(apiName ApiName) bool { + return c.isSupportedAPI(apiName) +} + func (c *ProviderConfig) setDefaultCapabilities(capabilities map[string]string) { for capability, path := range capabilities { c.capabilities[capability] = path @@ -855,8 +859,22 @@ func (c *ProviderConfig) handleRequestBody( return types.ActionContinue, nil } - // use openai protocol var err error + + // handle claude protocol input - auto-detect based on conversion marker + // If main.go detected a Claude request that needs conversion, convert the body + needClaudeConversion, _ := ctx.GetContext("needClaudeResponseConversion").(bool) + if needClaudeConversion { + // Convert Claude protocol to OpenAI protocol + converter := &ClaudeToOpenAIConverter{} + body, err = converter.ConvertClaudeRequestToOpenAI(body) + if err != nil { + return types.ActionContinue, fmt.Errorf("failed to convert claude request to openai: %v", err) + } + log.Debugf("[Auto Protocol] converted Claude request body to OpenAI format") + } + + // use openai protocol (either original openai or converted from claude) if handler, ok := provider.(TransformRequestBodyHandler); ok { body, err = handler.TransformRequestBody(ctx, apiName, body) } else if handler, ok := provider.(TransformRequestBodyHeadersHandler); ok {