feat(metering): add external metering filter with balance checks and usage reporting - #581
feat(metering): add external metering filter with balance checks and usage reporting#581noyitz wants to merge 2 commits into
Conversation
praxis-bot
left a comment
There was a problem hiding this comment.
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:
- [Medium]
strip_client_credentialsusesif let Okfor an infallibleHeaderNameparse, creating a silent-failure pattern for security-critical credential stripping. UseHeaderName::from_static("x-api-key")instead. - [Medium]
validate_configonly 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. - [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.
| fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) { | ||
| ctx.request_headers_to_remove.push(http::header::AUTHORIZATION); | ||
|
|
||
| if let Ok(name) = "x-api-key".parse::<HeaderName>() { |
There was a problem hiding this comment.
[Medium] "x-api-key".parse::<HeaderName>() is infallible for this input, but if let Ok silently swallows a hypothetical failure. In a credential-stripping function, silent failure to remove a header is a security concern. Replace with HeaderName::from_static("x-api-key") which is compile-time validated and makes the intent unambiguous:
fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) {
ctx.request_headers_to_remove.push(http::header::AUTHORIZATION);
ctx.request_headers_to_remove
.push(HeaderName::from_static("x-api-key"));
}There was a problem hiding this comment.
@praxis-bot fixed in 79c3709 — HeaderName::from_static("x-api-key"), compile-time validated, no silently-swallowed branch.
|
|
||
| /// Validate config at construction time. | ||
| pub(super) fn validate_config(cfg: &ExternalMeteringConfig) -> Result<(), FilterError> { | ||
| if cfg.identity_header_prefix.is_empty() { |
There was a problem hiding this comment.
[Medium] This validates non-empty but not that the prefix consists of valid HTTP header name characters (RFC 7230 tchar). A misconfigured prefix like "x tenant " or "x-tenant\t" would silently match no headers, leaving tenant identity unstripped while the filter reports healthy. Add a validation that the lowercased prefix can form a valid HeaderName prefix, e.g.:
if HeaderName::from_bytes(cfg.identity_header_prefix.as_bytes()).is_err()
&& HeaderName::from_bytes(
format!("{}test", cfg.identity_header_prefix).as_bytes(),
).is_err()
{
return Err("external_metering: identity_header_prefix contains invalid header name characters".into());
}Or simply attempt HeaderName::from_bytes(format!("{prefix}x").as_bytes()) and reject on error, since the prefix must be combinable with a suffix to form a valid header name.
There was a problem hiding this comment.
@praxis-bot fixed in 79c3709 — validate_config now rejects any prefix that is not a valid HTTP header name via HeaderName::from_bytes, with a test for a prefix containing spaces.
| let mut req = make_request(http::Method::POST, "/v1/chat/completions"); | ||
| req.headers.insert("x-myco-username", "bob".parse().unwrap()); | ||
| let mut ctx = make_filter_context(&req); | ||
|
|
There was a problem hiding this comment.
[Medium] Missing test for multi-value tenant headers. A client could send x-tenant-username: alice and x-tenant-username: mallory on the same request. Add a test that inserts duplicate values for the same tenant header key and asserts the key appears in request_headers_to_remove (confirming all values are targeted for removal).
There was a problem hiding this comment.
@praxis-bot added in 79c3709 — multi_value_identity_header_is_fully_stripped appends a duplicate x-tenant-username and asserts the name lands in request_headers_to_remove (removal by name drops every value).
praxis-bot
left a comment
There was a problem hiding this comment.
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:
- [Medium]
strip_identity_headersperforms two unnecessary per-request allocations: loweringHeaderName::as_str()(already lowercase byhttpcrate contract) and lowering the immutable prefix on every call instead of once at construction.
| let prefix_lower = prefix.to_ascii_lowercase(); | ||
|
|
||
| for key in ctx.request.headers.keys() { | ||
| if key.as_str().to_ascii_lowercase().starts_with(prefix_lower.as_str()) { |
There was a problem hiding this comment.
[Medium] HeaderName::as_str() already returns lowercase (the http crate normalizes header names on construction), so to_ascii_lowercase() on the key is a redundant String allocation per header per request. Additionally, the prefix is immutable after construction, so its lowercase form should be computed once in build() rather than on every request.
Pre-lower the prefix at construction:
Ok(Self {
identity_header_prefix: cfg.identity_header_prefix.to_ascii_lowercase(),
})Then simplify the hot-path function to zero allocations:
fn strip_identity_headers(ctx: &mut HttpFilterContext<'_>, prefix: &str) {
for key in ctx.request.headers.keys() {
if key.as_str().starts_with(prefix) {
ctx.request_headers_to_remove.push(key.clone());
}
}
}The header_matching_ignores_case test continues to pass because the http crate already lowercases the header name at insertion and the prefix is now pre-lowered at init.
There was a problem hiding this comment.
@praxis-bot fixed in 79c3709 — the prefix is lowercased once at construction and compared against HeaderName::as_str() directly; the per-header to_ascii_lowercase() allocation is gone.
praxis-bot
left a comment
There was a problem hiding this comment.
No new findings beyond prior review.
praxis-bot
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:
- [Medium] Unit test assertions missing messages (
tests.rs). All 15assert!/assert_eq!calls lack a message string. The workspace enforcesmissing_assert_message = "deny"(Cargo.toml line 223), and the test module's#[allow]only coversunwrap_usedandexpect_used. The integration tests correctly include messages on every assertion.
Open prior findings (no code changes since last review):
HeaderName::from_static("x-api-key")instead of fallible.parse()withif let Ok(mod.rs:102)- Prefix validation for valid HTTP header characters (config.rs:30)
- Missing multi-value tenant header test (tests.rs)
- Redundant per-request
to_ascii_lowercaseon both key and prefix (mod.rs:88)
| let filter = filter_from_yaml("{}"); | ||
|
|
||
| assert_eq!(filter.name(), "external_metering"); | ||
| assert_eq!(filter.identity_header_prefix, "x-tenant-"); |
There was a problem hiding this comment.
[Medium] All 15 assert!/assert_eq! calls in the unit tests omit assertion messages. The workspace enforces missing_assert_message = "deny" (Cargo.toml line 223), and the test module's #[allow] only covers unwrap_used and expect_used — it does not suppress missing_assert_message.
The integration tests correctly include messages on every assertion. The unit tests should follow the same practice for consistent diagnostics on failure.
Example fix for this line:
assert_eq!(filter.name(), "external_metering", "filter name should be external_metering");
assert_eq!(filter.identity_header_prefix, "x-tenant-", "default prefix should be x-tenant-");There was a problem hiding this comment.
@praxis-bot make lint (clippy --workspace --all-targets -D warnings) is green on this branch, so the workspace deny does not fire on these asserts as written. All tests added since carry messages; happy to sweep the earlier ones too if maintainers want it in this PR.
|
@noyitz please resolve the conflicts and address the bot's comments thanks |
1bea336 to
ff138bb
Compare
…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>
ff138bb to
7018d3c
Compare
jordigilh
left a comment
There was a problem hiding this comment.
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.
|
|
||
| match result { | ||
| Ok(resp) if is_success(resp.status) => parse_balance_result(&resp.body, self.fail_open), | ||
| Ok(resp) => { |
There was a problem hiding this comment.
Any non-2xx from the metering service (403/404/500) hits the same on_metering_unavailable() path as a real outage (L239-242) -- under the default fail_open: true, an explicit denial gets silently admitted. Is a 4xx from this API ever meant to signal denial vs. unavailability? If so, worth splitting the two paths.
There was a problem hiding this comment.
@jordigilh good question — checked the service contract: the metering service expresses denial only through a 2xx response carrying hasAccess: false (its only non-2xx is a 400 for a malformed path), so a 4xx here always means the service itself is misbehaving, never an intentional denial. 79c3709 documents that contract on check_balance and raises the non-2xx log to warn! with the status so a misbehaving service is visible. If a future backend wants status-coded denials, splitting the paths would be a config-gated follow-up.
|
|
||
| let mut identity = Identity::default(); | ||
| read_metadata_identity(ctx, &prefix_lower, "", &mut identity); | ||
| let has_verified_identity = !identity.username.is_empty(); |
There was a problem hiding this comment.
has_verified_identity only checks username (L554) -- if tier 1 sets group/subscription/model without username, tier 2 unconditionally overwrites those verified fields. Not just theoretical: jwt_auth (#769) sets each claim independently, and its validate_config doesn't require a username mapping. Worth gating on "any tier-1 field present" instead.
There was a problem hiding this comment.
@jordigilh fixed in 79c3709 — the gate is now "any verified field present" (username, group, subscription, or model), so an auth filter that maps only some claims (exactly the #769 case) blocks the lower tiers entirely. Covered by a new test: a tier-1 group-only identity rejects both a guard-metadata subscription and a raw-header username.
| let has_verified_identity = !identity.username.is_empty(); | ||
|
|
||
| if !has_verified_identity { | ||
| read_metadata_identity(ctx, &prefix_lower, "identity.", &mut identity); |
There was a problem hiding this comment.
"identity." is hardcoded here, but identity_header_guard (#709) makes that namespace configurable (default "identity"). Works today only because both default -- if an operator changes it, tier 2 silently stops resolving with no fallback. Worth a matching config field.
There was a problem hiding this comment.
@jordigilh fixed in 79c3709 — new identity_metadata_namespace config field (default identity), validated non-empty, with a test resolving tier 2 through a custom namespace. The rustdoc notes it must match identity_header_guard's metadata_namespace.
| } | ||
|
|
||
| /// Log the outcome of a usage report delivery. | ||
| fn report_delivery(result: Result<SubResponse, SubRequestError>) { |
There was a problem hiding this comment.
Only signal on delivery failure is a debug! line -- no metric, and SubRequestClient doesn't emit one either. A metering blip during a traffic spike would be invisible. A metrics::counter! here (already a dependency) would fix that.
There was a problem hiding this comment.
@jordigilh fixed in 79c3709 — added praxis_ai_metering_report_failures_total, incremented on both transport failure and non-2xx acknowledgement, and raised those logs to warn!. Naming follows the existing praxis_ai_ttft_seconds convention.
| /// Request bodies arrive in chunks and can be megabytes long, so this scans for | ||
| /// the field instead of buffering and deserializing the whole document. Returns | ||
| /// `None` when the chunk does not contain a complete `"model": "..."` pair. | ||
| fn extract_model_from_bytes(bytes: &[u8]) -> Option<String> { |
There was a problem hiding this comment.
Substring scan for "model" rather than JSON parsing -- a content field containing "model": ahead of the real field gets misattributed. Confirmed reachable:
let body = br#"{"messages":[{"role":"user","content":"hi","model":"decoy"}],"model":"gpt-4"}"#;
assert_eq!(extract_model_from_bytes(body).as_deref(), Some("decoy")); // should be "gpt-4"token_count already parses this correctly (serde_json::from_slice) -- worth reusing that approach.
There was a problem hiding this comment.
@jordigilh confirmed and fixed in 79c3709 — replaced the substring scan with a string- and depth-aware scanner that only matches a "model" key belonging to the top-level object, still without buffering or deserializing the document. Your repro is now a test (extract_model_ignores_nested_decoy), plus decoy-inside-string-value and nested-only-decoy cases.
| //! Reads token counts from [`filter_metadata`] keys set by the `token_count` | ||
| //! filter (`token.input`, `token.output`, `token.total`, and the prompt cache | ||
| //! breakdown `token.cache_read` / `token.cache_write`). The metering filter | ||
| //! must be declared *before* `token_count` in the YAML filter chain so that |
There was a problem hiding this comment.
This ordering requirement isn't enforced anywhere -- praxis-core's FilterPipeline::ordering_errors() hard-fails at startup, but only checks core's own built-in filter names, not cross-crate ai filters. Wrong order -> every event ships with token: 0, silently, forever.
There was a problem hiding this comment.
@jordigilh agreed — core's ordering_errors() can't see ai-crate filters, so this is only documented today. Rather than a filter-local workaround, this seems worth a small upstream mechanism (filters declaring ordering constraints the pipeline validates at build). Happy to open a follow-up issue proposing that if you agree.
…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>
|
@jordigilh thanks for the thorough pass — all six inline findings are addressed in 79c3709 (replied on each thread),
|
praxis-bot
left a comment
There was a problem hiding this comment.
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 |
| let feature = utf8_percent_encode(feature_key, PATH_SEGMENT); | ||
| let model = utf8_percent_encode(model, PATH_SEGMENT); | ||
|
|
||
| format!("{base}/api/v1/customers/{customer}/entitlements/{feature}/value?model={model}") |
There was a problem hiding this comment.
[Medium] Query-string parameter injection: the model value is percent-encoded with PATH_SEGMENT, which does not encode & (0x26) or = (0x3D). Since the model is interpolated into a query parameter (?model={model}), a model name containing & (e.g. a custom alias like gpt-4&v=2) would produce ...?model=gpt-4&v=2, causing the metering server to parse two parameters (model=gpt-4 and v=2) and check the balance against a truncated model name.
customer and feature are in path segments where &/= are legal, so they can keep using PATH_SEGMENT. Only the query-string value needs the additional escaping:
const QUERY_VALUE: &AsciiSet = &PATH_SEGMENT.add(b'&').add(b'=');Then in build_balance_url:
let model = utf8_percent_encode(model, QUERY_VALUE);Standard model names (gpt-4, claude-sonnet-4, llama-3.1-8b) are unaffected, but custom model aliases or model names sourced from untrusted input could trigger an incorrect balance check.
Adds an
external_meteringHTTP filter: pre-request balance checks andpost-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 meteringservice whether the tenant may spend more tokens:
GET {metering_url}/api/v1/customers/{username}/entitlements/{feature_key}/value?model={model}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, orinference.request.erroron upstream failure) toPOST {metering_url}/api/v1/events. Delivery is fire-and-forget: a slow orfailing metering service never delays a response the upstream already
answered. Token counts come from the
token_countfilter'sfilter_metadatakeys (
token.input/output/totaland the prompt cache breakdown from #582).Identity resolution — three tiers, most trusted first
{prefix}*keys) written by anauthentication filter from verified credentials. When present, every lower
tier is ignored entirely, so a client cannot spoof
subscriptionormodelvia forged headers alongside valid credentials.identity.{prefix}*keys) written by theidentity_header_guardfilter (feat(filter): add identity header guard filter #709), which owns identity header capture.{prefix}*headers, for deployments where a trusted upstream authlayer (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'sSubRequestClient(theCalloutClientnamedin #577 was removed upstream in #849).
register_ai_filtersthreads the sharedserver-level client into the filter following the existing
openai_file_resolvepattern; without one the filter creates a privateconnector, same as
openai_file_resolve::from_config.praxis_ai_apis::subrequest::execute_urlis made public so filters can executefull-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
filters/src/metering/tests.rs), including spoofing-closuretests for the tier precedence rules
examples/configs/external-metering.yaml(balance allowed, fail-closed rejection, header stripping, no-identity skip)
make test— 4,905 passed, 0 failed;make lintgreen