Copilot commented on code in PR #13191:
URL: https://github.com/apache/apisix/pull/13191#discussion_r3057744723


##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -136,8 +149,42 @@ local limit_conf_cache = core.lrucache.new({
 })
 
 
+-- safe math functions allowed in cost expressions
+local expr_safe_env = {
+    math = math,
+    abs = math.abs,
+    ceil = math.ceil,
+    floor = math.floor,
+    max = math.max,
+    min = math.min,
+}

Review Comment:
   The runtime env allows upstream-provided usage keys to overwrite 
reserved/safe names (e.g., a usage field named `math`/`abs` would shadow the 
safe functions), which can break evaluation and potentially bypass rate 
limiting. Consider preventing `raw` keys from overwriting names present in 
`expr_safe_env` (skip those keys), and avoid exposing the full `math` table if 
the intent is to allow only a small whitelist (e.g., provide a minimal `math` 
table with only `abs/ceil/floor/max/min`).



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr
+    -- build environment: safe math + usage variables (missing vars default to 
0)
+    local env = setmetatable({}, {
+        __index = function(_, k)
+            local v = expr_safe_env[k]
+            if v ~= nil then
+                return v
+            end
+            return 0
+        end
+    })
+    for k, v in pairs(raw) do
+        if type(v) == "number" then

Review Comment:
   The runtime env allows upstream-provided usage keys to overwrite 
reserved/safe names (e.g., a usage field named `math`/`abs` would shadow the 
safe functions), which can break evaluation and potentially bypass rate 
limiting. Consider preventing `raw` keys from overwriting names present in 
`expr_safe_env` (skip those keys), and avoid exposing the full `math` table if 
the intent is to allow only a small whitelist (e.g., provide a minimal `math` 
table with only `abs/ceil/floor/max/min`).
   ```suggestion
       local safe_math = {
           abs = math.abs,
           ceil = math.ceil,
           floor = math.floor,
           max = math.max,
           min = math.min,
       }
       local reserved_names = {
           math = true,
           abs = true,
           ceil = true,
           floor = true,
           max = true,
           min = true,
       }
       -- build environment: safe math + usage variables (missing vars default 
to 0)
       local env = setmetatable({}, {
           __index = function(_, k)
               if k == "math" then
                   return safe_math
               end
               local v = safe_math[k]
               if v ~= nil then
                   return v
               end
               v = expr_safe_env[k]
               if v ~= nil then
                   return v
               end
               return 0
           end
       })
       for k, v in pairs(raw) do
           if type(v) == "number" and not reserved_names[k] and 
expr_safe_env[k] == nil then
   ```



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr
+    -- build environment: safe math + usage variables (missing vars default to 
0)
+    local env = setmetatable({}, {
+        __index = function(_, k)
+            local v = expr_safe_env[k]
+            if v ~= nil then
+                return v
+            end
+            return 0
+        end
+    })
+    for k, v in pairs(raw) do
+        if type(v) == "number" then
+            env[k] = v
+        end
+    end
+    local fn, err = load(fn_code, "cost_expr", "t", env)
+    if not fn then
+        return nil, "failed to compile cost_expr: " .. err
+    end
+    local ok, result = pcall(fn)

Review Comment:
   The expression is recompiled (`load`) on every request. This adds avoidable 
overhead in a hot path. Consider caching the compiled function keyed by 
`conf.cost_expr` (e.g., via an `lrucache`) and only rebuilding the per-request 
variable environment (or setting a per-call env via `setfenv`/equivalent) while 
reusing the compiled chunk.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr

Review Comment:
   The expression is recompiled (`load`) on every request. This adds avoidable 
overhead in a hot path. Consider caching the compiled function keyed by 
`conf.cost_expr` (e.g., via an `lrucache`) and only rebuilding the per-request 
variable environment (or setting a per-call env via `setfenv`/equivalent) while 
reusing the compiled chunk.



##########
t/plugin/ai-rate-limiting-expression.t:
##########
@@ -0,0 +1,551 @@
+#
+# 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.
+#
+
+BEGIN {
+    $ENV{TEST_ENABLE_CONTROL_API_V1} = "0";
+}
+
+use t::APISIX 'no_plan';
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+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 anthropic;
+            listen 16725;
+
+            default_type 'application/json';
+
+            location /v1/messages {
+                content_by_lua_block {
+                    local json = require("cjson.safe")
+                    local ngx = ngx
+
+                    ngx.req.read_body()
+                    local body = ngx.req.get_body_data()
+                    body = json.decode(body)
+
+                    if not body or not body.messages then
+                        ngx.status = 400
+                        
ngx.say('{"type":"error","error":{"type":"invalid_request_error","message":"missing
 messages"}}')
+                        return
+                    end
+
+                    local api_key = ngx.req.get_headers()["x-api-key"]
+                    if api_key ~= "test-key" then
+                        ngx.status = 401
+                        
ngx.say('{"type":"error","error":{"type":"authentication_error","message":"invalid
 x-api-key"}}')
+                        return
+                    end
+
+                    if body.stream then
+                        ngx.header["Content-Type"] = "text/event-stream"
+
+                        -- message_start with input_tokens and cache tokens
+                        local message_start = json.encode({
+                            type = "message_start",
+                            message = {
+                                id = "msg_test123",
+                                type = "message",
+                                role = "assistant",
+                                model = body.model or 
"claude-sonnet-4-20250514",
+                                content = {},
+                                usage = {
+                                    input_tokens = 50,
+                                    output_tokens = 0,
+                                    cache_creation_input_tokens = 100,
+                                    cache_read_input_tokens = 200,
+                                },
+                            },
+                        })
+                        ngx.say("event: message_start")
+                        ngx.say("data: " .. message_start)
+                        ngx.say("")
+
+                        -- content_block_start
+                        ngx.say("event: content_block_start")
+                        ngx.say('data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}')
+                        ngx.say("")
+
+                        -- content_block_delta
+                        ngx.say("event: content_block_delta")
+                        ngx.say('data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello
 from Claude!"}}')
+                        ngx.say("")
+
+                        -- content_block_stop
+                        ngx.say("event: content_block_stop")
+                        ngx.say('data: 
{"type":"content_block_stop","index":0}')
+                        ngx.say("")
+
+                        -- message_delta with output_tokens
+                        local message_delta = json.encode({
+                            type = "message_delta",
+                            delta = { stop_reason = "end_turn" },
+                            usage = {
+                                output_tokens = 30,
+                            },
+                        })
+                        ngx.say("event: message_delta")
+                        ngx.say("data: " .. message_delta)
+                        ngx.say("")
+
+                        -- message_stop
+                        ngx.say("event: message_stop")
+                        ngx.say("data: {}")
+                        ngx.say("")
+                    else
+                        ngx.status = 200
+                        ngx.say(json.encode({
+                            id = "msg_test456",
+                            type = "message",
+                            role = "assistant",
+                            model = body.model or "claude-sonnet-4-20250514",
+                            content = {{
+                                type = "text",
+                                text = "Hello from Claude!",
+                            }},
+                            stop_reason = "end_turn",
+                            usage = {
+                                input_tokens = 50,
+                                output_tokens = 30,
+                                cache_creation_input_tokens = 100,
+                                cache_read_input_tokens = 200,
+                            },
+                        }))
+                    end
+                }
+            }
+        }
+_EOC_
+
+    $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: schema validation - expression strategy requires cost_expr
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-rate-limiting")
+            local configs = {
+                -- expression without cost_expr
+                {
+                    limit = 100,
+                    time_window = 60,
+                    limit_strategy = "expression",
+                },
+                -- expression with empty cost_expr
+                {
+                    limit = 100,
+                    time_window = 60,
+                    limit_strategy = "expression",
+                    cost_expr = "",
+                },
+                -- expression with invalid cost_expr syntax
+                {
+                    limit = 100,
+                    time_window = 60,
+                    limit_strategy = "expression",
+                    cost_expr = "invalid $$$ syntax %%%",
+                },
+                -- valid expression
+                {
+                    limit = 100,
+                    time_window = 60,
+                    limit_strategy = "expression",
+                    cost_expr = "input_tokens + output_tokens",
+                },
+                -- valid complex expression
+                {
+                    limit = 100,
+                    time_window = 60,
+                    limit_strategy = "expression",
+                    cost_expr = "(input_tokens - cache_read_input_tokens) + 
cache_creation_input_tokens * 1.25 + output_tokens",
+                },

Review Comment:
   Given expressions can subtract fields, it would be useful to add a test case 
where the expression evaluates to a negative value (e.g., `input_tokens - 
cache_read_input_tokens` with cache_read > input) and assert the intended 
behavior (clamp to 0 vs reject vs treat as error). This will lock in the safety 
behavior and prevent future bypasses.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)

Review Comment:
   Using `load()` on an arbitrary expression string allows constructs that are 
not 'arithmetic' (e.g., `(function() while true do end end)()`), which can hang 
a worker or consume excessive CPU/memory if configured incorrectly. If the 
feature is intended to support arithmetic only, add a strict validator at 
config-time (e.g., deny Lua keywords like `function/while/for/repeat/do/end`, 
quotes, braces, semicolons, etc., and allow only 
identifiers/numbers/operators/parentheses/whitespace) before calling `load()`.
   ```suggestion
   
   local function validate_cost_expr(conf_cost_expr)
       if type(conf_cost_expr) ~= "string" or conf_cost_expr == "" then
           return nil, "cost_expr must be a non-empty string"
       end
   
       -- Allow only arithmetic-style expressions: identifiers, numbers,
       -- arithmetic operators, parentheses, commas, decimal points and 
whitespace.
       if conf_cost_expr:find("[\"'%[%]{};:=]") then
           return nil, "cost_expr contains disallowed syntax"
       end
   
       if conf_cost_expr:find("%-%-") or conf_cost_expr:find("%.%.") then
           return nil, "cost_expr contains disallowed token sequence"
       end
   
       if conf_cost_expr:find("[^%w_%s%+%-%*%/%^%%%(%),%.]") then
           return nil, "cost_expr contains invalid characters"
       end
   
       local reserved_words = {
           "and", "break", "do", "else", "elseif", "end", "false", "for",
           "function", "goto", "if", "in", "local", "nil", "not", "or",
           "repeat", "return", "then", "true", "until", "while"
       }
   
       for _, word in ipairs(reserved_words) do
           if conf_cost_expr:find("%f[%a_]" .. word .. "%f[^%w_]") then
               return nil, "cost_expr contains disallowed keyword: " .. word
           end
       end
   
       local depth = 0
       for i = 1, #conf_cost_expr do
           local ch = conf_cost_expr:sub(i, i)
           if ch == "(" then
               depth = depth + 1
           elseif ch == ")" then
               depth = depth - 1
               if depth < 0 then
                   return nil, "cost_expr has unbalanced parentheses"
               end
           end
       end
   
       if depth ~= 0 then
           return nil, "cost_expr has unbalanced parentheses"
       end
   
       return true
   end
   
   local function eval_cost_expr(conf_cost_expr, raw)
       local ok, err = validate_cost_expr(conf_cost_expr)
       if not ok then
           return nil, "invalid cost_expr: " .. err
       end
   ```



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr
+    -- build environment: safe math + usage variables (missing vars default to 
0)
+    local env = setmetatable({}, {
+        __index = function(_, k)
+            local v = expr_safe_env[k]
+            if v ~= nil then
+                return v
+            end
+            return 0
+        end
+    })
+    for k, v in pairs(raw) do
+        if type(v) == "number" then
+            env[k] = v
+        end
+    end
+    local fn, err = load(fn_code, "cost_expr", "t", env)

Review Comment:
   Using `load()` on an arbitrary expression string allows constructs that are 
not 'arithmetic' (e.g., `(function() while true do end end)()`), which can hang 
a worker or consume excessive CPU/memory if configured incorrectly. If the 
feature is intended to support arithmetic only, add a strict validator at 
config-time (e.g., deny Lua keywords like `function/while/for/repeat/do/end`, 
quotes, braces, semicolons, etc., and allow only 
identifiers/numbers/operators/parentheses/whitespace) before calling `load()`.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -136,8 +149,42 @@ local limit_conf_cache = core.lrucache.new({
 })
 
 
+-- safe math functions allowed in cost expressions
+local expr_safe_env = {
+    math = math,
+    abs = math.abs,
+    ceil = math.ceil,
+    floor = math.floor,
+    max = math.max,
+    min = math.min,
+}
+
+local function compile_cost_expr(expr_str)
+    local fn_code = "return " .. expr_str
+    -- validate syntax by loading first
+    local fn, err = load(fn_code, "cost_expr", "t", expr_safe_env)
+    if not fn then
+        return nil, err

Review Comment:
   Using `load()` on an arbitrary expression string allows constructs that are 
not 'arithmetic' (e.g., `(function() while true do end end)()`), which can hang 
a worker or consume excessive CPU/memory if configured incorrectly. If the 
feature is intended to support arithmetic only, add a strict validator at 
config-time (e.g., deny Lua keywords like `function/while/for/repeat/do/end`, 
quotes, braces, semicolons, etc., and allow only 
identifiers/numbers/operators/parentheses/whitespace) before calling `load()`.
   ```suggestion
   local cost_expr_disallowed_keywords = {
       "and", "break", "do", "else", "elseif", "end", "false", "for",
       "function", "goto", "if", "in", "local", "nil", "not", "or",
       "repeat", "return", "then", "true", "until", "while",
   }
   
   local function validate_cost_expr(expr_str)
       if type(expr_str) ~= "string" or expr_str == "" then
           return nil, "cost_expr must be a non-empty string"
       end
   
       if expr_str:find("[\"'%[%]{};:]") then
           return nil, "cost_expr contains disallowed characters"
       end
   
       if expr_str:find("%-%-") or expr_str:find("%.%.") then
           return nil, "cost_expr contains disallowed tokens"
       end
   
       if expr_str:find("[^%w_%s%+%-%*/%%%^%(%)%,%.]") then
           return nil, "cost_expr may only contain identifiers, numbers, 
arithmetic operators, parentheses, commas, dots, and whitespace"
       end
   
       for _, keyword in ipairs(cost_expr_disallowed_keywords) do
           if expr_str:find("%f[%a_]" .. keyword .. "%f[^%a_]") then
               return nil, "cost_expr contains disallowed keyword: " .. keyword
           end
       end
   
       return true
   end
   
   local function compile_cost_expr(expr_str)
       local ok, err = validate_cost_expr(expr_str)
       if not ok then
           return nil, err
       end
   
       local fn_code = "return " .. expr_str
       -- validate syntax after enforcing the arithmetic-only subset
       local fn, load_err = load(fn_code, "cost_expr", "t", expr_safe_env)
       if not fn then
           return nil, load_err
   ```



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr
+    -- build environment: safe math + usage variables (missing vars default to 
0)
+    local env = setmetatable({}, {
+        __index = function(_, k)
+            local v = expr_safe_env[k]
+            if v ~= nil then
+                return v
+            end
+            return 0
+        end
+    })
+    for k, v in pairs(raw) do
+        if type(v) == "number" then
+            env[k] = v
+        end
+    end
+    local fn, err = load(fn_code, "cost_expr", "t", env)
+    if not fn then
+        return nil, "failed to compile cost_expr: " .. err
+    end
+    local ok, result = pcall(fn)
+    if not ok then
+        return nil, "failed to evaluate cost_expr: " .. result
+    end
+    if type(result) ~= "number" then
+        return nil, "cost_expr must return a number, got: " .. type(result)
+    end
+    return math_floor(result + 0.5)

Review Comment:
   The expression result is accepted as any Lua number, including negative 
values and non-finite values (NaN/inf). A negative cost can effectively 
'credit' tokens back (depending on how the counter is applied), and NaN/inf can 
break downstream logic. Consider enforcing `result` to be finite and >= 0 
(clamp or treat as an error), and document/choose a rounding strategy that 
avoids undercounting (often `ceil` is safer for rate limiting when fractional 
weights are allowed).



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -61,10 +65,19 @@ local schema = {
         show_limit_quota_header = {type = "boolean", default = true},
         limit_strategy = {
             type = "string",
-            enum = {"total_tokens", "prompt_tokens", "completion_tokens"},
+            enum = {"total_tokens", "prompt_tokens", "completion_tokens", 
"expression"},
             default = "total_tokens",
             description = "The strategy to limit the tokens"
         },
+        cost_expr = {
+            type = "string",
+            minLength = 1,
+            description = "Lua arithmetic expression for dynamic token cost 
calculation. "
+                .. "Variables are injected from the LLM API raw usage response 
fields. "
+                .. "Missing variables default to 0. "
+                .. "Only valid when limit_strategy is 'expression'. "
+                .. "Example: input_tokens + cache_creation_input_tokens + 
output_tokens",
+        },

Review Comment:
   `cost_expr` is described as 'Only valid when limit_strategy is expression', 
but the schema/check currently does not reject configs that set `cost_expr` 
with a different `limit_strategy`. Consider explicitly rejecting `cost_expr` 
unless `limit_strategy == 'expression'` to avoid silent 
misconfiguration/confusion.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -136,8 +149,42 @@ local limit_conf_cache = core.lrucache.new({
 })
 
 
+-- safe math functions allowed in cost expressions
+local expr_safe_env = {
+    math = math,
+    abs = math.abs,
+    ceil = math.ceil,
+    floor = math.floor,
+    max = math.max,
+    min = math.min,
+}
+
+local function compile_cost_expr(expr_str)
+    local fn_code = "return " .. expr_str
+    -- validate syntax by loading first
+    local fn, err = load(fn_code, "cost_expr", "t", expr_safe_env)
+    if not fn then
+        return nil, err
+    end
+    return fn_code
+end
+
+
 function _M.check_schema(conf)

Review Comment:
   `cost_expr` is described as 'Only valid when limit_strategy is expression', 
but the schema/check currently does not reject configs that set `cost_expr` 
with a different `limit_strategy`. Consider explicitly rejecting `cost_expr` 
unless `limit_strategy == 'expression'` to avoid silent 
misconfiguration/confusion.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -264,7 +311,51 @@ function _M.check_instance_status(conf, ctx, instance_name)
 end
 
 
+local function eval_cost_expr(conf_cost_expr, raw)
+    local fn_code = "return " .. conf_cost_expr
+    -- build environment: safe math + usage variables (missing vars default to 
0)
+    local env = setmetatable({}, {
+        __index = function(_, k)
+            local v = expr_safe_env[k]
+            if v ~= nil then
+                return v
+            end
+            return 0
+        end
+    })
+    for k, v in pairs(raw) do
+        if type(v) == "number" then
+            env[k] = v
+        end
+    end
+    local fn, err = load(fn_code, "cost_expr", "t", env)
+    if not fn then
+        return nil, "failed to compile cost_expr: " .. err
+    end
+    local ok, result = pcall(fn)

Review Comment:
   Using `load()` on an arbitrary expression string allows constructs that are 
not 'arithmetic' (e.g., `(function() while true do end end)()`), which can hang 
a worker or consume excessive CPU/memory if configured incorrectly. If the 
feature is intended to support arithmetic only, add a strict validator at 
config-time (e.g., deny Lua keywords like `function/while/for/repeat/do/end`, 
quotes, braces, semicolons, etc., and allow only 
identifiers/numbers/operators/parentheses/whitespace) before calling `load()`.



##########
apisix/plugins/ai-rate-limiting.lua:
##########
@@ -136,8 +149,42 @@ local limit_conf_cache = core.lrucache.new({
 })
 
 
+-- safe math functions allowed in cost expressions
+local expr_safe_env = {
+    math = math,
+    abs = math.abs,
+    ceil = math.ceil,
+    floor = math.floor,
+    max = math.max,
+    min = math.min,
+}
+
+local function compile_cost_expr(expr_str)
+    local fn_code = "return " .. expr_str
+    -- validate syntax by loading first
+    local fn, err = load(fn_code, "cost_expr", "t", expr_safe_env)
+    if not fn then
+        return nil, err
+    end
+    return fn_code
+end
+
+
 function _M.check_schema(conf)
-    return core.schema.check(schema, conf)
+    local ok, err = core.schema.check(schema, conf)
+    if not ok then
+        return false, err
+    end
+    if conf.limit_strategy == "expression" then

Review Comment:
   `cost_expr` is described as 'Only valid when limit_strategy is expression', 
but the schema/check currently does not reject configs that set `cost_expr` 
with a different `limit_strategy`. Consider explicitly rejecting `cost_expr` 
unless `limit_strategy == 'expression'` to avoid silent 
misconfiguration/confusion.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to