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 58a4aa647 feat(ai-rate-limiting): support redis policy for shared 
counters (#13670)
58a4aa647 is described below

commit 58a4aa647fb92f0580e0cb68a70994be635b3ed3
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Mon Jul 13 11:31:46 2026 +0800

    feat(ai-rate-limiting): support redis policy for shared counters (#13670)
---
 apisix/plugins/ai-rate-limiting.lua        |  84 ++++++++---
 apisix/plugins/limit-count/init.lua        |   1 +
 docs/en/latest/plugins/ai-rate-limiting.md |  79 +++++++++++
 t/plugin/ai-rate-limiting.t                | 215 +++++++++++++++++++++++++++++
 4 files changed, 356 insertions(+), 23 deletions(-)

diff --git a/apisix/plugins/ai-rate-limiting.lua 
b/apisix/plugins/ai-rate-limiting.lua
index cdf2d9fb1..489176a63 100644
--- a/apisix/plugins/ai-rate-limiting.lua
+++ b/apisix/plugins/ai-rate-limiting.lua
@@ -25,6 +25,7 @@ local math_floor = math.floor
 local math_huge = math.huge
 local core = require("apisix.core")
 local limit_count = require("apisix.plugins.limit-count.init")
+local policy_to_additional_properties = 
limit_count.policy_to_additional_properties
 
 local plugin_name = "ai-rate-limiting"
 
@@ -90,6 +91,12 @@ local schema = {
         rejected_msg = {
             type = "string", minLength = 1
         },
+        policy = {
+            type = "string",
+            enum = {"local", "redis", "redis-cluster", "redis-sentinel"},
+            default = "local",
+        },
+        allow_degradation = {type = "boolean", default = false},
         rules = {
             type = "array",
             items = {
@@ -135,7 +142,32 @@ local schema = {
         {
             required = {"rules"},
         }
-    }
+    },
+    ["if"] = {
+        properties = {
+            policy = {
+                enum = {"redis"},
+            },
+        },
+    },
+    ["then"] = policy_to_additional_properties.redis,
+    ["else"] = {
+        ["if"] = {
+            properties = {
+                policy = {
+                    enum = {"redis-cluster"},
+                },
+            },
+        },
+        ["then"] = policy_to_additional_properties["redis-cluster"],
+        ["else"] = {
+            ["if"] = {
+                properties = { policy = { enum = { "redis-sentinel" } } },
+            },
+            ["then"] = policy_to_additional_properties["redis-sentinel"],
+        },
+    },
+    encrypt_fields = {"redis_password", "sentinel_password"},
 }
 
 local _M = {
@@ -191,43 +223,49 @@ end
 
 local function transform_limit_conf(plugin_conf, instance_conf, instance_name)
     local limit_conf = {
+        _meta = plugin_conf._meta,
         rejected_code = plugin_conf.rejected_code,
         rejected_msg = plugin_conf.rejected_msg,
         show_limit_quota_header = plugin_conf.show_limit_quota_header,
 
-        -- we may expose those fields to ai-rate-limiting later
-        policy = "local",
+        -- counters can be shared across nodes via redis policies
+        policy = plugin_conf.policy or "local",
         key_type = "constant",
-        allow_degradation = false,
+        allow_degradation = plugin_conf.allow_degradation,
         sync_interval = -1,
         limit_header = "X-AI-RateLimit-Limit",
         remaining_header = "X-AI-RateLimit-Remaining",
         reset_header = "X-AI-RateLimit-Reset",
     }
+    local name = instance_name or ""
     if plugin_conf.rules and #plugin_conf.rules > 0 then
         limit_conf.rules = plugin_conf.rules
-        limit_conf._meta = plugin_conf._meta
-        return limit_conf
+    else
+        local key = plugin_name .. "#global"
+        local limit = plugin_conf.limit
+        local time_window = plugin_conf.time_window
+        if instance_conf then
+            name = instance_conf.name
+            key = instance_conf.name
+            limit = instance_conf.limit
+            time_window = instance_conf.time_window
+        end
+        limit_conf._vid = key
+        limit_conf.key = key
+        limit_conf.count = limit
+        limit_conf.time_window = time_window
+        limit_conf.limit_header = "X-AI-RateLimit-Limit-" .. name
+        limit_conf.remaining_header = "X-AI-RateLimit-Remaining-" .. name
+        limit_conf.reset_header = "X-AI-RateLimit-Reset-" .. name
     end
 
-    local key = plugin_name .. "#global"
-    local limit = plugin_conf.limit
-    local time_window = plugin_conf.time_window
-    local name = instance_name or ""
-    if instance_conf then
-        name = instance_conf.name
-        key = instance_conf.name
-        limit = instance_conf.limit
-        time_window = instance_conf.time_window
+    -- copy the redis fields straight from the policy's schema so no field is 
missed
+    local extra = policy_to_additional_properties[plugin_conf.policy]
+    if extra then
+        for k in pairs(extra.properties) do
+            limit_conf[k] = plugin_conf[k]
+        end
     end
-    limit_conf._vid = key
-    limit_conf.key = key
-    limit_conf._meta = plugin_conf._meta
-    limit_conf.count = limit
-    limit_conf.time_window = time_window
-    limit_conf.limit_header = "X-AI-RateLimit-Limit-" .. name
-    limit_conf.remaining_header = "X-AI-RateLimit-Remaining-" .. name
-    limit_conf.reset_header = "X-AI-RateLimit-Reset-" .. name
     return limit_conf
 end
 
diff --git a/apisix/plugins/limit-count/init.lua 
b/apisix/plugins/limit-count/init.lua
index 2cdfdac85..48d93cfd9 100644
--- a/apisix/plugins/limit-count/init.lua
+++ b/apisix/plugins/limit-count/init.lua
@@ -227,6 +227,7 @@ local schema = {
 local schema_copy = core.table.deepcopy(schema)
 
 local _M = {
+    policy_to_additional_properties = policy_to_additional_properties,
     schema = schema,
     metadata_schema = metadata_schema,
 }
diff --git a/docs/en/latest/plugins/ai-rate-limiting.md 
b/docs/en/latest/plugins/ai-rate-limiting.md
index 15ec23145..acf1367a9 100644
--- a/docs/en/latest/plugins/ai-rate-limiting.md
+++ b/docs/en/latest/plugins/ai-rate-limiting.md
@@ -55,6 +55,28 @@ The `ai-rate-limiting` Plugin enforces token-based rate 
limiting for requests se
 | instances.time_window | integer | True | | >0 | The time interval 
corresponding to the rate limiting `limit` in seconds for an instance. |
 | rejected_code | integer | False | 503 | [200, 599] | The HTTP status code 
returned when a request exceeding the quota is rejected. |
 | rejected_msg | string | False | | | The response body returned when a 
request exceeding the quota is rejected. |
+| policy | string | False | local | [`local`, `redis`, `redis-cluster`, 
`redis-sentinel`] | The policy for the rate limiting counter. If it is `local`, 
the counter is stored in local memory. If it is `redis`, the counter is stored 
on a Redis instance. If it is `redis-cluster`, the counter is stored in a Redis 
cluster. If it is `redis-sentinel`, the counter is stored on the Redis master 
discovered through Sentinel. Use a Redis-based policy to share token counters 
across gateway nodes in a [...]
+| allow_degradation | boolean | False | false | | If true, allow APISIX to 
continue serving requests without the Plugin when the Redis backend is 
unavailable. |
+| redis_host | string | False | | | The address of the Redis node. Required 
when `policy` is `redis`. |
+| redis_port | integer | False | 6379 | [1,...] | The port of the Redis node 
when `policy` is `redis`. |
+| redis_username | string | False | | | The username for Redis if Redis ACL is 
used. If you use the legacy authentication method `requirepass`, configure only 
the `redis_password`. Used when `policy` is `redis` or `redis-sentinel`. |
+| redis_password | string | False | | | The password of the Redis node when 
`policy` is `redis`, `redis-cluster`, or `redis-sentinel`. |
+| redis_database | integer | False | 0 | >= 0 | The database number in Redis 
when `policy` is `redis` or `redis-sentinel`. |
+| redis_timeout | integer | False | 1000 | [1,...] | The Redis timeout value 
in milliseconds when `policy` is `redis` or `redis-cluster`. |
+| redis_ssl | boolean | False | false | | If true, use SSL to connect to Redis 
when `policy` is `redis`. |
+| redis_ssl_verify | boolean | False | false | | If true, verify the server 
SSL certificate when `policy` is `redis`. |
+| redis_cluster_nodes | array[string] | False | | | The list of Redis cluster 
nodes with at least one address. Required when `policy` is `redis-cluster`. |
+| redis_cluster_name | string | False | | | The name of the Redis cluster. 
Required when `policy` is `redis-cluster`. |
+| redis_cluster_ssl | boolean | False | false | | If true, use SSL to connect 
to Redis when `policy` is `redis-cluster`. |
+| redis_cluster_ssl_verify | boolean | False | false | | If true, verify the 
server SSL certificate when `policy` is `redis-cluster`. |
+| redis_sentinels | array[object] | False | | | The list of Sentinel nodes. 
Required when `policy` is `redis-sentinel`. Each item must contain `host` and 
`port`. |
+| redis_master_name | string | False | | | The Redis master name monitored by 
Sentinel. Required when `policy` is `redis-sentinel`. |
+| sentinel_username | string | False | | | The username for Sentinel when 
`policy` is `redis-sentinel`. |
+| sentinel_password | string | False | | | The password for Sentinel when 
`policy` is `redis-sentinel`. |
+| redis_role | string | False | master | [`master`, `slave`] | The Redis role 
selected through Sentinel when `policy` is `redis-sentinel`. |
+| redis_connect_timeout | integer | False | 1000 | [1,...] | The Redis 
connection timeout in milliseconds when `policy` is `redis-sentinel`. |
+| redis_read_timeout | integer | False | 1000 | [1,...] | The Redis read 
timeout in milliseconds when `policy` is `redis-sentinel`. |
+| redis_keepalive_timeout | integer | False | 60000 | [1,...] | The Redis 
keepalive timeout in milliseconds when `policy` is `redis-sentinel`. |
 | rules | array[object] | False | | | An array of rate-limiting rules that are 
applied sequentially. If configured, this takes precedence over `limit` and 
`time_window`. |
 | rules.count | integer or string | True | | >0 or variable expression | The 
maximum number of tokens allowed within a given time interval. Can be a static 
integer or a variable expression like `$http_custom_limit`. |
 | rules.time_window | integer or string | True | | >0 or variable expression | 
The time interval corresponding to the rate limiting `count` in seconds. Can be 
a static integer or a variable expression. |
@@ -286,6 +308,63 @@ You should receive a response similar to the following:
 
 If the rate limiting quota of 300 prompt tokens has been consumed in a 
30-second window, all additional requests will be rejected.
 
+### Share Rate Limiting Counters Across Nodes with Redis
+
+By default, the `local` policy keeps token counters in each node's shared 
memory, so counters are not shared across a multi-node deployment and the 
effective quota scales with the number of nodes. Set `policy` to `redis`, 
`redis-cluster`, or `redis-sentinel` to centralize counters and enforce a 
single quota across all nodes.
+
+The following example demonstrates how you can use the `redis` policy to share 
the rate limiting counter across gateway nodes.
+
+Create a Route as such and update with your LLM providers, models, API keys, 
endpoints, and Redis address, if applicable:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "ai-rate-limiting-route",
+    "uri": "/anything",
+    "methods": ["POST"],
+    "plugins": {
+      "ai-proxy": {
+        "provider": "openai",
+        "auth": {
+          "header": {
+            "Authorization": "Bearer '"$OPENAI_API_KEY"'"
+          }
+        },
+        "options": {
+          "model": "gpt-35-turbo-instruct",
+          "max_tokens": 512,
+          "temperature": 1.0
+        }
+      },
+      "ai-rate-limiting": {
+        "limit": 300,
+        "time_window": 30,
+        "limit_strategy": "total_tokens",
+        "policy": "redis",
+        "redis_host": "127.0.0.1",
+        "redis_port": 6379,
+        "redis_database": 1
+      }
+    }
+  }'
+```
+
+Send a POST request to the Route with a sample question in the request body 
from any node:
+
+```shell
+curl "http://127.0.0.1:9080/anything"; -X POST \
+  -H "Content-Type: application/json" \
+  -d '{
+    "messages": [
+      { "role": "system", "content": "You are a mathematician" },
+      { "role": "user", "content": "What is 1+1?" }
+    ]
+  }'
+```
+
+The consumed tokens are counted against the shared Redis counter. Once the 
quota of 300 tokens is consumed in a 30-second window, additional requests are 
rejected on every node.
+
 ### Rate Limit One Instance Among Multiple
 
 The following example demonstrates how you can use `ai-proxy-multi` to 
configure two models for load balancing, forwarding 80% of the traffic to one 
instance and 20% to the other. Additionally, use `ai-rate-limiting` to 
configure token-based rate limiting on the instance that receives 80% of the 
traffic, such that when the configured quota is fully consumed, the additional 
traffic will be forwarded to the other instance.
diff --git a/t/plugin/ai-rate-limiting.t b/t/plugin/ai-rate-limiting.t
index 4ca49b5e9..9fcadbc1c 100644
--- a/t/plugin/ai-rate-limiting.t
+++ b/t/plugin/ai-rate-limiting.t
@@ -1538,3 +1538,218 @@ X-AI-Fixture: openai/chat-model-echo.json
 --- error_code: 200
 --- no_error_log
 [error]
+
+
+
+=== TEST 35: schema check, redis policy requires redis_host
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-rate-limiting")
+            local ok, err = plugin.check_schema({
+                limit = 30,
+                time_window = 60,
+                policy = "redis",
+            })
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("passed")
+            end
+        }
+    }
+--- response_body
+then clause did not match
+
+
+
+=== TEST 36: flush redis and set route with redis policy
+--- config
+    location /t {
+        content_by_lua_block {
+            local redis = require("resty.redis")
+            local red = redis:new()
+            red:set_timeout(1000)
+            local ok, err = red:connect("127.0.0.1", 6379)
+            if not ok then
+                ngx.say("failed to connect: ", err)
+                return
+            end
+            red:flushall()
+
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "uri": "/ai",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer token"
+                                }
+                            },
+                            "options": {
+                                "model": "gpt-35-turbo-instruct",
+                                "max_tokens": 512,
+                                "temperature": 1.0
+                            },
+                            "override": {
+                                "endpoint": "http://127.0.0.1:1980";
+                            },
+                            "ssl_verify": false
+                        },
+                        "ai-rate-limiting": {
+                            "limit": 30,
+                            "time_window": 60,
+                            "policy": "redis",
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "redis_database": 1
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "canbeanything.com": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 37: redis policy shares counter and rejects the 4th request
+--- pipelined_requests eval
+[
+    "POST /ai\n" . "{ \"messages\": [ { \"role\": \"system\", \"content\": 
\"You are a mathematician\" }, { \"role\": \"user\", \"content\": \"What is 
1+1?\"} ] }",
+    "POST /ai\n" . "{ \"messages\": [ { \"role\": \"system\", \"content\": 
\"You are a mathematician\" }, { \"role\": \"user\", \"content\": \"What is 
1+1?\"} ] }",
+    "POST /ai\n" . "{ \"messages\": [ { \"role\": \"system\", \"content\": 
\"You are a mathematician\" }, { \"role\": \"user\", \"content\": \"What is 
1+1?\"} ] }",
+    "POST /ai\n" . "{ \"messages\": [ { \"role\": \"system\", \"content\": 
\"You are a mathematician\" }, { \"role\": \"user\", \"content\": \"What is 
1+1?\"} ] }",
+]
+--- more_headers
+Authorization: Bearer token
+X-AI-Fixture: openai/chat-model-echo.json
+--- error_code eval
+[200, 200, 200, 503]
+
+
+
+=== TEST 38: counter is stored in redis, not local memory
+--- config
+    location /t {
+        content_by_lua_block {
+            local redis = require("resty.redis")
+            local red = redis:new()
+            red:set_timeout(1000)
+            local ok, err = red:connect("127.0.0.1", 6379)
+            if not ok then
+                ngx.say("failed to connect: ", err)
+                return
+            end
+            red:select(1)
+            local keys, err = red:keys("*ai-rate-limiting*")
+            if not keys then
+                ngx.say("failed to get keys: ", err)
+                return
+            end
+            ngx.say(#keys > 0 and "counter in redis" or "no counter")
+        }
+    }
+--- response_body
+counter in redis
+
+
+
+=== TEST 39: schema check, redis-sentinel accepts redis master auth
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-rate-limiting")
+            local ok, err = plugin.check_schema({
+                limit = 30,
+                time_window = 60,
+                policy = "redis-sentinel",
+                redis_sentinels = {
+                    { host = "127.0.0.1", port = 26379 },
+                },
+                redis_master_name = "mymaster",
+                redis_username = "alice",
+                redis_password = "somepassword",
+                sentinel_username = "bob",
+                sentinel_password = "sentinelpass",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 40: redis_password is encrypted at rest
+--- yaml_config
+apisix:
+    data_encryption:
+        enable_encrypt_fields: true
+        keyring:
+            - edd1c9f0985e76a2
+--- config
+    location /t {
+        content_by_lua_block {
+            local json = require("toolkit.json")
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/ai",
+                    "plugins": {
+                        "ai-rate-limiting": {
+                            "limit": 30,
+                            "time_window": 60,
+                            "policy": "redis",
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "redis_password": "somepassword"
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "canbeanything.com": 1
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+            ngx.sleep(0.1)
+
+            -- admin api returns the decrypted password
+            local code, _, res = t('/apisix/admin/routes/1', ngx.HTTP_GET)
+            res = json.decode(res)
+            ngx.say(res.value.plugins["ai-rate-limiting"].redis_password)
+
+            -- etcd stores the encrypted password
+            local etcd = require("apisix.core.etcd")
+            local res = assert(etcd.get('/routes/1'))
+            local stored = 
res.body.node.value.plugins["ai-rate-limiting"].redis_password
+            ngx.say(stored ~= "somepassword" and "encrypted" or "plaintext")
+        }
+    }
+--- response_body
+somepassword
+encrypted

Reply via email to