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

AlinsRan 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 89e627384 feat(control-api): report the health checks a plugin owns 
(#13899)
89e627384 is described below

commit 89e627384571c78f73e188c340a3b19861986c69
Author: AlinsRan <[email protected]>
AuthorDate: Tue Sep 1 15:45:44 2026 +0800

    feat(control-api): report the health checks a plugin owns (#13899)
---
 apisix/control/v1.lua                    | 103 ++++-
 apisix/plugin.lua                        |   3 +
 apisix/plugins/ai-proxy-multi.lua        |  37 +-
 docs/en/latest/control-api.md            |  64 +++
 docs/en/latest/plugins/ai-proxy-multi.md |   2 +
 docs/zh/latest/control-api.md            |  60 +++
 docs/zh/latest/plugins/ai-proxy-multi.md |   2 +
 t/control/healthcheck-ai-proxy-multi.t   | 660 +++++++++++++++++++++++++++++++
 8 files changed, 924 insertions(+), 7 deletions(-)

diff --git a/apisix/control/v1.lua b/apisix/control/v1.lua
index b224b6838..c73c8db5d 100644
--- a/apisix/control/v1.lua
+++ b/apisix/control/v1.lua
@@ -26,6 +26,7 @@ local healthcheck_manager = 
require("apisix.healthcheck_manager")
 local get_upstreams = upstream_mod.upstreams
 local collectgarbage = collectgarbage
 local ipairs = ipairs
+local pairs = pairs
 local pcall = pcall
 local setmetatable = setmetatable
 local str_format = string.format
@@ -77,12 +78,14 @@ function _M.schema()
 end
 
 local healthcheck
-local function extra_checker_info(value)
+-- `value` is anything get_healthchecker_name() accepts: a resource config, or 
a
+-- bare {resource_key = ...} for a checker a plugin owns.
+local function get_checker_nodes(value)
     if not healthcheck then
         healthcheck = require("resty.healthcheck")
     end
 
-    local name = healthcheck_manager.get_healthchecker_name(value.value)
+    local name = healthcheck_manager.get_healthchecker_name(value)
     local nodes, err = healthcheck.get_target_list(name, 
"upstream-healthcheck")
     if err then
         core.log.error("healthcheck.get_target_list failed: ", err)
@@ -92,9 +95,14 @@ local function extra_checker_info(value)
         -- so keep the field a JSON array to report `[]` instead of `{}` then
         setmetatable(nodes, core.json.array_mt)
     end
+    return nodes
+end
+
+
+local function extra_checker_info(value)
     return {
         name = value.key,
-        nodes = nodes,
+        nodes = get_checker_nodes(value.value),
     }
 end
 
@@ -108,6 +116,41 @@ local function get_checker_type(checks)
 end
 
 
+-- A plugin can run active health checks of its own on nodes that belong to no
+-- upstream -- ai-proxy-multi probes every LLM instance and skips the unhealthy
+-- ones when it picks a target. Those checkers are keyed by the resource key 
plus
+-- a JSON path, a layout only the plugin knows, so ask the plugin for them
+-- instead of guessing. What a checker stands for is the plugin's business too:
+-- it names itself in `meta`, reported verbatim.
+local function add_plugin_healthcheck_info(infos, value)
+    local plugins = value.value.plugins
+    if not plugins then
+        return
+    end
+
+    for name, plugin_conf in pairs(plugins) do
+        -- a disabled plugin never runs, so its checkers never exist; and its
+        -- config is kept even when check_schema() rejects it, so it must not 
be
+        -- handed to the plugin either
+        if not plugin.check_disable(plugin_conf) then
+            local plugin_obj = plugin.get(name)
+            if plugin_obj and plugin_obj.list_healthcheck_targets then
+                local targets = 
plugin_obj.list_healthcheck_targets(plugin_conf, value.key)
+                for _, target in ipairs(targets or {}) do
+                    core.table.insert(infos, {
+                        name = target.resource_path,
+                        plugin = name,
+                        meta = target.meta,
+                        type = get_checker_type(target.checks),
+                        nodes = get_checker_nodes({resource_key = 
target.resource_path}),
+                    })
+                end
+            end
+        end
+    end
+end
+
+
 local function iter_and_add_healthcheck_info(infos, values)
     if not values then
         return
@@ -120,6 +163,7 @@ local function iter_and_add_healthcheck_info(infos, values)
             info.type = get_checker_type(checks)
             core.table.insert(infos, info)
         end
+        add_plugin_healthcheck_info(infos, value)
     end
 end
 
@@ -241,15 +285,50 @@ local function iter_and_find_healthcheck_info(values, 
src_type, src_id)
 end
 
 
+-- Every checker a resource owns, in the same entry shape the /v1/healthcheck
+-- listing uses. A resource can own more than one -- its upstream plus one per
+-- plugin instance -- which the single object returned by
+-- /v1/healthcheck/{src_type}/{src_id} cannot express, so this is a 
sub-resource
+-- of its own rather than a new field on that object. A resource with no health
+-- check at all is not an error here: it owns an empty set of checkers.
+local function iter_and_find_resource_checkers(values, src_type, src_id)
+    if not values then
+        return nil, str_format("%s[%s] not found", src_type, src_id)
+    end
+
+    for _, value in core.config_util.iterate_values(values) do
+        if value.value.id == src_id then
+            local infos = core.table.new(1, 0)
+            local checks = value.value.checks or
+                (value.value.upstream and value.value.upstream.checks)
+            if checks then
+                local info = extra_checker_info(value)
+                info.type = get_checker_type(checks)
+                core.table.insert(infos, info)
+            end
+            add_plugin_healthcheck_info(infos, value)
+            -- an empty result must still serialize as [], not {}
+            return setmetatable(infos, core.json.array_mt)
+        end
+    end
+
+    return nil, str_format("%s[%s] not found", src_type, src_id)
+end
+
+
 function _M.get_health_checker()
     local uri_segs = core.utils.split_uri(ngx_var.uri)
     core.log.info("healthcheck uri: ", core.json.delay_encode(uri_segs))
 
-    local src_type, src_id = uri_segs[4], uri_segs[5]
+    local src_type, src_id, sub_res = uri_segs[4], uri_segs[5], uri_segs[6]
     if not src_id then
         return 404, {error_msg = str_format("missing src id for src type %s", 
src_type)}
     end
 
+    if sub_res and (sub_res ~= "checkers" or uri_segs[7]) then
+        return 400, {error_msg = str_format("invalid sub resource %s", 
sub_res)}
+    end
+
     local values
     if src_type == "routes" then
         values = get_routes()
@@ -263,6 +342,22 @@ function _M.get_health_checker()
         return 400, {error_msg = str_format("invalid src type %s", src_type)}
     end
 
+    if sub_res then
+        local infos, err = iter_and_find_resource_checkers(values, src_type, 
src_id)
+        if not infos then
+            return 404, {error_msg = err}
+        end
+        local out, err = try_render_html({stats = infos})
+        if out then
+            core.response.set_header("Content-Type", "text/html")
+            return 200, out
+        end
+        if err then
+            return 503, {error_msg = err}
+        end
+        return 200, infos
+    end
+
     local info, err = iter_and_find_healthcheck_info(values, src_type, src_id)
     if not info then
         return 404, {error_msg = err}
diff --git a/apisix/plugin.lua b/apisix/plugin.lua
index 6f988fd7d..5a7a405a0 100644
--- a/apisix/plugin.lua
+++ b/apisix/plugin.lua
@@ -162,6 +162,9 @@ local function check_disable(plugin_conf)
 
     return plugin_conf._meta.disable
 end
+-- exposed for callers that must not act on a plugin config which never runs,
+-- such as the control API reporting the health checkers a plugin owns
+_M.check_disable = check_disable
 
 local PLUGIN_TYPE_HTTP = 1
 local PLUGIN_TYPE_STREAM = 2
diff --git a/apisix/plugins/ai-proxy-multi.lua 
b/apisix/plugins/ai-proxy-multi.lua
index 6ce28e793..19aba41a3 100644
--- a/apisix/plugins/ai-proxy-multi.lua
+++ b/apisix/plugins/ai-proxy-multi.lua
@@ -597,6 +597,16 @@ local function create_server_picker(conf, ups_tab, 
checkers)
 end
 
 
+-- Identity of the health checker of the i-th instance: the parent resource key
+-- plus the JSON path of the instance. The health check manager parses it back
+-- to reach construct_upstream(), so both ends must agree on this exact layout.
+local function instance_resource_path(resource_key, index)
+    -- json path is 0 indexed so we need to decrement the index
+    return resource_key .. "#plugins['" .. plugin_name .. "'].instances["
+           .. index - 1 .. "]"
+end
+
+
 local function get_instance_conf(instances, name)
     for _, ins in ipairs(instances) do
         if ins.name == name then
@@ -616,9 +626,8 @@ local function pick_target(ctx, conf, ups_tab)
     for i, instance in ipairs(conf.instances) do
         if instance.checks then
             resolve_endpoint(instance)
-            -- json path is 0 indexed so we need to decrement i
-            local resource_path = conf._meta.parent.resource_key ..
-                                  "#plugins['ai-proxy-multi'].instances[" .. 
i-1 .. "]"
+            local resource_path = instance_resource_path(
+                                      conf._meta.parent.resource_key, i)
             local resource_version = conf._meta.parent.resource_version
             if instance._nodes_ver then
                 resource_version = resource_version .. instance._nodes_ver
@@ -982,6 +991,28 @@ local function retry_on_error(ctx, conf, code, body)
     return code
 end
 
+-- Each instance can run its own active health check, whose checker is keyed by
+-- instance_resource_path(). Nothing outside the plugin can guess that layout, 
so
+-- expose it for the control API, which reports the health status of every
+-- configured checker (control/v1.lua). `meta` describes the checker to whoever
+-- reports it -- what an instance is is the plugin's business, so the control 
API
+-- passes it through instead of knowing about it.
+function _M.list_healthcheck_targets(conf, resource_key)
+    local targets = {}
+    for i, instance in ipairs(conf.instances or {}) do
+        if instance.checks then
+            core.table.insert(targets, {
+                resource_path = instance_resource_path(resource_key, i),
+                checks = instance.checks,
+                meta = {instance = instance.name},
+            })
+        end
+    end
+
+    return targets
+end
+
+
 function _M.construct_upstream(instance)
     if not instance then
         return nil, "instance configuration is nil"
diff --git a/docs/en/latest/control-api.md b/docs/en/latest/control-api.md
index 6f594c402..a945b2db9 100644
--- a/docs/en/latest/control-api.md
+++ b/docs/en/latest/control-api.md
@@ -145,6 +145,38 @@ Each of the returned objects contain the following fields:
 * nodes[i].counter.tcp_failure: tcp connect/read/write failures count.
 * nodes[i].counter.timeout_failure: timeout count.
 
+A plugin can run active health checks of its own, on nodes that belong to no
+upstream. [ai-proxy-multi](./plugins/ai-proxy-multi.md) does this: it probes
+every LLM instance and skips the unhealthy ones when it picks a target. Such a
+checker is reported with two extra fields:
+
+```json
+{
+  "name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
+  "plugin": "ai-proxy-multi",
+  "meta": {
+    "instance": "openai"
+  },
+  "type": "http",
+  "nodes": [
+    {
+      "ip": "52.86.68.46",
+      "port": 443,
+      "status": "healthy",
+      "counter": {
+        "http_failure": 0,
+        "success": 2,
+        "timeout_failure": 0,
+        "tcp_failure": 0
+      }
+    }
+  ]
+}
+```
+
+* plugin: name of the plugin owning the health checker. Absent for an upstream 
health checker.
+* meta: filled by the plugin to describe what the checker stands for. Its 
content is plugin specific; `ai-proxy-multi` reports the instance name.
+
 You can also use `/v1/healthcheck/$src_type/$src_id` to get the health status 
of specific nodes.
 
 For example, `GET /v1/healthcheck/upstreams/1` returns:
@@ -193,6 +225,38 @@ If you use browser to access the control API URL, then you 
will get the HTML out
 
 ![Health Check Status 
Page](https://raw.githubusercontent.com/apache/apisix/master/docs/assets/images/health_check_status_page.png)
 
+### GET /v1/healthcheck/{src_type}/{src_id}/checkers
+
+Returns every health checker one resource owns, as an array of the entries
+described above. A resource can own more than one -- its upstream plus one per
+plugin instance -- which `GET /v1/healthcheck/$src_type/$src_id` cannot 
express,
+since it returns a single object.
+
+For example, `GET /v1/healthcheck/routes/1/checkers` returns:
+
+```json
+[
+  {
+    "name": "/apisix/routes/1",
+    "type": "http",
+    "nodes": [...]
+  },
+  {
+    "name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
+    "plugin": "ai-proxy-multi",
+    "meta": {
+      "instance": "openai"
+    },
+    "type": "http",
+    "nodes": [...]
+  }
+]
+```
+
+A resource with no health check at all is not an error here: it owns an empty
+set of checkers, and the endpoint returns `[]`. `404` is returned only when the
+resource itself does not exist.
+
 ### POST /v1/gc
 
 Introduced in [v2.8](https://github.com/apache/apisix/releases/tag/2.8).
diff --git a/docs/en/latest/plugins/ai-proxy-multi.md 
b/docs/en/latest/plugins/ai-proxy-multi.md
index 4f0dc647a..425771e30 100644
--- a/docs/en/latest/plugins/ai-proxy-multi.md
+++ b/docs/en/latest/plugins/ai-proxy-multi.md
@@ -2630,6 +2630,8 @@ kubectl apply -f ai-proxy-multi-ic.yaml
 
 For verification, the behaviours should be consistent with the verification in 
[active health checks](../tutorials/health-check.md).
 
+The status these checks produce is reported by the [Control 
API](../control-api.md): `GET /v1/healthcheck` lists one entry per instance, 
and `GET /v1/healthcheck/routes/{id}/checkers` returns every checker the Route 
owns. Each entry carries the instance name in `meta.instance`.
+
 ### Include LLM Information in Access Log
 
 The following example demonstrates how you can log LLM request related 
information in the gateway's access log to improve analytics and audit. The 
following variables are available:
diff --git a/docs/zh/latest/control-api.md b/docs/zh/latest/control-api.md
index 9040ce0e8..f7751e737 100644
--- a/docs/zh/latest/control-api.md
+++ b/docs/zh/latest/control-api.md
@@ -141,6 +141,36 @@ APISIX 中一些插件添加了自己的 control API。如果你对他们感兴
 * nodes[i].counter.tcp_failure: TCP 连接或读写的失败计数器。
 * nodes[i].counter.timeout_failure: 超时计数器。
 
+插件也可以对不属于任何上游的节点进行主动健康检查。[ai-proxy-multi](./plugins/ai-proxy-multi.md)
+就是这样:它探测每个 LLM 实例,并在选择目标时跳过不健康的实例。这类检查器会额外带上两个字段:
+
+```json
+{
+  "name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
+  "plugin": "ai-proxy-multi",
+  "meta": {
+    "instance": "openai"
+  },
+  "type": "http",
+  "nodes": [
+    {
+      "ip": "52.86.68.46",
+      "port": 443,
+      "status": "healthy",
+      "counter": {
+        "http_failure": 0,
+        "success": 2,
+        "timeout_failure": 0,
+        "tcp_failure": 0
+      }
+    }
+  ]
+}
+```
+
+* plugin: 拥有该健康检查器的插件名。上游自身的健康检查器没有该字段。
+* meta: 由插件填写,用于描述该检查器代表什么,内容由插件自行定义;`ai-proxy-multi` 在其中报告实例名。
+
 用户也可以通过 `/v1/healthcheck/$src_type/$src_id` 来获取指定 health checker 的状态。
 
 例如,`GET /v1/healthcheck/upstreams/1` 返回:
@@ -188,6 +218,36 @@ APISIX 中一些插件添加了自己的 control API。如果你对他们感兴
 
 ![Health Check Status 
Page](https://raw.githubusercontent.com/apache/apisix/master/docs/assets/images/health_check_status_page.png)
 
+### GET /v1/healthcheck/{src_type}/{src_id}/checkers
+
+以数组形式返回某个资源拥有的全部健康检查器,数组元素与上文描述的 entry 结构一致。
+一个资源可能拥有多个健康检查器——它自身的上游,加上每个插件实例各一个——而
+`GET /v1/healthcheck/$src_type/$src_id` 返回的是单个对象,无法表达这种情况。
+
+例如,`GET /v1/healthcheck/routes/1/checkers` 返回:
+
+```json
+[
+  {
+    "name": "/apisix/routes/1",
+    "type": "http",
+    "nodes": [...]
+  },
+  {
+    "name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
+    "plugin": "ai-proxy-multi",
+    "meta": {
+      "instance": "openai"
+    },
+    "type": "http",
+    "nodes": [...]
+  }
+]
+```
+
+资源没有配置任何健康检查在这里不算错误:它拥有一个空的检查器集合,接口返回 `[]`。
+只有资源本身不存在时才返回 `404`。
+
 ### POST /v1/gc
 
 引入自 2.8 版本
diff --git a/docs/zh/latest/plugins/ai-proxy-multi.md 
b/docs/zh/latest/plugins/ai-proxy-multi.md
index e5141d016..dd50b0259 100644
--- a/docs/zh/latest/plugins/ai-proxy-multi.md
+++ b/docs/zh/latest/plugins/ai-proxy-multi.md
@@ -2416,6 +2416,8 @@ kubectl apply -f ai-proxy-multi-ic.yaml
 
 为了验证,行为应与[主动健康检查](../tutorials/health-check.md)中的验证一致。
 
+这些健康检查产生的状态可以通过[控制接口](../control-api.md)获取:`GET /v1/healthcheck` 会为每个实例返回一个 
entry,`GET /v1/healthcheck/routes/{id}/checkers` 则返回该路由拥有的全部检查器。每个 entry 通过 
`meta.instance` 携带实例名。
+
 ### 发送请求日志到日志记录器
 
 以下示例演示了如何记录请求和响应信息(包括 LLM 模型、令牌和负载),并将其推送到日志记录器。在继续之前,您应该先设置一个日志记录器,例如 
Kafka。有关更多信息,请参阅 [`kafka-logger`](./kafka-logger.md)。
diff --git a/t/control/healthcheck-ai-proxy-multi.t 
b/t/control/healthcheck-ai-proxy-multi.t
new file mode 100644
index 000000000..ed271c0a3
--- /dev/null
+++ b/t/control/healthcheck-ai-proxy-multi.t
@@ -0,0 +1,660 @@
+#
+# 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();
+no_shuffle();
+
+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 127.0.0.1:16724;
+
+            default_type 'application/json';
+
+            location /v1/chat/completions {
+                content_by_lua_block {
+                    
ngx.say([[{"choices":[{"message":{"content":"ok","role":"assistant"}}]}]])
+                }
+            }
+
+            location /status {
+                content_by_lua_block {
+                    ngx.say("ok")
+                }
+            }
+        }
+_EOC_
+
+    $block->set_value("http_config", $http_config);
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: report the active health check status of each ai-proxy-multi 
instance
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local http = require("resty.http")
+
+            local checks = {
+                active = {
+                    timeout = 1,
+                    http_path = "/status",
+                    healthy = {
+                        interval = 1,
+                        successes = 1,
+                    },
+                    unhealthy = {
+                        interval = 1,
+                        http_failures = 1,
+                        tcp_failures = 1,
+                        timeouts = 1,
+                    },
+                },
+            }
+
+            local route = {
+                uri = "/ai",
+                plugins = {
+                    ["ai-proxy-multi"] = {
+                        fallback_strategy = 
"instance_health_and_rate_limiting",
+                        instances = {
+                            {
+                                name = "openai-healthy",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 1,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16724"},
+                                checks = checks,
+                            },
+                            {
+                                name = "openai-unhealthy",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 0,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16725"},
+                                checks = checks,
+                            },
+                        },
+                        ssl_verify = false,
+                    },
+                },
+            }
+
+            local code, body = t.test("/apisix/admin/routes/1", ngx.HTTP_PUT,
+                                      json.encode(route))
+            assert(code < 300, body)
+
+            -- the checkers are created on demand, so the route has to be hit 
once
+            local httpc = http.new()
+            local res, err = httpc:request_uri(
+                "http://127.0.0.1:"; .. ngx.var.server_port .. "/ai",
+                {
+                    method = "POST",
+                    body = json.encode({messages = {{role = "user", content = 
"hi"}}}),
+                    headers = {["Content-Type"] = "application/json"},
+                }
+            )
+            assert(res, err)
+            assert(res.status == 200, "unexpected status: " .. 
tostring(res.status))
+
+            ngx.sleep(3)
+
+            local code, body, res = t.test("/v1/healthcheck", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+
+            local infos = {}
+            for _, info in ipairs(res) do
+                if info.plugin and info.name:find("/routes/1#", 1, true) then
+                    table.insert(infos, info)
+                end
+            end
+            table.sort(infos, function(a, b) return a.name < b.name end)
+
+            for _, info in ipairs(infos) do
+                ngx.say(info.name, " plugin=", info.plugin, " instance=", 
info.meta.instance,
+                        " type=", info.type, " nodes=", #info.nodes,
+                        " ", info.nodes[1].ip, ":", info.nodes[1].port,
+                        " ", info.nodes[1].status)
+            end
+        }
+    }
+--- response_body
+/apisix/routes/1#plugins['ai-proxy-multi'].instances[0] plugin=ai-proxy-multi 
instance=openai-healthy type=http nodes=1 127.0.0.1:16724 healthy
+/apisix/routes/1#plugins['ai-proxy-multi'].instances[1] plugin=ai-proxy-multi 
instance=openai-unhealthy type=http nodes=1 127.0.0.1:16725 unhealthy
+--- timeout: 10
+
+
+
+=== TEST 2: plugin instance checkers coexist with upstream checkers
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local http = require("resty.http")
+
+            local checks = {
+                active = {
+                    timeout = 1,
+                    http_path = "/status",
+                    healthy = {
+                        interval = 1,
+                        successes = 1,
+                    },
+                    unhealthy = {
+                        interval = 1,
+                        http_failures = 1,
+                        tcp_failures = 1,
+                        timeouts = 1,
+                    },
+                },
+            }
+
+            local route = {
+                uri = "/ai",
+                plugins = {
+                    ["ai-proxy-multi"] = {
+                        fallback_strategy = 
"instance_health_and_rate_limiting",
+                        instances = {
+                            {
+                                name = "openai-first",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 1,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16724"},
+                                checks = checks,
+                            },
+                            {
+                                name = "openai-second",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 0,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16724"},
+                                checks = checks,
+                            },
+                        },
+                        ssl_verify = false,
+                    },
+                },
+            }
+
+            local code, body = t.test("/apisix/admin/routes/1", ngx.HTTP_PUT,
+                                      json.encode(route))
+            assert(code < 300, body)
+
+            code, body = t.test("/apisix/admin/routes/2", ngx.HTTP_PUT, 
json.encode({
+                uri = "/hello",
+                upstream = {
+                    type = "roundrobin",
+                    nodes = {["127.0.0.1:1980"] = 1},
+                    checks = checks,
+                },
+            }))
+            assert(code < 300, body)
+
+            for _, uri in ipairs({"/ai", "/hello"}) do
+                local httpc = http.new()
+                local res, err = httpc:request_uri(
+                    "http://127.0.0.1:"; .. ngx.var.server_port .. uri,
+                    {
+                        method = "POST",
+                        body = json.encode({messages = {{role = "user", 
content = "hi"}}}),
+                        headers = {["Content-Type"] = "application/json"},
+                    }
+                )
+                assert(res, err)
+            end
+
+            ngx.sleep(3)
+
+            local code, body, res = t.test("/v1/healthcheck", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+
+            local infos = {}
+            for _, info in ipairs(res) do
+                if info.name:find("/routes/1", 1, true)
+                   or info.name:find("/routes/2", 1, true) then
+                    table.insert(infos, info)
+                end
+            end
+            table.sort(infos, function(a, b) return a.name < b.name end)
+
+            for _, info in ipairs(infos) do
+                ngx.say(info.name, " plugin=", tostring(info.plugin),
+                        " nodes=", #info.nodes)
+            end
+        }
+    }
+--- response_body
+/apisix/routes/1#plugins['ai-proxy-multi'].instances[0] plugin=ai-proxy-multi 
nodes=1
+/apisix/routes/1#plugins['ai-proxy-multi'].instances[1] plugin=ai-proxy-multi 
nodes=1
+/apisix/routes/2 plugin=nil nodes=1
+--- timeout: 10
+
+
+
+=== TEST 3: disable_upstream_healthcheck stops probing the plugin instances too
+--- yaml_config
+apisix:
+    disable_upstream_healthcheck: true
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local http = require("resty.http")
+
+            local httpc = http.new()
+            local res, err = httpc:request_uri(
+                "http://127.0.0.1:"; .. ngx.var.server_port .. "/ai",
+                {
+                    method = "POST",
+                    body = json.encode({messages = {{role = "user", content = 
"hi"}}}),
+                    headers = {["Content-Type"] = "application/json"},
+                }
+            )
+            assert(res, err)
+
+            ngx.sleep(3)
+
+            -- the entries stay listed, but no checker was ever created, so 
every
+            -- node list is empty -- the same as for a plain upstream checker
+            local code, body, res = t.test("/v1/healthcheck", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+            local probed = 0
+            for _, info in ipairs(res) do
+                probed = probed + #info.nodes
+            end
+            ngx.say("probed nodes: ", probed)
+
+            local code, body, res = 
t.test("/v1/healthcheck/routes/1/checkers", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+            probed = 0
+            for _, info in ipairs(res) do
+                probed = probed + #info.nodes
+            end
+            ngx.say("checkers: ", #res, " probed nodes: ", probed)
+        }
+    }
+--- response_body
+probed nodes: 0
+checkers: 2 probed nodes: 0
+--- timeout: 10
+
+
+
+=== TEST 4: instances are listed before the checkers have probed anything
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+
+            local checks = {
+                active = {
+                    timeout = 1,
+                    http_path = "/status",
+                    healthy = {
+                        interval = 1,
+                        successes = 1,
+                    },
+                    unhealthy = {
+                        interval = 1,
+                        http_failures = 1,
+                        tcp_failures = 1,
+                        timeouts = 1,
+                    },
+                },
+            }
+
+            local instances = {}
+            for _, name in ipairs({"openai-a", "openai-b"}) do
+                table.insert(instances, {
+                    name = name,
+                    provider = "openai",
+                    weight = 1,
+                    auth = {header = {Authorization = "Bearer token"}},
+                    options = {model = "gpt-4"},
+                    override = {endpoint = "http://127.0.0.1:16724"},
+                    checks = checks,
+                })
+            end
+
+            local code, body = t.test("/apisix/admin/routes/3", ngx.HTTP_PUT, 
json.encode({
+                uri = "/ai-untouched",
+                plugins = {
+                    ["ai-proxy-multi"] = {
+                        instances = instances,
+                        ssl_verify = false,
+                    },
+                },
+            }))
+            assert(code < 300, body)
+
+            -- give the route time to reach the worker, but never send a 
request
+            ngx.sleep(1)
+
+            local code, body, res = t.test("/v1/healthcheck", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+
+            local infos = {}
+            for _, info in ipairs(res) do
+                if info.name:find("/routes/3#", 1, true) then
+                    table.insert(infos, info)
+                end
+            end
+            table.sort(infos, function(a, b) return a.name < b.name end)
+
+            for _, info in ipairs(infos) do
+                ngx.say(info.name, " instance=", info.meta.instance,
+                        " type=", info.type, " nodes=", #info.nodes)
+            end
+        }
+    }
+--- response_body
+/apisix/routes/3#plugins['ai-proxy-multi'].instances[0] instance=openai-a 
type=http nodes=0
+/apisix/routes/3#plugins['ai-proxy-multi'].instances[1] instance=openai-b 
type=http nodes=0
+--- timeout: 10
+
+
+
+=== TEST 5: the checkers sub-resource lists every checker of an AI route
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local http = require("resty.http")
+
+            local checks = {
+                active = {
+                    timeout = 1,
+                    http_path = "/status",
+                    healthy = {
+                        interval = 1,
+                        successes = 1,
+                    },
+                    unhealthy = {
+                        interval = 1,
+                        http_failures = 1,
+                        tcp_failures = 1,
+                        timeouts = 1,
+                    },
+                },
+            }
+
+            local code, body = t.test("/apisix/admin/routes/4", ngx.HTTP_PUT, 
json.encode({
+                uri = "/ai4",
+                plugins = {
+                    ["ai-proxy-multi"] = {
+                        fallback_strategy = 
"instance_health_and_rate_limiting",
+                        instances = {
+                            {
+                                name = "openai-up",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 1,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16724"},
+                                checks = checks,
+                            },
+                            {
+                                name = "openai-down",
+                                provider = "openai",
+                                weight = 1,
+                                priority = 0,
+                                auth = {header = {Authorization = "Bearer 
token"}},
+                                options = {model = "gpt-4"},
+                                override = {endpoint = 
"http://127.0.0.1:16725"},
+                                checks = checks,
+                            },
+                        },
+                        ssl_verify = false,
+                    },
+                },
+            }))
+            assert(code < 300, body)
+
+            local httpc = http.new()
+            local res, err = httpc:request_uri(
+                "http://127.0.0.1:"; .. ngx.var.server_port .. "/ai4",
+                {
+                    method = "POST",
+                    body = json.encode({messages = {{role = "user", content = 
"hi"}}}),
+                    headers = {["Content-Type"] = "application/json"},
+                }
+            )
+            assert(res, err)
+            assert(res.status == 200, "unexpected status: " .. 
tostring(res.status))
+
+            ngx.sleep(3)
+
+            local code, body, res = 
t.test("/v1/healthcheck/routes/4/checkers", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+            table.sort(res, function(a, b) return a.name < b.name end)
+
+            for _, info in ipairs(res) do
+                ngx.say(info.name, " plugin=", info.plugin,
+                        " instance=", info.meta.instance,
+                        " type=", info.type,
+                        " ", info.nodes[1].ip, ":", info.nodes[1].port,
+                        " ", info.nodes[1].status)
+            end
+        }
+    }
+--- response_body
+/apisix/routes/4#plugins['ai-proxy-multi'].instances[0] plugin=ai-proxy-multi 
instance=openai-up type=http 127.0.0.1:16724 healthy
+/apisix/routes/4#plugins['ai-proxy-multi'].instances[1] plugin=ai-proxy-multi 
instance=openai-down type=http 127.0.0.1:16725 unhealthy
+--- timeout: 10
+
+
+
+=== TEST 6: the checkers sub-resource reports a plain upstream checker too
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local http = require("resty.http")
+
+            local code, body = t.test("/apisix/admin/routes/5", ngx.HTTP_PUT, 
json.encode({
+                uri = "/hello5",
+                upstream = {
+                    type = "roundrobin",
+                    nodes = {["127.0.0.1:1980"] = 1},
+                    checks = {
+                        active = {
+                            timeout = 1,
+                            http_path = "/status",
+                            healthy = {
+                                interval = 1,
+                                successes = 1,
+                            },
+                            unhealthy = {
+                                interval = 1,
+                                http_failures = 1,
+                                tcp_failures = 1,
+                                timeouts = 1,
+                            },
+                        },
+                    },
+                },
+            }))
+            assert(code < 300, body)
+
+            local httpc = http.new()
+            local res, err = httpc:request_uri(
+                "http://127.0.0.1:"; .. ngx.var.server_port .. "/hello5",
+                {method = "GET"}
+            )
+            assert(res, err)
+
+            ngx.sleep(3)
+
+            local code, body, res = 
t.test("/v1/healthcheck/routes/5/checkers", ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+
+            for _, info in ipairs(res) do
+                ngx.say(info.name, " plugin=", tostring(info.plugin),
+                        " meta=", tostring(info.meta),
+                        " type=", info.type, " nodes=", #info.nodes)
+            end
+
+            -- the single-object endpoint keeps returning exactly one checker
+            local code, body, res = t.test("/v1/healthcheck/routes/5", 
ngx.HTTP_GET)
+            assert(code == 200, body)
+            res = json.decode(res)
+            ngx.say("single: ", res.name, " nodes=", #res.nodes)
+        }
+    }
+--- response_body
+/apisix/routes/5 plugin=nil meta=nil type=http nodes=1
+single: /apisix/routes/5 nodes=1
+--- timeout: 10
+
+
+
+=== TEST 7: a resource without health checks owns an empty checker set
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+
+            local code, body = t.test("/apisix/admin/routes/6", ngx.HTTP_PUT, 
json.encode({
+                uri = "/hello6",
+                upstream = {
+                    type = "roundrobin",
+                    nodes = {["127.0.0.1:1980"] = 1},
+                },
+            }))
+            assert(code < 300, body)
+
+            local function trim(s)
+                return (tostring(s):gsub("%s+$", ""))
+            end
+
+            local code, body, res = 
t.test("/v1/healthcheck/routes/6/checkers", ngx.HTTP_GET)
+            ngx.say("no checks: ", code, " ", trim(res))
+
+            -- the single-object endpoint still reports this as an error
+            local code, body = t.test("/v1/healthcheck/routes/6", ngx.HTTP_GET)
+            ngx.say("single: ", code, " ", trim(body))
+
+            local code, body = t.test("/v1/healthcheck/routes/404/checkers", 
ngx.HTTP_GET)
+            ngx.say("missing: ", code, " ", trim(body))
+
+            local code, body = t.test("/v1/healthcheck/routes/6/nodes", 
ngx.HTTP_GET)
+            ngx.say("bad sub resource: ", code, " ", trim(body))
+        }
+    }
+--- response_body
+no checks: 200 []
+single: 404 {"error_msg":"no checker for routes[6]"}
+missing: 404 {"error_msg":"routes[404] not found"}
+bad sub resource: 400 {"error_msg":"invalid sub resource nodes"}
+--- timeout: 10
+
+
+
+=== TEST 8: a disabled plugin owns no checkers
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+
+            -- a disabled plugin is kept even when its configuration does not 
pass
+            -- check_schema(), so `instances` here is not even an array
+            local code, body = t.test("/apisix/admin/routes/7", ngx.HTTP_PUT, 
json.encode({
+                uri = "/ai7",
+                plugins = {
+                    ["ai-proxy-multi"] = {
+                        _meta = {disable = true},
+                        instances = "not an array",
+                    },
+                },
+                upstream = {
+                    type = "roundrobin",
+                    nodes = {["127.0.0.1:1980"] = 1},
+                },
+            }))
+            assert(code < 300, body)
+
+            ngx.sleep(1)
+
+            local code, body, res = t.test("/v1/healthcheck", ngx.HTTP_GET)
+            ngx.say("list: ", code)
+
+            local code, body, res = 
t.test("/v1/healthcheck/routes/7/checkers", ngx.HTTP_GET)
+            ngx.say("checkers: ", code, " ", (tostring(res):gsub("%s+$", "")))
+        }
+    }
+--- response_body
+list: 200
+checkers: 200 []
+--- timeout: 10
+
+
+
+=== TEST 9: clean up
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            for _, id in ipairs({1, 2, 3, 4, 5, 6, 7}) do
+                local code, body = t.test("/apisix/admin/routes/" .. id, 
ngx.HTTP_DELETE)
+                assert(code < 300, body)
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed

Reply via email to