fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages - #888

Merged
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage
Aug 4, 2026
Merged

fix(obs): emit the detailed request metrics on every endpoint, not just chat and messages#888
jarvis9443 merged 6 commits into
mainfrom
fix/llm-request-metrics-endpoint-coverage

Conversation

@jarvis9443

Copy link
Copy Markdown
Contributor

Fixes api7/AISIX-Cloud#1234

Problem

aisix_llm_requests_total / aisix_proxy_requests_total / aisix_proxy_failed_requests_total and their duration histograms were emitted by the chat and messages handlers only. Every other endpoint recorded just the legacy aisix_requests_total.

So /v1/responses traffic (Codex and friends) never appeared in any request-count or success-rate query built on the detailed families — while still showing up in the legacy series, which made the gap read as a bad query rather than missing instrumentation.

The reported endpoint was /v1/responses, but it is the whole handler family: /v1/completions, /v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three /v1/audio/* routes, /v1/images/generations and /v1/videos* had the identical gap, as did the non-inference surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/fine-tuning) and the pre-dispatch rejections.

Metrics::record_proxy_request — the proxy-only variant, no LLM series — had no call sites at all, which is the tell that the two-tier design was intended from the start and simply never wired past chat and messages.

Implementation

Every handler now emits through one chokepoint, request_metrics::record, which writes the legacy series and the detailed families from a single call. Calling Metrics::record_request directly is what produced a request present in one family and absent from the others, so nothing does that any more (outside the admin API, which is not proxy traffic).

The LLM-vs-proxy split is a property of the route (LLM_ENDPOINTS), not of the call site. That matters for correctness, not just tidiness: a 413 refused before dispatch now lands in the same denominator as the model-not-found the handler itself records, instead of the two disagreeing about whether the endpoint had a failure.

Non-inference surfaces get the proxy families only — counting an MCP tool call, a batch-file upload or a 413 as an LLM request would corrupt every per-request token and cost average. /v1/realtime stays out for the same reason: it does reach a model but feeds none of the aisix_llm_*_tokens_total families, so counting it would inflate the denominator without contributing tokens.

chat and messages move onto the same helper so the family cannot drift apart again. Their emitted labels are unchanged.

Fixed alongside

All in the code paths this change touches:

  • normalize_endpoint_label was missing /v1/videos*, so all video traffic reported endpoint="other" on the in-flight gauge.
  • The passthrough error path used the caller-supplied :provider path segment verbatim as the provider label, so /passthrough/<random>/x minted one series per random value — security: prevent unauthenticated metric-label cardinality DoS #451's unbounded-cardinality hole on the provider axis. It now collapses to unresolved unless a configured model uses that provider. (The AisixPath rejection path passes the raw parts.uri.path(), so normalizing the endpoint label in reject is load-bearing too, not just defensive.)
  • /v1/embeddings and /v1/images/generations hardcoded status = 200 on a success arm that also carries the 501 NotImplemented response — mislabelling it in the access log and booking it as outcome="success". Same fixfeat(completions): emit UsageEvent on /v1/completions 200 (#403) #426 already made for completions/responses/rerank.
  • /v1/responses and the rest of the family now report a real upstream_model label instead of leaving it unresolved, matching chat and messages.

Behavior change

New series appear for the previously-missing endpoints. Existing chat/messages series are byte-identical. Dashboards that group aisix_proxy_requests_total by endpoint will now see the tool/management/tunnel surfaces too.

Baseline

LiteLLM increments litellm_proxy_total_requests_metric / litellm_proxy_failed_requests_metric from a single central callback (async_log_success_event + async_post_call_failure_hook) carrying a route label, so all routes are covered uniformly by construction. This change converges on that shape; no divergence.

Tests

tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts — verified failing before the fix and passing after:

  • /v1/responses reaches aisix_llm_requests_total (empty before), with the detailed labels actually populated (upstream_model="gpt-4o-mini", not unknown).
  • a failed /v1/responses lands in the same denominator, with model="unresolved" rather than the caller's text.
  • passthrough is counted as a proxy request but never an LLM one, and the bogus provider name does not reach a label.

Rust unit tests in request_metrics pin the classification: no route falls through to "other", no LLM_ENDPOINTS entry is unreachable (a typo there fails silently — the endpoint just stops appearing), and the tier split holds for both sides.

Full DP E2E suite passes (483 tests).

…st chat and messages
`aisix_proxy_requests_total`, `aisix_proxy_failed_requests_total`,
`aisix_llm_requests_total` and their duration histograms were emitted by
the chat and messages handlers only. Every other endpoint recorded just
the legacy `aisix_requests_total`, so `/v1/responses` traffic (Codex and
friends) was absent from every request-count and success-rate query built
on the detailed families while still appearing in the legacy one — which
made the gap read as a bad query rather than missing instrumentation.
Ten endpoints were affected: /v1/responses, /v1/completions,
/v1/embeddings, /v1/rerank, /v1/messages/count_tokens, the three audio
routes, /v1/images/generations and /v1/videos, plus the non-inference
surfaces (/mcp, /a2a, /v1/realtime, passthrough, files/batches/
fine-tuning) and the pre-dispatch rejections.
All of them now emit through one chokepoint, `request_metrics::record`,
which writes the legacy series and the detailed families together. The
LLM-vs-proxy split is a property of the route (`LLM_ENDPOINTS`), not of
the call site, so a request lands in the same families however it ended:
a 413 refused before dispatch now sits in the same denominator as the
model-not-found the handler itself records. Tool calls, the opaque
passthrough tunnel and the management routes stay out of the LLM
families — counting them there would corrupt every per-request token and
cost average. `/v1/realtime` stays out for the same reason: it feeds none
of the `aisix_llm_*_tokens_total` families.
chat and messages move onto the same helper so the family cannot drift
apart again; their emitted labels are unchanged.
Fixed alongside, all in the paths this touches:
- `normalize_endpoint_label` was missing `/v1/videos*`, so video traffic
reported `endpoint="other"`.
- The passthrough error path used the caller-supplied `:provider` path
segment verbatim as the `provider` label, letting
`/passthrough/<random>/x` mint unbounded series (#451 on the provider
axis). It now collapses to `unresolved` unless a configured model uses
that provider.
- `/v1/embeddings` and `/v1/images/generations` hardcoded `status = 200`
on a success arm that also carries the 501 NotImplemented response,
mislabelling it in the access log and booking it as
`outcome="success"`. Same fix#426 made for completions/responses/rerank.
- `/v1/responses` and the rest of the family now report a real
`upstream_model` label instead of leaving it unresolved.
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2802818-8729-47d2-bebe-6bc57f548889

📥 Commits

Reviewing files that changed from the base of the PR and between 17141a6 and 2d93bae.

📒 Files selected for processing (20)
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/reject.rs
  • crates/aisix-proxy/src/request_metrics.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs
  • tests/e2e/src/cases/request-metrics-endpoint-coverage-e2e.test.ts

Comment @coderabbitai help to get the list of available commands.

…ared emit
#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
The struct now carries the `AuthenticatedKey` for the caller labels, which
already owns the key id.
@jarvis9443jarvis9443 reopened this Aug 4, 2026
GitHub did not dispatch a workflow run for 513b1ea (no check-runs on the
commit, and close/reopen did not re-fire it).
Conflict in chat.rs' error tail, resolved by deleting both sides:
- #886 moved rate-limit rejection counting to `quota::reject`, which every
endpoint funnels through and which knows the offending layer, and removed
it from chat's `record_error` to avoid double-booking. Keeping this
branch's `note_ratelimit_rejection` would have reintroduced exactly that
double count.
- `record_error`'s remaining job — the legacy `record_request` — is now done
by `request_metrics::record` further down the same arm.
So neither helper has anything left to do.
@jarvis9443

Copy link
Copy Markdown
ContributorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443
jarvis9443 merged commit 2f0ae68 into mainAug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/llm-request-metrics-endpoint-coverage branch August 4, 2026 15:37
kilb pushed a commit to kilb/aisix that referenced this pull request Aug 19, 2026
…ecur
A third review pass over the areas the first two did not reach — credential
minting, the telemetry sinks, inbound JWT, the metric label path — plus a
class-based sweep of all 18 crates. Every finding is fixed here, and the
classes that have now recurred more than once are guarded by tests rather
than by remembering.
Correctness
- Serialise token minting per credential on the Azure AAD and Vertex
minters. Both released the cache read lock and then let every caller that
missed go to the identity provider, so a cold cache — and every expiry
after it, roughly hourly — sent one POST per in-flight request. Both
providers throttle their token endpoints, and a throttled mint fails the
request rather than slowing it. Measured before the fix: 16 concurrent
callers produced 16 mints.
- Cache a short-lived token for half its life instead of not at all.
`expires_in - 60s` saturates to zero below the refresh margin, and a
zero-lifetime entry never satisfies a lookup, so every request re-minted.
- Validate `ProviderKey.api_base` on the OpenAI-compatible and Anthropic
bridges. `https://api.openai.com@evil.example/v1` reads as one host and
resolves to another, which then receives the key. Vertex and Azure grew
this check in api7#390; their siblings did not. The check now lives in
`aisix-gateway` and all four call it.
- Validate an MCP server's `url` and `token_url` on the write path. The A2A
sibling constrains its `url` as a URI; MCP accepted any string, for a row
whose `auth` credential is sent to exactly that address.
- Canonicalize IPv4-mapped addresses in a passthrough route's
`source_cidrs`, and reject a malformed entry on write. Same shape as the
`Model::allowed_cidrs` fix, on the gate that is the whole boundary for an
anonymous route: on a `[::]` listener it rejected every IPv4 caller.
- Saturate upstream-reported token counts instead of wrapping them (12
sites). `as u32` understates — 4_294_967_297 became 1 — and the upstream
is not always a trusted party, since `api_base` points wherever the
operator says.
- Compare admin keys through a digest, without an early exit. The proxy's
own API-key path already looks callers up under `hash_bearer`; the admin
path compared plaintext with `==`, which stops at the first differing byte.
Observability
- Emit `aisix_otlp_fanout_drops_total` / `_failures_total`, and
`aisix_redis_failures_total`. All three existed with no caller, so the
series could never appear in a scrape — telemetry and rate-limit
degradation were visible only through the heartbeat, which a deployment
with no control plane does not have. The rate-limit store reaches metrics
through an injected sink (`RateLimitMetricsSink`, mirroring
`GuardrailMetricsSink`) rather than a new crate edge.
- Warn once per policy that a sub-minute or hourly `max_tokens` is inert,
not once per request. The gap is real and tracked (api7#396); the telling was
a warn line per request forever.
- Publish the rate-limit window from `/v1/audio/*`, `/v1/videos` and
`/v1/messages/count_tokens`, which dispatch a model and reserve quota but
were missed when the other seven endpoints were wired.
Performance
- Keep a wildcard index on `ResourceTable`, maintained beside `by_name` so
it cannot drift. Resolving a name no exact row serves used to walk the
whole model table — and materialise a `Vec` of it — on the
model-resolution path and on the metric-label path every endpoint reaches,
including for names that resolve to nothing.
- Reclaim health, runtime-status and routing-cursor state for config rows
the snapshot no longer carries, through the publication hook that already
drives exporter reconciliation. A target still holding a concurrency
permit is kept.
- State the realtime WebSocket frame bounds instead of inheriting them from
a transitive dependency's defaults. Same values, now a decision.
Guards for the recurring classes
Each of these fails the build on the next occurrence, which is what the
prose rules did not do:
- `every_emit_has_a_caller` — a `Metrics` emit with no caller. Third
occurrence (api7#888, api7#972, this pass); it found the Redis one immediately.
- `endpoint_family_parity` — a model-dispatch handler missing a shared
per-request mechanism.
- `api_base_validation_parity` — a bridge resolving an operator-supplied
base without validating its shape.
- `readiness-gate-lint` — an e2e gate that waits only on an admin-key probe,
or a spec that seeds a resource after the caller key. Both halves of the
rule in `tests/e2e/AGENTS.md`; five specs carried the first and eleven the
second, each failing as a load-dependent 401 or a missing limit that reads
like an infrastructure hiccup.
All four carry a staleness check, so a list entry that no longer applies
fails too.
Verified: 3322 Rust tests, 212/212 e2e files (647 tests) against real etcd
and Redis, clippy clean workspace-wide, tsc clean, schemas unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jarvis9443