This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 90a3ad577 feat(ai-proxy): add passthrough protocol for unrecognized 
API formats (#13320)
90a3ad577 is described below

commit 90a3ad577d7d566b3cfa8f0844e3bc866937d1a5
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Wed May 6 10:13:40 2026 +0800

    feat(ai-proxy): add passthrough protocol for unrecognized API formats 
(#13320)
---
 apisix/plugins/ai-protocols/init.lua        |   7 +-
 apisix/plugins/ai-protocols/passthrough.lua | 111 +++++++++++
 apisix/plugins/ai-proxy/base.lua            |   9 +-
 t/fixtures/openai/images-generation.json    |   9 +
 t/lib/server.lua                            |  10 +
 t/plugin/ai-proxy-passthrough.t             | 280 ++++++++++++++++++++++++++++
 6 files changed, 422 insertions(+), 4 deletions(-)

diff --git a/apisix/plugins/ai-protocols/init.lua 
b/apisix/plugins/ai-protocols/init.lua
index 2a20d1b0c..4058feb7e 100644
--- a/apisix/plugins/ai-protocols/init.lua
+++ b/apisix/plugins/ai-protocols/init.lua
@@ -34,16 +34,18 @@ local registered = {
     ["openai-embeddings"] = 
require("apisix.plugins.ai-protocols.openai-embeddings"),
     ["anthropic-messages"] = 
require("apisix.plugins.ai-protocols.anthropic-messages"),
     ["bedrock-converse"] = 
require("apisix.plugins.ai-protocols.bedrock-converse"),
+    ["passthrough"] = require("apisix.plugins.ai-protocols.passthrough"),
 }
 
 -- Detection order: URL+body first (bedrock, anthropic, responses),
--- then body-only (chat, embeddings).
+-- then body-only (chat, embeddings), passthrough last (catch-all).
 local detection_order = {
     { name = "bedrock-converse",  protocol = registered["bedrock-converse"] },
     { name = "anthropic-messages", protocol = registered["anthropic-messages"] 
},
     { name = "openai-responses",  protocol = registered["openai-responses"] },
     { name = "openai-chat",       protocol = registered["openai-chat"] },
     { name = "openai-embeddings", protocol = registered["openai-embeddings"] },
+    { name = "passthrough",       protocol = registered["passthrough"] },
 }
 
 
@@ -51,7 +53,8 @@ local detection_order = {
 -- @param body table The parsed request body
 -- @param ctx table The request context
 -- @return string Protocol name: "openai-chat" | "openai-responses"
---   | "openai-embeddings" | "anthropic-messages"
+--   | "openai-embeddings" | "anthropic-messages" | "bedrock-converse"
+--   | "passthrough"
 function _M.detect(body, ctx)
     for _, entry in ipairs(detection_order) do
         if entry.protocol.matches(body, ctx) then
diff --git a/apisix/plugins/ai-protocols/passthrough.lua 
b/apisix/plugins/ai-protocols/passthrough.lua
new file mode 100644
index 000000000..9df3ab10e
--- /dev/null
+++ b/apisix/plugins/ai-protocols/passthrough.lua
@@ -0,0 +1,111 @@
+--
+-- Licensed to the Apache Software Foundation (ASF) under one or more
+-- contributor license agreements.  See the NOTICE file distributed with
+-- this work for additional information regarding copyright ownership.
+-- The ASF licenses this file to You under the Apache License, Version 2.0
+-- (the "License"); you may not use this file except in compliance with
+-- the License.  You may obtain a copy of the License at
+--
+--     http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+
+--- Passthrough protocol adapter.
+-- Catch-all protocol that matches any non-empty request body when no other
+-- protocol matches. Proxies request/response without any transformation.
+-- Only model rewrite, auth header injection, and override.endpoint work.
+
+local type = type
+local next = next
+
+local _M = {}
+
+
+function _M.matches(body, ctx)
+    return type(body) == "table" and next(body) ~= nil
+end
+
+
+function _M.is_streaming(body)
+    return type(body) == "table" and body.stream == true
+end
+
+
+function _M.prepare_outgoing_request(_)
+end
+
+
+function _M.parse_sse_event(_, _, _)
+    return { type = "skip" }
+end
+
+
+function _M.extract_response_text(_)
+    return nil
+end
+
+
+function _M.extract_usage(_)
+    return nil, nil
+end
+
+
+function _M.extract_request_content(_)
+    return {}
+end
+
+
+function _M.get_messages(_)
+    return {}
+end
+
+
+function _M.prepend_messages(_, _)
+end
+
+
+function _M.append_messages(_, _)
+end
+
+
+function _M.get_request_content(_)
+    return nil
+end
+
+
+function _M.build_simple_request(_, _, _)
+    return {}
+end
+
+
+function _M.build_deny_response(_)
+    return ""
+end
+
+
+function _M.empty_usage()
+    return { prompt_tokens = 0, completion_tokens = 0, total_tokens = 0 }
+end
+
+
+function _M.is_data_event(_)
+    return false
+end
+
+
+function _M.is_done_event(_)
+    return false
+end
+
+
+function _M.build_done_event()
+    return "data: [DONE]"
+end
+
+
+return _M
diff --git a/apisix/plugins/ai-proxy/base.lua b/apisix/plugins/ai-proxy/base.lua
index 306d5b265..2996d6413 100644
--- a/apisix/plugins/ai-proxy/base.lua
+++ b/apisix/plugins/ai-proxy/base.lua
@@ -146,6 +146,11 @@ function _M.before_proxy(conf, ctx, on_error)
             -- Provider natively supports this protocol — passthrough
             converter = nil
             target_proto = client_protocol
+        elseif client_protocol == "passthrough" then
+            -- Catch-all: proxy to the original request URI path
+            converter = nil
+            target_proto = "passthrough"
+            target_path = ctx.var.uri
         else
             -- Find a converter to bridge the gap
             local conv, target_protocol = 
protocols.find_converter(client_protocol, caps)
@@ -176,8 +181,8 @@ function _M.before_proxy(conf, ctx, on_error)
             ctx.var.llm_model = model
         end
 
-        target_path = resolve_cap(caps[target_proto], "path",
-                                  provider_conf, ctx)
+        target_path = target_path or resolve_cap(caps[target_proto], "path",
+                                                  provider_conf, ctx)
         target_host = resolve_cap(caps[target_proto], "host",
                                   provider_conf, ctx)
 
diff --git a/t/fixtures/openai/images-generation.json 
b/t/fixtures/openai/images-generation.json
new file mode 100644
index 000000000..14f1e4f9e
--- /dev/null
+++ b/t/fixtures/openai/images-generation.json
@@ -0,0 +1,9 @@
+{
+  "created": 1723780938,
+  "data": [
+    {
+      "revised_prompt": "A cute baby sea otter floating on its back in calm 
water",
+      "url": "https://example.com/image1.png";
+    }
+  ]
+}
diff --git a/t/lib/server.lua b/t/lib/server.lua
index 97908e214..aaac46fe6 100644
--- a/t/lib/server.lua
+++ b/t/lib/server.lua
@@ -862,6 +862,16 @@ function _M.v1_embeddings()
     ai_fixture_dispatch()
 end
 
+function _M.v1_images_generations()
+    if ngx.req.get_headers()["x-ai-fixture"] then
+        ai_fixture_dispatch()
+        return
+    end
+    ngx.req.read_body()
+    ngx.header["Content-Type"] = "application/json"
+    ngx.print(ngx.req.get_body_data() or "{}")
+end
+
 function _M.v1_responses()
     if ngx.req.get_headers()["x-ai-fixture"] then
         ngx.req.read_body()
diff --git a/t/plugin/ai-proxy-passthrough.t b/t/plugin/ai-proxy-passthrough.t
new file mode 100644
index 000000000..b01e7a513
--- /dev/null
+++ b/t/plugin/ai-proxy-passthrough.t
@@ -0,0 +1,280 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+=encoding utf-8
+
+Tests for the passthrough protocol adapter. Verifies that requests whose
+body does not match any known AI protocol (e.g. OpenAI Images Generation)
+are proxied to the upstream without protocol-specific transformation.
+
+=cut
+
+BEGIN {
+    $ENV{TEST_ENABLE_CONTROL_API_V1} = "0";
+}
+
+use t::APISIX 'no_plan';
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set route for images generation (passthrough)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/v1/images/generations",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "override": {
+                                "endpoint": "http://127.0.0.1:1980";
+                            },
+                            "ssl_verify": false
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: images generation request goes through passthrough protocol
+--- request
+POST /v1/images/generations
+{"model":"dall-e-3","prompt":"A cute baby sea otter","n":1,"size":"1024x1024"}
+--- more_headers
+X-AI-Fixture: openai/images-generation.json
+--- response_body eval
+qr/baby sea otter/
+--- no_error_log
+no matching AI protocol
+
+
+
+=== TEST 3: passthrough protocol is detected last (chat still matches)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "override": {
+                                "endpoint": "http://127.0.0.1:1980";
+                            },
+                            "ssl_verify": false
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: request with messages field matches openai-chat, not passthrough
+--- request
+POST /anything
+{"messages":[{"role":"user","content":"hello"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_body eval
+qr/1 \+ 1 = 2\./
+--- no_error_log
+no matching AI protocol
+
+
+
+=== TEST 5: passthrough uses override.endpoint path
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/v1/images/generations",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "override": {
+                                "endpoint": 
"http://127.0.0.1:1980/v1/chat/completions";
+                            },
+                            "ssl_verify": false
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: passthrough with override.endpoint uses endpoint path not request 
URI
+--- request
+POST /v1/images/generations
+{"model":"gpt-4o","prompt":"test"}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_body eval
+qr/1 \+ 1 = 2\./
+--- no_error_log
+no matching AI protocol
+
+
+
+=== TEST 7: set route with model rewrite for passthrough
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/v1/images/generations",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "options": {
+                                "model": "dall-e-3-override"
+                            },
+                            "override": {
+                                "endpoint": "http://127.0.0.1:1980";
+                            },
+                            "ssl_verify": false
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: passthrough rewrites model from options
+--- request
+POST /v1/images/generations
+{"model":"dall-e-2","prompt":"test","n":1}
+--- response_body eval
+qr/dall-e-3-override/
+--- no_error_log
+no matching AI protocol
+
+
+
+=== TEST 9: protocol detection unit test
+--- config
+    location /t {
+        content_by_lua_block {
+            local protocols = require("apisix.plugins.ai-protocols")
+
+            -- chat body matches openai-chat
+            local name = protocols.detect({messages = {{role = "user", content 
= "hi"}}}, {})
+            ngx.say("chat: ", name)
+
+            -- embeddings body matches openai-embeddings
+            name = protocols.detect({input = "hello"}, {})
+            ngx.say("embeddings: ", name)
+
+            -- images body matches passthrough
+            name = protocols.detect({prompt = "a cat", model = "dall-e-3"}, {})
+            ngx.say("images: ", name)
+
+            -- empty body does NOT match passthrough (requires at least one 
key)
+            name = protocols.detect({}, {})
+            ngx.say("empty: ", name)
+        }
+    }
+--- response_body
+chat: openai-chat
+embeddings: openai-embeddings
+images: passthrough
+empty: nil
+--- no_error_log
+no matching AI protocol

Reply via email to