Skip to content

Commit 890733f

Browse files
committed
feat(ai-proxy): add protocol-aware request body override
Adds override.request_body to ai-proxy and ai-proxy-multi, letting operators set arbitrary nested fields on the outgoing request body, keyed by target protocol. The existing options field can only overwrite top-level fields and is protocol-agnostic, so it cannot express protocol-specific params like max_tokens vs max_output_tokens vs generationConfig.maxOutputTokens. request_body is keyed by target protocol (openai-chat, openai-responses, openai-embeddings, anthropic-messages) because converters only do structural format conversion, not per-parameter semantic normalization. The override is applied after converter + options, deep-merged into the body: objects recursive, scalars/arrays replace wholesale. Examples: override: request_body: openai-chat: { max_tokens: 500 } openai-responses: { max_output_tokens: 500 } anthropic-messages: { max_tokens: 500, stop_sequences: ['Human:'] }
1 parent 3d300f6 commit 890733f

10 files changed

Lines changed: 643 additions & 20 deletions

File tree

apisix/plugins/ai-protocols/init.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
local converters = require("apisix.plugins.ai-protocols.converters")
2424
local ipairs = ipairs
25+
local pairs = pairs
2526

2627
local _M = {}
2728

@@ -65,6 +66,17 @@ function _M.get(name)
6566
end
6667

6768

69+
--- Get the list of all registered protocol names.
70+
-- @return table Array of protocol names
71+
function _M.names()
72+
local names = {}
73+
for name in pairs(registered) do
74+
names[#names + 1] = name
75+
end
76+
return names
77+
end
78+
79+
6880
--- Find a converter that can bridge from client_protocol to a protocol
6981
-- supported by the driver. Delegates to the converters registry.
7082
-- @param client_protocol string The detected client protocol

apisix/plugins/ai-providers/base.lua

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ local transport_http = require("apisix.plugins.ai-transport.http")
3636
local transport_auth = require("apisix.plugins.ai-transport.auth")
3737
local log_sanitize = require("apisix.utils.log-sanitize")
3838
local protocols = require("apisix.plugins.ai-protocols")
39+
local deep_merge = require("apisix.plugins.ai-proxy.merge").deep_merge
3940
local ngx = ngx
4041
local ngx_now = ngx.now
4142

@@ -172,7 +173,7 @@ function _M.build_request(self, ctx, conf, request_body, opts)
172173
or opts.target_host or self.host,
173174
}
174175

175-
-- Inject model options
176+
-- Inject model options (flat overwrite)
176177
if opts.model_options then
177178
for opt, val in pairs(opts.model_options) do
178179
if request_body[opt] ~= nil then
@@ -181,6 +182,16 @@ function _M.build_request(self, ctx, conf, request_body, opts)
181182
request_body[opt] = val
182183
end
183184
end
185+
186+
-- Inject per-target-protocol request body override (deep merge)
187+
if opts.request_body_override_map then
188+
local patch = opts.request_body_override_map[ctx.ai_target_protocol]
189+
if patch then
190+
core.log.info("applying request_body override for target protocol '",
191+
ctx.ai_target_protocol, "'")
192+
deep_merge(request_body, patch)
193+
end
194+
end
184195
params.body = request_body
185196

186197
if self.remove_model then

apisix/plugins/ai-proxy/base.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ function _M.before_proxy(conf, ctx, on_error)
125125
model_options = ai_instance.options,
126126
conf = ai_instance.provider_conf or {},
127127
auth = ai_instance.auth,
128+
request_body_override_map =
129+
core.table.try_read_attr(ai_instance, "override", "request_body"),
128130
}
129-
130131
-- Step 1: Route client protocol to driver capability
131132
local client_protocol = ctx.ai_client_protocol
132133
local client_proto = protocols.get(client_protocol)

apisix/plugins/ai-proxy/merge.lua

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
18+
--- Deep-merge helper for ai-proxy request body overrides.
19+
-- Semantics:
20+
-- * Both sides are plain objects (string-keyed tables) -> recursive merge.
21+
-- * Otherwise (scalar, array, type mismatch, cjson.empty_array/empty_object)
22+
-- -> patch value replaces target value wholesale.
23+
-- This matches RFC 7396 JSON Merge Patch minus null-deletion.
24+
25+
local core = require("apisix.core")
26+
local pairs = pairs
27+
local next = next
28+
local type = type
29+
local getmetatable = getmetatable
30+
31+
local _M = {}
32+
33+
34+
-- Returns true when tbl is a plain object (string keys only) that we should
35+
-- recurse into. Empty tables, arrays, and cjson sentinels are treated as
36+
-- "replace wholesale" to avoid ambiguity.
37+
local function is_plain_object(tbl)
38+
if type(tbl) ~= "table" then
39+
return false
40+
end
41+
local mt = getmetatable(tbl)
42+
if mt == core.json.array_mt then
43+
return false
44+
end
45+
local k = next(tbl)
46+
if k == nil then
47+
-- Empty table: ambiguous; treat as "not an object" so patch replaces it.
48+
return false
49+
end
50+
return type(k) == "string"
51+
end
52+
53+
54+
local function deep_merge(target, patch)
55+
if not is_plain_object(target) or not is_plain_object(patch) then
56+
return patch
57+
end
58+
for k, v in pairs(patch) do
59+
target[k] = deep_merge(target[k], v)
60+
end
61+
return target
62+
end
63+
_M.deep_merge = deep_merge
64+
65+
66+
return _M

apisix/plugins/ai-proxy/schema.lua

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
--
1717
local schema_def = require("apisix.schema_def")
1818
local ai_providers_schema = require("apisix.plugins.ai-providers.schema")
19+
local protocols = require("apisix.plugins.ai-protocols")
20+
local pairs = pairs
1921

2022
local _M = {}
2123

@@ -72,6 +74,40 @@ local model_options_schema = {
7274
additionalProperties = true,
7375
}
7476

77+
-- Build per-target-protocol request body override schema.
78+
-- Each registered protocol gets an optional "any-shape object" entry.
79+
-- Values are applied via deep-merge after the model_options flat overwrite.
80+
local request_body_override_properties = {}
81+
for _, proto_name in pairs(protocols.names()) do
82+
request_body_override_properties[proto_name] = {
83+
type = "object",
84+
description = "Deep-merged into the outgoing request body when the "
85+
.. "target protocol is '" .. proto_name .. "'.",
86+
additionalProperties = true,
87+
}
88+
end
89+
90+
local request_body_override_schema = {
91+
type = "object",
92+
description = "Per target-protocol request body overrides. Keys are target "
93+
.. "protocol names; values are partial request bodies that are "
94+
.. "deep-merged into the outgoing body (objects merged recursively, "
95+
.. "arrays and scalars replaced wholesale).",
96+
properties = request_body_override_properties,
97+
additionalProperties = false,
98+
}
99+
100+
local override_schema = {
101+
type = "object",
102+
properties = {
103+
endpoint = {
104+
type = "string",
105+
description = "To be specified to override the endpoint of the AI Instance",
106+
},
107+
request_body = request_body_override_schema,
108+
},
109+
}
110+
75111
local provider_vertex_ai_schema = {
76112
type = "object",
77113
properties = {
@@ -115,15 +151,7 @@ local ai_instance_schema = {
115151
},
116152
auth = auth_schema,
117153
options = model_options_schema,
118-
override = {
119-
type = "object",
120-
properties = {
121-
endpoint = {
122-
type = "string",
123-
description = "To be specified to override the endpoint of the AI Instance",
124-
},
125-
},
126-
},
154+
override = override_schema,
127155
checks = {
128156
type = "object",
129157
properties = {
@@ -192,15 +220,7 @@ _M.ai_proxy_schema = {
192220
},
193221
keepalive_pool = {type = "integer", minimum = 1, default = 30},
194222
ssl_verify = {type = "boolean", default = true },
195-
override = {
196-
type = "object",
197-
properties = {
198-
endpoint = {
199-
type = "string",
200-
description = "To be specified to override the endpoint of the AI Instance",
201-
},
202-
},
203-
},
223+
override = override_schema,
204224
},
205225
required = {"provider", "auth"},
206226
encrypt_fields = {"auth.header", "auth.query", "auth.gcp.service_account_json"},

docs/en/latest/plugins/ai-proxy-multi.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ In addition, the Plugin also supports logging LLM request information in the acc
8181
| logging.payloads | boolean | False | false | | If true, log request and response payload. |
8282
| instances.override | object | False | | | Override setting. |
8383
| instances.override.endpoint | string | False | | | LLM provider endpoint to replace the default endpoint with. If not configured, the Plugin uses the default OpenAI endpoint `https://api.openai.com/v1/chat/completions`. |
84+
| instances.override.request_body | object | False | | | Per target-protocol request body overrides. Keys are target protocol names (`openai-chat`, `openai-responses`, `openai-embeddings`, `anthropic-messages`); each value is a partial request body that is **deep-merged** into the outgoing body sent to the provider after protocol conversion. Use this when the override field name is protocol-specific (for example `max_tokens` vs `max_output_tokens`). Objects merge recursively; scalars and arrays replace wholesale. Applied after `instances.options`, so a field set in both places takes the value from `request_body`. |
8485
| instances.checks | object | False | | | Health check configurations. Note that at the moment, OpenAI, DeepSeek, and AIMLAPI do not provide an official health check endpoint. Other LLM services that you can configure under `openai-compatible` provider may have available health check endpoints. |
8586
| instances.checks.active | object | True | | | Active health check configurations. |
8687
| instances.checks.active.type | string | False | http | [http, https, tcp] | Type of health check connection. |

docs/en/latest/plugins/ai-proxy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ In addition, the Plugin also supports logging LLM request information in the acc
6666
| options.model | string | False | | | Name of the LLM model, such as `gpt-4` or `gpt-3.5`. Refer to the LLM provider's API documentation for available models. |
6767
| override | object | False | | | Override setting. |
6868
| override.endpoint | string | False | | | Custom LLM provider endpoint, required when `provider` is `openai-compatible`. |
69+
| override.request_body | object | False | | | Per target-protocol request body overrides. Keys are target protocol names (`openai-chat`, `openai-responses`, `openai-embeddings`, `anthropic-messages`); each value is a partial request body that is **deep-merged** into the outgoing body sent to the provider after protocol conversion. Use this when the override field name is protocol-specific (for example `max_tokens` vs `max_output_tokens`). Objects merge recursively; scalars and arrays replace wholesale. Applied after `options`, so a field set in both places takes the value from `request_body`. |
6970
| logging | object | False | | | Logging configurations. Does not affect `error.log`. |
7071
| logging.summaries | boolean | False | false | | If true, logs request LLM model, duration, request, and response tokens. |
7172
| logging.payloads | boolean | False | false | | If true, logs request and response payload. |

docs/zh/latest/plugins/ai-proxy-multi.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import TabItem from '@theme/TabItem';
7878
| instances.options.model | string || | | LLM 模型的名称,如 `gpt-4``gpt-3.5`。有关更多可用模型,请参阅您的 LLM 提供商的 API 文档。 |
7979
| instances.override | object || | | 覆盖设置。 |
8080
| instances.override.endpoint | string || | | 用于替换默认端点的 LLM 提供商端点。如果未配置,插件使用默认的 OpenAI 端点 `https://api.openai.com/v1/chat/completions`|
81+
| instances.override.request_body | object || | | 按目标协议(`openai-chat``openai-responses``openai-embeddings``anthropic-messages`)配置的请求体覆盖。每个 key 对应一份部分请求体,会在协议转换后以**深度合并**的方式注入到发给上游的请求体中。适用于参数名因协议而异的场景(例如 `max_tokens` vs `max_output_tokens`)。对象递归合并,数组与标量整体替换。在 `instances.options` 之后应用,同名字段以 `request_body` 为准。 |
8182
| logging | object || | | 日志配置。不影响 `error.log`|
8283
| logging.summaries | boolean || false | | 如果为 true,记录请求 LLM 模型、持续时间、请求和响应令牌。 |
8384
| logging.payloads | boolean || false | | 如果为 true,记录请求和响应负载。 |

docs/zh/latest/plugins/ai-proxy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ description: ai-proxy 插件通过将插件配置转换为所需的请求格式
6666
| options.model | string || | | LLM 模型的名称,如 `gpt-4``gpt-3.5`。请参阅 LLM 提供商的 API 文档以了解可用模型。 |
6767
| override | object || | | 覆盖设置。 |
6868
| override.endpoint | string || | | 自定义 LLM 提供商端点,当 `provider``openai-compatible` 时必需。 |
69+
| override.request_body | object || | | 按目标协议(`openai-chat``openai-responses``openai-embeddings``anthropic-messages`)配置的请求体覆盖。每个 key 对应一份部分请求体,会在协议转换后以**深度合并**的方式注入到发给上游的请求体中。适用于参数名因协议而异的场景(例如 `max_tokens` vs `max_output_tokens`)。对象递归合并,数组与标量整体替换。在 `options` 之后应用,同名字段以 `request_body` 为准。 |
6970
| logging | object || | | 日志配置。不影响 `error.log`|
7071
| logging.summaries | boolean || false | | 如果为 true,记录请求 LLM 模型、持续时间、请求和响应令牌。 |
7172
| logging.payloads | boolean || false | | 如果为 true,记录请求和响应负载。 |

0 commit comments

Comments
 (0)