Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub mod openai;
pub mod promotion;
#[cfg(feature = "store")]
pub mod store;
pub(crate) mod subrequest;
pub mod subrequest;
pub(crate) mod web_search;

/// Whether a `Content-Type` header value indicates `text/event-stream`,
Expand Down
10 changes: 8 additions & 2 deletions apis/src/subrequest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use std::{future::Future, net::SocketAddr, time::Duration};

use pingora_core::upstreams::peer::HttpPeer;
pub(crate) use praxis_core::subrequest::{SubRequest, SubRequestClient, SubRequestError, SubResponse};
pub use praxis_core::subrequest::{SubRequest, SubRequestClient, SubRequestError, SubResponse};
use tracing::debug;

/// Parsed URL components needed to resolve and execute a request.
Expand Down Expand Up @@ -103,7 +103,13 @@ async fn with_deadline<T>(
/// The configured timeout covers URL resolution and the complete HTTP
/// exchange. All resolved addresses are tried in order when connecting,
/// while the original URL authority is preserved in `Host`.
pub(crate) async fn execute_url(
///
/// # Errors
///
/// Returns [`SubRequestError`] when the URL is invalid, DNS resolution
/// fails, every resolved address refuses the connection, the exchange
/// fails, or the deadline expires.
pub async fn execute_url(
client: &SubRequestClient,
url: &str,
request: SubRequest,
Expand Down
39 changes: 39 additions & 0 deletions docs/filters/external_metering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!-- Generated by: cargo xtask generate-filter-docs -->
<!-- Do not edit manually -->

# `external_metering`

Integrates with an external metering service for pre-request balance checks and post-response token usage reporting.

## Configuration Notes

Tenant identity is resolved from the highest-trust source available: verified `{prefix}*` metadata written by an authentication filter, then the `identity_header_guard` filter's namespaced `{namespace}.{prefix}*` metadata, then raw `{prefix}*` request headers. A higher tier always wins, so forged client headers can never override verified claims, and identity headers plus client credentials are always stripped before the request is forwarded.

## Configuration

| Field | Type | Required | Description |
|-------|------|---------|-------------|
| `metering_url` | string | yes | Base URL of the external metering service (required). |
| `timeout_seconds` | integer | no | HTTP timeout in seconds for all metering calls. |
| `feature_key` | string | no | Entitlement feature key used in balance check URL path. |
| `source` | string | no | `CloudEvents` `source` field value. |
| `fail_open` | bool | no | When `true` (default), requests proceed if the metering service is unavailable. When `false`, requests are rejected with 503. |
| `identity_header_prefix` | string | no | Prefix for tenant identity headers to capture and strip. Expected headers: `{prefix}username`, `{prefix}group`, `{prefix}subscription`, `{prefix}model`. |
| `identity_metadata_namespace` | string | no | Metadata namespace the `identity_header_guard` filter writes captured identity headers under. Must match that filter's `metadata_namespace` setting when both run in one pipeline. |
| `default_username` | string | no | Fallback username when no identity header is present. If set, requests without `{prefix}username` are still metered under this name. If unset, metering is skipped entirely. |
| `default_model` | string | no | Fallback model name when no identity model header is present. |

## Example

```yaml
filter: external_metering
metering_url: "http://metering-service:8080"
timeout_seconds: 5
feature_key: "inference-tokens"
source: "ai-gateway"
fail_open: true
identity_header_prefix: "x-tenant-"
identity_metadata_namespace: "identity"
default_username: "anonymous"
default_model: "unknown"
```
6 changes: 6 additions & 0 deletions docs/filters/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ see the [Praxis core filter reference][core-ref].
|--------|-------------|
| [`model_to_header`](model_to_header.md) | Promotes the JSON `"model"` field from the request body to a request header. |

### Metering

| Filter | Description |
|--------|-------------|
| [`external_metering`](external_metering.md) | Integrates with an external metering service for pre-request balance checks and post-response token usage reporting. |

### Prompt Enrich

| Filter | Description |
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ before sending requests.
| [aws-sigv4.yaml](configs/aws-sigv4.yaml) | Signs outbound requests to an AWS service (Bedrock, in this example) using Signature Version 4. Credentials are static, sourced from environment variables — see the module docs on Sigv4SignFilter for the planned OIDC/default-credential-chain follow-up |
| [azure-ad.yaml](configs/azure-ad.yaml) | Acquires an Entra ID bearer token via the client-credentials grant and injects "Authorization: Bearer <token>" on every proxied request to Azure OpenAI |
| [credential-injection.yaml](configs/credential-injection.yaml) | Injects per-cluster API credentials into upstream requests and strips client-provided credentials to prevent forwarding |
| [external-metering.yaml](configs/external-metering.yaml) | Pre-request balance check and post-response token usage reporting against an external metering service |
| [gcp-adc.yaml](configs/gcp-adc.yaml) | Establishes the gcp_adc filter's configuration surface and fail-closed behavior |
| [intelligent-route-all-capabilities.yaml](configs/intelligent-route-all-capabilities.yaml) | Demonstrates every candidate capability and selection input handled by intelligent_route today |
| [intelligent-route-inference.yaml](configs/intelligent-route-inference.yaml) | Routes requests to different upstream clusters based on the inference model name extracted from a configured request header. The header value is set by an earlier filter such as `json_body_field` |
Expand Down
69 changes: 69 additions & 0 deletions examples/configs/external-metering.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# External Metering
#
# Pre-request balance check and post-response token usage reporting
# against an external metering service. Identity headers are captured
# from configurable tenant headers (default prefix: x-tenant-) and
# stripped before forwarding upstream.
#
# Usage:
# cargo run -p praxis-ai-proxy -- -c examples/configs/external-metering.yaml
# curl -X POST http://localhost:8080/v1/chat/completions \
# -H "Content-Type: application/json" \
# -H "x-tenant-username: alice" \
# -H "x-tenant-group: engineering" \
# -H "x-tenant-subscription: sub-42" \
# -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}'
#
# Pipeline ordering matters: external_metering is declared before
# token_count so that response hooks (which run in reverse order)
# execute token_count first, writing token.input / token.output /
# token.total to filter_metadata, then external_metering reads them
# and sends a CloudEvent to the metering service.
#
# Balance check endpoint:
# GET {metering_url}/api/v1/customers/{username}/entitlements/{feature_key}/value?model={model}
#
# Usage report endpoint:
# POST {metering_url}/api/v1/events (CloudEvents 1.0 JSON)

listeners:
- name: default
address: "127.0.0.1:8080"
filter_chains:
- main

filter_chains:
- name: main
filters:
- filter: router
routes:
- path_prefix: "/"
cluster: backend

- filter: external_metering
metering_url: "http://127.0.0.1:9090"
timeout_seconds: 5
feature_key: "inference-tokens"
source: "ai-gateway"
fail_open: true
identity_header_prefix: "x-tenant-"
# Namespace the identity_header_guard filter writes captured headers
# under; must match that filter's metadata_namespace when both run.
# identity_metadata_namespace: "identity"
# Optional fallbacks for deployments where an upstream authentication
# layer does not inject identity headers. Without default_username,
# requests carrying no identity header are not metered at all.
# default_username: "anonymous"
# default_model: "unknown"

- filter: token_count
provider: openai

- filter: load_balancer
clusters:
- name: backend
endpoints:
- "127.0.0.1:3000"

insecure_options:
allow_private_endpoints: true # example proxies to local backends
1 change: 1 addition & 0 deletions filters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ futures = { workspace = true }
http = { workspace = true }
metrics = { workspace = true }
notify = { workspace = true }
percent-encoding = { workspace = true }
pingora-core.workspace = true
praxis-ai-apis = { workspace = true }
praxis-core = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions filters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod callout;
pub mod gcp;
pub mod guardrails;
pub mod inference;
pub mod metering;
#[cfg(feature = "opentelemetry")]
mod opentelemetry;
pub mod prompt_enrich;
Expand All @@ -38,6 +39,7 @@ pub use callout::HttpCalloutFilter;
pub use gcp::GcpAdcFilter;
pub use guardrails::AiGuardrailsFilter;
pub use inference::ModelToHeaderFilter;
pub use metering::ExternalMeteringFilter;
pub use prompt_enrich::PromptEnrichFilter;
pub use register::{build_ai_registry, register_ai_filters};
pub use routing::{CredentialInjectFilter, IntelligentRouteFilter, ProviderRouteFilter};
Expand Down
143 changes: 143 additions & 0 deletions filters/src/metering/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Praxis Contributors

//! Deserialized YAML configuration types for the external metering filter.

use praxis_filter::FilterError;
use serde::Deserialize;

/// Default HTTP timeout for metering service calls (5 seconds).
const DEFAULT_TIMEOUT_SECONDS: u64 = 5;

/// Default entitlement feature key for balance checks.
const DEFAULT_FEATURE_KEY: &str = "inference-tokens";

/// Default `CloudEvents` `source` field.
const DEFAULT_SOURCE: &str = "ai-gateway";

/// Default header prefix for tenant identity headers.
const DEFAULT_IDENTITY_HEADER_PREFIX: &str = "x-tenant-";

/// Default metadata namespace the `identity_header_guard` filter writes
/// captured identity headers under.
const DEFAULT_IDENTITY_METADATA_NAMESPACE: &str = "identity";

/// Deserialized YAML config for the `external_metering` filter.
///
/// ```yaml
/// filter: external_metering
/// metering_url: "http://metering-service:8080"
/// timeout_seconds: 5
/// feature_key: "inference-tokens"
/// source: "ai-gateway"
/// fail_open: true
/// identity_header_prefix: "x-tenant-"
/// identity_metadata_namespace: "identity"
/// default_username: "anonymous"
/// default_model: "unknown"
/// ```
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ExternalMeteringConfig {
/// Base URL of the external metering service (required).
pub metering_url: String,

/// HTTP timeout in seconds for all metering calls.
#[serde(default = "default_timeout_seconds")]
pub timeout_seconds: u64,

/// Entitlement feature key used in balance check URL path.
#[serde(default = "default_feature_key")]
pub feature_key: String,

/// `CloudEvents` `source` field value.
#[serde(default = "default_source")]
pub source: String,

/// When `true` (default), requests proceed if the metering service
/// is unavailable. When `false`, requests are rejected with 503.
#[serde(default = "default_true")]
pub fail_open: bool,

/// Prefix for tenant identity headers to capture and strip.
/// Expected headers: `{prefix}username`, `{prefix}group`,
/// `{prefix}subscription`, `{prefix}model`.
#[serde(default = "default_identity_header_prefix")]
pub identity_header_prefix: String,

/// Metadata namespace the `identity_header_guard` filter writes captured
/// identity headers under. Must match that filter's
/// `metadata_namespace` setting when both run in one pipeline.
#[serde(default = "default_identity_metadata_namespace")]
pub identity_metadata_namespace: String,

/// Fallback username when no identity header is present.
/// If set, requests without `{prefix}username` are still metered
/// under this name. If unset, metering is skipped entirely.
#[serde(default)]
pub default_username: Option<String>,

/// Fallback model name when no identity model header is present.
#[serde(default)]
pub default_model: Option<String>,
}

/// Validate config at construction time.
pub(super) fn validate_config(cfg: &ExternalMeteringConfig) -> Result<(), FilterError> {
if cfg.metering_url.is_empty() {
return Err("external_metering: metering_url must not be empty".into());
}

if cfg.timeout_seconds == 0 {
return Err("external_metering: timeout_seconds must be greater than 0".into());
}

if cfg.identity_header_prefix.is_empty() {
return Err("external_metering: identity_header_prefix must not be empty".into());
}

// A prefix with characters that cannot appear in an HTTP header name
// (e.g. a space) would silently match nothing, leaving tenant identity
// headers unstripped while the filter reports healthy.
if http::header::HeaderName::from_bytes(cfg.identity_header_prefix.as_bytes()).is_err() {
return Err(
"external_metering: identity_header_prefix must contain only valid HTTP header name characters".into(),
);
}

if cfg.identity_metadata_namespace.is_empty() {
return Err("external_metering: identity_metadata_namespace must not be empty".into());
}

Ok(())
}

/// Serde default for `timeout_seconds`.
fn default_timeout_seconds() -> u64 {
DEFAULT_TIMEOUT_SECONDS
}

/// Serde default for `feature_key`.
fn default_feature_key() -> String {
DEFAULT_FEATURE_KEY.to_owned()
}

/// Serde default for `source`.
fn default_source() -> String {
DEFAULT_SOURCE.to_owned()
}

/// Serde default for `fail_open`.
fn default_true() -> bool {
true
}

/// Serde default for `identity_header_prefix`.
fn default_identity_header_prefix() -> String {
DEFAULT_IDENTITY_HEADER_PREFIX.to_owned()
}

/// Serde default for `identity_metadata_namespace`.
fn default_identity_metadata_namespace() -> String {
DEFAULT_IDENTITY_METADATA_NAMESPACE.to_owned()
}
Loading
Loading