Skip to content

feat(metering): add external metering filter with balance checks and usage reporting - #581

Open
noyitz wants to merge 4 commits into
praxis-proxy:mainfrom
noyitz:feat/external-metering-identity-headers
Open

feat(metering): add external metering filter with balance checks and usage reporting#581
noyitz wants to merge 4 commits into
praxis-proxy:mainfrom
noyitz:feat/external-metering-identity-headers

Conversation

@noyitz

@noyitz noyitz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Adds an external_metering HTTP filter: pre-request balance checks and
post-response token usage reporting against an external metering service.

Part of #577.

What the filter does

Request phase — resolves tenant identity, strips identity headers and
client credentials (authorization, x-api-key), and asks the metering
service whether the tenant may spend more tokens:

  • GET {metering_url}/api/v1/customers/{username}/entitlements/{feature_key}/value?model={model}
  • Exhausted budget → 429. Metering unreachable → admit when fail_open: true
    (default), 503 otherwise, so a metering outage degrades to unmetered service
    rather than an inference outage.

Response phase — emits a CloudEvents 1.0 event
(inference.tokens.used, or inference.request.error on upstream failure) to
POST {metering_url}/api/v1/events. Delivery is fire-and-forget: a slow or
failing metering service never delays a response the upstream already
answered. Token counts come from the token_count filter's filter_metadata
keys (token.input/output/total and the prompt cache breakdown from #582).

Identity resolution — three tiers, most trusted first

  1. Verified metadata (unnamespaced {prefix}* keys) written by an
    authentication filter from verified credentials. When present, every lower
    tier is ignored entirely, so a client cannot spoof subscription or
    model via forged headers alongside valid credentials.
  2. Guard metadata (namespaced identity.{prefix}* keys) written by the
    identity_header_guard filter (feat(filter): add identity header guard filter #709), which owns identity header capture.
  3. Raw {prefix}* headers, for deployments where a trusted upstream auth
    layer (e.g. an external authorizer) injects them directly.

Identity headers are always stripped before the request reaches the upstream,
regardless of which tier supplied the identity.

Relationship to #709 and #577

This is the metering half of the split: #709 owns identity header capture into
namespaced metadata; this filter consumes that metadata (tier 2) and owns
admission control, credential stripping, and usage reporting. #577 documents
the original single-filter design and needs an update to reflect the split.

HTTP callouts use praxis-core's SubRequestClient (the CalloutClient named
in #577 was removed upstream in #849). register_ai_filters threads the shared
server-level client into the filter following the existing
openai_file_resolve pattern; without one the filter creates a private
connector, same as openai_file_resolve::from_config.
praxis_ai_apis::subrequest::execute_url is made public so filters can execute
full-URL sub-requests.

Size

1,876 insertions, of which 861 are unit + integration tests, 103 generated
docs + README rows, and 64 the example config. Functional filter code is
~1,010 lines across mod.rs, config.rs, and registration. Balance check,
event construction, and identity resolution are cohesive enough that splitting
them again would leave non-functional intermediate states; happy to split if
reviewers prefer.

Testing

  • 41 unit tests (filters/src/metering/tests.rs), including spoofing-closure
    tests for the tier precedence rules
  • 5 functional integration tests against examples/configs/external-metering.yaml
    (balance allowed, fail-closed rejection, header stripping, no-identity skip)
  • make test — 4,905 passed, 0 failed; make lint green

@noyitz
noyitz requested review from a team and leseb July 28, 2026 02:34

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Clean, well-structured filter with good test coverage and correct conventions. Three medium-severity items found.

Severity Count
Critical 0
Large 0
Medium 3

Findings:

  1. [Medium] strip_client_credentials uses if let Ok for an infallible HeaderName parse, creating a silent-failure pattern for security-critical credential stripping. Use HeaderName::from_static("x-api-key") instead.
  2. [Medium] validate_config only checks for empty prefix but does not validate that the prefix contains valid HTTP header name characters. An invalid prefix (e.g. containing spaces or control characters) would silently match no headers, leaving tenant identity headers unstripped.
  3. [Medium] No test covers multi-value tenant headers (x-tenant-username: alice\r\nx-tenant-username: mallory). Add a unit test verifying all values are marked for removal when a client sends duplicate tenant headers.

Comment thread filters/src/metering/mod.rs Outdated
Comment thread filters/src/metering/config.rs
Comment thread filters/src/metering/tests.rs

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Clean, well-structured filter with good test coverage and correct conventions. The prior review covered three medium items; this pass adds one more.

Severity Count
Critical 0
Large 0
Medium 1

Findings:

  1. [Medium] strip_identity_headers performs two unnecessary per-request allocations: lowering HeaderName::as_str() (already lowercase by http crate contract) and lowering the immutable prefix on every call instead of once at construction.

Comment thread filters/src/metering/mod.rs Outdated

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings beyond prior review.

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings beyond prior reviews.

Fourth pass confirms the four existing findings (infallible HeaderName parse, prefix validation gap, missing multi-value header test, redundant to_ascii_lowercase) are the substantive items. Code structure, conventions, test coverage, integration tests, documentation, and registry wiring are all correct.

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Fifth pass. One new medium finding (assertion messages in unit tests). Four items from prior reviews remain unaddressed in the code.

Severity Count (new) Count (prior, open)
Critical 0 0
Large 0 0
Medium 1 4

New finding:

  1. [Medium] Unit test assertions missing messages (tests.rs). All 15 assert!/assert_eq! calls lack a message string. The workspace enforces missing_assert_message = "deny" (Cargo.toml line 223), and the test module's #[allow] only covers unwrap_used and expect_used. The integration tests correctly include messages on every assertion.

Open prior findings (no code changes since last review):

  1. HeaderName::from_static("x-api-key") instead of fallible .parse() with if let Ok (mod.rs:102)
  2. Prefix validation for valid HTTP header characters (config.rs:30)
  3. Missing multi-value tenant header test (tests.rs)
  4. Redundant per-request to_ascii_lowercase on both key and prefix (mod.rs:88)

Comment thread filters/src/metering/tests.rs Outdated
@leseb

leseb commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@noyitz please resolve the conflicts and address the bot's comments thanks

@noyitz
noyitz force-pushed the feat/external-metering-identity-headers branch from 1bea336 to ff138bb Compare August 17, 2026 17:36
@noyitz noyitz closed this Aug 17, 2026
@noyitz noyitz reopened this Aug 17, 2026
@noyitz
noyitz force-pushed the feat/external-metering-identity-headers branch from ff138bb to 7018d3c Compare August 17, 2026 20:56
@noyitz noyitz changed the title feat(filter): add external metering filter with identity header handling feat(metering): add external metering filter with balance checks and usage reporting Aug 17, 2026

@jordigilh jordigilh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things flagged inline, plus some broader ones here. Identity stripping, the CloudEvents payload, and URL-encoding of client-influenced path segments all held up -- no SSRF or credential-leakage concerns.

Circuit breaking is available but unused for this gateway. SubRequestConnector supports one (with_options, praxis#879) and the base proxy wires it up; praxis-ai's server never does (server.rs#L141, commands.rs#L40 both use plain ::new()). runtime.subrequest_circuit_breaker parses fine in an AI-gateway config today and is silently dropped. external_metering is the first mandatory-every-request consumer of that shared pool (the other 5 are opportunistic) -- worth a follow-up before a high-traffic rollout.

execute_url (+ client/error types) are now pub, not pub(crate) -- needed cross-crate, but worth flagging as a public API addition on praxis-ai-apis, semver-relevant going forward.

Generated docs skip the 3-tier identity design -- it only lives in a doc comment on the private read_identity_headers, which the doc generator never harvests (it reads a second paragraph of the module/struct doc -- credential_inject.md already does this). Worth moving it up.

#577 (this filter's linked design doc) still describes the single-filter/CalloutClient design this PR (split across #581 + #709) supersedes.

Minor: iterative_request_router's validate() doesn't block nesting external_metering inside a step (it does block nested IRR and compression) -- would double-report with no idempotency key if misconfigured. Not urgent, just flagging.

Comment thread filters/src/metering/mod.rs
Comment thread filters/src/metering/mod.rs Outdated
Comment thread filters/src/metering/mod.rs Outdated
Comment thread filters/src/metering/mod.rs
Comment thread filters/src/metering/mod.rs
Comment thread filters/src/metering/mod.rs
@noyitz

noyitz commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@jordigilh thanks for the thorough pass — all six inline findings are addressed in 79c3709 (replied on each thread), make test 4,913 green, lint green. On the broader notes:

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (6th pass)

All four code findings from prior reviews have been addressed:

  • HeaderName::from_static("x-api-key") (fixed, line 667)
  • Prefix validation for valid header characters (fixed, config.rs lines 99–106)
  • Multi-value identity header test (added, tests.rs lines 411–426)
  • Redundant per-request to_ascii_lowercase (pre-lowered at construction, line 229)

Assertion messages (R5) are partially addressed: security and spoofing tests now include messages, config/parsing tests still lack them.

One new medium finding below.

Severity Count (new)
Critical 0
Large 0
Medium 1

Comment thread filters/src/metering/mod.rs

@jordigilh jordigilh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — all six findings resolved: balance-check contract documented, tier-gating fixed, configurable metadata namespace added, failure-reporting metric added, nested-JSON model extraction hardened (79c3709), and the cross-filter ordering-constraint gap now has a tracked follow-up (ai#812).

noyitz added 2 commits August 31, 2026 09:41
…usage reporting

Add an external_metering filter that integrates the gateway with an
external metering service:

- Pre-request balance check against the metering service's entitlement
  endpoint, with configurable fail-open behavior: 429 when the tenant's
  token budget is exhausted, 503 when metering is unreachable and
  fail_open is disabled.
- Post-response usage reporting as CloudEvents 1.0 events
  (inference.tokens.used / inference.request.error), fire-and-forget so
  a slow metering service never delays a response the upstream already
  answered. Token counts are read from the token_count filter's
  filter_metadata keys, including the prompt cache breakdown.
- Three-tier identity resolution, most trusted source first: verified
  unnamespaced {prefix}* metadata written by an authentication filter,
  then the identity_header_guard's namespaced identity.{prefix}*
  metadata, then raw {prefix}* request headers for deployments where a
  trusted upstream auth layer injects them. Once a higher tier supplies
  identity, lower tiers are ignored entirely so forged headers cannot
  override verified claims. Identity headers and client credentials
  (authorization, x-api-key) are always stripped before the request is
  forwarded upstream.

HTTP callouts use praxis-core's SubRequestClient. register_ai_filters
threads the shared server-level client into the filter following the
existing openai_file_resolve pattern, and
praxis_ai_apis::subrequest::execute_url is made public so filters can
execute full-URL sub-requests.

Part of praxis-proxy#577.

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
…ction, and observability

Review follow-up, one finding per change:

- Gate the lower identity tiers on any verified field, not just the
  username: an auth filter may map only some claims (e.g. group without
  username), and a partially verified identity must not be extended by
  forgeable sources.
- Make the identity_header_guard metadata namespace configurable
  (identity_metadata_namespace, default "identity") instead of
  hardcoding the "identity." prefix.
- Extract the model with a string- and depth-aware scanner that only
  matches the top-level "model" key, so a decoy inside message content
  can no longer misattribute the request.
- Count failed usage-report deliveries in a
  praxis_ai_metering_report_failures_total metric and log them at warn,
  so dropped billing events are visible on a dashboard.
- Document the balance-check contract: the metering service expresses
  denial only via 2xx + hasAccess=false, so any non-2xx is handled by
  the availability policy; log those at warn with the status.
- Strip x-api-key via HeaderName::from_static instead of a fallible
  parse that silently skipped removal on error.
- Validate that identity_header_prefix contains only valid HTTP header
  name characters, so a misconfigured prefix fails at startup instead
  of silently matching nothing.
- Lowercase the prefix once at construction and compare against
  HeaderName::as_str() directly, dropping a per-header allocation.
- Surface the three-tier identity design in the struct documentation so
  the generated filter docs carry it.

Part of praxis-proxy#577.

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
@noyitz
noyitz force-pushed the feat/external-metering-identity-headers branch from f4ab2b1 to 190db56 Compare August 31, 2026 16:42

@leseb leseb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly stamping since @jordigilh did some thorough reviews already, thanks!

@leseb
leseb enabled auto-merge September 1, 2026 10:25
Comment thread filters/src/metering/tests.rs Outdated

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Re-review

Re-review: new commits since last review on 2026-08-20.

The force-pushed commits address four of the six prior findings (from_static for x-api-key, prefix validation, multi-value header test, pre-lowered prefix). The query-string encoding gap and partial assertion-message gap remain open from prior reviews and are not repeated here.

Summary: One new medium finding — the default_username and default_model fallback paths lack runtime test coverage.

Severity Count
Critical 0
Large 0
Medium 1

Comment thread filters/src/metering/tests.rs
@leseb

leseb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@noyitz see ci failures

…ame in assert

- examples/configs/external-metering.yaml: add insecure_options.allow_private_endpoints
  (loopback backend endpoints now require the opt-in, per schema validation added on main)
- filters/src/metering/tests.rs: drop username value from assert message (CodeQL:
  cleartext logging of sensitive information)

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
auto-merge was automatically disabled September 1, 2026 15:20

Head branch was pushed to by a user without write access

…backs

Extract the report-time model resolution into resolve_report_model so the
fallback chain (captured model, body/header metadata, configured default)
is directly testable. Add a runtime test for default_username metering an
unauthenticated request, per praxis-bot review feedback.

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants