This is an automated email from the ASF dual-hosted git repository.
nic-6443 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 d0c3ceed1 feat(prometheus): add metrics for AI cache hits, misses,
bypasses, and embedding latency (#13659)
d0c3ceed1 is described below
commit d0c3ceed12f14b90b43971a5e50406399749a388
Author: Mohammad Izzraff Janius
<[email protected]>
AuthorDate: Tue Jul 7 10:59:34 2026 +0800
feat(prometheus): add metrics for AI cache hits, misses, bypasses, and
embedding latency (#13659)
---
apisix/plugins/ai-cache.lua | 1 +
apisix/plugins/ai-cache/semantic.lua | 3 +
apisix/plugins/prometheus.lua | 1 +
apisix/plugins/prometheus/exporter.lua | 113 ++++++++++++++
docs/en/latest/plugins/prometheus.md | 26 +++-
docs/zh/latest/plugins/prometheus.md | 26 +++-
t/plugin/prometheus-ai-cache.t | 270 +++++++++++++++++++++++++++++++++
7 files changed, 438 insertions(+), 2 deletions(-)
diff --git a/apisix/plugins/ai-cache.lua b/apisix/plugins/ai-cache.lua
index 5a09f0d85..62e69436a 100644
--- a/apisix/plugins/ai-cache.lua
+++ b/apisix/plugins/ai-cache.lua
@@ -132,6 +132,7 @@ end
local function serve_hit(conf, ctx, cached, similarity)
local status = "HIT"
ctx.ai_cache_status = status
+ ctx.ai_cache_hit_layer = similarity and "semantic" or "exact"
if conf.cache_headers ~= false then
core.response.set_header(CACHE_STATUS_HEADER, status)
local age = ngx.time() - (cached.created_at or ngx.time())
diff --git a/apisix/plugins/ai-cache/semantic.lua
b/apisix/plugins/ai-cache/semantic.lua
index 6cb580283..7c2e54da2 100644
--- a/apisix/plugins/ai-cache/semantic.lua
+++ b/apisix/plugins/ai-cache/semantic.lua
@@ -26,6 +26,7 @@ local type = type
local next = next
local concat = table.concat
local tostring = tostring
+local ngx_now = ngx.now
-- Pre-require both drivers so a misconfigured provider name cannot escape
-- lookup()'s fail-open boundary via a request-time require() raise.
@@ -262,7 +263,9 @@ function _M.embed_query(conf, ctx, body)
return nil
end
+ local started = ngx_now()
local vec, err = embed(conf, text)
+ ctx.ai_cache_embedding_latency = (ngx_now() - started) * 1000
if not vec then
core.log.warn("ai-cache: embedding failed, fail-open as MISS: ", err)
return nil
diff --git a/apisix/plugins/prometheus.lua b/apisix/plugins/prometheus.lua
index b1f3c58b3..fe9e851c7 100644
--- a/apisix/plugins/prometheus.lua
+++ b/apisix/plugins/prometheus.lua
@@ -38,6 +38,7 @@ local structural_labels = {
http_latency = {type = true},
bandwidth = {type = true},
llm_latency = {type = true},
+ ai_cache_hits_total = {layer = true},
}
diff --git a/apisix/plugins/prometheus/exporter.lua
b/apisix/plugins/prometheus/exporter.lua
index de3e4a8bf..ca71a1972 100644
--- a/apisix/plugins/prometheus/exporter.lua
+++ b/apisix/plugins/prometheus/exporter.lua
@@ -158,6 +158,14 @@ local metric_label_map = {
"request_type", "request_llm_model", "llm_model"},
llm_completion_tokens_dist = {"route_id", "service_id", "consumer", "node",
"request_type", "request_llm_model", "llm_model"},
+ ai_cache_hits_total = {"layer", "route", "route_id", "service",
"service_id",
+ "consumer", "node", "request_type", "request_llm_model", "llm_model"},
+ ai_cache_misses_total = {"route", "route_id", "service", "service_id",
+ "consumer", "node", "request_type", "request_llm_model", "llm_model"},
+ ai_cache_bypasses_total = {"route", "route_id", "service", "service_id",
+ "consumer", "node", "request_type", "request_llm_model", "llm_model"},
+ ai_cache_embedding_latency = {"route", "route_id", "service", "service_id",
+ "consumer", "node", "request_type", "request_llm_model", "llm_model"},
}
@@ -282,6 +290,14 @@ function _M.http_init(prometheus_enabled_in_stream)
"llm_prompt_tokens_dist", "expire")
local llm_completion_tokens_dist_exptime = core.table.try_read_attr(attr,
"metrics",
"llm_completion_tokens_dist", "expire")
+ local ai_cache_hits_exptime = core.table.try_read_attr(attr, "metrics",
+
"ai_cache_hits_total", "expire")
+ local ai_cache_misses_exptime = core.table.try_read_attr(attr, "metrics",
+
"ai_cache_misses_total", "expire")
+ local ai_cache_bypasses_exptime = core.table.try_read_attr(attr, "metrics",
+
"ai_cache_bypasses_total", "expire")
+ local ai_cache_embedding_latency_exptime = core.table.try_read_attr(attr,
"metrics",
+
"ai_cache_embedding_latency", "expire")
prometheus = base_prometheus.init("prometheus-metrics", metric_prefix)
@@ -395,6 +411,35 @@ function _M.http_init(prometheus_enabled_in_stream)
llm_completion_tokens_buckets,
llm_completion_tokens_dist_exptime)
+ metrics.ai_cache_hits_total = prometheus:counter("ai_cache_hits_total",
+ "Total AI cache hits served, per cache layer",
+ append_tables(metric_label_map.ai_cache_hits_total,
+ extra_labels("ai_cache_hits_total")),
+ ai_cache_hits_exptime)
+
+ metrics.ai_cache_misses_total = prometheus:counter("ai_cache_misses_total",
+ "Total AI cache misses",
+ append_tables(metric_label_map.ai_cache_misses_total,
+ extra_labels("ai_cache_misses_total")),
+ ai_cache_misses_exptime)
+
+ metrics.ai_cache_bypasses_total =
prometheus:counter("ai_cache_bypasses_total",
+ "Total AI cache bypassed requests",
+ append_tables(metric_label_map.ai_cache_bypasses_total,
+ extra_labels("ai_cache_bypasses_total")),
+ ai_cache_bypasses_exptime)
+
+ local ai_cache_embedding_latency_buckets = DEFAULT_BUCKETS
+ if attr and attr.ai_cache_embedding_latency_buckets then
+ ai_cache_embedding_latency_buckets =
attr.ai_cache_embedding_latency_buckets
+ end
+ metrics.ai_cache_embedding_latency =
prometheus:histogram("ai_cache_embedding_latency",
+ "Latency of AI cache embedding calls in milliseconds",
+ append_tables(metric_label_map.ai_cache_embedding_latency,
+ extra_labels("ai_cache_embedding_latency")),
+ ai_cache_embedding_latency_buckets,
+ ai_cache_embedding_latency_exptime)
+
if prometheus_enabled_in_stream then
init_stream_metrics()
end
@@ -425,6 +470,55 @@ function _M.stream_init()
end
+local AI_CACHE_STATUS_METRICS = {
+ HIT = "ai_cache_hits_total",
+ MISS = "ai_cache_misses_total",
+ BYPASS = "ai_cache_bypasses_total",
+}
+
+
+-- `layer` is only registered on ai_cache_hits_total, where it leads the label
list
+local function ai_cache_label_values(name, ctx, layer)
+ local vars = ctx.var
+
+ local route_id = ""
+ local route_name = ""
+ local balancer_ip = ctx.balancer_ip or ""
+ local service_id = ""
+ local service_name = ""
+ local consumer_name = ctx.consumer_name or ""
+
+ local matched_route = ctx.matched_route and ctx.matched_route.value
+ if matched_route then
+ route_id = matched_route.id
+ route_name = matched_route.name or ""
+ service_id = matched_route.service_id or ""
+ if service_id ~= "" then
+ local fetched_service = service_fetch(service_id)
+ service_name = fetched_service and fetched_service.value.name or ""
+ end
+ end
+
+ local disabled_label_metric_map = get_disabled_label_metric_map()
+
+ if layer then
+ return get_enabled_label_values_for_metric(name,
disabled_label_metric_map,
+ layer, route_name, route_id, service_name, service_id,
+ consumer_name, balancer_ip,
+ vars.request_type, model_to_label(vars.request_llm_model),
+ model_to_label(vars.llm_model),
+ unpack(extra_labels(name, ctx)))
+ end
+
+ return get_enabled_label_values_for_metric(name, disabled_label_metric_map,
+ route_name, route_id, service_name, service_id,
+ consumer_name, balancer_ip,
+ vars.request_type, model_to_label(vars.request_llm_model),
+ model_to_label(vars.llm_model),
+ unpack(extra_labels(name, ctx)))
+end
+
+
function _M.http_log(conf, ctx)
local vars = ctx.var
local disabled_label_metric_map = get_disabled_label_metric_map()
@@ -557,6 +651,24 @@ function _M.http_log(conf, ctx)
vars.request_type, request_llm_model_label, llm_model_label,
unpack(extra_labels("llm_completion_tokens_dist", ctx))))
end
+
+ local ai_cache_metric = ctx.ai_cache_status
+ and AI_CACHE_STATUS_METRICS[ctx.ai_cache_status]
+ if ai_cache_metric then
+ if ctx.ai_cache_status == "HIT" then
+ metrics[ai_cache_metric]:inc(1,
+ ai_cache_label_values(ai_cache_metric, ctx,
+ ctx.ai_cache_hit_layer or "exact"))
+ else
+ metrics[ai_cache_metric]:inc(1,
+ ai_cache_label_values(ai_cache_metric, ctx))
+ end
+ end
+
+ if ctx.ai_cache_embedding_latency then
+
metrics.ai_cache_embedding_latency:observe(ctx.ai_cache_embedding_latency,
+ ai_cache_label_values("ai_cache_embedding_latency", ctx))
+ end
end
@@ -974,6 +1086,7 @@ function _M.dec_llm_active_connections(ctx)
inc_llm_active_connections(ctx, -1)
end
+
function _M.get_prometheus()
return prometheus
end
diff --git a/docs/en/latest/plugins/prometheus.md
b/docs/en/latest/plugins/prometheus.md
index e31ff269e..45a830209 100644
--- a/docs/en/latest/plugins/prometheus.md
+++ b/docs/en/latest/plugins/prometheus.md
@@ -100,7 +100,7 @@ You can configure the Plugin through its [Plugin
Metadata](../terminology/plugin
| Name | Type | Required | Description
|
| --------------- | ------ | -------- |
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
-| disabled_labels | object | False | Per-metric map of built-in label names
whose values are collapsed to an empty string `""` to reduce metric
cardinality. Keyed by metric name: `http_status`, `http_latency`, `bandwidth`,
`llm_latency`, `llm_prompt_tokens`, `llm_completion_tokens`,
`llm_active_connections`, `llm_prompt_tokens_dist`,
`llm_completion_tokens_dist`. Structural labels that define a metric's identity
(`code` on `http_status`, `type` on `http_latency`, `bandwidth` and `llm_ [...]
+| disabled_labels | object | False | Per-metric map of built-in label names
whose values are collapsed to an empty string `""` to reduce metric
cardinality. Keyed by metric name: `http_status`, `http_latency`, `bandwidth`,
`llm_latency`, `llm_prompt_tokens`, `llm_completion_tokens`,
`llm_active_connections`, `llm_prompt_tokens_dist`,
`llm_completion_tokens_dist`, `ai_cache_hits_total`, `ai_cache_misses_total`,
`ai_cache_bypasses_total`, `ai_cache_embedding_latency`. Structural labels
[...]
Collapsing a label's value to `""` keeps the label registered in the metric
schema, so existing dashboards, `absent()` alerts, and recording rules keep
working — only the high-cardinality time series that differ solely by those
labels are collapsed into one. This is useful in dynamic environments such as
Kubernetes autoscaling, where the upstream node IP (`node` label) churns
rapidly and would otherwise overflow the `prometheus-metrics` shared dict.
@@ -249,6 +249,30 @@ The `type` label distinguishes the kind of latency,
similar to `apisix_http_late
| request_type | traditional_http / ai_chat / ai_stream
|
| llm_model | For non-traditional_http requests, name of the llm_model
|
+### Labels for the `apisix_ai_cache_*` metrics
+
+The [`ai-cache`](./ai-cache.md) Plugin exports four metrics:
+
+- `apisix_ai_cache_hits_total` counts requests served from the cache, per
serving layer.
+- `apisix_ai_cache_misses_total` counts requests the Plugin looked up but
could not serve from the cache.
+- `apisix_ai_cache_bypasses_total` counts requests that bypassed the cache
lookup entirely.
+- `apisix_ai_cache_embedding_latency` is a histogram of the latency, in
milliseconds, of the embedding calls made by the semantic layer, measured
around the embedding provider round-trip for successful and failed calls alike.
+
+They share the following labels:
+
+| Name | Description
|
+| ---------- |
-----------------------------------------------------------------------------------------------------------------------------
|
+| layer | Only on `apisix_ai_cache_hits_total`. Cache layer that served
the hit: `exact` or `semantic`.
|
+| route | Name of the Route that the metric corresponds to. Default to an
empty string if the Route has no name or a request does not match any Route.
|
+| route_id | ID of the Route that the metric corresponds to. Default to
an empty string if a request does not match any Route. |
+| service | Name of the Service that the matched Route belongs to. Default
to an empty string if the matched Route does not belong to any Service. |
+| service_id | ID of the Service that the matched Route belongs to. Default
to an empty string if the matched Route does not belong to any Service. |
+| consumer | Name of the Consumer associated with a request. Default to an
empty string if no Consumer is associated with the request.
|
+| node | Name of the LLM instance picked by the `ai-proxy` or
`ai-proxy-multi` Plugin, such as `ai-proxy-openai`. These Plugins report the
instance name instead of an upstream IP address, on cache hits and misses
alike.
|
+| request_type | traditional_http / ai_chat / ai_stream
|
+| request_llm_model | Model name requested by the client.
|
+| llm_model | Model name reported by the LLM response. Empty on cache
hits, which are served without reaching the LLM.
|
+
### Labels for `apisix_http_latency`
The following labels are used to differentiate `apisix_http_latency` metrics.
diff --git a/docs/zh/latest/plugins/prometheus.md
b/docs/zh/latest/plugins/prometheus.md
index f647b5d6a..054fed1f4 100644
--- a/docs/zh/latest/plugins/prometheus.md
+++ b/docs/zh/latest/plugins/prometheus.md
@@ -100,7 +100,7 @@ plugin_attr:
| 名称 | 类型 | 必选项 | 描述
|
| --------------- | ------ | ------ |
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
-| disabled_labels | object | 否 | 按指标配置的内置标签列表,列出的标签其值会被设置为空字符串 `""`
以降低指标基数。以指标名称作为键:`http_status`、`http_latency`、`bandwidth`、`llm_latency`、`llm_prompt_tokens`、`llm_completion_tokens`、`llm_active_connections`、`llm_prompt_tokens_dist`、`llm_completion_tokens_dist`。定义指标本身含义的结构性标签(`http_status`
的 `code`、`http_latency`、`bandwidth` 与 `llm_latency` 的 `type`)不可被禁用。 |
+| disabled_labels | object | 否 | 按指标配置的内置标签列表,列出的标签其值会被设置为空字符串 `""`
以降低指标基数。以指标名称作为键:`http_status`、`http_latency`、`bandwidth`、`llm_latency`、`llm_prompt_tokens`、`llm_completion_tokens`、`llm_active_connections`、`llm_prompt_tokens_dist`、`llm_completion_tokens_dist`、`ai_cache_hits_total`、`ai_cache_misses_total`、`ai_cache_bypasses_total`、`ai_cache_embedding_latency`。定义指标本身含义的结构性标签(`http_status`
的 `code`、`http_latency`、`bandwidth` 与 `llm_latency` 的
`type`、`ai_cache_hits_total` 的 `layer`)不可被禁用。 |
将标签值设置为 `""` 时,标签仍保留在指标 schema 中,因此现有的仪表盘、`absent()` 告警和 recording rule
都不受影响——只是将仅因这些标签而不同的高基数时间序列合并为一条。这在 Kubernetes 弹性伸缩等动态环境中尤其有用:此时上游节点 IP(`node`
标签)频繁变化,否则会很快撑爆 `prometheus-metrics` 共享字典。
@@ -249,6 +249,30 @@ Prometheus 中有不同类型的指标。要了解它们之间的区别,请参
| request_type | traditional_http / ai_chat / ai_stream
|
| llm_model | 对于非传统的 http 请求,llm 模型的名称
|
+### `apisix_ai_cache_*` 系列指标的标签
+
+[`ai-cache`](./ai-cache.md) 插件导出以下四个指标:
+
+- `apisix_ai_cache_hits_total`:统计由缓存命中并直接返回的请求数,按命中的缓存层区分。
+- `apisix_ai_cache_misses_total`:统计经过插件查询但未命中缓存的请求数。
+- `apisix_ai_cache_bypasses_total`:统计完全绕过缓存查询的请求数。
+- `apisix_ai_cache_embedding_latency`:语义层发起的 embedding 调用延迟(毫秒)的直方图,围绕
embedding 服务的完整往返计时,成功与失败的调用均会记录。
+
+它们共享以下标签:
+
+| 名称 | 描述 |
+| ---------- |
-----------------------------------------------------------------------------------------------------------------------------
|
+| layer | 仅存在于 `apisix_ai_cache_hits_total`。命中的缓存层:`exact` 或 `semantic`。
|
+| route | 指标对应的路由名称。如果路由未配置名称或请求不匹配任何路由,则默认为空字符串。
|
+| route_id | 指标对应的路由 ID。如果请求不匹配任何路由,则默认为空字符串。 |
+| service | 匹配路由所属的服务名称。如果匹配的路由不属于任何服务,则默认为空字符串。 |
+| service_id | 匹配路由所属的服务 ID。如果匹配的路由不属于任何服务,则默认为空字符串。 |
+| consumer | 与请求关联的消费者名称。如果请求没有与之关联的消费者,则默认为空字符串。 |
+| node | `ai-proxy` 或 `ai-proxy-multi` 插件选中的 LLM 实例名称,例如
`ai-proxy-openai`。这些插件上报的是实例名称而非上游 IP 地址,缓存命中与未命中时均是如此。
|
+| request_type | traditional_http / ai_chat / ai_stream
|
+| request_llm_model | 客户端请求的模型名称。
|
+| llm_model | LLM 响应中报告的模型名称。缓存命中的请求不会到达 LLM,此标签为空字符串。
|
+
### `apisix_http_latency` 的标签
以下标签用于区分 `apisix_http_latency` 指标。
diff --git a/t/plugin/prometheus-ai-cache.t b/t/plugin/prometheus-ai-cache.t
new file mode 100644
index 000000000..31e07f2ea
--- /dev/null
+++ b/t/plugin/prometheus-ai-cache.t
@@ -0,0 +1,270 @@
+#
+# 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 {
+ if ($ENV{TEST_NGINX_CHECK_LEAK}) {
+ $SkipReason = "unavailable for the hup tests";
+
+ } else {
+ $ENV{TEST_NGINX_USE_HUP} = 1;
+ undef $ENV{TEST_NGINX_USE_STAP};
+ }
+}
+
+use t::APISIX 'no_plan';
+
+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 $extra_yaml_config = <<_EOC_;
+plugin_attr:
+ prometheus:
+ refresh_interval: 0.1
+plugins:
+ - ai-proxy
+ - ai-cache
+ - prometheus
+ - public-api
+_EOC_
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ my $http_config = <<_EOC_;
+ server {
+ listen 6724;
+ default_type 'application/json';
+
+ location /v1/embeddings {
+ content_by_lua_block { require("lib.ai_cache_mock").embeddings() }
+ }
+ }
+_EOC_
+ $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: create a route with ai-proxy, ai-cache and the metrics public-api
route
+--- config
+ location /t {
+ content_by_lua_block {
+ require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+ local data = {
+ {
+ url = "/apisix/admin/routes/1",
+ data = [[{
+ "uri": "/chat",
+ "name": "ai-cache-route",
+ "plugins": {
+ "prometheus": {},
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
test-key" } },
+ "options": { "model": "gpt-4o" },
+ "override": { "endpoint":
"http://127.0.0.1:1980" }
+ },
+ "ai-cache": {
+ "redis_host": "127.0.0.1",
+ "redis_port": 6379,
+ "layers": ["exact", "semantic"],
+ "bypass_on": [{"header": "X-No-Cache",
"equals": "1"}],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": {} }
+ }
+ }
+ }
+ }]],
+ },
+ {
+ url = "/apisix/admin/routes/metrics",
+ data = [[{
+ "plugins": {
+ "public-api": {}
+ },
+ "uri": "/apisix/prometheus/metrics"
+ }]]
+ },
+ }
+
+ local t = require("lib.test_admin").test
+
+ for _, d in ipairs(data) do
+ local code, body = t(d.url, ngx.HTTP_PUT, d.data)
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ end
+ }
+ }
+--- response_body eval
+"passed\n" x 2
+
+
+
+=== TEST 2: send a chat request (cache MISS)
+--- request
+POST /chat
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- error_code: 200
+--- wait: 0.5
+
+
+
+=== TEST 3: assert ai_cache_misses_total metric
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_misses_total\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model="gpt-4o"\}
1/
+
+
+
+=== TEST 4: send the same chat request (exact-layer HIT)
+--- request
+POST /chat
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- response_headers
+X-AI-Cache-Status: HIT
+! X-AI-Cache-Similarity
+--- error_code: 200
+--- wait: 0.3
+
+
+
+=== TEST 5: assert ai_cache_hits_total metric with layer=exact and empty
llm_model
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_hits_total\{layer="exact",route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model=""\}
1/
+
+
+
+=== TEST 6: ai_cache_embedding_latency is not recorded for the exact hit
+--- request
+GET /apisix/prometheus/metrics
+--- response_body_unlike eval
+qr/apisix_ai_cache_embedding_latency_count\{[^}]*llm_model=""\}/
+
+
+
+=== TEST 7: send a paraphrased chat request (semantic-layer HIT, cos 0.922 >=
0.9)
+--- request
+POST /chat
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+--- error_code: 200
+--- wait: 0.3
+
+
+
+=== TEST 8: assert ai_cache_hits_total metric with layer=semantic
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_hits_total\{layer="semantic",route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model=""\}
1/
+
+
+
+=== TEST 9: assert ai_cache_embedding_latency_bucket metric for the miss lookup
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_embedding_latency_bucket\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model="gpt-4o",le="\d+"\}
1/
+
+
+
+=== TEST 10: assert ai_cache_embedding_latency_count metric for the miss lookup
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_embedding_latency_count\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model="gpt-4o"\}
1/
+
+
+
+=== TEST 11: assert ai_cache_embedding_latency_sum metric for the miss lookup
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_embedding_latency_sum\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model="gpt-4o"\}
\d+/
+
+
+
+=== TEST 12: assert ai_cache_embedding_latency_count metric for the
semantic-hit lookup
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_embedding_latency_count\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model=""\}
1/
+
+
+
+=== TEST 13: send a chat request with the bypass header (BYPASS)
+--- request
+POST /chat
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-No-Cache: 1
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: BYPASS
+--- error_code: 200
+--- wait: 0.3
+
+
+
+=== TEST 14: assert ai_cache_bypasses_total metric
+--- request
+GET /apisix/prometheus/metrics
+--- response_body eval
+qr/apisix_ai_cache_bypasses_total\{route="ai-cache-route",route_id="1",.*node="ai-proxy-openai",request_type="ai_chat",request_llm_model="gpt-4o",llm_model="gpt-4o"\}
1/
+
+
+
+=== TEST 15: reject disabling the structural `layer` label on
ai_cache_hits_total
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/plugin_metadata/prometheus',
+ ngx.HTTP_PUT,
+ [[{"disabled_labels": {"ai_cache_hits_total": ["layer"]}}]])
+ ngx.say(body)
+ }
+ }
+--- response_body eval
+qr/failed to validate item 1/