Skip to content

Commit bf9091a

Browse files
authored
feat(ai-proxy): add built-in nginx variables for LLM observability (#13477)
1 parent 418a8c6 commit bf9091a

16 files changed

Lines changed: 596 additions & 33 deletions

apisix/cli/ngx_tpl.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,14 @@ http {
830830
set $llm_model '';
831831
set $llm_prompt_tokens '0';
832832
set $llm_completion_tokens '0';
833+
set $llm_total_tokens '0';
834+
set $llm_stream 'false';
835+
set $llm_has_tool_calls 'false';
836+
set $llm_tool_count '0';
837+
set $llm_end_user_id '';
838+
set $llm_cache_read_input_tokens '0';
839+
set $llm_cache_creation_input_tokens '0';
840+
set $llm_reasoning_tokens '0';
833841
834842
835843
{% if use_apisix_base then %}

apisix/core/ctx.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ do
204204
llm_model = true,
205205
llm_prompt_tokens = true,
206206
llm_completion_tokens = true,
207+
llm_total_tokens = true,
208+
llm_stream = true,
209+
llm_has_tool_calls = true,
210+
llm_tool_count = true,
211+
llm_end_user_id = true,
212+
llm_cache_read_input_tokens = true,
213+
llm_cache_creation_input_tokens = true,
214+
llm_reasoning_tokens = true,
207215

208216
upstream_mirror_host = true,
209217
upstream_mirror_uri = true,

apisix/plugins/ai-protocols/anthropic-messages.lua

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ function _M.parse_sse_event(event, ctx, state)
6666
end
6767
return { type = "skip" }
6868

69+
elseif event.type == "content_block_start" then
70+
local data = core.json.decode(event.data, { null_as_nil = true })
71+
if data and type(data.content_block) == "table"
72+
and data.content_block.type == "tool_use" then
73+
return { type = "skip", has_tool_call = true }
74+
end
75+
return { type = "skip" }
76+
6977
elseif event.type == "message_delta" then
7078
local data, err = core.json.decode(event.data, { null_as_nil = true })
7179
if not data then
@@ -102,6 +110,9 @@ function _M.parse_sse_event(event, ctx, state)
102110
prompt_tokens = usage.input_tokens or 0,
103111
completion_tokens = usage.output_tokens or 0,
104112
total_tokens = (usage.input_tokens or 0) + (usage.output_tokens or 0),
113+
cache_read_input_tokens = usage.cache_read_input_tokens or 0,
114+
cache_creation_input_tokens = usage.cache_creation_input_tokens or 0,
115+
reasoning_tokens = 0,
105116
},
106117
raw_usage = usage,
107118
}
@@ -169,10 +180,40 @@ function _M.extract_usage(res_body)
169180
prompt_tokens = prompt,
170181
completion_tokens = completion,
171182
total_tokens = prompt + completion,
183+
cache_read_input_tokens = raw.cache_read_input_tokens or 0,
184+
cache_creation_input_tokens = raw.cache_creation_input_tokens or 0,
185+
reasoning_tokens = 0,
172186
}, raw
173187
end
174188

175189

190+
--- Detect whether a non-streaming response contains tool calls.
191+
function _M.has_tool_call(res_body)
192+
if type(res_body) ~= "table" or type(res_body.content) ~= "table" then
193+
return false
194+
end
195+
for _, block in ipairs(res_body.content) do
196+
if type(block) == "table" and block.type == "tool_use" then
197+
return true
198+
end
199+
end
200+
return false
201+
end
202+
203+
204+
--- Extract the end-user identifier from a request body.
205+
function _M.extract_end_user_id(body)
206+
if type(body) ~= "table" then
207+
return nil
208+
end
209+
local meta = body.metadata
210+
if type(meta) == "table" and type(meta.user_id) == "string" then
211+
return meta.user_id
212+
end
213+
return nil
214+
end
215+
216+
176217
--- Extract all text content from a request body for moderation.
177218
function _M.extract_request_content(body)
178219
local contents = {}

apisix/plugins/ai-protocols/openai-chat.lua

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,18 @@ function _M.parse_sse_event(event, ctx, state)
7979

8080
local result = { type = "delta", data = data }
8181

82-
-- Extract text content from choices
82+
-- Extract text content and detect tool calls from choices
8383
if type(data.choices) == "table" and #data.choices > 0 then
8484
local texts = {}
8585
for _, choice in ipairs(data.choices) do
86-
if type(choice) == "table"
87-
and type(choice.delta) == "table"
88-
and type(choice.delta.content) == "string" then
89-
core.table.insert(texts, choice.delta.content)
86+
if type(choice) == "table" and type(choice.delta) == "table" then
87+
if type(choice.delta.content) == "string" then
88+
core.table.insert(texts, choice.delta.content)
89+
end
90+
if type(choice.delta.tool_calls) == "table"
91+
and #choice.delta.tool_calls > 0 then
92+
result.has_tool_call = true
93+
end
9094
end
9195
end
9296
if #texts > 0 then
@@ -96,13 +100,20 @@ function _M.parse_sse_event(event, ctx, state)
96100

97101
-- Extract usage (null for non-final chunks; cjson decodes null as userdata)
98102
if type(data.usage) == "table" then
103+
local u = data.usage
104+
local pd = type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details
105+
local cd = type(u.completion_tokens_details) == "table" and u.completion_tokens_details
99106
result.type = "usage"
100107
result.usage = {
101-
prompt_tokens = data.usage.prompt_tokens or 0,
102-
completion_tokens = data.usage.completion_tokens or 0,
103-
total_tokens = data.usage.total_tokens or 0,
108+
prompt_tokens = u.prompt_tokens or 0,
109+
completion_tokens = u.completion_tokens or 0,
110+
total_tokens = u.total_tokens or 0,
111+
cache_read_input_tokens = pd and pd.cached_tokens
112+
or u.prompt_cache_hit_tokens or 0,
113+
cache_creation_input_tokens = pd and pd.cache_creation_input_tokens or 0,
114+
reasoning_tokens = cd and cd.reasoning_tokens or 0,
104115
}
105-
result.raw_usage = data.usage
116+
result.raw_usage = u
106117
end
107118

108119
return result
@@ -160,14 +171,53 @@ function _M.extract_usage(res_body)
160171
return nil, nil
161172
end
162173
local raw = res_body.usage
174+
local pdetails = type(raw.prompt_tokens_details) == "table" and raw.prompt_tokens_details
175+
local cdetails = type(raw.completion_tokens_details) == "table"
176+
and raw.completion_tokens_details
177+
-- OpenAI uses prompt_tokens_details.cached_tokens; DeepSeek uses prompt_cache_hit_tokens
178+
local cache_read = pdetails and pdetails.cached_tokens or raw.prompt_cache_hit_tokens or 0
163179
return {
164180
prompt_tokens = raw.prompt_tokens or 0,
165181
completion_tokens = raw.completion_tokens or 0,
166182
total_tokens = raw.total_tokens or (raw.prompt_tokens or 0) + (raw.completion_tokens or 0),
183+
cache_read_input_tokens = cache_read,
184+
cache_creation_input_tokens = pdetails and pdetails.cache_creation_input_tokens or 0,
185+
reasoning_tokens = cdetails and cdetails.reasoning_tokens or 0,
167186
}, raw
168187
end
169188

170189

190+
--- Detect whether a non-streaming response contains tool calls.
191+
function _M.has_tool_call(res_body)
192+
if type(res_body) ~= "table" or type(res_body.choices) ~= "table" then
193+
return false
194+
end
195+
for _, choice in ipairs(res_body.choices) do
196+
if type(choice) == "table" and type(choice.message) == "table"
197+
and type(choice.message.tool_calls) == "table"
198+
and #choice.message.tool_calls > 0 then
199+
return true
200+
end
201+
end
202+
return false
203+
end
204+
205+
206+
--- Extract the end-user identifier from a request body.
207+
function _M.extract_end_user_id(body)
208+
if type(body) ~= "table" then
209+
return nil
210+
end
211+
if type(body.safety_identifier) == "string" then
212+
return body.safety_identifier
213+
end
214+
if type(body.user) == "string" then
215+
return body.user
216+
end
217+
return nil
218+
end
219+
220+
171221
--- Extract all text content from a request body for moderation.
172222
function _M.extract_request_content(body)
173223
local contents = {}

apisix/plugins/ai-protocols/openai-responses.lua

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,30 @@ function _M.parse_sse_event(event, ctx, state)
6868
core.log.warn("failed to decode response.completed SSE data: ", err)
6969
return result
7070
end
71-
if type(data.response) == "table"
72-
and type(data.response.usage) == "table" then
73-
local usage = data.response.usage
74-
result.type = "usage_and_done"
75-
result.usage = {
76-
prompt_tokens = usage.input_tokens or 0,
77-
completion_tokens = usage.output_tokens or 0,
78-
total_tokens = usage.total_tokens or 0,
79-
}
80-
result.raw_usage = usage
71+
if type(data.response) == "table" then
72+
local resp = data.response
73+
if type(resp.usage) == "table" then
74+
local usage = resp.usage
75+
result.type = "usage_and_done"
76+
result.usage = {
77+
prompt_tokens = usage.input_tokens or 0,
78+
completion_tokens = usage.output_tokens or 0,
79+
total_tokens = usage.total_tokens or 0,
80+
cache_read_input_tokens = type(usage.input_tokens_details) == "table"
81+
and usage.input_tokens_details.cached_tokens or 0,
82+
reasoning_tokens = type(usage.output_tokens_details) == "table"
83+
and usage.output_tokens_details.reasoning_tokens or 0,
84+
}
85+
result.raw_usage = usage
86+
end
87+
if type(resp.output) == "table" then
88+
for _, item in ipairs(resp.output) do
89+
if type(item) == "table" and item.type == "function_call" then
90+
result.has_tool_call = true
91+
break
92+
end
93+
end
94+
end
8195
end
8296
return result
8397

@@ -135,17 +149,50 @@ function _M.extract_usage(res_body)
135149
return nil, nil
136150
end
137151
local raw = res_body.usage
138-
-- Responses API uses input_tokens / output_tokens
152+
local idetails = type(raw.input_tokens_details) == "table" and raw.input_tokens_details
153+
local odetails = type(raw.output_tokens_details) == "table" and raw.output_tokens_details
139154
local prompt = raw.input_tokens or 0
140155
local completion = raw.output_tokens or 0
141156
return {
142157
prompt_tokens = prompt,
143158
completion_tokens = completion,
144159
total_tokens = raw.total_tokens or (prompt + completion),
160+
cache_read_input_tokens = idetails and idetails.cached_tokens or 0,
161+
cache_creation_input_tokens = 0,
162+
reasoning_tokens = odetails and odetails.reasoning_tokens or 0,
145163
}, raw
146164
end
147165

148166

167+
--- Detect whether a non-streaming response contains tool calls.
168+
function _M.has_tool_call(res_body)
169+
if type(res_body) ~= "table" or type(res_body.output) ~= "table" then
170+
return false
171+
end
172+
for _, item in ipairs(res_body.output) do
173+
if type(item) == "table" and item.type == "function_call" then
174+
return true
175+
end
176+
end
177+
return false
178+
end
179+
180+
181+
--- Extract the end-user identifier from a request body.
182+
function _M.extract_end_user_id(body)
183+
if type(body) ~= "table" then
184+
return nil
185+
end
186+
if type(body.safety_identifier) == "string" then
187+
return body.safety_identifier
188+
end
189+
if type(body.user) == "string" then
190+
return body.user
191+
end
192+
return nil
193+
end
194+
195+
149196
--- Extract all text content from a request body for moderation.
150197
function _M.extract_request_content(body)
151198
local contents = {}

apisix/plugins/ai-providers/base.lua

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ local function merge_usage(ctx, parsed)
7777
ctx.ai_token_usage[k] = v
7878
end
7979
end
80+
-- Recompute total from accumulated parts (handles split events, e.g. Anthropic
81+
-- message_start carries input tokens and message_delta carries output tokens)
82+
local computed = (ctx.ai_token_usage.prompt_tokens or 0)
83+
+ (ctx.ai_token_usage.completion_tokens or 0)
84+
if computed > (ctx.ai_token_usage.total_tokens or 0) then
85+
ctx.ai_token_usage.total_tokens = computed
86+
end
8087
end
8188

8289
local raw = parsed.raw_usage or parsed.usage
@@ -396,12 +403,22 @@ function _M.parse_response(self, ctx, res, client_proto, converter, conf)
396403
end
397404
ctx.var.llm_prompt_tokens = ctx.ai_token_usage.prompt_tokens or 0
398405
ctx.var.llm_completion_tokens = ctx.ai_token_usage.completion_tokens or 0
406+
ctx.var.llm_total_tokens = ctx.ai_token_usage.total_tokens or 0
407+
ctx.var.llm_cache_read_input_tokens = ctx.ai_token_usage.cache_read_input_tokens or 0
408+
ctx.var.llm_cache_creation_input_tokens = ctx.ai_token_usage.cache_creation_input_tokens or 0
409+
ctx.var.llm_reasoning_tokens = ctx.ai_token_usage.reasoning_tokens or 0
399410

400411
local response_text = client_proto.extract_response_text(res_body)
401412
if response_text then
402413
ctx.var.llm_response_text = response_text
403414
end
404415

416+
-- Detect tool calls (mirrors the streaming path, which sets this from
417+
-- parse_sse_event.has_tool_call). Each client protocol knows its own shape.
418+
if client_proto.has_tool_call and client_proto.has_tool_call(res_body) then
419+
ctx.var.llm_has_tool_calls = "true"
420+
end
421+
405422
plugin.lua_response_filter(ctx, headers, raw_res_body)
406423
return res_body
407424
end
@@ -591,6 +608,9 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
591608
for _, event in ipairs(events) do
592609
-- Target protocol parses the provider's SSE format
593610
local parsed = target_proto.parse_sse_event(event, ctx, sse_state)
611+
if parsed and parsed.has_tool_call then
612+
ctx.var.llm_has_tool_calls = "true"
613+
end
594614
if not parsed or parsed.type == "skip" then
595615
goto CONTINUE
596616
end
@@ -618,6 +638,12 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
618638
merge_usage(ctx, parsed)
619639
ctx.var.llm_prompt_tokens = ctx.ai_token_usage.prompt_tokens
620640
ctx.var.llm_completion_tokens = ctx.ai_token_usage.completion_tokens
641+
ctx.var.llm_total_tokens = ctx.ai_token_usage.total_tokens or 0
642+
ctx.var.llm_cache_read_input_tokens =
643+
ctx.ai_token_usage.cache_read_input_tokens or 0
644+
ctx.var.llm_cache_creation_input_tokens =
645+
ctx.ai_token_usage.cache_creation_input_tokens or 0
646+
ctx.var.llm_reasoning_tokens = ctx.ai_token_usage.reasoning_tokens or 0
621647
ctx.var.llm_response_text = table.concat(contents, "")
622648
end
623649

0 commit comments

Comments
 (0)