This is an automated email from the ASF dual-hosted git repository. shreemaanabhishek 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 88164f47e feat(ai-proxy): support proxying openai compatible LLMs (#12004) 88164f47e is described below commit 88164f47ead10965c4c9e9a8a3368ce20f6b022c Author: Shreemaan Abhishek <shreemaan.abhis...@gmail.com> AuthorDate: Sat Mar 1 15:00:35 2025 +0545 feat(ai-proxy): support proxying openai compatible LLMs (#12004) --- apisix/plugins/ai-drivers/deepseek.lua | 2 +- .../{openai-compatible.lua => openai-base.lua} | 9 +- apisix/plugins/ai-drivers/openai-compatible.lua | 91 +---- apisix/plugins/ai-drivers/openai.lua | 2 +- apisix/plugins/ai-proxy/schema.lua | 6 +- docs/en/latest/plugins/ai-proxy-multi.md | 55 +++ docs/en/latest/plugins/ai-proxy.md | 36 ++ t/plugin/ai-proxy-multi.openai-compatible.t | 326 +++++++++++++++++ t/plugin/ai-proxy.openai-compatible.t | 384 +++++++++++++++++++++ 9 files changed, 813 insertions(+), 98 deletions(-) diff --git a/apisix/plugins/ai-drivers/deepseek.lua b/apisix/plugins/ai-drivers/deepseek.lua index ab441c636..19c2e90e3 100644 --- a/apisix/plugins/ai-drivers/deepseek.lua +++ b/apisix/plugins/ai-drivers/deepseek.lua @@ -15,7 +15,7 @@ -- limitations under the License. -- -return require("apisix.plugins.ai-drivers.openai-compatible").new( +return require("apisix.plugins.ai-drivers.openai-base").new( { host = "api.deepseek.com", path = "/chat/completions", diff --git a/apisix/plugins/ai-drivers/openai-compatible.lua b/apisix/plugins/ai-drivers/openai-base.lua similarity index 95% copy from apisix/plugins/ai-drivers/openai-compatible.lua copy to apisix/plugins/ai-drivers/openai-base.lua index fd5d2163c..a9eb31059 100644 --- a/apisix/plugins/ai-drivers/openai-compatible.lua +++ b/apisix/plugins/ai-drivers/openai-base.lua @@ -93,9 +93,14 @@ function _M.request(self, conf, request_table, extra_opts) request_table[opt] = val end end - params.body = core.json.encode(request_table) - httpc:set_timeout(conf.keepalive_timeout) + local req_json, err = core.json.encode(request_table) + if not req_json then + return nil, err + end + + params.body = req_json + local res, err = httpc:request(params) if not res then return nil, err diff --git a/apisix/plugins/ai-drivers/openai-compatible.lua b/apisix/plugins/ai-drivers/openai-compatible.lua index fd5d2163c..b6c21cf51 100644 --- a/apisix/plugins/ai-drivers/openai-compatible.lua +++ b/apisix/plugins/ai-drivers/openai-compatible.lua @@ -14,94 +14,5 @@ -- See the License for the specific language governing permissions and -- limitations under the License. -- -local _M = {} -local mt = { - __index = _M -} - -local core = require("apisix.core") -local http = require("resty.http") -local url = require("socket.url") - -local pairs = pairs -local type = type -local setmetatable = setmetatable - - -function _M.new(opts) - - local self = { - host = opts.host, - port = opts.port, - path = opts.path, - } - return setmetatable(self, mt) -end - - -function _M.request(self, conf, request_table, extra_opts) - local httpc, err = http.new() - if not httpc then - return nil, "failed to create http client to send request to LLM server: " .. err - end - httpc:set_timeout(conf.timeout) - - local endpoint = extra_opts.endpoint - local parsed_url - if endpoint then - parsed_url = url.parse(endpoint) - end - - local ok, err = httpc:connect({ - scheme = endpoint and parsed_url.scheme or "https", - host = endpoint and parsed_url.host or self.host, - port = endpoint and parsed_url.port or self.port, - ssl_verify = conf.ssl_verify, - ssl_server_name = endpoint and parsed_url.host or self.host, - pool_size = conf.keepalive and conf.keepalive_pool, - }) - - if not ok then - return nil, "failed to connect to LLM server: " .. err - end - - local query_params = extra_opts.query_params - - if type(parsed_url) == "table" and parsed_url.query and #parsed_url.query > 0 then - local args_tab = core.string.decode_args(parsed_url.query) - if type(args_tab) == "table" then - core.table.merge(query_params, args_tab) - end - end - - local path = (endpoint and parsed_url.path or self.path) - - local headers = extra_opts.headers - headers["Content-Type"] = "application/json" - local params = { - method = "POST", - headers = headers, - keepalive = conf.keepalive, - ssl_verify = conf.ssl_verify, - path = path, - query = query_params - } - - if extra_opts.model_options then - for opt, val in pairs(extra_opts.model_options) do - request_table[opt] = val - end - end - params.body = core.json.encode(request_table) - - httpc:set_timeout(conf.keepalive_timeout) - local res, err = httpc:request(params) - if not res then - return nil, err - end - - return res, nil, httpc -end - -return _M +return require("apisix.plugins.ai-drivers.openai-base").new({}) diff --git a/apisix/plugins/ai-drivers/openai.lua b/apisix/plugins/ai-drivers/openai.lua index 785ede193..e922c8b1e 100644 --- a/apisix/plugins/ai-drivers/openai.lua +++ b/apisix/plugins/ai-drivers/openai.lua @@ -15,7 +15,7 @@ -- limitations under the License. -- -return require("apisix.plugins.ai-drivers.openai-compatible").new( +return require("apisix.plugins.ai-drivers.openai-base").new( { host = "api.openai.com", path = "/v1/chat/completions", diff --git a/apisix/plugins/ai-proxy/schema.lua b/apisix/plugins/ai-proxy/schema.lua index d0ba33fdc..4f756216a 100644 --- a/apisix/plugins/ai-proxy/schema.lua +++ b/apisix/plugins/ai-proxy/schema.lua @@ -84,8 +84,7 @@ local model_schema = { provider = { type = "string", description = "Name of the AI service provider.", - oneOf = { "openai" }, -- add more providers later - + enum = { "openai", "openai-compatible", "deepseek" }, -- add more providers later }, name = { type = "string", @@ -114,7 +113,7 @@ local provider_schema = { name = { type = "string", description = "Name of the AI service provider.", - enum = { "openai", "deepseek" }, -- add more providers later + enum = { "openai", "deepseek", "openai-compatible" }, -- add more providers later }, model = { @@ -160,7 +159,6 @@ _M.ai_proxy_schema = { description = "timeout in milliseconds", }, keepalive = {type = "boolean", default = true}, - keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, keepalive_pool = {type = "integer", minimum = 1, default = 30}, ssl_verify = {type = "boolean", default = true }, }, diff --git a/docs/en/latest/plugins/ai-proxy-multi.md b/docs/en/latest/plugins/ai-proxy-multi.md index 72d8a9cfa..2359d4b9a 100644 --- a/docs/en/latest/plugins/ai-proxy-multi.md +++ b/docs/en/latest/plugins/ai-proxy-multi.md @@ -193,3 +193,58 @@ curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ ``` In the above configuration `priority` for the deepseek provider is set to `0`. Which means if `openai` provider is unavailable then `ai-proxy-multi` plugin will retry sending request to `deepseek` in the second attempt. + +### Send request to an OpenAI compatible LLM + +Create a route with the `ai-proxy-multi` plugin with `provider.name` set to `openai-compatible` and the endpoint of the model set to `provider.override.endpoint` like so: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${ADMIN_API_KEY}" \ + -d '{ + "id": "ai-proxy-multi-route", + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-proxy-multi": { + "providers": [ + { + "name": "openai-compatible", + "model": "qwen-plus", + "weight": 1, + "priority": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$OPENAI_API_KEY"'" + } + }, + "override": { + "endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + } + }, + { + "name": "deepseek", + "model": "deepseek-chat", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$DEEPSEEK_API_KEY"'" + } + }, + "options": { + "max_tokens": 512, + "temperature": 1.0 + } + } + ], + "passthrough": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org": 1 + } + } + }' +``` diff --git a/docs/en/latest/plugins/ai-proxy.md b/docs/en/latest/plugins/ai-proxy.md index 0f68911bb..0194205d9 100644 --- a/docs/en/latest/plugins/ai-proxy.md +++ b/docs/en/latest/plugins/ai-proxy.md @@ -142,3 +142,39 @@ You will receive a response like this: "usage": { "completion_tokens": 15, "prompt_tokens": 23, "total_tokens": 38 } } ``` + +### Send request to an OpenAI compatible LLM + +Create a route with the `ai-proxy` plugin with `provider` set to `openai-compatible` and the endpoint of the model set to `override.endpoint` like so: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${ADMIN_API_KEY}" \ + -d '{ + "uri": "/anything", + "plugins": { + "ai-proxy": { + "auth": { + "header": { + "Authorization": "Bearer <some-token>" + } + }, + "model": { + "provider": "openai-compatible", + "name": "qwen-plus" + }, + "override": { + "endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "somerandom.com:443": 1 + }, + "scheme": "https", + "pass_host": "node" + } + }' +``` diff --git a/t/plugin/ai-proxy-multi.openai-compatible.t b/t/plugin/ai-proxy-multi.openai-compatible.t new file mode 100644 index 000000000..f80be6dc4 --- /dev/null +++ b/t/plugin/ai-proxy-multi.openai-compatible.t @@ -0,0 +1,326 @@ +# +# 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. +# + +use t::APISIX 'no_plan'; + +log_level("info"); +repeat_each(1); +no_long_string(); +no_root_location(); + + +my $resp_file = 't/assets/ai-proxy-response.json'; +open(my $fh, '<', $resp_file) or die "Could not open file '$resp_file' $!"; +my $resp = do { local $/; <$fh> }; +close($fh); + +print "Hello, World!\n"; +print $resp; + + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + my $user_yaml_config = <<_EOC_; +plugins: + - ai-proxy-multi +_EOC_ + $block->set_value("extra_yaml_config", $user_yaml_config); + + my $http_config = $block->http_config // <<_EOC_; + server { + server_name openai; + listen 6724; + + default_type 'application/json'; + + location /anything { + content_by_lua_block { + local json = require("cjson.safe") + + if ngx.req.get_method() ~= "POST" then + ngx.status = 400 + ngx.say("Unsupported request method: ", ngx.req.get_method()) + end + ngx.req.read_body() + local body = ngx.req.get_body_data() + + if body ~= "SELECT * FROM STUDENTS" then + ngx.status = 503 + ngx.say("passthrough doesn't work") + return + end + ngx.say('{"foo", "bar"}') + } + } + + location /v1/chat/completions { + content_by_lua_block { + local json = require("cjson.safe") + + if ngx.req.get_method() ~= "POST" then + ngx.status = 400 + ngx.say("Unsupported request method: ", ngx.req.get_method()) + end + ngx.req.read_body() + local body, err = ngx.req.get_body_data() + body, err = json.decode(body) + + local test_type = ngx.req.get_headers()["test-type"] + if test_type == "options" then + if body.foo == "bar" then + ngx.status = 200 + ngx.say("options works") + else + ngx.status = 500 + ngx.say("model options feature doesn't work") + end + return + end + + local header_auth = ngx.req.get_headers()["authorization"] + local query_auth = ngx.req.get_uri_args()["apikey"] + + if header_auth ~= "Bearer token" and query_auth ~= "apikey" then + ngx.status = 401 + ngx.say("Unauthorized") + return + end + + if header_auth == "Bearer token" or query_auth == "apikey" then + ngx.req.read_body() + local body, err = ngx.req.get_body_data() + body, err = json.decode(body) + + if not body.messages or #body.messages < 1 then + ngx.status = 400 + ngx.say([[{ "error": "bad request"}]]) + return + end + + if body.messages[1].content == "write an SQL query to get all rows from student table" then + ngx.print("SELECT * FROM STUDENTS") + return + end + + ngx.status = 200 + ngx.say([[$resp]]) + return + end + + + ngx.status = 503 + ngx.say("reached the end of the test suite") + } + } + + location /random { + content_by_lua_block { + ngx.say("path override works") + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests(); + +__DATA__ + +=== TEST 1: set route with right auth header +--- 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-multi": { + "providers": [ + { + "name": "openai-compatible", + "model": "custom", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer token" + } + }, + "options": { + "max_tokens": 512, + "temperature": 1.0 + }, + "override": { + "endpoint": "http://localhost:6724/v1/chat/completions" + } + } + ], + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "canbeanything.com": 1 + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: send request +--- request +POST /anything +{ "messages": [ { "role": "system", "content": "You are a mathematician" }, { "role": "user", "content": "What is 1+1?"} ] } +--- more_headers +Authorization: Bearer token +--- error_code: 200 +--- response_body eval +qr/\{ "content": "1 \+ 1 = 2\.", "role": "assistant" \}/ + + + +=== TEST 3: set route with stream = true (SSE) +--- 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-multi": { + "providers": [ + { + "name": "openai-compatible", + "model": "custom-instruct", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer token" + } + }, + "options": { + "max_tokens": 512, + "temperature": 1.0, + "stream": true + }, + "override": { + "endpoint": "http://localhost:7737/v1/chat/completions" + } + } + ], + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "canbeanything.com": 1 + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: test is SSE works as expected +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local httpc = http.new() + local core = require("apisix.core") + + local ok, err = httpc:connect({ + scheme = "http", + host = "localhost", + port = ngx.var.server_port, + }) + + if not ok then + ngx.status = 500 + ngx.say(err) + return + end + + local params = { + method = "POST", + headers = { + ["Content-Type"] = "application/json", + }, + path = "/anything", + body = [[{ + "messages": [ + { "role": "system", "content": "some content" } + ] + }]], + } + + local res, err = httpc:request(params) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + + local final_res = {} + while true do + local chunk, err = res.body_reader() -- will read chunk by chunk + if err then + core.log.error("failed to read response chunk: ", err) + break + end + if not chunk then + break + end + core.table.insert_tail(final_res, chunk) + end + + ngx.print(#final_res .. final_res[6]) + } + } +--- response_body_like eval +qr/6data: \[DONE\]\n\n/ diff --git a/t/plugin/ai-proxy.openai-compatible.t b/t/plugin/ai-proxy.openai-compatible.t new file mode 100644 index 000000000..bba9db40c --- /dev/null +++ b/t/plugin/ai-proxy.openai-compatible.t @@ -0,0 +1,384 @@ +# +# 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. +# + +use t::APISIX 'no_plan'; + +log_level("info"); +repeat_each(1); +no_long_string(); +no_root_location(); + + +my $resp_file = 't/assets/ai-proxy-response.json'; +open(my $fh, '<', $resp_file) or die "Could not open file '$resp_file' $!"; +my $resp = do { local $/; <$fh> }; +close($fh); + +print "Hello, World!\n"; +print $resp; + + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + my $http_config = $block->http_config // <<_EOC_; + server { + server_name openai; + listen 6724; + + default_type 'application/json'; + + location /anything { + content_by_lua_block { + local json = require("cjson.safe") + + if ngx.req.get_method() ~= "POST" then + ngx.status = 400 + ngx.say("Unsupported request method: ", ngx.req.get_method()) + end + ngx.req.read_body() + local body = ngx.req.get_body_data() + + if body ~= "SELECT * FROM STUDENTS" then + ngx.status = 503 + ngx.say("passthrough doesn't work") + return + end + ngx.say('{"foo", "bar"}') + } + } + + location /v1/chat/completions { + content_by_lua_block { + local json = require("cjson.safe") + + if ngx.req.get_method() ~= "POST" then + ngx.status = 400 + ngx.say("Unsupported request method: ", ngx.req.get_method()) + end + ngx.req.read_body() + local body, err = ngx.req.get_body_data() + body, err = json.decode(body) + + local test_type = ngx.req.get_headers()["test-type"] + if test_type == "options" then + if body.foo == "bar" then + ngx.status = 200 + ngx.say("options works") + else + ngx.status = 500 + ngx.say("model options feature doesn't work") + end + return + end + + local header_auth = ngx.req.get_headers()["authorization"] + local query_auth = ngx.req.get_uri_args()["apikey"] + + if header_auth ~= "Bearer token" and query_auth ~= "apikey" then + ngx.status = 401 + ngx.say("Unauthorized") + return + end + + if header_auth == "Bearer token" or query_auth == "apikey" then + ngx.req.read_body() + local body, err = ngx.req.get_body_data() + body, err = json.decode(body) + + if not body.messages or #body.messages < 1 then + ngx.status = 400 + ngx.say([[{ "error": "bad request"}]]) + return + end + + if body.messages[1].content == "write an SQL query to get all rows from student table" then + ngx.print("SELECT * FROM STUDENTS") + return + end + + ngx.status = 200 + ngx.say([[$resp]]) + return + end + + + ngx.status = 503 + ngx.say("reached the end of the test suite") + } + } + + location /random { + content_by_lua_block { + ngx.say("path override works") + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests(); + +__DATA__ + +=== TEST 1: set route with right auth header +--- 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": { + "auth": { + "header": { + "Authorization": "Bearer token" + } + }, + "model": { + "provider": "openai-compatible", + "name": "custom", + "options": { + "max_tokens": 512, + "temperature": 1.0 + } + }, + "override": { + "endpoint": "http://localhost:6724/v1/chat/completions" + }, + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "canbeanything.com": 1 + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: send request +--- request +POST /anything +{ "messages": [ { "role": "system", "content": "You are a mathematician" }, { "role": "user", "content": "What is 1+1?"} ] } +--- more_headers +Authorization: Bearer token +--- error_code: 200 +--- response_body eval +qr/\{ "content": "1 \+ 1 = 2\.", "role": "assistant" \}/ + + + +=== TEST 3: override 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": "/anything", + "plugins": { + "ai-proxy": { + "auth": { + "header": { + "Authorization": "Bearer token" + } + }, + "model": { + "provider": "openai-compatible", + "name": "some-model", + "options": { + "foo": "bar", + "temperature": 1.0 + } + }, + "override": { + "endpoint": "http://localhost:6724/random" + }, + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "canbeanything.com": 1 + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local code, body, actual_body = t("/anything", + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "system", "content": "You are a mathematician" }, + { "role": "user", "content": "What is 1+1?" } + ] + }]], + nil, + { + ["test-type"] = "path", + ["Content-Type"] = "application/json", + } + ) + + ngx.status = code + ngx.say(actual_body) + + } + } +--- response_body_chomp +path override works + + + +=== TEST 4: set route with stream = true (SSE) +--- 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": { + "auth": { + "header": { + "Authorization": "Bearer token" + } + }, + "model": { + "provider": "openai-compatible", + "name": "custom", + "options": { + "max_tokens": 512, + "temperature": 1.0, + "stream": true + } + }, + "override": { + "endpoint": "http://localhost:7737/v1/chat/completions" + }, + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "canbeanything.com": 1 + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 5: test is SSE works as expected +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local httpc = http.new() + local core = require("apisix.core") + + local ok, err = httpc:connect({ + scheme = "http", + host = "localhost", + port = ngx.var.server_port, + }) + + if not ok then + ngx.status = 500 + ngx.say(err) + return + end + + local params = { + method = "POST", + headers = { + ["Content-Type"] = "application/json", + }, + path = "/anything", + body = [[{ + "messages": [ + { "role": "system", "content": "some content" } + ] + }]], + } + + local res, err = httpc:request(params) + if not res then + ngx.status = 500 + ngx.say(err) + return + end + + local final_res = {} + while true do + local chunk, err = res.body_reader() -- will read chunk by chunk + if err then + core.log.error("failed to read response chunk: ", err) + break + end + if not chunk then + break + end + core.table.insert_tail(final_res, chunk) + end + + ngx.print(#final_res .. final_res[6]) + } + } +--- response_body_like eval +qr/6data: \[DONE\]\n\n/