diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 13e3cde..edfaf08 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -32,6 +32,14 @@ jobs: env: NODE_OPTIONS: '--max-old-space-size=4096' + - name: Lint examples catalog + run: | + git clone --depth 1 https://github.com/praxis-proxy/praxis.git /tmp/praxis-examples-src + git clone --depth 1 https://github.com/praxis-proxy/ai.git /tmp/ai-examples-src + PRAXIS_EXAMPLES_README=/tmp/praxis-examples-src/examples/README.md \ + AI_EXAMPLES_README=/tmp/ai-examples-src/examples/README.md \ + bash scripts/lint-examples-catalog.sh + - name: Setup Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 diff --git a/README.md b/README.md index c58b12d..96dd936 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,31 @@ # Praxis Website Source for [praxis.fast](https://praxis.fast), the project website for -[Praxis](https://github.com/praxis-proxy/praxis). +[Praxis](https://github.com/praxis-proxy/praxis) and +[praxis-ai](https://github.com/praxis-proxy/ai). + +## Documentation sync + +Source of truth for user docs: + +- Core proxy: `praxis/docs/` +- AI gateway: `ai/docs/` +- Generated filter reference: `cargo xtask generate-filter-docs` in each repo + +Port content to this site on release (or when docs change materially). +Run `npm run build` before merging — `onBrokenLinks: 'throw'` fails the +build on broken internal links. + +The examples catalog (`src/data/examples.ts`) should stay aligned with +`praxis/examples/README.md` and `ai/examples/README.md`. Check locally: + +```console +PRAXIS_EXAMPLES_README=../praxis/examples/README.md \ +AI_EXAMPLES_README=../ai/examples/README.md \ +bash scripts/lint-examples-catalog.sh +``` + +CI runs the same check against the upstream `praxis` and `ai` repos. ## Development diff --git a/docs/ai/_category_.json b/docs/ai/_category_.json new file mode 100644 index 0000000..63ca039 --- /dev/null +++ b/docs/ai/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "AI Gateway", + "position": 4 +} diff --git a/docs/ai/agentic-protocols.md b/docs/ai/agentic-protocols.md new file mode 100644 index 0000000..4ae6403 --- /dev/null +++ b/docs/ai/agentic-protocols.md @@ -0,0 +1,215 @@ +--- +title: Agentic Protocols +--- + +# Agentic Protocols + +JSON-RPC 2.0 envelope parsing and protocol-specific +metadata extraction for MCP and A2A agent traffic. + +## Overview + +Three filters form a layered stack for agentic +protocol support. Each parses the JSON-RPC envelope +from request bodies and promotes protocol-specific +metadata to headers, metadata, and filter results. + +```text + JSON-RPC 2.0 Envelope + | + +------------+------------+ + | | + MCP Filter A2A Filter + | | + +--------+--------+ +-------+-------+ + | | | | + Broker Tool/ Task SSE + (catalog, Resource Routing Scanner + session) metadata (streaming) +``` + +## JSON-RPC Foundation + +The `json_rpc` filter extracts JSON-RPC 2.0 envelope +metadata from HTTP POST request bodies: + +- **Kind**: Request, Notification, Response, or Batch +- **Method**: the `method` string field +- **ID**: the `id` field (string, integer, or null) +- **Batch length**: element count for batch requests + +Both MCP and A2A filters reuse `parse_json_rpc_value` +as their first parsing step, then layer +protocol-specific extraction on top. + +### Configuration + +- `max_body_bytes`: maximum body size to parse + (default 1 MiB) +- `batch_policy`: `reject` (default) or `first` + (extract metadata from the first element) +- `on_invalid`: `continue` (default), `reject` + (400), or `error` (JSON-RPC error response) +- Header names are configurable (default + `X-Json-Rpc-Method`, `X-Json-Rpc-Id`, + `X-Json-Rpc-Kind`) + +## MCP Protocol + +The `mcp` filter extracts Model Context Protocol +metadata and optionally acts as a tool catalog +broker. + +### Metadata Extraction + +From the JSON-RPC envelope, the filter extracts: + +- **Method**: 14 known MCP methods + (`initialize`, `tools/list`, `tools/call`, + `resources/read`, `prompts/get`, `ping`, etc.) + plus `Other(String)` for extensions +- **Name**: tool, resource, or prompt name from + `params` (for methods like `tools/call`) +- **Protocol version**: from `initialize` params or + `Mcp-Protocol-Version` header +- **Session ID**: from `Mcp-Session-Id` header + +### Header Validation + +The filter validates `Mcp-Method` and `Mcp-Name` +headers against body-derived values. Configurable +behavior on mismatch (`reject` or `ignore`) and on +missing headers (`ignore`, `synthesize`, or +`reject`). + +### Broker Architecture + +When the `servers` key is present in config, the +filter activates as an MCP broker with a static +tool catalog: + +```text +Client --> MCP Broker Filter + | + +-- initialize: session + version + | negotiation + +-- tools/list: aggregated catalog + | from all servers + +-- ping: local response + +-- tools/call: not yet supported + | (returns -32601) + +-- DELETE: session termination +``` + +Each server entry specifies a name, upstream cluster, +path prefix, tool prefix (for namespace isolation), +and tool list. The broker aggregates tools from all +configured servers into a single catalog response. + +### Filter Results + +Written under key `"mcp"` with fields: `method`, +`name`, `protocol_version`, `session_present`, +`kind`. + +## A2A Protocol + +The `a2a` filter extracts Agent-to-Agent protocol +metadata with optional task-ownership routing. + +### Metadata Extraction + +From the JSON-RPC envelope, the filter extracts: + +- **Method**: 11 known A2A methods (`SendMessage`, + `SendStreamingMessage`, `GetTask`, `ListTasks`, + `CancelTask`, `SubscribeToTask`, push notification + config methods, `GetExtendedAgentCard`) plus + `Unknown(String)` +- **Family**: `Message`, `Task`, + `PushNotification`, `AgentCard`, or `Unknown` +- **Streaming**: whether the method produces SSE +- **Task ID**: from request params (for task + methods) or response bodies +- **Version**: from `A2A-Version` header +- **Method aliases**: configurable mapping for + alternative method names (e.g. slash-style to + PascalCase) + +### Task Routing + +When enabled, the filter maintains an in-process +task-to-cluster mapping for routing follow-up +requests to the agent that owns a task: + +```text +1. SendMessage --> upstream A + response contains task_id: "t1" + store: t1 -> cluster A + +2. GetTask(t1) --> route to cluster A + (looked up from store) + +3. CancelTask(t1) [terminal] --> cluster A + remove: t1 from store (after TTL) +``` + +The `LocalTaskRouteStore` uses `RwLock` +with TTL-based expiry. Non-terminal task states +default to 3600s TTL; terminal states (`completed`, +`failed`, `canceled`, `rejected`) default to 300s. + +### SSE Response Scanning + +For streaming A2A methods, the filter scans response +bodies incrementally using `SseScanState`. The +scanner handles arbitrary chunk boundaries, +CRLF/LF/CR line endings, multi-line `data:` fields, +and scratch buffer overflow. Extracted task routes +from SSE payloads are written to the task route +store. + +### Response Body Processing + +The A2A filter captures response bodies to extract +task routes via two paths: + +- **JSON responses**: hex-encoded buffering in + `filter_metadata`, parsed at end-of-stream +- **SSE responses**: incremental line scanning via + `SseScanState`, routes extracted per event + +### Filter Results + +Written under key `"a2a"` with fields: `method`, +`family`, `streaming`, `kind`, `task_id`, `version`. + +## Shared Patterns + +All three filters share these conventions: + +- **Body access**: `BodyAccess::ReadOnly` + + `BodyMode::StreamBuffer` with configurable + `max_body_bytes` +- **Value length limit**: 256 bytes for any + promoted header or metadata value +- **Control character check**: values are validated + before promotion +- **`on_invalid` policy**: configurable behavior + when the request body is not valid JSON-RPC + (`continue`, `reject`, or `error`) +- **Feature gating**: agentic filters are always + compiled (not behind a feature flag) + +## Key Files + +- `praxis/filter/src/builtins/http/payload_processing/json_rpc/` (core): + JSON-RPC 2.0 envelope parser +- `filters/src/agentic/mcp/`: MCP filter, broker, envelope +- `filters/src/agentic/a2a/`: A2A filter, task routing, SSE scanner + +## Related + +- [AI Inference](/docs/ai/ai-inference) +- [Response Store](/docs/ai/response-store) +- [Features](/docs/getting-started/features) diff --git a/docs/ai/ai-inference.md b/docs/ai/ai-inference.md new file mode 100644 index 0000000..ee0a674 --- /dev/null +++ b/docs/ai/ai-inference.md @@ -0,0 +1,182 @@ +--- +title: Ai Inference +--- + +# AI Inference + +Body-aware classification, routing, and enrichment +for AI inference traffic, built on the filter +pipeline and StreamBuffer body access pattern. + +## Overview + +AI inference filters classify request bodies to +determine the API format (OpenAI Responses, Anthropic +Messages, Chat Completions), extract routing signals +(model, stream mode, store flag), and promote them to +headers, metadata, and filter results for downstream +routing via branch chains. + +```text +Request Body + | + v +Classifier (pure function) + | + v +Format Filter (promotes facts to headers/metadata/results) + | + v +Validate Filter (parameter checks, ID generation) + | + v +Branch Chains / Router (routing decisions) + | + v +Upstream +``` + +## Classification Pipeline + +### Format Detection + +The classifier (`classifier/mod.rs`) is a pure +function with no I/O. It parses the request body +JSON once and returns a `ClassifiedRequest` struct +with extracted facts. + +Detection precedence: + +1. `input` field present or path-based responses + endpoint: **Responses API** +2. `messages` + `max_tokens` + Anthropic signals + (`system` or typed content blocks): + **Anthropic Messages API** +3. `messages` alone: **Chat Completions API** +4. Valid JSON without recognized fields: + **UnknownJson** +5. Invalid JSON: **InvalidJson** +6. Non-JSON content type: **NonJson** + +Path-based classification handles sub-resource +endpoints (`GET /v1/responses/{id}`, +`POST /v1/responses/{id}/cancel`, etc.) that lack +a request body. + +### Metadata Propagation + +The format filter promotes classified facts using +three channels: + +- **Filter metadata**: durable key-value pairs + (e.g. `openai_responses_format.model`) that + persist across Pingora phases. Used for + cross-filter communication. +- **Extra request headers**: added to the upstream + request (e.g. `X-Praxis-AI-Format`). Used for + header-based routing in the router filter. +- **Filter results**: written to `FilterResultSet` + for branch chain condition evaluation. + +All promoted values are validated against a 256-byte +length limit and checked for control characters +before propagation. + +### Stateful vs Stateless + +Responses API requests are classified as "stateful" +when any of these hold: + +- `previous_response_id` is present +- `tools` is present +- `store` is not explicitly `false` +- `background` is `true` +- `has_conversation` is true +- `has_prompt_id` is true + +Stateful mode influences routing decisions (e.g. +directing to clusters with response store access). + +## StreamBuffer Body Access + +StreamBuffer is the key enabler for AI inference +filters. It accumulates request body chunks and +defers upstream forwarding until the filter releases +or end-of-stream: + +1. Buffer the JSON envelope (model name, parameters, + prompt prefix). +2. Extract routing signals from the buffered bytes. +3. Select the upstream based on body content. +4. Release the buffered prefix and stream the + remainder. + +This peek-then-stream pattern avoids the latency of +external processor architectures while providing body +visibility where it matters. + +Filters declare `BodyAccess::ReadOnly` + +`BodyMode::StreamBuffer { max_bytes }` to opt in. +Only `PromptEnrichFilter` uses `ReadWrite` (it +modifies the messages array). + +## Filters + +### `model_to_header` + +Extracts the `model` field from JSON request bodies +and promotes it to a configurable header (default +`X-Model`). Enables header-based routing to +provider-specific clusters. + +### `openai_responses_format` + +Classifies AI API request bodies and promotes format, +model, stream, store, background, and mode to +headers, metadata, and filter results. + +### `openai_responses_validate` + +Validates Responses API parameter combinations +(e.g. stream + background conflicts) and generates +cryptographically random response and conversation +IDs with `resp_` and `conv_` prefixes. + +### `anthropic_messages_format` + +Classifies Anthropic Messages API requests and +promotes format metadata. + +### `prompt_enrich` + +Injects system or user messages into +OpenAI-compatible chat completion request bodies. +Static configured messages are prepended or appended +to the `messages` array. Uses `BodyAccess::ReadWrite`. + +### `credential_injection` (core builtin) + +Per-cluster API key injection with client credential +stripping. Provided by Praxis core, commonly paired with +AI pipelines. See +[core credential_injection docs](/docs/filters/http/security/credential_injection). + +### `openai_response_store` + +Persists non-streaming Responses API responses. See +[Response Store](/docs/ai/response-store) for details. + +## Key Files + +- `apis/src/classifier/mod.rs`: pure format classifier +- `apis/src/openai/responses/`: Responses format, validate, store filters +- `filters/src/inference/model_to_header.rs`: `ModelToHeaderFilter` +- `filters/src/prompt_enrich/`: prompt enrichment filter +- `apis/src/anthropic/`: Anthropic Messages filters +- `praxis/filter/src/body/mode.rs` (core): `BodyMode::StreamBuffer` + +## Related + +- [Response Store](/docs/ai/response-store) +- [Agentic Protocols](/docs/ai/agentic-protocols) +- [Features](/docs/getting-started/features) diff --git a/docs/ai/anthropic-messages.md b/docs/ai/anthropic-messages.md new file mode 100644 index 0000000..cdc309d --- /dev/null +++ b/docs/ai/anthropic-messages.md @@ -0,0 +1,290 @@ +--- +title: Anthropic Messages +--- + +# Anthropic Messages API + +Praxis supports the Anthropic Messages API +(`/v1/messages`) through five composable filters. +Operators can route, validate JSON envelopes, and transform +Anthropic requests to reach any backend. + +## Filters + +| Filter | Purpose | +| ------ | ------- | +| `anthropic_messages_format` | Classify requests and promote routing facts to headers | +| `anthropic_validate` | Validate the JSON request envelope before forwarding | +| `anthropic_messages_protocol` | Header management for native `/v1/messages` backends | +| `anthropic_to_openai` | Bidirectional body transformation to `OpenAI` Chat Completions | +| `anthropic_stream_events` | SSE event transformation (per-chunk streaming, conformant with Inference Proxy Conformance Guidelines) | + +## Passthrough to vLLM + +Route Anthropic requests directly to a backend that +supports `/v1/messages` natively (e.g. vLLM with +Anthropic endpoint enabled). + +```yaml +listeners: + - name: gateway + address: "0.0.0.0:8080" + filter_chains: [anthropic] + +filter_chains: + - name: anthropic + filters: + - filter: anthropic_messages_format + on_invalid: continue + + - filter: anthropic_validate + + - filter: anthropic_messages_protocol + default_version: "2023-06-01" + + - filter: router + routes: + - path_prefix: "/" + cluster: vllm + + - filter: load_balancer + clusters: + - name: vllm + endpoints: + - "127.0.0.1:8000" +``` + +Test: + +```console +curl http://localhost:8080/v1/messages \ + -H "content-type: application/json" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "openai/gpt-oss-20b", + "max_tokens": 100, + "system": "Reply concisely.", + "messages": [{"role": "user", "content": "Hi"}] + }' +``` + +## Passthrough to Anthropic API + +Route to `api.anthropic.com` with credential +injection for the `x-api-key` header. + +```yaml +listeners: + - name: gateway + address: "0.0.0.0:8080" + filter_chains: [anthropic] + +filter_chains: + - name: anthropic + filters: + - filter: anthropic_messages_format + on_invalid: continue + + - filter: anthropic_validate + + - filter: anthropic_messages_protocol + default_version: "2023-06-01" + + - filter: headers + request_set: + - name: Host + value: "api.anthropic.com" + + - filter: router + routes: + - path_prefix: "/" + cluster: anthropic + + - filter: credential_injection + clusters: + - name: anthropic + header: x-api-key + env_var: ANTHROPIC_API_KEY + strip_client_credential: true + + - filter: load_balancer + clusters: + - name: anthropic + tls: + sni: "api.anthropic.com" + endpoints: + - "api.anthropic.com:443" +``` + +Test: + +```console +curl http://localhost:8080/v1/messages \ + -H "content-type: application/json" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "claude-haiku-4-5", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +## Transformation to OpenAI Backend + +Transform Anthropic requests to OpenAI Chat +Completions format for backends that only speak +OpenAI (e.g. llm-d with disaggregation, KServe +without Anthropic support). + +```yaml +listeners: + - name: gateway + address: "0.0.0.0:8080" + filter_chains: [transform] + +filter_chains: + - name: transform + filters: + - filter: anthropic_messages_format + on_invalid: continue + + - filter: anthropic_validate + + - filter: anthropic_to_openai + max_body_bytes: 1048576 + + - filter: anthropic_stream_events + response_conditions: + - when: + headers: + content-type: "text/event-stream" + + - filter: path_rewrite + replace: + pattern: "^/v1/messages$" + replacement: "/v1/chat/completions" + conditions: + - when: + path_prefix: "/v1/messages" + + - filter: router + routes: + - path_prefix: "/" + cluster: vllm + + - filter: load_balancer + clusters: + - name: vllm + endpoints: + - "127.0.0.1:8000" +``` + +The `anthropic_to_openai` filter: +- Hoists `system` to an OpenAI system message +- Flattens content blocks (text, image, tool_use, + tool_result) +- Maps `stop_sequences` to `stop`, + `tool_choice` semantics, tool definitions +- Preserves `top_k` as an extra body parameter +- Drops `thinking` blocks with a log warning +- Transforms the response back to Anthropic format +- Preserves original `finish_reason` in filter + metadata as `openai.finish_reason` + +Add `anthropic_stream_events` with a `text/event-stream` +response condition when the backend may return streaming +Chat Completions SSE. Keep it response-gated so normal +JSON responses stay on the buffered `anthropic_to_openai` +path. + +## Filter Configuration Reference + +### `anthropic_messages_format` + +Classifies requests by body structure, then promotes +ambiguous `/v1/messages` or `anthropic-version` +requests to Anthropic Messages when the body otherwise +looks like Chat Completions. + +```yaml +filter: anthropic_messages_format +on_invalid: continue # continue | reject +max_body_bytes: 1048576 # 1 MiB +headers: + format: x-praxis-ai-format + model: x-praxis-ai-model + stream: x-praxis-ai-stream +``` + +Body classification precedence: +1. `input` or object-valued `prompt` → OpenAI Responses +2. `messages` + `max_tokens` + Anthropic structural + signals → Anthropic Messages +3. `messages` alone → OpenAI Chat Completions + +`anthropic-version` and `/v1/messages` upgrade only the +ambiguous Chat Completions result to Anthropic Messages; +they do not override Responses-shaped bodies. + +### `anthropic_validate` + +Validates the proxy-owned JSON envelope before forwarding. +Backend-owned Anthropic semantics such as model availability, +message shape, role ordering, and token limits are deferred +to the backend. + +```yaml +filter: anthropic_validate +max_body_bytes: 1048576 # 1 MiB +``` + +Checks: request body is present, valid JSON, and a JSON object. + +### `anthropic_messages_protocol` + +Injects `anthropic-version` header if absent. +No body transformation. + +```yaml +filter: anthropic_messages_protocol +default_version: "2023-06-01" +``` + +### `anthropic_to_openai` + +Bidirectional request/response transformation. +Non-streaming only; use `anthropic_stream_events` +for SSE responses. + +```yaml +filter: anthropic_to_openai +max_body_bytes: 1048576 # 1 MiB +``` + +### `anthropic_stream_events` + +Transforms OpenAI SSE chunks to Anthropic SSE +events. Processes SSE chunks incrementally as they arrive. + +```yaml +filter: anthropic_stream_events +max_partial_event_bytes: 10485760 +response_conditions: + - when: + headers: + content-type: "text/event-stream" +``` + +## Running with Debug Logging + +See filter activity in real time: + +```console +RUST_LOG=debug cargo run -p praxis-proxy -- -c config.yaml +``` + +Filter-specific logging: + +```console +RUST_LOG=praxis_filter::builtins::http::ai=debug cargo run -p praxis-proxy -- -c config.yaml +``` diff --git a/docs/ai/crate-layout.md b/docs/ai/crate-layout.md new file mode 100644 index 0000000..e73b0bf --- /dev/null +++ b/docs/ai/crate-layout.md @@ -0,0 +1,60 @@ +--- +title: Crate Layout +--- + +# Crate Layout + +Praxis AI extends the core proxy with three workspace crates. +All AI filters implement the same `HttpFilter` trait from +`praxis-filter` and register at startup in `server/src/lib.rs`. + +## Dependency flow + +```text +praxis-ai-proxy (server) + → praxis-ai-filters + → praxis-ai-apis + → praxis-filter / praxis-core / praxis-protocol (core) +``` + +| Crate | Role | +| ----- | ---- | +| `server` (`praxis-ai-proxy`) | Binary `praxis-ai`; registers AI + core filters | +| `filters` (`praxis-ai-filters`) | MCP, A2A, guardrails, model routing, token headers | +| `apis` (`praxis-ai-apis`) | OpenAI/Anthropic filters, classifier, store, token usage | + +## Key modules + +```text +apis/src/ + classifier/ Request body format detection + openai/ Responses API, conversations, SSE + anthropic/ Messages API filters + store/ ResponseStore trait, SQLite/Postgres + token_usage/ Provider-specific usage extraction + +filters/src/ + agentic/ MCP and A2A filters + guardrails/ ai_guardrails + inference/ model_to_header + prompt_enrich/ Chat completion enrichment +``` + +## Pipeline extensions + +`ResponseStoreRegistry` implements `PipelineExtension` and is +injected when pipelines are built (`server/src/pipelines.rs`). +Filters such as `openai_response_store` and +`openai_responses_rehydrate` access stores through +`ctx.extensions`. + +## Dynamic reload + +Config hot-reload is inherited from Praxis core. Pipelines +swap atomically; `ResponseStoreRegistry` is recreated per +pipeline build. + +## Related + +- [AGENTS.md](https://github.com/praxis-proxy/ai/blob/main/AGENTS.md) — agent and contributor reference +- [Core crate layout](https://github.com/praxis-proxy/praxis/blob/main/docs/architecture/crate-layout.md) diff --git a/docs/ai/filters/README.md b/docs/ai/filters/README.md new file mode 100644 index 0000000..61bfe0c --- /dev/null +++ b/docs/ai/filters/README.md @@ -0,0 +1,91 @@ +# Filters + +Praxis AI registers AI-specific filters into the +[Praxis filter pipeline][praxis-filters]. For the base +filter system architecture (pipeline execution, filter +traits, body access, conditional execution, filter +chains), see the Praxis core filter documentation. + +[praxis-filters]: /docs/filters/filter-model + +## AI Filter Categories + +AI filters are organized across two crates: + +```text +apis/src/ Provider API filters + anthropic/ Anthropic Messages API + openai/ OpenAI Responses/Chat API + classifier/ Request classification + store/ Response persistence + token_usage/ Token counting + +filters/src/ Cross-provider filters + agentic/ MCP, A2A + guardrails/ AI content guardrails + inference/ model_to_header + prompt_enrich/ Prompt injection + token_count/ Token usage extraction +``` + +Core builtins used alongside AI filters: `json_rpc`, +`credential_injection`, `router`, `load_balancer`. See +[Praxis core filter reference][praxis-filters]. + +### Provider APIs (`praxis-ai-apis`) + +| Filter | Description | +|--------|-------------| +| `anthropic_messages_format` | Classifies Anthropic Messages API requests | +| `anthropic_messages_protocol` | Normalizes Anthropic protocol headers | +| `anthropic_stream_events` | SSE format translation (OpenAI / Anthropic) | +| `anthropic_to_openai` | Anthropic-to-Chat Completions body translation | +| `anthropic_validate` | Anthropic request envelope validation | +| `openai_responses_format` | Classifies Responses/Chat Completions requests | +| `openai_responses_model_rewrite` | Rewrites `model` field in request bodies | +| `openai_responses_validate` | Validates and enriches Responses API requests | +| `openai_responses_rehydrate` | Fetches stored responses for conversation context | +| `openai_response_store` | Persists responses to storage backend | +| `openai_conversations` | Handles `/v1/conversations` endpoints | +| `responses_proxy` | Rebuilds request body from `ResponsesState` | +| `openai_stream_events` | Accumulates Responses API SSE events | +| `tool_parse` | Parses tools for branch routing | + +### Cross-Provider Filters (`praxis-ai-filters`) + +| Filter | Description | +|--------|-------------| +| `a2a` | A2A protocol metadata extraction | +| `mcp` | MCP protocol broker and routing | +| `ai_guardrails` | Pass-through scaffold for external AI guardrail evaluation | +| `model_to_header` | Promotes `model` body field to header | +| `prompt_enrich` | Injects messages into chat completions | +| `token_count` | Extracts token usage into filter metadata | +| `token_usage_headers` | Token count response headers | + +## Registration + +AI filters are registered at startup in +`server/src/lib.rs` via the `register_ai_filters` +function. This adds them to the base `FilterRegistry` +alongside Praxis core builtins: + +```rust +let mut registry = FilterRegistry::with_builtins(); +register_ai_filters(&mut registry); +``` + +## Base Proxy Filters + +Praxis AI inherits all base proxy filters from Praxis +core (router, load balancer, rate limiter, headers, +CORS, IP ACL, guardrails, compression, etc.). These are +included via `FilterRegistry::with_builtins()`. See the +[Praxis core filter reference][praxis-filters] for their +configuration. + +## Related + +- [Filter Reference](/docs/ai/filters/reference): + configuration for all AI filters +- [Extensions](/docs/ai/filters/extensions): writing custom filters diff --git a/docs/ai/filters/a2a.md b/docs/ai/filters/a2a.md new file mode 100644 index 0000000..76b5666 --- /dev/null +++ b/docs/ai/filters/a2a.md @@ -0,0 +1,69 @@ + +# `a2a` + +Extracts A2A protocol metadata from JSON-RPC request bodies and promotes method, family, task ID, streaming detection, and version to request headers, filter results, and durable metadata for routing. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `headers` | A2aHeaders | no | Header names for A2A metadata promotion. | +| `headers.context_id` | string | no | Header name for the extracted context ID (e.g. `x-praxis-a2a-context-id`). | +| `headers.family` | string | no | Header name for the A2A family (e.g. `x-praxis-a2a-family`). | +| `headers.kind` | string | no | Header name for the JSON-RPC kind (e.g. `x-praxis-a2a-kind`). | +| `headers.method` | string | no | Header name for the canonical A2A method (e.g. `x-praxis-a2a-method`). | +| `headers.streaming` | string | no | Header name for streaming detection (e.g. `x-praxis-a2a-streaming`). | +| `headers.task_id` | string | no | Header name for the extracted task ID (e.g. `x-praxis-a2a-task-id`). | +| `headers.version` | string | no | Header name for A2A version (e.g. `x-praxis-a2a-version`). | +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer`. | +| `method_aliases` | `object` | no | Method aliases for compatibility (slash-delimited → `PascalCase`). | +| `on_invalid` | `continue` \| `reject` \| `error` | no | Invalid input handling behavior. | +| `task_routing` | TaskRoutingConfig | no | Task-ownership routing configuration. | +| `task_routing.enabled` | bool | no | Whether task routing is enabled. | +| `task_routing.max_response_body_bytes` | integer | no | Maximum response body bytes to buffer for task route capture. | +| `task_routing.on_lookup_miss` | `continue` | no | Behavior when a task route lookup misses. | +| `task_routing.route_cluster_header` | string | no | Internal header name injected on task route hit. | +| `task_routing.store` | `local` | no | Storage backend for task routes. | +| `task_routing.terminal_ttl_seconds` | integer | no | TTL in seconds for terminal task routes (0 = remove immediately). | +| `task_routing.ttl_seconds` | integer | no | TTL in seconds for non-terminal task routes. | + +## Examples + +### Example 1 + +```yaml +filter: a2a +``` + +### Example 2 + +```yaml +filter: a2a +max_body_bytes: 65536 +on_invalid: reject +method_aliases: + message/send: SendMessage + message/stream: SendStreamingMessage + tasks/get: GetTask + tasks/cancel: CancelTask +headers: + method: x-praxis-a2a-method + family: x-praxis-a2a-family + context_id: x-praxis-a2a-context-id + task_id: x-praxis-a2a-task-id + kind: x-praxis-a2a-kind + streaming: x-praxis-a2a-streaming + version: x-praxis-a2a-version +task_routing: + enabled: true + store: local + route_cluster_header: x-praxis-a2a-route-cluster + ttl_seconds: 3600 + terminal_ttl_seconds: 300 + max_response_body_bytes: 65536 +``` + +## Related examples +- `examples/configs/a2a-agent-card-routing.yaml` +- `examples/configs/a2a-classifier-routing.yaml` +- `examples/configs/a2a-task-routing.yaml` diff --git a/docs/ai/filters/ai_guardrails.md b/docs/ai/filters/ai_guardrails.md new file mode 100644 index 0000000..3ab491f --- /dev/null +++ b/docs/ai/filters/ai_guardrails.md @@ -0,0 +1,33 @@ +# `ai_guardrails` + +Pass-through scaffold for external AI guardrail evaluation. + +## Configuration Notes + +Buffers request bodies via `StreamBuffer` but does not call the configured provider yet; `on_request_body` returns `Continue` unconditionally. Response-side evaluation is also not wired. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `provider` | ProviderConfig | yes | External provider configuration (required). | +| `provider.type` | `nemo` | yes | Provider type selector. | +| `phase` | PhaseConfig | no | Which phases to evaluate. | +| `phase.request` | bool | no | Evaluate client requests before forwarding to the upstream. | +| `phase.response` | bool | no | Evaluate upstream responses before forwarding to the client. | + +## Example + +```yaml +filter: ai_guardrails +provider: + type: nemo + endpoint: "http://nemo:8000/v1/guardrail/checks" + timeout_ms: 5000 +phase: + request: true + response: false +``` + +## Related examples +- [ai-guardrails.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/ai-guardrails.yaml) diff --git a/docs/ai/filters/anthropic_messages_format.md b/docs/ai/filters/anthropic_messages_format.md new file mode 100644 index 0000000..9c683f7 --- /dev/null +++ b/docs/ai/filters/anthropic_messages_format.md @@ -0,0 +1,41 @@ + +# `anthropic_messages_format` + +Classifies Anthropic Messages API requests and promotes routing facts to headers, metadata, and filter results. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `on_invalid` | `continue` \| `reject` \| `error` | no | Behavior when the body cannot be classified. | +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | +| `headers` | AnthropicMessagesFormatHeaders | no | Header names for promoted classification facts. | +| `headers.format` | string | no | Header name for the detected format. | +| `headers.model` | string | no | Header name for the extracted model value. | +| `headers.stream` | string | no | Header name for the extracted stream flag. | + +## Examples + +### Example 1 + +```yaml +filter: anthropic_messages_format +``` + +### Example 2 + +```yaml +filter: anthropic_messages_format +on_invalid: continue +max_body_bytes: 1048576 +headers: + format: x-praxis-ai-format + model: x-praxis-ai-model + stream: x-praxis-ai-stream +``` + +## Related examples +- `examples/configs/anthropic/messages-protocol.yaml` +- `examples/configs/anthropic/messages-to-openai.yaml` +- `examples/configs/anthropic/request-validate.yaml` +- `examples/configs/anthropic/unified-gateway.yaml` diff --git a/docs/ai/filters/anthropic_messages_protocol.md b/docs/ai/filters/anthropic_messages_protocol.md new file mode 100644 index 0000000..a89a87e --- /dev/null +++ b/docs/ai/filters/anthropic_messages_protocol.md @@ -0,0 +1,28 @@ + +# `anthropic_messages_protocol` + +Normalizes Anthropic Messages protocol headers for native backends. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `default_version` | string | no | Default `anthropic-version` header value when absent. | + +## Examples + +### Example 1 + +```yaml +filter: anthropic_messages_protocol +``` + +### Example 2 + +```yaml +filter: anthropic_messages_protocol +default_version: "2023-06-01" +``` + +## Related examples +- `examples/configs/anthropic/messages-protocol.yaml` diff --git a/docs/ai/filters/anthropic_stream_events.md b/docs/ai/filters/anthropic_stream_events.md new file mode 100644 index 0000000..c584056 --- /dev/null +++ b/docs/ai/filters/anthropic_stream_events.md @@ -0,0 +1,32 @@ + +# `anthropic_stream_events` + +Transforms streaming SSE responses between `OpenAI` and Anthropic formats, processing each chunk as it arrives. + +## Configuration Notes + +Arms automatically when an upstream classifier or transform filter sets `anthropic_messages_format.stream` or `anthropic_to_openai.streaming` metadata to `"true"` and the backend response has `Content-Type: text/event-stream` (with or without parameters such as `charset=utf-8`). No `response_conditions` configuration is needed. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_partial_event_bytes` | integer | no | Maximum incomplete SSE event bytes retained between chunks. | + +## Examples + +### Example 1 + +```yaml +filter: anthropic_stream_events +``` + +### Example 2 + +```yaml +filter: anthropic_stream_events +max_partial_event_bytes: 10485760 +``` + +## Related examples +- `examples/configs/anthropic/messages-to-openai.yaml` diff --git a/docs/ai/filters/anthropic_to_openai.md b/docs/ai/filters/anthropic_to_openai.md new file mode 100644 index 0000000..2efd7af --- /dev/null +++ b/docs/ai/filters/anthropic_to_openai.md @@ -0,0 +1,28 @@ + +# `anthropic_to_openai` + +Transforms Anthropic Messages API requests to Chat Completions-compatible request bodies and transforms compatible responses back. The filter name refers to the OpenAI Chat Completions wire shape, not the Responses API; non-OpenAI compatible backends are valid targets. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | + +## Examples + +### Example 1 + +```yaml +filter: anthropic_to_openai +``` + +### Example 2 + +```yaml +filter: anthropic_to_openai +max_body_bytes: 1048576 +``` + +## Related examples +- `examples/configs/anthropic/messages-to-openai.yaml` diff --git a/docs/ai/filters/anthropic_validate.md b/docs/ai/filters/anthropic_validate.md new file mode 100644 index 0000000..490fa47 --- /dev/null +++ b/docs/ai/filters/anthropic_validate.md @@ -0,0 +1,19 @@ + +# `anthropic_validate` + +Validates Anthropic Messages request bodies for proxy-owned JSON envelope requirements. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | + +## Example + +```yaml +filter: anthropic_validate +``` + +## Related examples +- `examples/configs/anthropic/request-validate.yaml` diff --git a/docs/ai/filters/extensions.md b/docs/ai/filters/extensions.md new file mode 100644 index 0000000..b66a682 --- /dev/null +++ b/docs/ai/filters/extensions.md @@ -0,0 +1,337 @@ +# Extensions + +Praxis AI inherits the full extension system from +[Praxis core][praxis]. Custom filters implement +`HttpFilter` or `TcpFilter` from `praxis-filter` and +register into the shared `FilterRegistry`. + +[praxis]: https://github.com/praxis-proxy/praxis + +## filter_metadata + +Praxis attaches a per-request `filter_metadata` map to +[`HttpFilterContext`][http-filter-context] from Praxis +core. Filters store flat string key-value pairs that +persist across all HTTP lifecycle phases on the same +request (request, request-body, response, +response-body). + +Keys use dot-prefix namespacing by convention (for +example `token.input`, `token.output`, `a2a.method`). +Upstream filters write values; downstream filters read +them in later phases without coupling to each other's +internals. Note the phase-ordering constraint: `token_count` +writes body-derived counts in `on_response_body`, while +`token_usage_headers` reads metadata in `on_response`, so +those two filters cannot chain into response headers in one +pass (see [token-counting](/docs/ai/token-counting) and +[proposal 00214](https://github.com/praxis-proxy/ai/blob/main/docs/proposals/00214_token-usage-response-headers.md)). + +Custom filters can read and write `filter_metadata` via +`ctx.filter_metadata` in `on_request`, `on_response`, +and body hooks. + +[http-filter-context]: https://github.com/praxis-proxy/praxis/blob/main/filter/src/context.rs + +## Auto-Discovery (Recommended) + +External filter crates can self-register into Praxis AI +at build time. The operator adds a `Cargo.toml` +dependency and writes YAML config with zero Rust code +changes. + +### How It Works + +1. The external crate uses `export_filters!` to declare + its filters +2. The crate's `Cargo.toml` carries a + `[package.metadata.praxis-filters]` marker +3. The Praxis AI server's `build.rs` runs + `cargo metadata`, discovers marked crates, and + generates registration code +4. At startup, discovered filters are registered + alongside built-ins and AI filters + +### External Crate Setup + +In the external crate's `Cargo.toml`: + +```toml +[package] +name = "my-token-quota" +version = "0.1.0" + +# Marker: tells the build script this crate exports +# filters. +[package.metadata.praxis-filters] + +[dependencies] +async-trait = "0.1" +praxis-proxy-filter = "0.4" +serde = { version = "1", features = ["derive"] } +serde_yaml = { package = "yaml_serde", version = "0.10" } +``` + +In the external crate's `src/lib.rs`: + +```rust +use async_trait::async_trait; +use praxis_filter::{ + FilterAction, FilterError, HttpFilter, + HttpFilterContext, export_filters, +}; + +pub struct TokenQuotaFilter { /* ... */ } + +#[async_trait] +impl HttpFilter for TokenQuotaFilter { + fn name(&self) -> &'static str { "token_quota" } + + async fn on_request( + &self, _ctx: &mut HttpFilterContext<'_>, + ) -> Result { + Ok(FilterAction::Continue) + } +} + +impl TokenQuotaFilter { + pub fn from_config( + config: &serde_yaml::Value, + ) -> Result, FilterError> { + // Parse config and construct the filter + Ok(Box::new(Self { /* ... */ })) + } +} + +export_filters! { + http "token_quota" => TokenQuotaFilter::from_config, +} +``` + +### Operator Usage + +Add the crate to the Praxis AI server's `Cargo.toml`: + +```toml +[dependencies] +my-token-quota = "0.1" +``` + +Then reference the filter by name in YAML: + +```yaml +filter_chains: + - name: main + filters: + - filter: token_quota + max_tokens_per_minute: 100000 +``` + +Rebuild and run — no other changes needed. + +### Duplicate Detection + +If an external filter name collides with a built-in, AI, +or another external filter, the server panics at startup +with a clear error message. Filter names must be unique +across all sources. + +## Rust Extensions (Manual Registration) + +Compile-time extensions with zero overhead. Implement +`HttpFilter` from `praxis-filter`, register it, and +reference it in YAML config. Use this approach when +building a custom Praxis AI binary with inline filters +that don't need to be shared as a separate crate. + +1. Implement `HttpFilter` (`on_request`, `on_response`, + body hooks) +2. Register with `register_filters!` +3. Reference by name in YAML filter chains + +### HTTP Filter + +```rust +use async_trait::async_trait; +use serde::Deserialize; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterError, + HttpFilter, HttpFilterContext, Rejection, + register_filters, +}; + +struct ModelBlocklist { + blocked: Vec, +} + +impl ModelBlocklist { + pub fn from_config( + config: &serde_yaml::Value, + ) -> Result, FilterError> { + #[derive(Deserialize)] + struct Cfg { + blocked_models: Vec, + } + + let cfg: Cfg = + serde_yaml::from_value(config.clone())?; + Ok(Box::new(Self { + blocked: cfg.blocked_models, + })) + } +} + +#[async_trait] +impl HttpFilter for ModelBlocklist { + fn name(&self) -> &'static str { + "model_blocklist" + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { max_bytes: Some(8_192) } + } + + async fn on_request( + &self, ctx: &mut HttpFilterContext<'_>, + ) -> Result { + Ok(FilterAction::Continue) + } +} + +// In your binary: +register_filters! { + http "model_blocklist" => ModelBlocklist::from_config, +} +``` + +### Registration + +The `register_filters!` macro uses protocol-prefixed +syntax: + +```rust +register_filters! { + http "model_blocklist" => ModelBlocklist::from_config, +} +``` + +The macro generates a `custom_registry()` function that +returns a `FilterRegistry` with built-in, AI, and custom +filters. Use it with the test utilities +(`start_proxy_with_registry`) or build your own server +bootstrap from the workspace crates (`praxis-core`, +`praxis-filter`, `praxis-ai-apis`, `praxis-ai-filters`). + +### YAML Config + +Any keys placed alongside `filter:` in the filter chain +entry are passed to `from_config` as a +`serde_yaml::Value`: + +```yaml +filter_chains: + - name: ai + filters: + - filter: model_blocklist + blocked_models: + - "gpt-3.5-turbo" + - "claude-2" + conditions: + - when: + methods: ["POST"] +``` + +Custom filters participate identically to built-ins: +same ordering, context access, and short-circuit +capability. + +See the [filter system documentation](/docs/ai/filters/reference) for +the AI filter overview. + +## Best Practices + +### Keep filters stateless when possible + +Prefer reading all configuration at construction time +(in `from_config`) and keeping the filter struct +immutable. When shared mutable state is required (e.g. +counters, connection tracking), use atomics or interior +mutability with minimal lock scope. Filters are shared +across requests and must be `Send + Sync`. + +### Return early with `Reject`, not panics + +Use `FilterAction::Reject(Rejection::status(code))` to +abort request processing. Never panic inside a filter; +a panic takes down the worker thread. Return +`Err(...)` for unexpected failures and let the pipeline +handle the 500 response. + +### Declare body access accurately + +Only declare `request_body_access()` or +`response_body_access()` if your filter actually +inspects or modifies the body. Each declaration changes +how the pipeline buffers data. `BodyAccess::None` (the +default) avoids overhead. Use `ReadOnly` if you inspect +but do not modify, and `ReadWrite` only if you mutate +chunks in place. + +### Choose the right body mode + +- `Stream`: lowest latency; chunks flow through as they + arrive. Best for filters that inspect headers only or + process chunks independently. +- `StreamBuffer`: chunks flow through filters + incrementally but forwarding to upstream is deferred + until `Release` or end-of-stream. Use when body + content influences routing (e.g. model field + extraction), when you need the complete body (e.g. + guardrail scanning), or when you need to inspect the + full body before upstream selection. Set `max_bytes` + to avoid unbounded memory growth. + +### Use `extra_request_headers` for metadata + +When your filter extracts values from the body or +computes derived data, promote it to a request header +via `ctx.extra_request_headers`. This makes the value +visible to downstream filters (e.g. the router) without +coupling filters to each other. + +### Provide `from_config` validation + +Validate all configuration values in `from_config` +rather than deferring checks to request time. Fail fast +at startup with a descriptive error. Parse and +type-check every field; use `#[serde(default)]` for +optional fields with sensible defaults. + +### Test with the integration harness + +Use the integration test utilities (`free_port`, +`start_backend`, `start_proxy_with_registry`) to write +end-to-end tests for custom filters. Register your +filter with `FilterFactory::Http(Arc::new(factory))`, +build a minimal YAML config, and assert on status codes +and response bodies. See `tests/integration/` for +examples. + +Built-in filter reference pages are generated from source. +After changing a filter config struct, run: + +```console +cargo xtask generate-filter-docs +``` + +CI runs `cargo xtask lint-filter-docs` as part of `make lint`. +Every example under `examples/configs/` must have an +integration test (or an entry in the SKIP allowlist): + +```console +cargo xtask lint-example-tests +``` diff --git a/docs/ai/filters/mcp.md b/docs/ai/filters/mcp.md new file mode 100644 index 0000000..738e14b --- /dev/null +++ b/docs/ai/filters/mcp.md @@ -0,0 +1,104 @@ + +# `mcp` + +Extracts MCP protocol metadata from JSON-RPC request bodies and promotes method, tool/resource/prompt name, JSON-RPC kind, protocol version, and session presence to request headers/filter results; stores session ID in durable metadata. + +MCP static catalog filter that aggregates tool catalogs from multiple backend MCP servers and handles `initialize`, `tools/list`, `tools/call`, `ping`, and `notifications/initialized` directly as a static broker. + +## Configuration Notes + +Recognized methods include `initialize`, `tools/call`, `tools/list`, `resources/read`, `resources/list`, `prompts/get`, `prompts/list`, and `ping`. + +Methods requiring a name selector (`tools/call`, `resources/read`, `prompts/get`) return a JSON-RPC error if the selector is missing and `on_invalid` is `reject`. + +Writes `mcp.*` and `json_rpc.*` entries to the filter result set for branch chain conditions. + +The broker serves configured catalog operations locally while backend tool routing is not implemented. It deliberately returns `-32601` for `tools/call` rather than forwarding a request whose target is unresolved. + +Supports two protocol profiles: `current` (session-based, default) and `stateless` (MCP 2026-07-28, configurable). Version and cache fields are derived from the selected profile when omitted. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `header_validation` | HeaderValidation | no | Header validation settings. | +| `header_validation.mismatch` | `reject` \| `ignore` | no | Behavior when header value conflicts with body-derived value. | +| `header_validation.missing` | `ignore` \| `synthesize` \| `reject` | no | Behavior when expected MCP headers are absent. | +| `headers` | McpHeaders | no | Header names for MCP metadata promotion. | +| `headers.kind` | string | no | Header name for the JSON-RPC kind (e.g. `x-praxis-mcp-kind`). | +| `headers.method` | string | no | Header name for the MCP method (e.g. `x-praxis-mcp-method`). | +| `headers.name` | string | no | Header name for the tool/resource/prompt name (e.g. `x-praxis-mcp-name`). | +| `headers.protocol_version` | string | no | Header name for the MCP protocol version (e.g. `x-praxis-mcp-protocol-version`). | +| `headers.session_present` | string | no | Header name for MCP session presence (e.g. `x-praxis-mcp-session-present`). | +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer`. | +| `on_invalid` | `continue` \| `reject` \| `error` | no | Invalid input handling behavior. | +| `cache_scope` | `public` \| `private` | no | Cache scope for stateless responses. Requires `protocol_profile: stateless`. | +| `cache_ttl_ms` | integer | no | Cache TTL in milliseconds for stateless responses. Requires `protocol_profile: stateless`. | +| `default_version` | string | no | Fallback MCP protocol version. When omitted, derived from the profile. | +| `invalid_tool_policy` | `reject_server` \| `filter_out` | no | Behavior when a tool has an invalid schema. | +| `path` | string | no | Public MCP path handled by Praxis. | +| `protocol_profile` | `current` \| `stateless` | no | Protocol profile governing session semantics and header requirements for this broker instance. | +| `servers` | McpServerConfig[] | no | Backend server definitions. | +| `servers[].name` | string | yes | Unique server name. | +| `servers[].cluster` | string | yes | Backend cluster name. | +| `servers[].path` | string | no | Backend MCP path. | +| `servers[].tool_prefix` | string | no | Tool prefix for this server. | +| `servers[].tools` | ToolConfig[] | no | Statically defined tools. | +| `servers[].tools[].name` | string | yes | Tool name on the backend. | +| `servers[].tools[].description` | string | no | Optional description. | +| `servers[].tools[].inputSchema` | any | no | Optional input schema. `schema` is accepted as a local shorthand. | +| `servers[].tools[].annotations` | any | no | Optional tool annotations. | +| `supported_versions` | string[] | no | Protocol versions accepted during negotiation. When omitted, derived from the profile. | + +## Examples + +### Example 1 + +```yaml +filter: mcp +``` + +### Example 2 + +```yaml +filter: mcp +max_body_bytes: 65536 +on_invalid: reject +header_validation: + mismatch: reject + missing: ignore +headers: + method: x-praxis-mcp-method + name: x-praxis-mcp-name + kind: x-praxis-mcp-kind + protocol_version: x-praxis-mcp-protocol-version + session_present: x-praxis-mcp-session-present +``` + +### Example 3 + +```yaml +filter: mcp +path: /mcp +max_body_bytes: 65536 +servers: + - name: weather + cluster: weather-mcp + path: /mcp + tool_prefix: weather_ + tools: + - name: get_weather + description: Get current weather + - name: calendar + cluster: calendar-mcp + path: /mcp + tool_prefix: cal_ + tools: + - name: create_event + description: Create a calendar event +``` + +## Related examples +- `examples/configs/mcp-classifier-routing.yaml` +- `examples/configs/mcp-stateless-broker.yaml` +- `examples/configs/payload-processing/mcp-static-catalog.yaml` diff --git a/docs/ai/filters/model_to_header.md b/docs/ai/filters/model_to_header.md new file mode 100644 index 0000000..6491abc --- /dev/null +++ b/docs/ai/filters/model_to_header.md @@ -0,0 +1,20 @@ + +# `model_to_header` + +Promotes the JSON `"model"` field from the request body to a request header. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `header` | string | no | Header name for the promoted model value. | + +## Example + +```yaml +filter: model_to_header +header: X-Model # optional, defaults to X-Model +``` + +## Related examples +- `examples/configs/model-to-header-routing.yaml` diff --git a/docs/ai/filters/openai_conversations.md b/docs/ai/filters/openai_conversations.md new file mode 100644 index 0000000..309cab7 --- /dev/null +++ b/docs/ai/filters/openai_conversations.md @@ -0,0 +1,34 @@ + +# `openai_conversations` + +Handles all `/v1/conversations` endpoints locally. + +## Configuration Notes + +All matched requests are served from the local store and never forwarded upstream. Unmatched paths pass through as `Continue`. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `backend` | `sqlite` \| `postgres` | yes | Storage backend to use. | +| `database_url` | string (secret) | yes | Database connection URL. Wrapped in `SecretString` to prevent accidental logging of credentials. | +| `conversations_table` | string | no | Table name for conversation records. | +| `items_table` | string | no | Table name for conversation item records. | +| `ssl_mode` | SslMode | no | TLS mode for `PostgreSQL` connections. Only valid when `backend` is `postgres`. Overrides any `sslmode` parameter in the connection URL. | +| `ssl_root_cert` | string (secret) | no | Path to a PEM-encoded root CA certificate for `PostgreSQL` TLS verification. Only valid when `backend` is `postgres` and the effective SSL mode is `verify-ca` or `verify-full`. | +| `allow_private_database_url` | bool | no | Allow `PostgreSQL` URLs that target local-sensitive addresses. By default, DNS names, localhost, loopback, private, link-local, cloud metadata, unspecified, and Unix socket targets are rejected. This opt-in is intended for local development and tests. | + +## Example + +```yaml +filter: openai_conversations +backend: sqlite +database_url: sqlite://conversations.db?mode=rwc +conversations_table: conversations +items_table: conversation_items +``` + +## Related examples +- `examples/configs/openai/conversations/conversations.yaml` +- `examples/configs/openai/responses/full-flow.yaml` diff --git a/docs/ai/filters/openai_response_store.md b/docs/ai/filters/openai_response_store.md new file mode 100644 index 0000000..4e42669 --- /dev/null +++ b/docs/ai/filters/openai_response_store.md @@ -0,0 +1,32 @@ + +# `openai_response_store` + +Persists Responses API responses to the configured response store backend. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `backend` | `sqlite` \| `postgres` | yes | Storage backend to use. | +| `database_url` | string (secret) | yes | Database connection URL. Wrapped in `SecretString` to prevent accidental logging of credentials. | +| `responses_table` | string | yes | Table name for response records. | +| `conversations_table` | string | yes | Table name for conversation message records. | +| `ssl_mode` | SslMode | no | TLS mode for `PostgreSQL` connections. Only valid when `backend` is `postgres`. Overrides any `sslmode` parameter in the connection URL. | +| `ssl_root_cert` | string (secret) | no | Path to a PEM-encoded root CA certificate for `PostgreSQL` TLS verification. Only valid when `backend` is `postgres` and the effective SSL mode is `verify-ca` or `verify-full`. | +| `allow_private_database_url` | bool | no | Allow `PostgreSQL` URLs that target local-sensitive addresses. By default, DNS names, localhost, loopback, private, link-local, cloud metadata, unspecified, and Unix socket targets are rejected. This opt-in is intended for local development and tests. | + +## Example + +```yaml +filter: openai_response_store +backend: sqlite +database_url: sqlite://responses.db?mode=rwc +responses_table: openai_responses +conversations_table: openai_conversation_messages +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/rehydrate.yaml` +- `examples/configs/openai/responses/response-store.yaml` +- `examples/configs/openai/responses/stream-events.yaml` diff --git a/docs/ai/filters/openai_responses_format.md b/docs/ai/filters/openai_responses_format.md new file mode 100644 index 0000000..a624557 --- /dev/null +++ b/docs/ai/filters/openai_responses_format.md @@ -0,0 +1,56 @@ + +# `openai_responses_format` + +Classifies AI API request bodies and promotes routing facts to headers, metadata, and filter results without mutating the body. + +## Configuration Notes + +Classification formats: `openai_responses`, `openai_chat_completions`, `unknown_json`, `invalid_json`, `non_json`. + +Routing mode for Responses API: `stateful` when the request contains `previous_response_id`, non-empty `tools`, `store=true` (default when omitted), `background=true`, `conversation`, or `prompt.id`; `stateless` when `store=false` with no other stateful markers. + +Use with branch chains to route stateful and stateless requests to different clusters. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `on_invalid` | `continue` \| `reject` \| `error` | no | Behavior when the body cannot be classified. | +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | +| `headers` | ResponsesFormatHeaders | no | Header names for promoted classification facts. | +| `headers.format` | string | no | Header name for the detected format (e.g. `openai_responses`, `openai_chat_completions`). | +| `headers.model` | string | no | Header name for the extracted model value. | +| `headers.stream` | string | no | Header name for the extracted stream flag. | +| `headers.mode` | string | no | Header name for the computed mode (`stateless` or `stateful`). | + +## Examples + +### Example 1 + +```yaml +filter: openai_responses_format +``` + +### Example 2 + +```yaml +filter: openai_responses_format +on_invalid: continue +max_body_bytes: 67108864 +headers: + format: x-praxis-ai-format + model: x-praxis-ai-model + stream: x-praxis-ai-stream + mode: x-praxis-responses-mode +``` + +## Related examples +- `examples/configs/anthropic/unified-gateway.yaml` +- `examples/configs/openai/responses/format-routing.yaml` +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/model-rewrite.yaml` +- `examples/configs/openai/responses/rehydrate.yaml` +- `examples/configs/openai/responses/request-validate.yaml` +- `examples/configs/openai/responses/response-store.yaml` +- `examples/configs/openai/responses/responses-routing.yaml` +- `examples/configs/openai/responses/stream-events.yaml` diff --git a/docs/ai/filters/openai_responses_model_rewrite.md b/docs/ai/filters/openai_responses_model_rewrite.md new file mode 100644 index 0000000..746c9c1 --- /dev/null +++ b/docs/ai/filters/openai_responses_model_rewrite.md @@ -0,0 +1,51 @@ + +# `openai_responses_model_rewrite` + +Rewrites the `model` field in Responses API request bodies. + +## Configuration Notes + +Quote wildcard alias keys in YAML, such as `"gpt-4.1-*"`, so `*` is parsed as a literal character rather than YAML alias syntax. The examples quote all alias keys for consistency. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `default_model` | string | no | Model name to inject when the request body has no `model` field or when the field is `null`. | +| `headers` | ModelRewriteHeaders | no | Header names for promoted model values. | +| `headers.effective_model` | string | no | Header name for the effective (post-rewrite) model value. | +| `headers.original_model` | string | no | Header name for the original (pre-rewrite) model value. | +| `max_body_bytes` | integer | no | Maximum request body size to buffer before parsing. | +| `model_aliases` | `object` | no | Map from client-facing model names or single-wildcard patterns to backend model names. Quote wildcard keys in YAML. Exact aliases win before wildcard aliases; wildcard aliases are matched by literal specificity. | +| `on_invalid` | `continue` \| `reject` | no | Behavior when the body is not valid JSON. | + +## Examples + +### Example 1 + +```yaml +filter: openai_responses_model_rewrite +default_model: "llama-3.3-70b" +model_aliases: + "codex-mini-latest": "llama-3.3-70b" + "gpt-4.1-*": "qwen-2.5-72b" + "gpt-4.1-mini": "qwen-2.5-72b" +``` + +### Example 2 + +```yaml +filter: openai_responses_model_rewrite +default_model: "llama-3.3-70b" +model_aliases: + "codex-mini-latest": "llama-3.3-70b" + "gpt-4.1-*": "qwen-2.5-72b" +max_body_bytes: 10485760 +on_invalid: continue +headers: + effective_model: x-praxis-ai-effective-model + original_model: x-praxis-ai-original-model +``` + +## Related examples +- `examples/configs/openai/responses/model-rewrite.yaml` diff --git a/docs/ai/filters/openai_responses_rehydrate.md b/docs/ai/filters/openai_responses_rehydrate.md new file mode 100644 index 0000000..bf13fe5 --- /dev/null +++ b/docs/ai/filters/openai_responses_rehydrate.md @@ -0,0 +1,18 @@ + +# `openai_responses_rehydrate` + +Validates `previous_response_id` by fetching the stored response, confirming its status is `"completed"`, and populating `ResponsesState` with the full conversation history (stored turns + current input). + +## Configuration Notes + +The request body is **not** modified; downstream filters read from `ResponsesState.messages` instead. + +## Example + +```yaml +filter: openai_responses_rehydrate +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/rehydrate.yaml` diff --git a/docs/ai/filters/openai_responses_validate.md b/docs/ai/filters/openai_responses_validate.md new file mode 100644 index 0000000..73e8f8e --- /dev/null +++ b/docs/ai/filters/openai_responses_validate.md @@ -0,0 +1,28 @@ + +# `openai_responses_validate` + +Validates and enriches Responses API requests. + +## Configuration Notes + +Reads classifier metadata for parameter-combination checks, then parses the body as `serde_json::Value` for targeted field extraction. Does not deserialize the full body into a typed struct. + +Must be placed after `openai_responses_format` in the filter chain. Skips non-Responses API requests (those not classified as `openai_responses`). + +Validation rules: rejects `stream=true` combined with `background=true` (400), rejects `background=true` combined with `store=false` (400). + +Generates metadata: `responses.response_id` (format: `resp_` + 32 hex chars, CSPRNG), `responses.conversation_id`, `responses.store`, `responses.background`, `responses.stream`. + +This filter has no configuration, body buffering is handled by the upstream `openai_responses_format` classifier. + +## Example + +```yaml +filter: openai_responses_validate +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/rehydrate.yaml` +- `examples/configs/openai/responses/request-validate.yaml` +- `examples/configs/openai/responses/stream-events.yaml` diff --git a/docs/ai/filters/openai_stream_events.md b/docs/ai/filters/openai_stream_events.md new file mode 100644 index 0000000..38d4c54 --- /dev/null +++ b/docs/ai/filters/openai_stream_events.md @@ -0,0 +1,32 @@ + +# `openai_stream_events` + +Accumulates state from native Responses API SSE event streams. + +## Configuration Notes + +All fields are optional; omitted values fall back to `SseParserConfig` defaults. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_buffer_bytes` | integer | no | Maximum bytes buffered for incomplete SSE lines/data across chunk boundaries. Default: 10 MiB. | +| `max_events` | integer | no | Maximum number of SSE events before the parser errors. Default: 100,000. | +| `timeout_secs` | integer | no | Maximum seconds from first chunk to stream completion. Default: 300 (5 minutes). | +| `max_tool_call_argument_bytes` | integer | no | Maximum bytes accumulated per function-call argument string from `function_call_arguments.delta` events. Default: 1 MiB. | + +## Example + +```yaml +filter: openai_stream_events +# All fields optional: +# max_buffer_bytes: 10485760 +# max_events: 100000 +# timeout_secs: 300 +# max_tool_call_argument_bytes: 1048576 +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/stream-events.yaml` diff --git a/docs/ai/filters/prompt_enrich.md b/docs/ai/filters/prompt_enrich.md new file mode 100644 index 0000000..87ab8ba --- /dev/null +++ b/docs/ai/filters/prompt_enrich.md @@ -0,0 +1,42 @@ + +# `prompt_enrich` + +Injects statically configured messages into the `messages` array of OpenAI-compatible chat completion request bodies. + +## Configuration Notes + +Messages are pre-serialized at construction time. At request time, the filter parses the JSON body, splices prepend messages at the beginning and appends messages at the end, then re-serializes the modified body. + +At least one of `prepend` or `append` must be non-empty. JSON is re-serialized, so byte-for-byte body identity is not preserved. + +In chains that also use `json_body_field` or `model_to_header`, place `prompt_enrich` first. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_body_bytes` | integer | no | Maximum request body size to buffer before parsing. | +| `on_invalid` | `continue` \| `reject` | no | Behavior when the body is not valid JSON or lacks a `messages` array. | +| `prepend` | MessageConfig[] | no | Messages to prepend at the beginning of the `messages` array. | +| `prepend[].role` | `system` \| `user` | yes | Role for the injected message. | +| `prepend[].content` | string | yes | Text content of the injected message. | +| `append` | MessageConfig[] | no | Messages to append at the end of the `messages` array. | +| `append[].role` | `system` \| `user` | yes | Role for the injected message. | +| `append[].content` | string | yes | Text content of the injected message. | + +## Example + +```yaml +filter: prompt_enrich +max_body_bytes: 10485760 +on_invalid: continue +prepend: + - role: system + content: "You are a helpful assistant." +append: + - role: user + content: "Remember to cite your sources." +``` + +## Related examples +- `examples/configs/prompt-enrichment.yaml` diff --git a/docs/ai/filters/reference.md b/docs/ai/filters/reference.md new file mode 100644 index 0000000..50bf795 --- /dev/null +++ b/docs/ai/filters/reference.md @@ -0,0 +1,76 @@ +--- +title: Filter Reference +--- + +# Filter Reference + +AI filters provided by Praxis AI. For base proxy +filters (router, load balancer, headers, CORS, etc.), +see the [Praxis core filter reference][core-ref]. + +[core-ref]: /docs/reference/core-filters + +## Provider APIs (praxis-ai-apis) + +### Anthropic + +| Filter | Description | +|--------|-------------| +| [`anthropic_messages_format`](/docs/ai/filters/anthropic_messages_format) | Classifies Anthropic Messages API requests and promotes routing facts to headers, metadata, and filter results. | +| [`anthropic_messages_protocol`](/docs/ai/filters/anthropic_messages_protocol) | Normalizes Anthropic Messages protocol headers for native backends. | +| [`anthropic_stream_events`](/docs/ai/filters/anthropic_stream_events) | Transforms streaming SSE responses between `OpenAI` and Anthropic formats, processing each chunk as it arrives. | +| [`anthropic_to_openai`](/docs/ai/filters/anthropic_to_openai) | Transforms Anthropic Messages API requests to Chat Completions-compatible request bodies and transforms compatible responses back. The filter name refers to the OpenAI Chat Completions wire shape, not the Responses API; non-OpenAI compatible backends are valid targets. | +| [`anthropic_validate`](/docs/ai/filters/anthropic_validate) | Validates Anthropic Messages request bodies for proxy-owned JSON envelope requirements. | + +### OpenAI + +| Filter | Description | +|--------|-------------| +| [`openai_conversations`](/docs/ai/filters/openai_conversations) | Handles all `/v1/conversations` endpoints locally. | +| [`openai_response_store`](/docs/ai/filters/openai_response_store) | Persists Responses API responses to the configured response store backend. | +| [`openai_responses_format`](/docs/ai/filters/openai_responses_format) | Classifies AI API request bodies and promotes routing facts to headers, metadata, and filter results without mutating the body. | +| [`openai_responses_model_rewrite`](/docs/ai/filters/openai_responses_model_rewrite) | Rewrites the `model` field in Responses API request bodies. | +| [`openai_responses_rehydrate`](/docs/ai/filters/openai_responses_rehydrate) | Validates `previous_response_id` by fetching the stored response, confirming its status is `"completed"`, and populating `ResponsesState` with the full conversation history (stored turns + current input). | +| [`openai_responses_validate`](/docs/ai/filters/openai_responses_validate) | Validates and enriches Responses API requests. | +| [`openai_stream_events`](/docs/ai/filters/openai_stream_events) | Accumulates state from native Responses API SSE event streams. | +| [`responses_proxy`](/docs/ai/filters/responses_proxy) | Rebuilds the request body from `ResponsesState` when present. | +| [`tool_parse`](/docs/ai/filters/tool_parse) | Parses tool definitions and `tool_choice` from Responses API request bodies and promotes routing facts to metadata and filter results without mutating the body. | + +## Cross-Provider Filters (praxis-ai-filters) + +### Agentic + +| Filter | Description | +|--------|-------------| +| [`a2a`](/docs/ai/filters/a2a) | Extracts A2A protocol metadata from JSON-RPC request bodies and promotes method, family, task ID, streaming detection, and version to request headers, filter results, and durable metadata for routing. | +| [`mcp`](/docs/ai/filters/mcp) | Extracts MCP protocol metadata from JSON-RPC request bodies and promotes method, tool/resource/prompt name, JSON-RPC kind, protocol version, and session presence to request headers/filter results; stores session ID in durable metadata. | + +### Guardrails + +| Filter | Description | +|--------|-------------| +| [`ai_guardrails`](/docs/ai/filters/ai_guardrails) | Pass-through scaffold for external AI guardrail evaluation. | + +### Inference + +| Filter | Description | +|--------|-------------| +| [`model_to_header`](/docs/ai/filters/model_to_header) | Promotes the JSON `"model"` field from the request body to a request header. | + +### Prompt Enrich + +| Filter | Description | +|--------|-------------| +| [`prompt_enrich`](/docs/ai/filters/prompt_enrich) | Injects statically configured messages into the `messages` array of OpenAI-compatible chat completion request bodies. | + +### Token Count + +| Filter | Description | +|--------|-------------| +| [`token_count`](/docs/ai/filters/token_count) | Extracts token usage from AI inference responses and writes unified counts to [filter_metadata](/docs/ai/filters/extensions#filter_metadata). | + +### Token Usage + +| Filter | Description | +|--------|-------------| +| [`token_usage_headers`](/docs/ai/filters/token_usage_headers) | Injects `Praxis-Token-Input`, `Praxis-Token-Output`, and `Praxis-Token-Total` headers into downstream responses when token usage data is present in [filter_metadata](/docs/ai/filters/extensions#filter_metadata). | diff --git a/docs/ai/filters/responses_proxy.md b/docs/ai/filters/responses_proxy.md new file mode 100644 index 0000000..c6a9be8 --- /dev/null +++ b/docs/ai/filters/responses_proxy.md @@ -0,0 +1,35 @@ + +# `responses_proxy` + +Rebuilds the request body from `ResponsesState` when present. + +## Configuration Notes + +Reads the assembled conversation history from `ResponsesState::messages` and replaces the `input` field in the outbound body. Strips `previous_response_id` since Praxis already resolved it locally via the rehydrate filter. + +When no `ResponsesState` exists (non-Responses requests, or requests without `previous_response_id`), passes through unchanged. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | + +## Examples + +### Example 1 + +```yaml +filter: responses_proxy +``` + +### Example 2 + +```yaml +filter: responses_proxy +max_body_bytes: 67108864 +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/responses-proxy.yaml` diff --git a/docs/ai/filters/token_count.md b/docs/ai/filters/token_count.md new file mode 100644 index 0000000..f3d0a43 --- /dev/null +++ b/docs/ai/filters/token_count.md @@ -0,0 +1,23 @@ +# `token_count` + +Extracts token usage from AI inference responses and writes unified counts to [filter_metadata](/docs/ai/filters/extensions#filter_metadata). + +## Configuration Notes + +Supports both streaming (SSE) and non-streaming (JSON) responses across all five providers (OpenAI, Anthropic, Google, Bedrock, Azure). + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `provider` | TokenUsageProvider | yes | AI provider whose response format to parse. | + +## Example + +```yaml +filter: token_count +provider: openai +``` + +## Related examples +- [token-counting.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/token-counting.yaml) diff --git a/docs/ai/filters/token_usage_headers.md b/docs/ai/filters/token_usage_headers.md new file mode 100644 index 0000000..23a8780 --- /dev/null +++ b/docs/ai/filters/token_usage_headers.md @@ -0,0 +1,18 @@ +# `token_usage_headers` + +Injects `Praxis-Token-Input`, `Praxis-Token-Output`, and `Praxis-Token-Total` headers into downstream responses when token usage data is present in [filter_metadata](/docs/ai/filters/extensions#filter_metadata). + +## Configuration Notes + +Reads token counts written by upstream filters and exposes them as HTTP response headers for infrastructure-level consumption (billing, monitoring, logging). + +When no token metadata is present the filter is a no-op. + +## Example + +```yaml +filter: token_usage_headers +``` + +## Related examples +- [token-usage-headers.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/token-usage-headers.yaml) diff --git a/docs/ai/filters/tool_parse.md b/docs/ai/filters/tool_parse.md new file mode 100644 index 0000000..c5d651b --- /dev/null +++ b/docs/ai/filters/tool_parse.md @@ -0,0 +1,29 @@ + +# `tool_parse` + +Parses tool definitions and `tool_choice` from Responses API request bodies and promotes routing facts to metadata and filter results without mutating the body. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer` mode. | + +## Examples + +### Example 1 + +```yaml +filter: tool_parse +``` + +### Example 2 + +```yaml +filter: tool_parse +max_body_bytes: 67108864 +``` + +## Related examples +- `examples/configs/openai/responses/full-flow.yaml` +- `examples/configs/openai/responses/tool-routing.yaml` diff --git a/docs/ai/openai-responses.md b/docs/ai/openai-responses.md new file mode 100644 index 0000000..23df577 --- /dev/null +++ b/docs/ai/openai-responses.md @@ -0,0 +1,81 @@ +--- +title: Openai Responses +--- + +# OpenAI Responses API + +Praxis AI supports the OpenAI Responses API (`/v1/responses`) +and related endpoints through composable filters. Deploy them +in pipeline order: classify, validate, optionally rehydrate +and proxy, then route upstream. + +## Filters + +| Filter | Phase | Purpose | +| ------ | ----- | ------- | +| `openai_conversations` | Request/response | Local `/v1/conversations` CRUD | +| `openai_responses_format` | Request | Classify format; promote routing headers | +| `openai_responses_validate` | Request | Parameter checks; generate IDs | +| `tool_parse` | Request | Parse tools for branch routing | +| `openai_responses_rehydrate` | Request | Load history from `previous_response_id` | +| `responses_proxy` | Request | Rebuild body from `ResponsesState` | +| `openai_response_store` | Request/response | Persist responses; local GET/DELETE | +| `openai_stream_events` | Request/response | Accumulate streaming SSE events | +| `openai_responses_model_rewrite` | Request body | Rewrite `model` field | + +## Minimal gateway + +Classify, validate, and route to an OpenAI-compatible backend: + +```yaml +listeners: + - name: gateway + address: "127.0.0.1:8080" + filter_chains: [responses] + +filter_chains: + - name: responses + filters: + - filter: openai_responses_format + - filter: openai_responses_validate + - filter: router + routes: + - path_prefix: "/v1" + cluster: openai + - filter: load_balancer + clusters: + - name: openai + endpoints: ["api.openai.com:443"] + tls: + sni: "api.openai.com" +``` + +```console +curl -X POST http://127.0.0.1:8080/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -d '{"model":"gpt-4o","input":"Hello"}' +``` + +## Stateful pipeline + +For multi-turn traffic with storage and rehydration, see +[full-flow.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/openai/responses/full-flow.yaml). +Typical order: + +```text +openai_conversations → openai_responses_format → openai_responses_validate + → tool_parse → openai_response_store → openai_stream_events + → openai_responses_rehydrate → responses_proxy → router → load_balancer +``` + +A request is **stateful** when any of these hold: +`previous_response_id`, non-empty `tools`, `store: true` +(default), `background: true`, `conversation`, or `prompt.id`. + +## Related + +- [Response store](/docs/ai/response-store) +- [AI inference](/docs/ai/ai-inference) +- [Filter reference](/docs/reference/ai-filters) +- [Example configs](/examples) diff --git a/docs/ai/overview.md b/docs/ai/overview.md new file mode 100644 index 0000000..25c295f --- /dev/null +++ b/docs/ai/overview.md @@ -0,0 +1,30 @@ +--- +sidebar_position: 0 +title: Overview +--- + +# AI Gateway + +Praxis AI (`praxis-ai`) extends the core proxy with filters +for LLM provider APIs, agentic protocols, and response +storage. It uses the same YAML config format as +[praxis](/docs/getting-started/product-map). + +## Guides + +- [AI inference pipeline](/docs/ai/ai-inference) +- [OpenAI Responses API](/docs/ai/openai-responses) +- [Anthropic Messages API](/docs/ai/anthropic-messages) +- [Agentic protocols (MCP / A2A)](/docs/ai/agentic-protocols) +- [Response store](/docs/ai/response-store) +- [Token counting](/docs/ai/token-counting) + +## Reference + +- [AI filter reference](/docs/reference/ai-filters) +- [Per-filter configuration](/docs/ai/filters/reference) + +## Source + +Develop and contribute in +[github.com/praxis-proxy/ai](https://github.com/praxis-proxy/ai). diff --git a/docs/ai/response-store.md b/docs/ai/response-store.md new file mode 100644 index 0000000..2d5533e --- /dev/null +++ b/docs/ai/response-store.md @@ -0,0 +1,150 @@ +--- +title: Response Store +--- + +# Response Store + +Durable persistence for OpenAI Responses API responses, +enabling retrieval (`GET`), deletion (`DELETE`), and +input-item pagination across proxy restarts. + +## Design + +The response store is split into two layers: + +```text +ResponseStoreFilter (filter layer) + | + |-- classifies request (POST/GET/DELETE) + |-- gates persistence via metadata + |-- persists at end-of-stream via block_in_place + v +ResponseStore trait (storage layer) + | + +-- SqliteResponseStore + +-- PostgresResponseStore +``` + +The filter layer lives in the OpenAI responses module +and handles HTTP lifecycle concerns. The storage layer +is a generic async trait shared across providers. + +## Request Phases + +The filter spans three Pingora phases, each refining +the persistence decision as new information arrives: + +### `on_request` + +- Reads classifier metadata to determine if the + request is persistable (POST, responses format, + store enabled, non-streaming). +- Handles `GET /v1/responses/{id}` retrieval and + `GET /v1/responses/{id}/input_items` pagination + directly from the store. +- Handles `DELETE /v1/responses/{id}` locally. +- Lazily initializes the store backend. +- Sets `responses.skip_persist` metadata on init + failure. + +### `on_response` + +- Re-checks skip conditions with response headers. +- Non-2xx or non-JSON responses set + `responses.skip_persist` and bail early. + +### `on_response_body` + +- At end-of-stream, extracts the record from the + buffered response JSON. +- Persists synchronously via `block_in_place` + before returning to Pingora. +- Non-persistable exchanges release chunks via + `FilterAction::Release` to avoid holding + pass-through traffic. + +## Threading Model + +The response body hook (`on_response_body`) is a +synchronous `fn`, not `async fn`. The filter bridges +to the async store trait using: + +```rust +let handle = tokio::runtime::Handle::current(); +tokio::task::block_in_place(|| { + handle.block_on(store.upsert_response(record)) +}) +``` + +This guarantees the record is durable before the +client observes the completed response, preventing +races where a subsequent `DELETE` arrives before the +upsert completes. + +## Store Initialization + +Store backends are lazily initialized via +`tokio::sync::OnceCell`: + +- **SQLite**: Failed init is cached permanently as + `None` and never retried (local file; unlikely to + recover without config change). +- **PostgreSQL**: Uses `get_or_try_init` so transient + connection failures are retried on subsequent + requests. + +## Storage Backends + +### SQLite + +File-backed or in-memory. In-memory databases use a +single-connection pool to avoid cross-connection +isolation. JSON columns stored as `TEXT`. + +### PostgreSQL + +Connection-pooled via `sqlx::PgPool`. Upsert uses +`ON CONFLICT (tenant_id, id) DO UPDATE SET ...` for +idempotent persistence. Supports configurable +`SslMode` (`disable`, `prefer`, `require`, +`verify-ca`, `verify-full`) and custom root CA +certificates. + +SSRF protections reject DNS hostnames, localhost, +loopback, private, link-local, and unspecified +addresses by default. `allow_private_database_url` +opts in for development. Host validation is re-run +on every connection attempt to guard against DNS +rebinding. + +## Tenant Isolation + +Every query is scoped by `tenant_id`. The composite +primary key `(tenant_id, id)` enforces isolation at +the database level. `get_response` returns `None` for +wrong-tenant lookups to prevent information leakage. +Single-tenant deployments use a `"default"` sentinel. + +## Body Buffering + +The filter declares `BodyMode::StreamBuffer` with a +64 MiB ceiling globally. Non-streaming Responses API +payloads are bounded by output token limits (typically +under 2 MiB). Non-persistable exchanges release chunks +immediately via `FilterAction::Release` so +pass-through traffic is not held. + +## Key Files + +- `apis/src/openai/responses/store/filter.rs`: store filter lifecycle +- `apis/src/openai/responses/store/config.rs`: configuration, SSRF validation +- `apis/src/store/mod.rs`: `ResponseStore` trait, `ResponseStoreRegistry` +- `apis/src/store/types.rs`: `ResponseRecord`, `StoreError` +- `apis/src/store/sqlite.rs`: SQLite backend +- `apis/src/store/postgres.rs`: PostgreSQL backend +- `server/src/pipelines.rs`: injects `ResponseStoreRegistry` per pipeline + +## Related + +- [AI Inference](/docs/ai/ai-inference) +- [Features](/docs/getting-started/features) diff --git a/docs/ai/token-counting.md b/docs/ai/token-counting.md new file mode 100644 index 0000000..a5a5b36 --- /dev/null +++ b/docs/ai/token-counting.md @@ -0,0 +1,70 @@ +--- +title: Token Counting +--- + +# Token Counting + +Extract token usage from AI provider responses and write +unified counts to filter metadata. + +## `token_count` filter + +`token_count` inspects upstream response bodies (JSON or SSE), +parses provider-specific usage fields, and writes +`token.input`, `token.output`, and `token.total` to +`filter_metadata`. Response bodies pass through unchanged. + +Set `provider` to match your upstream: + +| Value | Formats | +| ----- | ------- | +| `openai` | OpenAI Chat Completions, Responses API | +| `anthropic` | Anthropic Messages (JSON and SSE) | +| `google` | Gemini-style usage blocks | +| `bedrock` | AWS Bedrock Converse API and Claude InvokeModel | +| `azure` | Azure OpenAI (same JSON as OpenAI) | + +Example: + +```yaml +filter_chains: + - name: ai + filters: + - filter: router + routes: + - path_prefix: "/v1" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: ["127.0.0.1:3000"] + - filter: token_count + provider: openai +``` + +Working config: [token-counting.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/token-counting.yaml). + +## `token_usage_headers` filter + +`token_usage_headers` injects `Praxis-Token-Input`, +`Praxis-Token-Output`, and `Praxis-Token-Total` when those +metadata keys are already present. It runs in `on_response`, +before response body chunks are processed. + +`token_count` writes usage in `on_response_body` after the +body is parsed. **A single-pass pipeline cannot yet turn +body-derived counts into client response headers.** The +[token usage headers proposal](https://github.com/praxis-proxy/ai/blob/main/docs/proposals/00214_token-usage-response-headers.md) +documents the limitation and open options (trailers, buffering, +or a later pipeline hook). + +Use `token_usage_headers` today when another filter (or an +earlier hook) populates token metadata before `on_response`. +Otherwise it is a no-op, as in +[token-usage-headers.yaml](https://github.com/praxis-proxy/ai/blob/main/examples/configs/token-usage-headers.yaml). + +## Related + +- [Filter reference](/docs/reference/ai-filters) +- [Features](/docs/getting-started/features#security-and-observability) +- [Proposal 00214](https://github.com/praxis-proxy/ai/blob/main/docs/proposals/00214_token-usage-response-headers.md) diff --git a/docs/architecture/_category_.json b/docs/architecture/_category_.json index b7a3ae9..62b7c00 100644 --- a/docs/architecture/_category_.json +++ b/docs/architecture/_category_.json @@ -1,4 +1 @@ -{ - "label": "Architecture", - "position": 2 -} +{"label":"Architecture","position":6} diff --git a/docs/architecture/connection-lifecycle.md b/docs/architecture/connection-lifecycle.md index 03f471e..35c4a55 100644 --- a/docs/architecture/connection-lifecycle.md +++ b/docs/architecture/connection-lifecycle.md @@ -1,3 +1,8 @@ +--- +sidebar_position: 7 +title: Connection Lifecycle +--- + # Connection Lifecycle ## HTTP Connection Lifecycle @@ -72,6 +77,6 @@ sequenceDiagram ## Related -- [Architecture Overview](overview.md) +- [Architecture Overview](/docs/architecture/system-design) - [Payload Processing](payload-processing.md) - [HTTP Correctness](http-correctness.md) diff --git a/docs/architecture/crate-layout.md b/docs/architecture/crate-layout.md index 228a52b..9bbf49e 100644 --- a/docs/architecture/crate-layout.md +++ b/docs/architecture/crate-layout.md @@ -1,3 +1,8 @@ +--- +sidebar_position: 4 +title: Crate Layout +--- + # Crate Layout ## Workspace Crates @@ -339,6 +344,6 @@ a higher level and across multiple crates. ## Related -- [Architecture Overview](overview.md) +- [Architecture Overview](/docs/architecture/system-design) - [Connection Lifecycle](connection-lifecycle.md) - [HTTP Correctness](http-correctness.md) diff --git a/docs/architecture/http-correctness.md b/docs/architecture/http-correctness.md index 6fe3cc3..1ef4cae 100644 --- a/docs/architecture/http-correctness.md +++ b/docs/architecture/http-correctness.md @@ -1,3 +1,8 @@ +--- +sidebar_position: 8 +title: HTTP Correctness +--- + # HTTP Correctness A proxy must enforce HTTP invariants that upstream servers @@ -73,6 +78,6 @@ the framework level: ## Related -- [Architecture Overview](overview.md) +- [Architecture Overview](/docs/architecture/system-design) - [Connection Lifecycle](connection-lifecycle.md) - [RFC Conformance conventions](../development/contributing.md#rfc-conformance) diff --git a/docs/architecture/life-of-a-request.md b/docs/architecture/life-of-a-request.md new file mode 100644 index 0000000..19454c7 --- /dev/null +++ b/docs/architecture/life-of-a-request.md @@ -0,0 +1,243 @@ +--- +sidebar_position: 5 +title: Life of a Request +--- + +# Life of a Request + +This document traces a single HTTP request from +arrival to response through the Praxis proxy. + +## Overview + +1. Pingora accepts the TCP connection and Praxis + resolves TLS certificates via SNI. +2. The listener's protocol type determines the + handler (HTTP or TCP). +3. The handler loads a pipeline snapshot from + `ArcSwap`, pinned for this request's lifetime. +4. Request filters execute forward (index 0 to N): + conditions are checked, `on_request` runs, + branches are evaluated. +5. If any filter declares body access, request body + chunks pass through body filters in forward order. +6. The router filter sets `ctx.cluster` and the load + balancer sets `ctx.upstream`, selecting the + backend. +7. Pingora connects to the upstream, stripping + hop-by-hop headers and injecting proxy headers. +8. Response filters execute in reverse (index N to + 0), processing only filters that ran during the + request phase. +9. Response body chunks pass through body filters + in reverse order. +10. Pingora sends the response to the client and + returns the upstream connection to the pool. + +The sections below expand each step. Operators can +stop at the overview; the detail sections are for +contributors and filter developers. + +## Step 1: Connection Accept + +Pingora accepts the TCP connection on the listener's +bound address. If TLS is configured, Praxis resolves +the certificate via SNI using `ReloadableCertResolver` +(`tls/src/`), which supports hot-reload via `ArcSwap`. + +Relevant files: +- `tls/src/sni.rs` — SNI resolution +- `tls/src/reload.rs` — certificate hot-reload +- `protocol/src/http/pingora/handler/` — HTTP handler + +## Step 2: Protocol Detection + +The listener's `protocol` field (default: `http`) +determines which protocol adapter handles the +connection. Each adapter implements the `Protocol` +trait (`protocol/src/lib.rs`) and translates +Pingora callbacks into pipeline invocations. + +```text +HTTP listener --> Pingora HTTP handler +TCP listener --> Pingora TCP handler +``` + +An HTTP listener supports both HTTP and TCP filters. +A TCP listener supports only TCP filters. + +## Step 3: Pipeline Snapshot + +The protocol adapter loads the current pipeline from +`Arc>` via +`ListenerPipelines::get()`. The `load()` call returns +an `Arc` guard pinned for this request's lifetime. + +This is how hot reload works without disrupting +in-flight requests: a reload stores a new pipeline +into the `ArcSwap`. The next request loads the new +pointer, while requests already holding a guard +continue on the old pipeline. + +Relevant files: +- `protocol/src/pipelines.rs` — `ListenerPipelines` +- `server/src/reload.rs` — reload orchestration + +## Step 4: Request Filter Execution + +The pipeline executor (`filter/src/pipeline/http.rs`) +runs a while-loop over the flat filter list: + +```text +idx = 0 +while idx < filters.len(): + check conditions → skip if unmet + run on_request + evaluate branches → adjust idx +``` + +For each filter: + +1. **Condition check**: if the filter has `when` or + `unless` conditions, they are evaluated against + the request (path, method, headers). Unmet + conditions skip the filter. +2. **`on_request`**: the filter processes the request. + It may set `ctx.cluster` (router), set + `ctx.upstream` (load balancer), write filter + results, inject headers, or reject the request. +3. **Branch evaluation**: if the filter has + `branch_chains`, each branch's `on_result` + condition is checked against `ctx.filter_results`. + The first matching branch fires and its rejoin + target controls the loop index: + +| Outcome | Effect | +|---------|--------| +| `Continue` | Advance to next filter (`idx + 1`) | +| `SkipTo(target)` | Jump forward to the target filter index | +| `ReEnter(target)` | Loop back to the target index (re-entrance) | +| `Terminal` | Stop the pipeline, proceed to upstream | +| `Reject(status)` | Abort with an error response to the client | + +Filter results are cleared after branch evaluation +at each filter. + +Relevant file: `filter/src/pipeline/http.rs` + +## Step 5: Request Body Processing + +Body processing only occurs if any filter in the +pipeline declared body access. The pipeline's +`BodyCapabilities` (pre-computed at build time) +determines this. + +Body delivery depends on `BodyMode`: + +| Mode | Behavior | +|------|----------| +| `Stream` | Chunks forwarded immediately; filters see each chunk once | +| `StreamBuffer` | Chunks buffered until the filter returns `Release` or end-of-stream | +| `SizeLimit` | Like `Stream`, but enforces a maximum total size | + +Body filters run in forward order. Filters that +returned `BodyDone` are skipped on subsequent chunks. + +Relevant file: `filter/src/pipeline/http.rs` + +## Step 6: Upstream Selection + +Two filters collaborate to select the backend: + +1. **Router** (`router` filter): matches the request + path, host, and headers against configured routes. + Sets `ctx.cluster` to the winning cluster name. +2. **Load balancer** (`load_balancer` filter): + selects an endpoint from the cluster using the + configured strategy (round-robin, least + connections, P2C, consistent hash). Sets + `ctx.upstream` to the endpoint address. + +The protocol adapter reads `ctx.upstream` to build +an `HttpPeer` for the Pingora connection. + +## Step 7: Upstream Request + +Pingora connects to the upstream (or reuses a pooled +connection). Before sending: + +1. Hop-by-hop headers are stripped (with conditional + preservation for upgrade requests like WebSocket) +2. `Host` header is validated +3. `X-Forwarded-For`, `X-Forwarded-Proto`, and + `X-Forwarded-Host` are injected (if the + `forwarded_headers` filter ran) +4. Reserved internal headers (`x-praxis-*`) are + stripped + +Retry logic handles idempotent failures based on +the cluster's retry configuration. + +Relevant file: +`protocol/src/http/pingora/handler/upstream_peer.rs` + +## Step 8: Response Filter Execution + +Response filters execute in **reverse order** (last +filter first). Only filters that actually executed +during Step 4 run — filters skipped by conditions +or `SkipTo` are also skipped in the response phase. + +Each filter's `on_response` receives the upstream +response headers and can modify them or reject the +response. + +Response conditions (`response_conditions` on the +filter entry) can further gate execution based on +response status or headers. + +Relevant file: `filter/src/pipeline/http.rs` + +## Step 9: Response Body Processing + +Response body filters run in **reverse order**, +using the same `BodyMode` logic as request body +processing. This phase is synchronous (a Pingora +constraint). + +Filters that returned `BodyDone` during earlier +chunks are skipped. + +## Step 10: Response Delivery + +Pingora sends the complete response to the client. +The upstream connection is returned to the +connection pool for reuse. The `Arc` guard on the +pipeline snapshot is released. + +## What Can Go Wrong + +| Scenario | What happens | +|----------|-------------| +| Filter returns `Reject` | Pipeline stops; error response sent to client | +| Filter returns an error | Behavior depends on `failure_mode`: `closed` aborts, `open` logs and continues | +| No cluster set | 502 Bad Gateway (no router matched) | +| Upstream unreachable | Retry if idempotent, else 502 | +| Body exceeds size limit | 413 Payload Too Large | +| Pipeline validation fails at startup | Server refuses to start (unless `skip_pipeline_validation` is set) | + +For production hardening, see +[Security Hardening](/docs/security/hardening). + +## Related + +- [Pipeline Concepts](/docs/architecture/pipeline-concepts): + mental model for chains, pipelines, naming +- [Connection Lifecycle](/docs/architecture/connection-lifecycle): + Pingora-level sequence diagrams +- [Payload Processing](/docs/architecture/payload-processing): + body access, StreamBuffer, conditions +- [Filter System](/docs/filters/filter-model): + HttpFilter/TcpFilter traits, context fields +- [Branch Chains](/docs/filters/branch-chains): + conditional branching in pipelines diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md deleted file mode 100644 index bf724a6..0000000 --- a/docs/architecture/overview.md +++ /dev/null @@ -1,200 +0,0 @@ -# Architecture Overview - -## Design Principles - -**Fast.** Performance is a primary design goal. - -**Secure by default.** Security is a primary design goal. - -**Composable.** Everything is a filter. Routing, load -balancing, rate limiting, AI model selection: all filters, -all using the same traits, all assembled through chains. - -**Extensible.** Your filters implement the same [`HttpFilter`] -and [`TcpFilter`] traits as built-in filters. Register with -one macro. - -**Adaptive.** Praxis is a framework for building proxies, -not just a proxy. Use a provided build out of the box, or -compose a bespoke proxy server from the same primitives. - -[`HttpFilter`]:../filters/README.md -[`TcpFilter`]:../filters/README.md - -## Primary Use-Cases - -- **Ingress**: Reverse proxy, API gateway, edge proxy -- **Egress**: Outbound proxy, service-to-service -- **East/West**: Sidecar or converged proxy for service mesh -- **AI Inference**: Proxy for AI inference workloads -- **AI Agents**: Proxy for AI agents -- **Security Gateway**: Guardrails, Network Policy - -:::tip Interactive Diagram -See the [interactive architecture diagram](pathname:///architecture/diagram.html) -for a visual overview of crate dependencies, filter -categories, and request flow. -::: - -## System Architecture - -### Protocol Adapters - -Adapters translate upstream library callbacks into pipeline -invocations. When feasible Praxis owns no protocol logic, -instead handing it off to well-maintained and battle-tested -upstream solutions. - -```text -HTTP --> praxis-protocol/http --> Pingora -TCP --> praxis-protocol/tcp --> Pingora -QUIC --> praxis-protocol/http3 --> Quiche (planned, not yet implemented) -``` - -These adapters are modular, it's intended to enable adding new protocols by -writing new adapters, and even having multiple implementations of a single -protocol that can be swapped via build features or runtime configuration. - -### Filter-First Design - -Every behavior is a filter. Built-in filters use the same -traits as user-provided filters. - -```mermaid -sequenceDiagram - participant C as Client - participant F1 as RequestIdFilter - participant F2 as RouterFilter - participant F3 as HeaderFilter - participant F4 as LoadBalancerFilter - participant U as Upstream - - C ->> F1: request - F1 ->> F2: on_request - F2 ->> F3: on_request (sets ctx.cluster) - F3 ->> F4: on_request - F4 ->> U: on_request (sets ctx.upstream) - - U -->> F4: response - F4 -->> F3: on_response - F3 -->> F2: on_response - F2 -->> F1: on_response - F1 -->> C: response -``` - -Request filters run in declared order, response filters in -reverse. Any filter can short-circuit, and multiple payload -processing options are available to do filtering, routing, -caching and load-balancing based on request or response bodies. - -See the [filter system documentation] for more extensive -documentation, and the [extensions guide] for how to write -your own. - -[filter system documentation]:../filters/README.md -[extensions guide]:../filters/extensions.md - -### Listeners - -```mermaid -flowchart LR - Client -->|TCP| L1["Listener (named)"] - L1 -->|rustls| TLS - TLS --> Resolve["Chain Resolution"] - Resolve --> Pipeline["Filter Pipeline"] - Pipeline --> Pool["Upstream Pool"] - Pool --> Backend - - Config["Config (YAML)"] -. startup .-> Chains - Chains["filter_chains:"] -. per listener .-> Resolve -``` - -Each listener has a `name` and a list of `filter_chains`. -At startup, the referenced chains are resolved and -concatenated into a single pipeline per listener. Different -listeners can compose different subsets of chains. - -### Filters - -Filter chains are named, reusable groups of filters defined -at the top level of the config. A listener references one or -more chains by name; the filters are concatenated in order -to form that listener's pipeline. - -```mermaid -flowchart LR - subgraph "Listener: public" - direction LR - S["security chain"] --> O["observability chain"] - O --> R["routing chain"] - end - - subgraph "Listener: internal" - direction LR - O2["observability chain"] --> R2["routing chain"] - end -``` - -This enables reuse without duplication. A "security" chain -can be shared across public listeners while internal -listeners skip it entirely. - -#### Protocol-Aware Filters - -Filters are protocol-aware. HTTP filters implement the -`HttpFilter` trait (`on_request`, `on_response`, body hooks). -TCP filters implement the `TcpFilter` trait (`on_connect`, -`on_disconnect`). The `AnyFilter` enum wraps both variants -for storage in a unified pipeline. - -Protocol compatibility is enforced via `ProtocolKind::stack()` -and `supports()`. An HTTP listener supports both HTTP and TCP -filters. A TCP listener supports only TCP filters. - -```mermaid -flowchart TD - AnyFilter --> HttpFilter - AnyFilter --> TcpFilter - - HttpListener["HTTP Listener"] -->|supports| HttpFilter - HttpListener -->|supports| TcpFilter - TcpListener["TCP Listener"] -->|supports| TcpFilter -``` - -### What Stays Outside Filters - -- TCP/TLS, HTTP framing, connection pooling: adapters -- Config loading and validation: `praxis-core` -- Pipeline executor and `HttpFilterContext`: `praxis-filter` - -## Dynamic Configuration Reload - -Praxis swaps filter pipelines at runtime without -restarting the server or disrupting in-flight requests. - -Each handler holds an `Arc>` -instead of a plain `Arc`. On every -request, the handler calls `pipeline.load()` to get a -snapshot pinned for that request's lifetime. A reload -stores a new pipeline into the `ArcSwap`; the next -request loads the new pointer while in-flight requests -drain on the old one. - -A file watcher (`notify` crate, 500ms debounce) monitors -the config file. On change it validates the new config, -rebuilds all pipelines, and swaps them atomically. If -validation fails, nothing changes. Health check tasks -are cancelled and respawned with a fresh registry on -each successful reload. - -Changes that cannot be applied dynamically (listener -topology, protocol type, compression module, TLS toggle) -are detected by diffing old and new configs and logged -as warnings. - -## Related - -- [Connection Lifecycle](connection-lifecycle.md) -- [Payload Processing](payload-processing.md) -- [Crate Layout](crate-layout.md) -- [HTTP Correctness](http-correctness.md) diff --git a/docs/architecture/payload-processing.md b/docs/architecture/payload-processing.md index 3e14307..b37cfc5 100644 --- a/docs/architecture/payload-processing.md +++ b/docs/architecture/payload-processing.md @@ -1,3 +1,8 @@ +--- +sidebar_position: 6 +title: Payload Processing +--- + # Payload Processing Filters declare body access needs at construction time via @@ -75,6 +80,6 @@ body hooks. ## Related -- [Architecture Overview](overview.md) +- [Architecture Overview](/docs/architecture/system-design) - [Connection Lifecycle](connection-lifecycle.md) -- [Filter System](../filters/README.md) +- [Filter System](/docs/filters/filter-model) diff --git a/docs/architecture/pipeline-concepts.md b/docs/architecture/pipeline-concepts.md new file mode 100644 index 0000000..65f62f3 --- /dev/null +++ b/docs/architecture/pipeline-concepts.md @@ -0,0 +1,49 @@ +--- +title: Pipeline Concepts +sidebar_position: 4 +--- + +# Pipeline Concepts + +Core ideas behind Praxis filter pipelines: how filters +compose, how results flow into branch chains, and how +names are resolved. + +## Filter Results + +Filters can write structured key-value pairs to a +`FilterResultSet` during request processing. Branch +conditions read these results to decide whether to +divert, short-circuit, skip ahead, or loop back. + +- Results are keyed by the filter **type name** (from + `HttpFilter::name()`), not the user-assigned `name:` + on the filter entry. +- Results are cleared after branch evaluation completes. +- Built-in writers include `guardrails`, `json_rpc`, + `grpc_detection`, and AI classifiers (`mcp`, `a2a`, + `openai_responses_format`, and others). + +See [Branch Chains](/docs/filters/branch-chains) for +condition syntax and examples. + +## The Two Meanings of "Name" + +Praxis uses `name` in two different places: + +1. **Filter entry `name:`** — optional label on a + filter chain entry. Used for logging, skip-to targets, + and reentrance loops. This is user-assigned. +2. **Filter type name** — the string returned by + `HttpFilter::name()` (e.g. `"guardrails"`). Branch + `on_result.filter` and result keys always use the type + name. + +When configuring `on_result.filter`, use the type name, +not the entry label. + +## Related + +- [Filter model](/docs/filters/filter-model) +- [Branch chains](/docs/filters/branch-chains) +- [Life of a request](/docs/architecture/life-of-a-request) diff --git a/docs/architecture/system-design.md b/docs/architecture/system-design.md index 0dcaf55..1aba23b 100644 --- a/docs/architecture/system-design.md +++ b/docs/architecture/system-design.md @@ -15,16 +15,16 @@ title: System Design balancing, rate limiting, AI model selection: all filters, all using the same traits, all assembled through chains. -**Extensible.** Your filters implement the same [`HttpFilter`] -and [`TcpFilter`] traits as built-in filters. Register with +**Extensible.** Your filters implement the same `HttpFilter` +and `TcpFilter` traits as built-in filters. Register with one macro. **Adaptive.** Praxis is a framework for building proxies, not just a proxy. Use a provided build out of the box, or compose a bespoke proxy server from the same primitives. -[`HttpFilter`]:/docs/filters/filter-model -[`TcpFilter`]:/docs/filters/filter-model +`HttpFilter`:/docs/filters/filter-model +`TcpFilter`:/docs/filters/filter-model ## Primary Use-Cases @@ -93,7 +93,7 @@ processing options are available to do filtering, routing, caching and load-balancing based on request or response bodies. See [Filter Model](/docs/filters/filter-model) for more extensive documentation on the filter -system, and [Custom Filters](/docs/filters/custom-filters) for how to write your own. +system, and [Custom Filters](/docs/filters/extensions) for how to write your own. ### Listeners diff --git a/docs/configuration/_category_.json b/docs/configuration/_category_.json index 918fb7c..af76642 100644 --- a/docs/configuration/_category_.json +++ b/docs/configuration/_category_.json @@ -1,4 +1 @@ -{ - "label": "Configuration", - "position": 3 -} +{"label":"Configuration","position":2} diff --git a/docs/configuration/overview.md b/docs/configuration/overview.md index 71cc3c6..b32e598 100644 --- a/docs/configuration/overview.md +++ b/docs/configuration/overview.md @@ -19,7 +19,7 @@ admin: # Optional. Admin health endpoint. body_limits: # Optional. Global body size ceilings. runtime: # Optional. Thread pool and logging tuning. shutdown_timeout_secs: # Optional. Graceful drain time (default: 30). -insecure_options: # Optional. Dev/test overrides. See development.md. +insecure_options: # Optional. Dev/test overrides. See /docs/development/testing#insecure-options. ``` ## Validating Configuration @@ -366,6 +366,11 @@ supports TCP-level filters too. ## Built-in Filters +Core filters ship with the `praxis` binary. AI filters +require `praxis-ai` — see [AI filter reference](/docs/reference/ai-filters). + +Full core reference: [core filter reference](/docs/reference/core-filters). + | Filter | Category | Protocol | | --- | --- | --- | | `router` | Traffic Management | HTTP | @@ -374,6 +379,7 @@ supports TCP-level filters too. | `static_response` | Traffic Management | HTTP | | `rate_limit` | Traffic Management | HTTP | | `circuit_breaker` | Traffic Management | HTTP | +| `grpc_detection` | Traffic Management | HTTP | | `headers` | Transformation | HTTP | | `request_id` | Observability | HTTP | | `access_log` | Observability | HTTP | @@ -382,10 +388,8 @@ supports TCP-level filters too. | `guardrails` | Security | HTTP | | `ip_acl` | Security | HTTP | | `credential_injection` | Security | HTTP | -| `a2a` | Payload Processing | HTTP | | `json_body_field` | Payload Processing | HTTP | | `json_rpc` | Payload Processing | HTTP | -| `mcp` | Payload Processing | HTTP | | `compression` | Payload Processing | HTTP | | `cors` | Security | HTTP | | `csrf` | Security | HTTP | @@ -394,20 +398,19 @@ supports TCP-level filters too. | `url_rewrite` | Transformation | HTTP | | `sni_router` | Traffic Management | TCP | | `tcp_load_balancer` | Traffic Management | TCP | -| `model_to_header` | AI / Inference | HTTP (requires `ai-inference` feature) | -| `prompt_enrich` | AI / Inference | HTTP (requires `ai-inference` feature) | -### Router +## Filter Configuration + +Per-filter fields, defaults, and YAML examples live in the +[core filter reference](/docs/reference/core-filters). Each +filter has a dedicated page under `docs/filters/http/` and +`docs/filters/tcp/`. -Routes requests to clusters by path prefix. Longest prefix -wins. Optional `host` restricts matching to a specific -`Host` header. Optional `headers` restricts matching to -requests with all specified header values present (AND -semantics, case-sensitive). Routes without `host` match -any host. +AI filters (`model_to_header`, `mcp`, OpenAI Responses, and +others) require `praxis-ai` — see +[AI filter reference](/docs/reference/ai-filters). -Example configs: [path-based-routing.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/path-based-routing.yaml), -[hosts.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/hosts.yaml), [canary-routing.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/canary-routing.yaml). +## Cluster Load Balancing ### Load Balancing @@ -473,504 +476,6 @@ By default, health check endpoints that resolve to loopback, link-local, or cloud metadata addresses are rejected (SSRF protection). -### Headers - -Add headers to requests; add, set, or remove headers on -responses: - -```yaml -- filter: headers - request_add: - - name: "X-Forwarded-Proto" - value: "https" - response_add: - - name: "X-Served-By" - value: "praxis" - response_set: - - name: "Server" - value: "praxis" - response_remove: - - "X-Powered-By" -``` - -`add` appends (preserves existing), `set` replaces, -`remove` deletes. Request headers support `add` only. -Response headers support all three operations. - -### Timeout - -Returns 504 if upstream response exceeds configured duration: - -```yaml -- filter: timeout - timeout_ms: 5000 -``` - -### Request ID - -Propagates an existing request ID header or generates a -new one: - -```yaml -- filter: request_id - header_name: "X-Request-Id" # optional, this is the default -``` - -### Access Log - -Structured JSON logging of method, path, status, and -timing: - -```yaml -- filter: access_log -``` - -Optional sampling to reduce log volume: - -```yaml -- filter: access_log - sample_rate: 0.1 # log ~10% of requests -``` - -### Forwarded Headers - -Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and -`X-Forwarded-Host` into upstream requests: - -```yaml -- filter: forwarded_headers - trusted_proxies: - - "10.0.0.0/8" - - "172.16.0.0/12" -``` - -When the client IP is from a trusted proxy, existing -`X-Forwarded-For` values are preserved. Otherwise, the -header is overwritten to prevent spoofing. - -### IP ACL - -Allow or deny requests by source IP/CIDR: - -```yaml -- filter: ip_acl - allow: - - "10.0.0.0/8" -``` - -Use either `allow` or `deny`, not both (mutually -exclusive). When `allow` is set, only matching IPs are -permitted (implicit deny-all). Denied requests receive -a `403 Forbidden` response. - -### Credential Injection - -Injects per-cluster API credentials into upstream -requests and strips client-provided credentials to -prevent forwarding. Pair with a source discriminator -(IP ACL, client authentication) to control which -clients receive credential upgrades. See -[credential-injection.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/ai/credential-injection.yaml). - -```yaml -- filter: credential_injection - clusters: - - name: openai - header: Authorization - value: "sk-example-key" - header_prefix: "Bearer " - strip_client_credential: true -``` - -| Field | Type | Required | Description | -| ----- | ---- | -------- | ----------- | -| `clusters[].name` | string | yes | Cluster to inject credentials for | -| `clusters[].header` | string | yes | Header name to set | -| `clusters[].value` | string | one of | Inline credential value | -| `clusters[].env_var` | string | one of | Environment variable containing the credential | -| `clusters[].header_prefix` | string | no | Prefix prepended to the value (e.g. `"Bearer "`) | -| `clusters[].strip_client_credential` | bool | no | Remove client-sent value before injection (default: true) | - -### TCP Access Log - -Structured JSON logging of TCP connections. Works on both -TCP and HTTP listeners: - -```yaml -- filter: tcp_access_log -``` - -### SNI Router - -Routes TLS connections to upstream addresses based on -the SNI hostname from the TLS ClientHello. Supports -exact matches and wildcard patterns (e.g. -`*.example.com`). Performs exact-match lookup first, -then longest-suffix wildcard match. Matching is -case-insensitive per RFC 4343. - -```yaml -- filter: sni_router - routes: - - server_names: ["api.example.com"] - upstream: "10.0.0.1:443" - - server_names: ["*.example.com"] - upstream: "10.0.0.2:443" - default_upstream: "10.0.0.3:443" -``` - -| Field | Type | Required | Description | -| ----- | ---- | -------- | ----------- | -| `routes` | list | yes | SNI route entries | -| `routes[].server_names` | list | yes | Exact or wildcard hostnames | -| `routes[].upstream` | string | yes | Upstream address for matches | -| `default_upstream` | string | no | Fallback when no route matches | - -Connections without SNI or with no matching route use -`default_upstream` if configured, otherwise receive a -421 rejection. Bare wildcards (`*`), IP addresses as -server names, and duplicate server names across routes -are rejected at config validation. - -### TCP Load Balancer - -Selects an upstream TCP endpoint from a cluster using -the configured load-balancing strategy. Reads -`ctx.cluster` to find the target cluster, selects an -endpoint, and writes `ctx.upstream_addr`. Supports -round-robin (default), least-connections, and -consistent-hash strategies. - -```yaml -- filter: tcp_load_balancer - clusters: - - name: db_pool - endpoints: - - "10.0.0.1:5432" - - "10.0.0.2:5432" -``` - -Weighted endpoints and strategy selection follow the -same syntax as the HTTP `load_balancer` filter. Health -check integration is supported; if all endpoints are -unhealthy, the filter enters panic mode and routes to -all endpoints. - -### JSON Body Field - -Extracts a top-level field from a JSON request body and -promotes its value to a request header. Uses StreamBuffer -mode to inspect the body before upstream selection, -enabling body-based routing. - -```yaml -- filter: json_body_field - field: model - header: X-Model -``` - -`field` is the JSON key to extract. `header` is the -request header name to promote the value into. If the -field is missing or the body is not valid JSON, the -filter passes through without modification. - -### JSON-RPC - -Parses JSON-RPC 2.0 request bodies and promotes -method, id, and message kind to request headers for -routing. Uses StreamBuffer mode to inspect the body -before upstream selection. - -```yaml -- filter: json_rpc - max_body_bytes: 1048576 - batch_policy: reject - on_invalid: continue -``` - -| Field | Type | Default | Description | -| ----- | ---- | ------- | ----------- | -| `max_body_bytes` | integer | 1048576 | Maximum body size to buffer (1 MiB) | -| `batch_policy` | string | `"reject"` | `"reject"` returns 400 for batch arrays; `"first"` uses first valid request | -| `on_invalid` | string | `"continue"` | `"continue"` passes non-JSON through; `"reject"` returns 400; `"error"` raises a filter error | -| `headers.method` | string | `"X-Json-Rpc-Method"` | Header name for the JSON-RPC method | -| `headers.id` | string | `"X-Json-Rpc-Id"` | Header name for the JSON-RPC id | -| `headers.kind` | string | `"X-Json-Rpc-Kind"` | Header name for the message kind | - -Message kinds: `request`, `notification`, `response`, -`batch`. The filter also writes `json_rpc.*` entries -to the filter result set for branch chain conditions. - -### MCP - -Extracts Model Context Protocol metadata from JSON-RPC -request bodies and promotes method, tool/resource/prompt -name, session ID, and protocol version to request -headers and filter results for routing. Validates MCP -headers against body-derived values when -`header_validation` is configured. - -```yaml -- filter: mcp - max_body_bytes: 65536 - on_invalid: reject -``` - -| Field | Type | Default | Description | -| ----- | ---- | ------- | ----------- | -| `max_body_bytes` | integer | 65536 | Maximum body size to buffer (64 KiB) | -| `on_invalid` | string | `"reject"` | `"reject"` returns 400 for non-MCP; `"continue"` passes through | -| `header_validation.mismatch` | string | `"reject"` | `"reject"` or `"ignore"` when MCP headers conflict with body values | -| `header_validation.missing` | string | `"ignore"` | `"ignore"`, `"synthesize"` (inject from body), or `"reject"` | -| `headers.method` | string | `"x-praxis-mcp-method"` | Header name for MCP method | -| `headers.name` | string | `"x-praxis-mcp-name"` | Header name for tool/resource/prompt name | -| `headers.kind` | string | `"x-praxis-mcp-kind"` | Header name for JSON-RPC kind | -| `headers.session_present` | string | `"x-praxis-mcp-session-present"` | Header name for session presence | - -Recognized MCP methods include `initialize`, -`tools/call`, `tools/list`, `resources/read`, -`resources/list`, `prompts/get`, `prompts/list`, -`ping`, and others. Methods requiring a name selector -(`tools/call`, `resources/read`, `prompts/get`) return -a JSON-RPC error if the selector is missing and -`on_invalid` is `"reject"`. The filter writes `mcp.*` -and `json_rpc.*` entries to the filter result set for -branch chain conditions. - -### Static Response - -Returns a fixed response without contacting any upstream. -Useful for health checks, status endpoints, or stub routes: - -```yaml -- filter: static_response - status: 200 - headers: - - name: Content-Type - value: application/json - body: '{"status": "ok", "server": "praxis"}' -``` - -`status` is required. `headers` and `body` are optional. -Combine with conditions to serve static responses on -specific paths. - -### Rate Limit - -Token bucket rate limiter. Supports `per_ip` (one bucket -per source IP) and `global` (one shared bucket) modes. -Rejects excess traffic with 429 and `Retry-After` header. -Injects `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and -`X-RateLimit-Reset` headers into both rejections and -successful responses. - -```yaml -- filter: rate_limit - mode: per_ip # "per_ip" or "global" - rate: 100 # tokens replenished per second - burst: 200 # maximum bucket capacity -``` - -| Field | Type | Required | Description | -| ------- | ------ | ---------- | ------------- | -| `mode` | string | yes | `"per_ip"` or `"global"` | -| `rate` | float | yes | Tokens per second (must be > 0) | -| `burst` | integer | yes | Max bucket capacity (must be >= rate) | - -### Circuit Breaker - -Per-cluster circuit breaker that prevents cascading -failures. When consecutive upstream failures reach the -threshold, the circuit opens and subsequent requests -receive 503 immediately. After the recovery window, a -single probe request is forwarded; if it succeeds the -circuit closes. See [circuit-breaker.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/circuit-breaker.yaml). - -```yaml -- filter: circuit_breaker - clusters: - - name: backend - consecutive_failures: 5 - recovery_window_secs: 30 -``` - -| Field | Type | Required | Description | -| ----- | ---- | -------- | ----------- | -| `clusters[].name` | string | yes | Cluster name to protect | -| `clusters[].consecutive_failures` | integer | yes | Failures before opening | -| `clusters[].recovery_window_secs` | integer | yes | Seconds before half-open probe | - -### Guardrails - -Rejects requests matching string or regex rules against -headers and/or body content. Rejected requests receive -403 Forbidden. - -```yaml -- filter: guardrails - rules: - - target: header - name: "User-Agent" - pattern: "bad-bot.*" - - target: body - contains: "DROP TABLE" - - target: body - pattern: "^\\{.*\\}$" - negate: true -``` - -Each rule has: - -| Field | Type | Required | Description | -| ------- | ------ | ---------- | ------------- | -| `target` | string | yes | `"header"` or `"body"` | -| `name` | string | header only | Header name to inspect | -| `contains` | string | one of | Literal substring match | -| `pattern` | string | one of | Regex pattern match | -| `negate` | bool | no | Invert match (default: false) | - -Each rule must have either `contains` or `pattern`, not -both. Body rules use StreamBuffer mode (up to 1 MiB by -default) to inspect the full request body. - -### CORS - -Spec-compliant CORS filter with preflight handling, origin -validation, and credential support. See [cors.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/security/cors.yaml). - -| Field | Type | Default | Description | -| ------- | ------ | --------- | ------------- | -| `allow_origins` | list | required | Origins to allow; `["*"]` for any | -| `allow_methods` | list | GET, HEAD, POST | Allowed HTTP methods | -| `allow_headers` | list | none | Allowed request headers | -| `expose_headers` | list | none | Response headers exposed to client | -| `allow_credentials` | bool | false | Include credentials header | -| `max_age` | integer | 86400 | Preflight cache duration (seconds) | -| `allow_private_network` | bool | false | Private Network Access support | -| `disallowed_origin_mode` | string | "omit" | `"omit"` or `"reject"` for non-matching origins | -| `allow_null_origin` | bool | false | Allow `Origin: null` | - -Wildcard subdomain patterns (e.g. `https://*.example.com`) -are supported. `allow_credentials: true` is incompatible -with wildcard origins, methods, or headers per the Fetch -spec. - -### CSRF - -Cross-site request forgery protection via origin -validation. Safe methods (GET, HEAD, OPTIONS by default) -bypass the check. State-changing methods require an -`Origin` or `Referer` header matching the trusted -origins. Rejected requests receive 403 Forbidden. -See [csrf.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/security/csrf.yaml). - -```yaml -- filter: csrf - trusted_origins: - - "https://app.example.com" - - "https://*.example.com" - enforce_percentage: 100 - enable_sec_fetch_site: true -``` - -| Field | Type | Default | Description | -| ------- | ------ | --------- | ------------- | -| `trusted_origins` | list | required | Origins to allow; `["*"]` for any | -| `safe_methods` | list | GET, HEAD, OPTIONS | Methods that bypass CSRF checks | -| `enforce_percentage` | integer | 100 | Percentage of requests to enforce (0..=100); enables gradual rollout | -| `enable_sec_fetch_site` | bool | false | Reject requests with `Sec-Fetch-Site: cross-site` | - -Wildcard subdomain patterns (e.g. -`https://*.example.com`) are supported. A bare wildcard -(`"*"`) cannot be mixed with other origins. Set -`insecure_options.csrf_log_only: true` to log violations -without rejecting requests during initial rollout. - -### Redirect - -Returns a 3xx redirect without contacting any upstream: - -```yaml -- filter: redirect - status: 301 - location: "https://example.com${path}" -``` - -| Field | Type | Default | Description | -| ------- | ------ | --------- | ------------- | -| `status` | integer | 301 | Redirect status (301, 302, 307, or 308) | -| `location` | string | required | URL template; `${path}` and `${query}` are substituted | - -### Path Rewrite - -Rewrites the request path before forwarding to upstream. -Exactly one of `strip_prefix`, `add_prefix`, or `replace` -per filter instance. Query strings are preserved. See -[path-rewriting.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/transformation/path-rewriting.yaml). - -| Field | Type | Description | -| ------- | ------ | ------------- | -| `strip_prefix` | string | Remove this prefix from the path | -| `add_prefix` | string | Prepend this prefix to the path | -| `replace.pattern` | string | Regex pattern to match | -| `replace.replacement` | string | Replacement string (`$1`, `$name` captures) | - -### URL Rewrite - -Regex-based path transformation and query string -manipulation. Operations applied in order: -`regex_replace`, `strip_query_params`, -`add_query_params`. See [url-rewriting.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/transformation/url-rewriting.yaml). - -### Compression - -Gzip, brotli, and zstd response compression. All three -enabled by default. See [compression.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/payload-processing/compression.yaml). - -| Field | Type | Default | Description | -| ------- | ------ | --------- | ------------- | -| `level` | integer | 6 | Default compression level (1-12) | -| `min_size_bytes` | integer | 256 | Skip responses smaller than this | -| `gzip` | object | enabled | Per-algorithm `enabled` and `level` | -| `brotli` | object | enabled | Per-algorithm `enabled` and `level` | -| `zstd` | object | enabled | Per-algorithm `enabled` and `level` | -| `content_types` | list | see above | MIME type prefixes that qualify | - -At least one algorithm must be enabled. - -### Prompt Enrich - -Injects statically configured messages into -OpenAI-compatible chat completion request bodies. The -filter parses the JSON body, splices configured messages -into the `messages` array, re-serializes, and updates -`Content-Length`. Requires the `ai-inference` feature. -See [prompt-enrichment.yaml](https://github.com/praxis-proxy/praxis/blob/main/examples/configs/ai/prompt-enrichment.yaml). - -```yaml -- filter: prompt_enrich - prepend: - - role: system - content: "You are a helpful assistant." - append: - - role: user - content: "Cite your sources." -``` - -| Field | Type | Default | Description | -| ------- | ------ | --------- | ------------- | -| `prepend` | list | `[]` | Messages inserted at the beginning of `messages` (system role only) | -| `append` | list | `[]` | Messages added at the end of `messages` (system or user role) | -| `on_invalid` | string | `"continue"` | `"continue"` passes non-JSON through; `"reject"` returns 400 | -| `max_body_bytes` | integer | 10485760 | Maximum body size to buffer (10 MiB) | - -At least one of `prepend` or `append` must be non-empty. -Each message has a `role` (system or user) and a -non-empty `content` string. JSON is re-serialized, so -byte-for-byte body identity is not preserved. In chains -that also use `json_body_field` or `model_to_header`, -place `prompt_enrich` first. - ### Conditions `when`/`unless` gates on any filter chain entry. Request diff --git a/docs/development/_category_.json b/docs/development/_category_.json index 579aed1..65e6d09 100644 --- a/docs/development/_category_.json +++ b/docs/development/_category_.json @@ -1,4 +1 @@ -{ - "label": "Development", - "position": 7 -} +{"label":"Development","position":9} diff --git a/docs/development/contributing.md b/docs/development/contributing.md index ea4ddc8..e25f98f 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -3,7 +3,7 @@ sidebar_position: 1 title: Contributing --- -# Development Conventions +# Contributing ## Coding Style @@ -27,6 +27,7 @@ title: Contributing - **cargo-deny**: Enforce supply chain safety policies - **rustdoc**: Generate the API documentation - **cargo xtask**: Developer task runner for benchmarks, flamegraphs, and debug utilities +- **Filter docs**: After changing filter config structs, run `cargo xtask generate-filter-docs`; CI runs `cargo xtask lint-filter-docs` via `make lint` in the source repos - **benchmarks**: Criterion microbenchmarks and scenario-based load tests ([Fortio](https://github.com/fortio/fortio), [Vegeta](https://github.com/tsenart/vegeta)) ### Comments vs Tracing diff --git a/docs/development/testing.md b/docs/development/testing.md index 9916208..262c536 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -3,11 +3,11 @@ sidebar_position: 2 title: Testing --- -# Development +# Testing ## Requirements -- Rust stable 1.94+ +- Rust stable 1.96+ - Rust nightly - CMake 3.31+ - Docker 29.3.0+ @@ -56,7 +56,7 @@ deployment guidance. ## Adding a new Built-in Filter -Review [Custom Filters](/docs/filters/custom-filters) first. +Review [Custom Filters](/docs/filters/extensions) first. 1. Create the filter module under `filter/src/builtins///`. diff --git a/docs/filters/README.md b/docs/filters/README.md deleted file mode 100644 index a3a5efd..0000000 --- a/docs/filters/README.md +++ /dev/null @@ -1,492 +0,0 @@ -# Filters - -## Filter Model - -Filters are the core processing units in Praxis. Each -filter is a small (preferably), composable function that -inspects or transforms traffic at a single point in the -request/response lifecycle. - -Filters are chained into pipelines; the pipeline executor -calls each filter in order on requests and in reverse on responses. - -For listener and chain resolution architecture, see -the [architecture overview](../architecture/overview.md). - -### What Filters Receive - -**HTTP filters** receive an `HttpFilterContext` containing: - -- `client_addr`: downstream IP (from the TCP connection) -- `downstream_tls`: whether the client connection uses TLS -- `health_registry`: endpoint health state -- `request`: method, URI, and headers -- `response_header`: response status and headers (**only in response phase**) -- `cluster` / `upstream`: current routing selections (may be set by earlier filters) -- `rewritten_path`: path set by a preceding rewrite filter (**Praxis will include this in routing decisions**) -- Request and response body chunks (only if the filter declares `BodyAccess::ReadOnly` or `BodyAccess::ReadWrite`) - -**TCP filters** receive a `TcpFilterContext` with -connection metadata: `remote_addr`, `local_addr`, `sni` -(SNI hostname from TLS ClientHello), `upstream_addr` -(mutable via `Cow`), timing, and byte counters. - -### What Filters Can Do - -Every filter hook returns a `FilterAction`: - -- **`Continue`**: pass to the next filter in the pipeline. -- **`Reject`**: short-circuit with an HTTP response (status code, optional headers and body). - Used by `static_response`, `redirect`, `rate_limit`, `guardrails`, `cors` preflight, and similar filters. -- **`Release`**: forward accumulated body data to upstream when using `StreamBuffer` mode. - Behaves as `Continue` when body data is not relevant. - -Filters also mutate `HttpFilterContext` fields to -influence downstream processing: - -- `ctx.cluster`: select which upstream cluster to route to. -- `ctx.upstream`: select a specific endpoint. -- `ctx.rewritten_path`: rewrite the upstream request path. -- `ctx.extra_request_headers`: inject headers into the upstream request. -- `ctx.response_header`: mutate response headers directly in `on_response`. -- `ctx.response_headers_modified`: flag that response headers were changed - -### Lifecycle Hooks - -| Hook | Direction | Phase | -| --- | --- | --- | -| `on_request` | Forward (pipeline order) | Request | -| `on_response` | Reverse (pipeline order) | Response | -| `on_request_body` | Forward | Request body chunks | -| `on_response_body` | Reverse | Response body chunks | - -Request `conditions` gate both the request and body -hooks. Response `response_conditions` gate only the -response hooks. A filter skipped on request is also -skipped on response. - -### Common Patterns - -See [`examples/configs/`] for working examples of every -pattern. A few highlights: - -- **Host-based routing**: [hosts.yaml] -- **Path-based routing with rewriting**: [path-based-routing.yaml] -- **Security chain** (guardrails + IP ACL): [guardrails.yaml], [ip-acl.yaml] -- **Rate limiting with headers**: [rate-limiting.yaml] -- **Composed filter chains**: [composed-chains.yaml] -- **Conditional filters**: [conditional-filters.yaml] -- **Production gateway**: [production-gateway.yaml] - -[`examples/configs/`]: https://github.com/praxis-proxy/praxis/tree/main/examples/configs -[hosts.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/hosts.yaml -[path-based-routing.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/path-based-routing.yaml -[guardrails.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/security/guardrails.yaml -[ip-acl.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/security/ip-acl.yaml -[rate-limiting.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/traffic-management/rate-limiting.yaml -[composed-chains.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/pipeline/composed-chains.yaml -[conditional-filters.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/pipeline/conditional-filters.yaml -[production-gateway.yaml]: https://github.com/praxis-proxy/praxis/blob/main/examples/configs/operations/production-gateway.yaml - -## Filter Chains - -Filter chains are named, reusable groups of filters defined -at the top level of the config. A listener references one or -more chains by name; the filters are concatenated in order -to form that listener's pipeline. - -```mermaid -flowchart LR - subgraph "Listener: public" - direction LR - S["security chain"] --> O["observability chain"] - O --> R["routing chain"] - end - - subgraph "Listener: internal" - direction LR - O1["observability chain"] --> R2["routing chain"] - end -``` - -This enables reuse without duplication. A "security" chain -can be shared across public listeners while internal -listeners skip it entirely. - -### Protocol-Specific Filters - -Every filter belongs to exactly one protocol level. HTTP -filters implement the `HttpFilter` trait (`on_request`, -`on_response`, body hooks). TCP filters implement the -`TcpFilter` trait (`on_connect`, `on_disconnect`). There -is no generic filter that operates at both levels. The -`AnyFilter` enum tags each filter with its protocol for -storage in a unified pipeline. - -Built-in filters are organized by protocol, then by -category: - -```text -builtins/ - http/ HTTP protocol filters - ai/ AI workloads (inference) - observability/ Access logs, request IDs - payload_processing/ Compression, body field extraction - security/ CORS, CSRF, forwarded headers, guardrails, IP ACL - traffic_management/ Router, load balancer, timeout, rate limit, redirect, static response - transformation/ Header, path rewrite, URL rewrite - tcp/ TCP protocol filters - observability/ Connection logging - traffic_management/ SNI-based routing -``` - -At runtime, pipeline execution dispatches to the correct -filter type. HTTP execution (`execute_http_request`, -`execute_http_response`, body hooks) calls only HTTP -filters, skipping TCP entries. TCP execution -(`execute_tcp_connect`, `execute_tcp_disconnect`) calls -only TCP filters, skipping HTTP entries. - -**Protocol stack model.** Higher-level protocols include -lower levels. HTTP's stack includes TCP, so an HTTP -listener accepts both HTTP and TCP filters in its -pipeline. A TCP listener accepts only TCP filters. -Validation enforces this via `ProtocolKind::supports()`. - -| Listener Protocol | HTTP Filters | TCP Filters | -| --- | --- | --- | -| `http` (default) | Yes | Yes | -| `tcp` | No | Yes | - -```mermaid -flowchart TD - AnyFilter --> HttpFilter - AnyFilter --> TcpFilter - - HttpListener["HTTP Listener"] -->|supports| HttpFilter - HttpListener -->|supports| TcpFilter - TcpListener["TCP Listener"] -->|supports| TcpFilter -``` - -## What Stays Outside Filters - -- TCP/TLS, HTTP framing, connection pooling: adapters -- Config loading and validation: `praxis-core` -- Pipeline executor and `HttpFilterContext`: `praxis-filter` - -## HttpFilter Trait - -Every HTTP behavior in Praxis is an `HttpFilter`: - -```rust -#[async_trait] -pub trait HttpFilter: Send + Sync { - fn name(&self) -> &'static str; - async fn on_request( - &self, ctx: &mut HttpFilterContext<'_>, - ) -> Result; - async fn on_response( - &self, ctx: &mut HttpFilterContext<'_>, - ) -> Result { - Ok(FilterAction::Continue) - } - // Body hooks and access/mode methods omitted for - // brevity; see "Body Access" section below. -} -``` - -The trait also defines body access, body mode, and body -hook methods. See [Body Access](#body-access-http-only) -below for the full API. - -`on_request` runs in order, `on_response` in reverse. - -## TcpFilter Trait - -TCP-level filters implement `TcpFilter`: - -```rust -#[async_trait] -pub trait TcpFilter: Send + Sync { - fn name(&self) -> &'static str; - async fn on_connect( - &self, ctx: &mut TcpFilterContext<'_>, - ) -> Result { - Ok(FilterAction::Continue) - } - async fn on_disconnect( - &self, ctx: &mut TcpFilterContext<'_>, - ) -> Result<(), FilterError> { - Ok(()) - } -} -``` - -`on_connect` fires when a TCP connection is accepted. -`on_disconnect` fires when the connection closes. Both -hooks have default implementations that pass through. - -## FilterAction - -- `Continue` : pass to next filter -- `Reject(rejection)` : stop pipeline, respond now -- `Release` : forward accumulated StreamBuffer data to - upstream; behaves as `Continue` in non-StreamBuffer - contexts -- `BodyDone` : signal that this filter has finished body - processing; subsequent body chunks skip this filter - while other filters continue normally - -```rust -FilterAction::Reject(Rejection::status(429) - .with_header("Retry-After", "60") - .with_body(b"rate limit exceeded" as &[u8])) -``` - -## HttpFilterContext - -Shared state flowing through HTTP filters for a request: - -```rust -pub struct HttpFilterContext<'a> { - pub client_addr: Option, - pub cluster: Option>, - pub downstream_tls: bool, - pub extra_request_headers: Vec<(Cow<'static, str>, String)>, - pub filter_metadata: HashMap, - pub filter_results: HashMap<&'static str, FilterResultSet>, - pub health_registry: Option<&'a HealthRegistry>, - pub kv_stores: Option<&'a KvStoreRegistry>, - pub request: &'a Request, - pub request_body_bytes: u64, - pub request_body_mode: BodyMode, - pub request_headers_to_remove: Vec, - pub request_headers_to_set: Vec<(HeaderName, HeaderValue)>, - pub request_start: Instant, - pub response_body_bytes: u64, - pub response_body_mode: BodyMode, - pub response_header: Option<&'a mut Response>, - pub response_headers_modified: bool, - pub rewritten_path: Option, - pub selected_endpoint_index: Option, - pub upstream: Option, - // Internal pipeline tracking fields omitted. -} -``` - -## TcpFilterContext - -Per-connection state for TCP filters: - -```rust -pub struct TcpFilterContext<'a> { - pub remote_addr: &'a str, - pub local_addr: &'a str, - pub sni: Option<&'a str>, - pub upstream_addr: Cow<'a, str>, - pub connect_time: Instant, - pub bytes_in: u64, - pub bytes_out: u64, -} -``` - -The `sni` field is populated by the TCP proxy when it peeks -at the first bytes of a TLS connection and extracts the SNI -hostname from the ClientHello. Filters like `sni_router` use -this to select an upstream. The `upstream_addr` field is a -`Cow` so filters can replace it with an owned value without -requiring the listener config to provide a static upstream. - -## AnyFilter - -The `AnyFilter` enum wraps both filter variants for storage -in a unified registry and pipeline: - -```rust -pub enum AnyFilter { - Http(Box), - Tcp(Box), -} -``` - -Each variant reports its `protocol_level()` as -`ProtocolKind::Http` or `ProtocolKind::Tcp`. - -## Body Access (HTTP only) - -Filters see headers only by default. Opt in: - -```rust -fn request_body_access(&self) -> BodyAccess { - BodyAccess::ReadOnly // or ReadWrite -} -``` - -| Access | Hooks? | Modify? | -| ---------------- | ------ | ------- | -| `None` (default) | No | No | -| `ReadOnly` | Yes | No | -| `ReadWrite` | Yes | Yes | - -### Body Mode - -| Mode | Behavior | Use case | -| ----------------------------- | --------------- | ------------------------- | -| `Stream` (default) | Per chunk | Logging, transforms | -| `StreamBuffer { max_bytes }` | Deferred stream | Inspection before forward | -| `SizeLimit { max_bytes }` | Stream + ceiling | Global size enforcement | - -If any filter requests `StreamBuffer`, the pipeline -defers upstream forwarding until release. `SizeLimit` -streams chunks without buffering but enforces a byte -ceiling, returning 413 on overflow. It is used when no -filter needs body access but a global size limit is -configured. Precedence: `StreamBuffer` > `SizeLimit` > -`Stream`. - -### StreamBuffer Mode - -`StreamBuffer` combines streaming inspection with deferred -forwarding. Filters see each chunk as it arrives (like -`Stream`) but the protocol layer accumulates them and does -not forward to upstream until a filter returns -`FilterAction::Release` or end-of-stream is reached. - -```rust -fn request_body_mode(&self) -> BodyMode { - // No limit (default): - BodyMode::StreamBuffer { max_bytes: None } - - // With a limit (413 on overflow): - // BodyMode::StreamBuffer { max_bytes: Some(1_048_576) } -} -``` - -A filter signals release by returning -`FilterAction::Release` from `on_request_body` or -`on_response_body`. After release, remaining chunks flow -through in stream mode. - -When `max_bytes` is `None` (default), StreamBuffer -accumulates without limit. When `Some(n)`, requests -exceeding `n` bytes receive 413. - -This mode is useful for: - -- **AI inference proxies**: inspect prompt content for - routing, token counting, or content policy before - forwarding -- **Security gateways**: scan payloads for malware - signatures, PII, or injection attacks with early - rejection -- **Body-based routing**: peek at request body content - (e.g. JSON model field) to select a cluster, then - release and forward - -### Body Hooks - -```rust -// Async -async fn on_request_body( - &self, ctx: &mut HttpFilterContext<'_>, - body: &mut Option, - end_of_stream: bool, -) -> Result; - -// Sync (upstream constraint) -fn on_response_body( - &self, ctx: &mut HttpFilterContext<'_>, - body: &mut Option, - end_of_stream: bool, -) -> Result; -``` - -Override `needs_request_context() -> true` to access request -headers in body hooks. - -## Conditional Execution - -Add `conditions` to any filter chain entry. Fields within a -condition are ANDed; all conditions must pass. - -| Field | Matches when | -| ------------- | ---------------------------- | -| `path` | URI exactly equals value | -| `path_prefix` | URI starts with value | -| `methods` | Method in list | -| `headers` | All listed headers match | - -```yaml -filter_chains: - - name: main - filters: - - filter: headers - conditions: - - when: - path_prefix: "/api" - - unless: - headers: - x-internal: "true" - request_add: - - name: "X-Api-Version" - value: "v2" -``` - -Use `path` for exact matching (e.g., health checks on `/`): - -```yaml -- filter: static_response - conditions: - - when: - path: "/" - status: 200 - body: "ok" -``` - -Skipped on request = skipped on response. - -### Response Conditions - -Use `response_conditions` to gate `on_response` execution. -Response predicates: `status` (list of status codes), -`headers`. - -```yaml -- filter: headers - response_conditions: - - when: - status: [200, 201] - response_set: - - name: "Cache-Control" - value: "public, max-age=60" -``` - -A filter can have both `conditions` (request phase) and -`response_conditions` (response phase). - -### Security Filter Restrictions - -Security-critical filters (`ip_acl`, `forwarded_headers`) -reject `failure_mode: open` by default. Open failure mode -on these filters means runtime errors would bypass security -enforcement. To override this check, set -`insecure_options.allow_open_security_filters: true`, which -demotes the error to a warning. - -### Rewrite and Routing Interaction - -Both `path_rewrite` and `url_rewrite` set -`ctx.rewritten_path`. The router checks `rewritten_path` -before the original URI, enabling "rewrite then route" -pipelines. If both rewrite filters appear in the same -pipeline, only the last one takes effect. Validation -rejects this by default; set `allow_rewrite_override: true` -on the later filter to permit it. - -## Related - -- [Filter Reference](../configuration/overview.md#built-in-filters): - configuration for all built-in filters -- [Extensions](extensions.md): writing custom filters -- [Payload Processing](../architecture/payload-processing.md): - body access architecture diff --git a/docs/filters/_category_.json b/docs/filters/_category_.json index bdbe6a2..6fea3ff 100644 --- a/docs/filters/_category_.json +++ b/docs/filters/_category_.json @@ -1,4 +1 @@ -{ - "label": "Filters", - "position": 4 -} +{"label":"Filters","position":3} diff --git a/docs/filters/branch-chains.md b/docs/filters/branch-chains.md new file mode 100644 index 0000000..f93771b --- /dev/null +++ b/docs/filters/branch-chains.md @@ -0,0 +1,397 @@ +--- +sidebar_position: 3 +title: Branch Chains +--- + +# Branch Chains + +Branch chains add conditional paths to your filter +pipeline. A filter produces a result, and a branch +condition reads it to decide whether to divert, +short-circuit, skip ahead, or loop back. + +## What Branch Chains Do + +- **Short-circuit responses**: block a request and + return a static response without reaching the + backend +- **Skip filters**: bypass middleware (CORS, headers) + for requests that don't need it +- **Retry loops**: re-run a section of the pipeline + after mutating headers or refreshing tokens +- **Protocol-specific paths**: route gRPC and + JSON-RPC requests through different filter chains + +## Quick Start + +This example blocks requests when the `guardrails` +filter detects a dangerous header: + +```yaml +filter_chains: + - name: main + filters: + - filter: guardrails + action: flag + rules: + - target: header + name: "X-Danger" + contains: "true" + branch_chains: + - name: block_banned + on_result: + filter: guardrails + result: blocked + rejoin: terminal + chains: + - name: blocked_response + filters: + - filter: static_response + status: 403 + + - filter: router + routes: + - path_prefix: "/" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: ["127.0.0.1:3000"] +``` + +When guardrails flags a request, it writes +`status=blocked` to its filter results. The branch +condition matches, the static 403 fires, and +`rejoin: terminal` stops the pipeline. Normal +requests pass through to routing. + +See +`examples/configs/branching/conditional-terminal.yaml` +for the complete runnable config. + +## How It Works + +### Writing Filter Results + +Filters write key-value pairs to a +`FilterResultSet`, keyed by the filter's **type +name** (from `HttpFilter::name()`). Results are +cleared after branch evaluation. For the full +lifecycle and API, see +[Pipeline Concepts: Filter Results](/docs/architecture/pipeline-concepts#filter-results). + +Built-in filters that write results: +- `guardrails`: `status` = `blocked` | `passed` +- `json_rpc`: `kind`, `method`, `id`, `id_kind`, + `batch_len` +- `grpc_detection`: `kind` = `grpc` | `grpc-web` + +### Defining Branches + +Branches are defined in the `branch_chains` field on +a filter entry: + +```yaml +- filter: guardrails + branch_chains: + - name: block_banned # Globally unique + on_result: # Condition (omit for unconditional) + filter: guardrails # Filter TYPE name + result: blocked # Expected value + rejoin: terminal # Where to resume + chains: # Filters to execute + - name: response + filters: + - filter: static_response + status: 403 +``` + +**`on_result.filter`** must match a filter **type +name** — the return value of `HttpFilter::name()` +(e.g., `"guardrails"`), not the user-assigned +`name:` on the filter entry. See +[Pipeline Concepts: Two Meanings of "Name"][names]. + +[names]: /docs/architecture/pipeline-concepts#the-two-meanings-of-name + +**`on_result.result`** is the expected value. In the +Rust code, this field is called `value` but is +serialized as `result` in YAML for readability. + +**`on_result.key`** defaults to `"status"`. Override +it to match other result keys (e.g., `key: kind` +for `grpc_detection`). + +**Unconditional branches** omit `on_result` entirely +and always fire. + +**Multiple branches** on one filter are evaluated in +order; the first match with a non-`next` rejoin wins. +Up to 16 branches per filter. + +### Rejoin Points + +After a branch's filters execute, the pipeline +resumes at the rejoin point: + +| Rejoin value | Behavior | +|-------------|----------| +| `next` (default) | Continue with the filter after the branch point | +| `terminal` or `client` | Stop the pipeline; respond to client | +| `` (forward) | Skip to the named filter (must be after the branch point) | +| `` (backward) | Re-enter at the named filter; requires `max_iterations` | + +Named rejoin targets use the **entry name** (the +user-assigned `name:` on a filter entry), not the +filter type name. + +**Re-entrance** (backward rejoin) requires +`max_iterations` to prevent infinite loops. The +counter increments each time the branch fires; when +exceeded, the branch falls through to `Continue`. + +### Chain References + +Branch chains reference filters via `chains:`, which +accepts two formats: + +**Named reference** — points to a top-level chain: + +```yaml +chains: + - utility_chain +``` + +**Inline definition** — defines filters directly: + +```yaml +chains: + - name: inline_chain + filters: + - filter: static_response + status: 403 +``` + +Both can be mixed and are concatenated in order. + +## Patterns + +### Unconditional Sub-Chain + +Always run an audit chain, then continue: + +```yaml +branch_chains: + - name: always_audit + chains: + - audit +``` + +See `examples/configs/branching/unconditional-branch.yaml`. + +### Conditional Terminal + +Short-circuit on a condition — block and respond +without reaching the backend: + +```yaml +branch_chains: + - name: block_request + on_result: + filter: guardrails + result: blocked + rejoin: terminal + chains: + - name: response + filters: + - filter: static_response + status: 403 +``` + +See `examples/configs/branching/conditional-terminal.yaml`. + +### Skip-Forward + +Skip browser middleware for API requests: + +```yaml +- filter: headers + name: classify + branch_chains: + - name: skip_browser + on_result: + filter: headers + key: status + result: api + rejoin: routing # Named filter ahead + chains: + - name: api_prep + filters: + - filter: headers + request_add: + - name: X-API + value: "true" + +- filter: cors # Skipped for API requests +- filter: forwarded_headers # Skipped for API requests + +- filter: router + name: routing # rejoin target + routes: [...] +``` + +See `examples/configs/branching/conditional-skip-to.yaml`. + +### Re-Entrance Loop + +Re-run classification after mutating headers, capped +at 2 iterations: + +```yaml +- filter: headers + name: classify + branch_chains: + - name: reclassify + on_result: + filter: headers + key: action + result: retry + rejoin: classify # Loop back + max_iterations: 2 + chains: + - name: retry_prep + filters: + - filter: headers + request_add: + - name: X-Retry + value: "true" +``` + +See `examples/configs/branching/reentrance.yaml`. + +### Multiple Branches + +Multiple conditional branches on one filter act like +a switch/case with first-match-wins: + +```yaml +branch_chains: + - name: blocked + on_result: + filter: guardrails + result: blocked + rejoin: terminal + chains: [blocked_response] + - name: flagged + on_result: + filter: guardrails + result: flagged + rejoin: next + chains: [flag_handler] +``` + +See `examples/configs/branching/multiple-branches.yaml`. + +### Cross-Chain Rejoin + +A branch in one chain can rejoin at a named filter +in another chain, because all listener chains are +concatenated into one flat pipeline: + +```yaml +listeners: + - name: web + filter_chains: [preprocessing, routing] + +filter_chains: + - name: preprocessing + filters: + - filter: headers + branch_chains: + - name: skip_to_route + rejoin: route # In the 'routing' chain + chains: [utility] + - name: routing + filters: + - filter: router + name: route # Target for rejoin + routes: [...] +``` + +See `examples/configs/branching/cross-chain-flat.yaml`. + +## Constraints + +| Limit | Value | +|-------|-------| +| Maximum branch nesting depth | 10 | +| `max_iterations` range | 1-100 | +| Branches per filter | 16 | +| Total branches across config | 256 | + +**Body hooks** (`on_request_body`, `on_response_body`) +do **not** run for filters inside branch chains. +Body-transforming filters must be in the main +pipeline path. + +**Nested control flow**: `SkipTo` and `ReEnter` from +nested branches (branches within branches) are +discarded. Only `Terminal` and `Reject` propagate +upward from nested branches. + +**Same-type result sharing**: two instances of the +same filter type in a pipeline share the same +`filter_results` key. The second instance's results +overwrite the first's. + +## Configuration Reference + +### BranchChainConfig + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | required | Globally unique branch name | +| `chains` | list | required | Chain references (named or inline) | +| `on_result` | object | `null` | Condition; omit for unconditional | +| `rejoin` | string | `"next"` | Where to resume (`next`, `terminal`, `client`, or filter name) | +| `max_iterations` | integer | `null` | Required for backward rejoin; range 1-100 | + +### BranchCondition (`on_result`) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `filter` | string | required | Filter TYPE name (from `HttpFilter::name()`) | +| `key` | string | `"status"` | Result key to check | +| `result` | string | required | Expected value | + +### ChainRef + +Named reference (plain string): + +```yaml +chains: + - utility_chain +``` + +Inline definition (object with `name` and `filters`): + +```yaml +chains: + - name: inline + filters: + - filter: static_response + status: 200 +``` + +## Related + +- [Pipeline Concepts](/docs/architecture/pipeline-concepts): + how chains become pipelines, filter results + lifecycle, naming +- [Filter System](/docs/filters/filter-model): + HttpFilter/TcpFilter traits, context, body access +- [Life of a Request](/docs/architecture/life-of-a-request): + step-by-step request walkthrough +- Example configs: + `examples/configs/branching/`(../../examples/configs/branching/), + `examples/configs/pipeline/branch-chains.yaml`(../../examples/configs/pipeline/branch-chains.yaml) diff --git a/docs/filters/custom-filters.md b/docs/filters/custom-filters.md deleted file mode 100644 index 6b5364b..0000000 --- a/docs/filters/custom-filters.md +++ /dev/null @@ -1,457 +0,0 @@ ---- -sidebar_position: 8 -title: Custom Filters ---- - -# Extensions - -Praxis is designed to be extended. The core library provides -the building blocks for building bespoke proxy servers. -Multiple extension mechanisms are provided to support a -variety of needs. - -## Rust Extensions (Preferred) - -Compile-time extensions with zero overhead. Implement -`HttpFilter` or `TcpFilter` in your own crate, register it, -and reference it in YAML config. - -1. Implement `HttpFilter` (`on_request`, `on_response`, - body hooks) or `TcpFilter` (`on_connect`, - `on_disconnect`) -2. Register with `register_filters!` -3. Reference by name in YAML filter chains - -### HTTP Filter - -```rust -use async_trait::async_trait; -use serde::Deserialize; -use praxis_filter::{ - FilterAction, FilterError, HttpFilter, - HttpFilterContext, Rejection, register_filters, -}; - -struct MaxBodyGuard { - max_content_length: u64, - reject_status: u16, -} - -impl MaxBodyGuard { - pub fn from_config( - config: &serde_yaml::Value, - ) -> Result, FilterError> { - #[derive(Deserialize)] - struct Cfg { - max_content_length: u64, - #[serde(default = "default_status")] - reject_status: u16, - } - fn default_status() -> u16 { 413 } - - let cfg: Cfg = - serde_yaml::from_value(config.clone())?; - Ok(Box::new(Self { - max_content_length: cfg.max_content_length, - reject_status: cfg.reject_status, - })) - } -} - -#[async_trait] -impl HttpFilter for MaxBodyGuard { - fn name(&self) -> &'static str { "max_body_guard" } - - async fn on_request( - &self, ctx: &mut HttpFilterContext<'_>, - ) -> Result { - let too_large = ctx.request.headers - .get("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .is_some_and(|len| { - len > self.max_content_length - }); - - if too_large { - return Ok(FilterAction::Reject( - Rejection::status(self.reject_status), - )); - } - Ok(FilterAction::Continue) - } -} - -// In your binary: -register_filters! { - http "max_body_guard" => MaxBodyGuard::from_config, -} -``` - -### TCP Filter - -TCP custom filters implement `TcpFilter` and register with -the `tcp` keyword: - -```rust -use async_trait::async_trait; -use praxis_filter::{ - FilterAction, FilterError, TcpFilter, TcpFilterContext, -}; - -struct ConnectionCounter { /* ... */ } - -#[async_trait] -impl TcpFilter for ConnectionCounter { - fn name(&self) -> &'static str { - "connection_counter" - } - - async fn on_connect( - &self, ctx: &mut TcpFilterContext<'_>, - ) -> Result { - // Track connection metrics - Ok(FilterAction::Continue) - } -} -``` - -### Custom Load Balancer - -Load balancers are ordinary HTTP filters. The contract: -read `ctx.cluster` (set by the router), select an -endpoint, and set `ctx.upstream`. If your algorithm -tracks in-flight requests, use `on_response` to release -counters. - -```rust -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, -}; - -use async_trait::async_trait; -use praxis_core::connectivity::{ConnectionOptions, Upstream}; -use praxis_filter::{ - FilterAction, FilterError, HttpFilter, HttpFilterContext, -}; - -/// Picks the endpoint that has handled the fewest -/// total requests (lifetime, not in-flight). -pub struct FewestServedFilter { - clusters: HashMap>, -} - -struct EndpointCounter { - address: Arc, - served: AtomicUsize, -} - -impl FewestServedFilter { - pub fn from_config( - config: &serde_yaml::Value, - ) -> Result, FilterError> { - #[derive(serde::Deserialize)] - struct ClusterCfg { - name: String, - endpoints: Vec, - } - - let cfgs: Vec = serde_yaml::from_value( - config - .get("clusters") - .cloned() - .unwrap_or_default(), - )?; - - let clusters = cfgs - .into_iter() - .map(|c| { - let counters = c - .endpoints - .into_iter() - .map(|addr| EndpointCounter { - address: Arc::from(addr.as_str()), - served: AtomicUsize::new(0), - }) - .collect(); - (c.name, counters) - }) - .collect(); - - Ok(Box::new(Self { clusters })) - } -} - -#[async_trait] -impl HttpFilter for FewestServedFilter { - fn name(&self) -> &'static str { "fewest_served" } - - async fn on_request( - &self, - ctx: &mut HttpFilterContext<'_>, - ) -> Result { - let cluster = ctx.cluster.as_deref().ok_or( - "fewest_served: no cluster set", - )?; - let endpoints = - self.clusters.get(cluster).ok_or_else(|| { - format!("fewest_served: unknown cluster \ - '{cluster}'") - })?; - - // Pick endpoint with lowest lifetime count. - let pick = endpoints - .iter() - .min_by_key(|e| e.served.load(Ordering::Relaxed)) - .expect("cluster must have endpoints"); - - pick.served.fetch_add(1, Ordering::Relaxed); - - ctx.upstream = Some(Upstream { - address: Arc::clone(&pick.address), - tls: None, - connection: Arc::new(ConnectionOptions::default()), - }); - - Ok(FilterAction::Continue) - } -} - -// Register alongside the built-in filters: -register_filters! { - http "fewest_served" => FewestServedFilter::from_config, -} -``` - -Then use it in config: - -```yaml -filter_chains: - - name: main - filters: - - filter: router - routes: - - path_prefix: "/" - cluster: backend - - - filter: fewest_served - clusters: - - name: backend - endpoints: - - "127.0.0.1:3001" - - "127.0.0.1:3002" -``` - -Key points: - -- The router runs first and sets `ctx.cluster`. -- Your filter reads the cluster name, selects an endpoint, - and writes `ctx.upstream`. -- The protocol layer connects to whatever `Upstream` you - set (address, TLS, SNI, timeouts). -- For stateful algorithms, override `on_response` to - update counters when a request completes. - -### Registration - -The `register_filters!` macro uses protocol-prefixed -syntax: - -```rust -register_filters! { - http "max_body_guard" => MaxBodyGuard::from_config, -} -``` - -TCP filters would use `tcp "name" => factory` syntax. - -### YAML Config - -Any keys placed alongside `filter:` in the filter chain -entry are passed to `from_config` as a `serde_yaml::Value`: - -```yaml -filter_chains: - - name: security - filters: - - filter: max_body_guard - max_content_length: 1048576 # 1 MiB - reject_status: 413 - conditions: - - when: - methods: ["POST", "PUT", "PATCH"] -``` - -Custom filters participate identically to built-ins: same -ordering, context access, and short-circuit capability. - -See [Filter Model](filter-model) for extensive documentation. - -## Best Practices - -### Header trust boundaries - -Never blindly trust `X-Forwarded-For` or -`X-Forwarded-Proto`. Attackers spoof these unless trusted -upstream sources are explicitly defined. - -### Keep filters stateless when possible - -Prefer reading all configuration at construction time -(in `from_config`) and keeping the filter struct -immutable. When shared mutable state is required (e.g. -counters, connection tracking), use atomics or interior -mutability with minimal lock scope. Filters are shared -across requests and must be `Send + Sync`. - -### Return early with `Reject`, not panics - -Use `FilterAction::Reject(Rejection::status(code))` to -abort request processing. Never panic inside a filter; -a panic takes down the worker thread. Return -`Err(...)` for unexpected failures and let the pipeline -handle the 500 response. - -### Declare body access accurately - -Only declare `request_body_access()` or -`response_body_access()` if your filter actually -inspects or modifies the body. Each declaration changes -how the pipeline buffers data. `BodyAccess::None` (the -default) avoids overhead. Use `ReadOnly` if you inspect -but do not modify, and `ReadWrite` only if you mutate -chunks in place. - -### Choose the right body mode - -- `Stream`: lowest latency; chunks flow through as they - arrive. Best for filters that inspect headers only or - process chunks independently. -- `StreamBuffer`: chunks flow through filters - incrementally but forwarding to upstream is deferred - until `Release` or end-of-stream. Use when body - content influences routing, when you need the complete - body (e.g. signature verification), or when you need - to inspect the full body before upstream selection. - Set `max_bytes` to avoid unbounded memory growth. - -Two patterns for declaring `StreamBuffer`: - -**Static declaration** (filter always needs the body): - -```rust -fn request_body_mode(&self) -> BodyMode { - BodyMode::StreamBuffer { max_bytes: Some(1_048_576) } -} -``` - -**Per-request upgrade** (conditional buffering): - -```rust -async fn on_request( - &self, ctx: &mut HttpFilterContext<'_>, -) -> Result { - if needs_body_inspection(ctx) { - ctx.set_request_body_mode( - BodyMode::StreamBuffer { - max_bytes: Some(1_048_576), - }, - ); - } - Ok(FilterAction::Continue) -} -``` - -### `on_response_body` is synchronous - -Pingora's response body callback is not async. Do not -block the thread with `block_on` or heavy computation. -If you need async I/O during response payload processing, -spawn a background task and communicate via a channel. - -### Use conditions instead of internal checks - -Rather than writing `if req.method != "POST" { return -Continue }` inside your filter, declare conditions in -YAML: - -```yaml -- filter: my_filter - conditions: - - when: - methods: ["POST", "PUT"] -``` - -This keeps filter logic focused and lets operators -adjust gating without code changes. - -### Use `extra_request_headers` for metadata - -When your filter extracts values from the body or -computes derived data, promote it to a request header -via `ctx.extra_request_headers`. This makes the value -visible to downstream filters (e.g. the router) without -coupling filters to each other. - -### Access KV stores for runtime mappings - -Use `ctx.kv_stores` with `get_or_create` to access -runtime-updatable key-value data without restarting -the proxy. Stores are created on demand: - -```rust -async fn on_request( - &self, - ctx: &mut HttpFilterContext<'_>, -) -> Result { - if let Some(registry) = ctx.kv_stores { - let store = registry.get_or_create("routing_overrides"); - if let Some(cluster) = store.get("preferred_cluster") { - ctx.cluster = Some(Arc::from(cluster.as_ref())); - } - } - Ok(FilterAction::Continue) -} -``` - -Operators update stores at runtime via the admin API -without config reloads: - -```console -curl -X PUT http://127.0.0.1:9901/api/kv/routing_overrides/preferred_cluster \ - -d 'us-east-cluster' -``` - -### Handle missing `ctx.cluster` gracefully - -If your filter depends on a cluster being set (like a -load balancer), return a clear error when -`ctx.cluster` is `None` rather than panicking: - -```rust -let cluster = ctx.cluster.as_deref() - .ok_or("my_filter: no cluster set")?; -``` - -### Provide `from_config` validation - -Validate all configuration values in `from_config` -rather than deferring checks to request time. Fail fast -at startup with a descriptive error. Parse and -type-check every field; use `#[serde(default)]` for -optional fields with sensible defaults. - -### Test with the integration harness - -Use the integration test utilities (`free_port`, -`start_backend`, `start_proxy_with_registry`) to write -end-to-end tests for custom filters. Register your -filter with `FilterFactory::Http(Arc::new(factory))`, -build a minimal YAML config, and assert on status codes -and response bodies. See the -[test suite](https://github.com/praxis-proxy/praxis/tree/main/tests/integration) for -examples. diff --git a/docs/filters/extensions.md b/docs/filters/extensions.md index 6033028..93637c4 100644 --- a/docs/filters/extensions.md +++ b/docs/filters/extensions.md @@ -291,7 +291,7 @@ filter_chains: Custom filters participate identically to built-ins: same ordering, context access, and short-circuit capability. -See the [filter system documentation](README.md) for the +See the [filter system documentation](/docs/filters/filter-model) for the full API reference. ## Best Practices diff --git a/docs/filters/filter-model.md b/docs/filters/filter-model.md index 5b337b3..989d1ac 100644 --- a/docs/filters/filter-model.md +++ b/docs/filters/filter-model.md @@ -496,18 +496,19 @@ A filter can have both `conditions` (request phase) and | `guardrails` | Security | HTTP | Reject requests matching header/body string or regex rules | | `ip_acl` | Security | HTTP | `allow` or `deny` (CIDR lists, mutually exclusive); 403 on denial | | `credential_injection` | Security | HTTP | Per-cluster API key injection with client credential stripping. Literal `value` fields are redacted in `--dump` output. | -| `a2a` | Payload Processing | HTTP | A2A protocol classifier: extract method, family, task ID, streaming detection, and version for routing | | `json_body_field` | Payload Processing | HTTP | Extract a JSON body field and promote to header | -| `json_rpc` | AI / Agentic | HTTP | Parse JSON-RPC 2.0 envelopes and extract method/id/kind for routing | -| `mcp` | AI / Agentic | HTTP | MCP protocol classifier: extract method, tool/resource/prompt name, and session metadata for routing | +| `json_rpc` | Payload Processing | HTTP | Parse JSON-RPC 2.0 envelopes and extract method/id/kind for routing | +| `grpc_detection` | Traffic Management | HTTP | Detect gRPC from Content-Type and promote variant to metadata | | `compression` | Payload Processing | HTTP | Gzip, brotli, and zstd response compression | | `cors` | Security | HTTP | CORS preflight handling, origin validation, credential support | | `csrf` | Security | HTTP | Origin-based CSRF protection with gradual rollout and Sec-Fetch-Site support | | `redirect` | Traffic Management | HTTP | `status` (301/302/307/308), `location` template with `${path}`/`${query}` | | `path_rewrite` | Transformation | HTTP | `strip_prefix`, `add_prefix`, or `replace` (regex) on request path | | `url_rewrite` | Transformation | HTTP | `operations[]`: `regex_replace`, `strip_query_params`, `add_query_params` | -| `model_to_header` | AI / Inference | HTTP | Extract JSON "model" field and promote to X-Model header. Requires `ai-inference` feature. | -| `prompt_enrich` | AI / Inference | HTTP | Inject messages into OpenAI-compatible chat completion request bodies. Requires `ai-inference` feature. | + +AI filters (`model_to_header`, `mcp`, `a2a`, OpenAI Responses, +Anthropic Messages, and others) ship with the `praxis-ai` binary. +See [AI filter reference](/docs/reference/ai-filters). For detailed configuration of each built-in filter, see [Configuration](/docs/configuration/overview). @@ -675,4 +676,4 @@ extra code. For detailed configuration of individual built-in filters, see [Configuration](/docs/configuration/overview). For best practices when writing custom filters, see -[Custom Filters](custom-filters). +[Custom Filters](/docs/filters/extensions). diff --git a/docs/filters/http/observability/access_log.md b/docs/filters/http/observability/access_log.md new file mode 100644 index 0000000..c9ad360 --- /dev/null +++ b/docs/filters/http/observability/access_log.md @@ -0,0 +1,27 @@ + +# `access_log` + +Logs structured access records for each request and response. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `sample_rate` | number | no | Fraction of requests to log (0.0, 1.0]. Defaults to 1.0. | + +## Example + +```yaml +filter: access_log +sample_rate: 0.1 # optional; log ~10% of requests +``` + +## Related examples +- `examples/configs/observability/access-logging.yaml` +- `examples/configs/observability/logging.yaml` +- `examples/configs/operations/multi-listener.yaml` +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/pipeline/composed-chains.yaml` +- `examples/configs/pipeline/failure-mode.yaml` +- `examples/configs/protocols/mixed-protocol.yaml` +- `examples/configs/protocols/websocket.yaml` diff --git a/docs/filters/http/observability/request_id.md b/docs/filters/http/observability/request_id.md new file mode 100644 index 0000000..ffd0157 --- /dev/null +++ b/docs/filters/http/observability/request_id.md @@ -0,0 +1,36 @@ + +# `request_id` + +Ensures every request carries a correlation ID. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `header_name` | string | no | Name of the header to read, generate, and forward. | + +## Example + +```yaml +filter: request_id +header_name: X-Correlation-ID # optional +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/conditional-terminal.yaml` +- `examples/configs/branching/cross-chain-flat.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/named-chain-ref.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/branching/reentrance.yaml` +- `examples/configs/branching/unconditional-branch.yaml` +- `examples/configs/observability/access-logging.yaml` +- `examples/configs/observability/logging.yaml` +- `examples/configs/operations/hot-reload.yaml` +- `examples/configs/operations/multi-listener.yaml` +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/pipeline/composed-chains.yaml` +- `examples/configs/protocols/mixed-protocol.yaml` +- `examples/configs/protocols/websocket.yaml` diff --git a/docs/filters/http/payload_processing/compression.md b/docs/filters/http/payload_processing/compression.md new file mode 100644 index 0000000..00304e5 --- /dev/null +++ b/docs/filters/http/payload_processing/compression.md @@ -0,0 +1,47 @@ + +# `compression` + +Enables Pingora's built-in response compression when present in a filter chain. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `level` | integer | no | Default compression level for all algorithms (1..=12). | +| `gzip` | AlgorithmConfig | no | Gzip algorithm settings. | +| `gzip.enabled` | bool | no | Whether this algorithm is enabled. | +| `gzip.level` | integer | no | Compression level for this algorithm. | +| `brotli` | AlgorithmConfig | no | Brotli algorithm settings. | +| `brotli.enabled` | bool | no | Whether this algorithm is enabled. | +| `brotli.level` | integer | no | Compression level for this algorithm. | +| `zstd` | AlgorithmConfig | no | Zstd algorithm settings. | +| `zstd.enabled` | bool | no | Whether this algorithm is enabled. | +| `zstd.level` | integer | no | Compression level for this algorithm. | +| `min_size_bytes` | integer | no | Minimum body size in bytes; smaller responses skip compression. | +| `content_types` | string[] | no | MIME type prefixes that qualify for compression. | + +## Example + +```yaml +filter: compression +level: 6 # default level for all algorithms +min_size_bytes: 256 # skip responses smaller than this +gzip: + enabled: true + level: 6 +brotli: + enabled: true + level: 4 +zstd: + enabled: true + level: 3 +content_types: + - "text/" + - "application/json" + - "application/javascript" + - "application/xml" + - "application/wasm" +``` + +## Related examples +- `examples/configs/payload-processing/compression.yaml` diff --git a/docs/filters/http/payload_processing/json_body_field.md b/docs/filters/http/payload_processing/json_body_field.md new file mode 100644 index 0000000..3eef89e --- /dev/null +++ b/docs/filters/http/payload_processing/json_body_field.md @@ -0,0 +1,50 @@ + +# `json_body_field` + +Extracts top-level fields from a JSON request body and promotes their values to request headers using `StreamBuffer` mode. + +## Configuration Notes + +If the field is missing or the body is not valid JSON, the filter passes through without modification. + +Accepts either single-field syntax (`field` + `header`) or multi-field syntax (`fields` list), but not both. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `field` | string | no | Single-field: top-level JSON field name to extract. | +| `header` | string | no | Single-field: request header name to promote into. | +| `fields` | JsonBodyFieldMapping[] | no | Multi-field: list of field-to-header mappings. | +| `fields[].field` | string | yes | Top-level JSON field name to extract. | +| `fields[].header` | string | yes | Request header name to promote the extracted value into. | +| `max_body_bytes` | integer | no | Maximum request body size in bytes for `StreamBuffer` mode. | + +## Examples + +### Example 1 + +```yaml +filter: json_body_field +field: model +header: X-Model +``` + +### Example 2 + +```yaml +filter: json_body_field +fields: + - field: model + header: X-Model + - field: user_id + header: X-User-Id +``` + +## Related examples +- `examples/configs/payload-processing/body-size-limit-with-extraction.yaml` +- `examples/configs/payload-processing/conditional-field-extraction.yaml` +- `examples/configs/payload-processing/field-extraction-access-control.yaml` +- `examples/configs/payload-processing/multi-field-extraction.yaml` +- `examples/configs/payload-processing/multi-listener-body-pipeline.yaml` +- `examples/configs/payload-processing/stream-buffer.yaml` diff --git a/docs/filters/http/payload_processing/json_rpc.md b/docs/filters/http/payload_processing/json_rpc.md new file mode 100644 index 0000000..96d6175 --- /dev/null +++ b/docs/filters/http/payload_processing/json_rpc.md @@ -0,0 +1,43 @@ + +# `json_rpc` + +Extracts JSON-RPC 2.0 envelope metadata from request bodies and promotes method, id, and kind to request headers and filter results for routing. + +## Configuration Notes + +Message kinds: `request`, `notification`, `response`, `batch`. + +Writes `json_rpc.*` entries to the filter result set for branch chain conditions. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `batch_policy` | `reject` \| `first` | no | Batch handling policy. | +| `headers` | JsonRpcHeaders | no | Header names for metadata promotion. | +| `headers.id` | string | no | Header name for JSON-RPC id (e.g., `X-Json-Rpc-Id`). | +| `headers.kind` | string | no | Header name for JSON-RPC kind (e.g., `X-Json-Rpc-Kind`). | +| `headers.method` | string | no | Header name for JSON-RPC method (e.g., `X-Json-Rpc-Method`). | +| `max_body_bytes` | integer | no | Maximum body size in bytes for `StreamBuffer`. | +| `on_invalid` | `continue` \| `reject` \| `error` | no | Invalid input handling behavior. | + +## Examples + +### Example 1 + +```yaml +filter: json_rpc +``` + +### Example 2 + +```yaml +filter: json_rpc +max_body_bytes: 1048576 +batch_policy: reject +on_invalid: continue +headers: + method: X-Json-Rpc-Method + id: X-Json-Rpc-Id + kind: X-Json-Rpc-Kind +``` diff --git a/docs/filters/http/security/cors.md b/docs/filters/http/security/cors.md new file mode 100644 index 0000000..39d4d06 --- /dev/null +++ b/docs/filters/http/security/cors.md @@ -0,0 +1,50 @@ + +# `cors` + +Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection. + +## Configuration Notes + +Wildcard subdomain patterns (e.g. `https://*.example.com`) are supported in `allow_origins`. + +`allow_credentials: true` is incompatible with wildcard origins, methods, or headers per the Fetch spec. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `allow_origins` | string[] | yes | Allowed origins. Use `["*"]` for any origin. | +| `allow_methods` | string[] | no | Allowed HTTP methods. Defaults to `["GET", "HEAD", "POST"]`. | +| `allow_headers` | string[] | no | Allowed request headers. | +| `expose_headers` | string[] | no | Response headers exposed to the client. | +| `allow_credentials` | bool | no | Whether to include `Access-Control-Allow-Credentials: true`. | +| `max_age` | integer | no | Preflight cache duration in seconds. | +| `allow_private_network` | bool | no | Whether to support Private Network Access. | +| `disallowed_origin_mode` | `omit` \| `reject` | no | Behavior when origin is not in the allow list. | +| `allow_null_origin` | bool | no | Whether to allow `Origin: null`. | + +## Example + +```yaml +filter: cors +allow_origins: + - "https://app.example.com" + - "https://*.example.com" +allow_methods: + - GET + - POST + - PUT +allow_headers: + - Content-Type + - Authorization +expose_headers: + - X-Request-ID +max_age: 3600 +allow_credentials: false +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/cross-chain-flat.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/security/cors.yaml` diff --git a/docs/filters/http/security/credential_injection.md b/docs/filters/http/security/credential_injection.md new file mode 100644 index 0000000..f7095c5 --- /dev/null +++ b/docs/filters/http/security/credential_injection.md @@ -0,0 +1,37 @@ + +# `credential_injection` + +Injects per-cluster API credentials into upstream requests. + +## Configuration Notes + +For each configured cluster, injects a header (e.g. `Authorization: Bearer sk-...`) and optionally strips client-provided values for that header to prevent credential forwarding. + +Credentials are resolved at construction time (inline values or environment variables). The filter matches on the cluster name selected by the router filter earlier in the pipeline. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `clusters` | ClusterCredentialConfig[] | yes | Per-cluster credential injection rules. | +| `clusters[].name` | string | yes | Cluster name this rule applies to. | +| `clusters[].env_var` | string | no | Environment variable name containing the credential. Resolved at filter construction time. Mutually exclusive with `value`. | +| `clusters[].header` | string | yes | Header name to inject (e.g. `"Authorization"`, `"x-api-key"`). | +| `clusters[].header_prefix` | string | no | Optional prefix prepended to the credential value before injection (e.g. `"Bearer "`). | +| `clusters[].strip_client_credential` | bool | no | Deprecated: injection always replaces any client-provided value for the header. Retained for config compatibility. | +| `clusters[].value` | string | no | Literal credential value. Mutually exclusive with `env_var`. | + +## Example + +```yaml +filter: credential_injection +clusters: + - name: provider-a + header: Authorization + env_var: PROVIDER_A_API_KEY + header_prefix: "Bearer " + strip_client_credential: true + - name: internal + header: x-api-key + value: "internal-secret" +``` diff --git a/docs/filters/http/security/csrf.md b/docs/filters/http/security/csrf.md new file mode 100644 index 0000000..bad6dc2 --- /dev/null +++ b/docs/filters/http/security/csrf.md @@ -0,0 +1,35 @@ + +# `csrf` + +CSRF protection filter that validates request origins against a trusted allowlist. + +## Configuration Notes + +Safe methods (GET, HEAD, OPTIONS by default) bypass the check. State-changing methods require a matching `Origin` or `Referer` header. + +State-changing methods require an `Origin` or `Referer` header matching the trusted origins. Rejected requests receive 403 Forbidden. + +A bare wildcard (`"*"`) cannot be mixed with other origins. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `enable_sec_fetch_site` | bool | no | Whether to also validate the `Sec-Fetch-Site` header. | +| `enforce_percentage` | integer | no | Percentage of requests to enforce (0..=100). | +| `safe_methods` | string[] | no | HTTP methods that bypass CSRF checks. | +| `trusted_origins` | string[] | yes | Allowed origin values (scheme + host + optional port). | + +## Example + +```yaml +filter: csrf +trusted_origins: + - "https://app.example.com" + - "https://*.example.com" +enforce_percentage: 100 +enable_sec_fetch_site: true +``` + +## Related examples +- `examples/configs/security/csrf.yaml` diff --git a/docs/filters/http/security/forwarded_headers.md b/docs/filters/http/security/forwarded_headers.md new file mode 100644 index 0000000..c113028 --- /dev/null +++ b/docs/filters/http/security/forwarded_headers.md @@ -0,0 +1,30 @@ + +# `forwarded_headers` + +Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers into upstream requests. + +## Configuration Notes + +When the client IP is from a trusted proxy, existing `X-Forwarded-For` values are preserved and the client IP is appended. Otherwise, the header is overwritten with the client IP to prevent spoofing. + +When `use_standard_header` is `true`, also injects the [RFC 7239] `Forwarded` header with `for`, `proto`, and `host` parameters. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `trusted_proxies` | string[] | no | CIDR ranges of trusted proxies whose existing X-Forwarded-For values are preserved (appended to). Untrusted sources have the header overwritten. | +| `use_standard_header` | bool | no | When `true`, inject the standard [RFC 7239] `Forwarded` header instead of (or in addition to) X-Forwarded-* headers. | + +## Example + +```yaml +filter: forwarded_headers +trusted_proxies: ["10.0.0.0/8"] +use_standard_header: true +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/security/forwarded-headers.yaml` diff --git a/docs/filters/http/security/guardrails.md b/docs/filters/http/security/guardrails.md new file mode 100644 index 0000000..42f84b5 --- /dev/null +++ b/docs/filters/http/security/guardrails.md @@ -0,0 +1,50 @@ + +# `guardrails` + +Rejects requests matching string, regex, or PII rules against headers and/or body content. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `action` | `reject` \| `flag` | no | What to do when a rule matches (default: reject). | +| `reject_oversized` | bool | no | Reject requests whose body exceeds the inspection buffer limit (1 MiB) when body rules are active, instead of silently truncating inspection. | +| `rules` | RuleConfig[] | yes | List of rules to evaluate. | +| `rules[].name` | string | no | Header name (required when `target` is `Header`). | +| `rules[].contains` | string \| (`ssn` \| `credit_card` \| `phone` \| `email`)[] | no | Literal substring (case-insensitive) or PII category list. | +| `rules[].negate` | bool | no | Invert the match: reject when the content does NOT match. For negated header rules, a missing header also triggers rejection. Defaults to `false`. | +| `rules[].pattern` | string | no | Regex pattern match. | +| `rules[].target` | `header` \| `body` | yes | What to inspect: header or body. | + +## Example + +```yaml +filter: guardrails +action: flag # or "reject" (default) +rules: + # Detect PII in a header + - target: header + name: "Authorization" + contains: [ssn, credit_card, email] + # Detect PII in body + - target: body + contains: [ssn, credit_card, phone, email] + # Block SQL injection in body + - target: body + contains: "DROP TABLE" + # Block requests from bad bots + - target: header + name: "User-Agent" + pattern: "bad-bot.*" + # Require body to look like JSON (reject if NOT matching) + - target: body + pattern: "^\\{.*\\}$" + negate: true +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/conditional-terminal.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/security/guardrails.yaml` diff --git a/docs/filters/http/security/ip_acl.md b/docs/filters/http/security/ip_acl.md new file mode 100644 index 0000000..4ed1a11 --- /dev/null +++ b/docs/filters/http/security/ip_acl.md @@ -0,0 +1,30 @@ + +# `ip_acl` + +IP-based access control filter. + +## Configuration Notes + +When `allow` is configured, only matching clients are permitted. When `deny` is configured, matching clients are rejected. When both are set, `allow` takes precedence: a client matching an allow entry is never denied. + +Denied requests receive a 403 Forbidden response. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `allow` | string[] | no | IPs/CIDRs to allow. If non-empty, only these are permitted. | +| `deny` | string[] | no | IPs/CIDRs to deny. | + +## Example + +```yaml +filter: ip_acl +allow: + - "10.0.0.0/8" + - "192.168.0.0/16" +``` + +## Related examples +- `examples/configs/pipeline/failure-mode.yaml` +- `examples/configs/security/ip-acl.yaml` diff --git a/docs/filters/http/security/policy.md b/docs/filters/http/security/policy.md new file mode 100644 index 0000000..809f80c --- /dev/null +++ b/docs/filters/http/security/policy.md @@ -0,0 +1,44 @@ + +# `policy` + +Embeds the CPEX policy engine in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting. + +Requires Cargo feature: `cpex-policy-engine`. + +## Configuration Notes + +Experimental: requires the `cpex-policy-engine` cargo feature, which is off by default. Registered under the YAML filter name `policy`. + +A single request can carry multiple identity sources — user JWT in `Authorization`, agent JWT in `X-Agent-Token`, workload JWT in `X-Workload-Token`, etc. Each registered identity plugin reads its own configured header and contributes to a typed `Extensions` context. + +On the body phase, the filter consumes protocol classifier filter metadata (from the `praxis-ai` package) to dispatch the matching CMF hook chain. APL routes (declared in the CPEX YAML) gate the tool/prompt/resource call by role, attribute, or Cedar PDP decision. `delegate(...)` steps mint audience-scoped tokens (RFC 8693) that the allow path attaches as upstream headers. + +`body_access: read_write` enables the JSON-RPC re-serialization round-trip so APL field mutators (`redact()`, `assign()`) rewrite the upstream request body and the downstream response. + +Praxis filter configs are flat: the filter's typed fields sit directly under the `- filter:` entry alongside the structural keys (`name`, `conditions`), not nested under a `config:` wrapper. See `examples/configs/security/policy.yaml` for a runnable example. + +The referenced YAML is the CPEX policy document — plugins, routes, and identity-source declarations. The filter loads it once at construction and rejects misconfigured policy at server startup (fail-fast rather than at first request). + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `body_access` | `read_only` \| `read_write` | no | Body-access tier. `ReadOnly` (default) lets APL inspect request and response bodies for routing / policy decisions but discards any mutations. `ReadWrite` enables the CMF → JSON-RPC re-serialization round-trip so APL field mutators (e.g. `args.ssn: redact(!perm.view_ssn)`) rewrite the upstream body and response. Pay the round-trip cost only when needed. | +| `config_path` | string | yes | Filesystem path to the CPEX YAML policy document. | +| `init_timeout_secs` | integer | no | Maximum time, in seconds, to wait for `PluginManager::initialize` at filter construction. Identity plugins fetch JWKS over HTTPS during init; a reachable-but-unresponsive identity provider would otherwise hang startup or hot-reload indefinitely. On expiry, filter construction returns an error and the server fails fast. 30s is generous for legitimate cold-cache JWKS fetches over the public internet, while short enough that misbehavior is noticed during the deploy. | +| `max_buffer_bytes` | integer | no | Maximum request/response body bytes buffered in `ReadWrite` mode. `ReadWrite` uses `StreamBuffer` to accumulate the whole body before APL field mutators run; without a cap an oversized payload could exhaust memory. Ignored in `ReadOnly` mode, which streams. The pipeline rejects an unbounded buffer at config load, so this always carries a concrete ceiling. | +| `require_protocol_metadata` | bool | no | Fail-closed policy gate for misconfigured chains. When `true` (default), `on_request_body` rejects any request that reaches it without `protocol.method` filter-metadata. The metadata is set by the protocol classifier filter (available in the `praxis-ai` package), so its absence means either (a) the protocol classifier filter is missing from the chain, or (b) it is ordered AFTER `policy` instead of before. Either is a misconfiguration that would silently bypass CMF/APL policy. Set to `false` only when intentionally fronting non-classified traffic through the `policy` filter for identity-only enforcement (legacy behavior). Note: JSON-RPC methods that legitimately carry no entity (e.g. `service/list`, `initialize`, `template/list`) still pass — `require_protocol_metadata` only rejects when the metadata is missing entirely. | + +## Example + +```yaml +filter: policy +config_path: /etc/praxis/cpex-policy.yaml +body_access: read_write # optional; default read_only +require_protocol_metadata: true # optional; default true +init_timeout_secs: 30 # optional; default 30 +max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) +``` + +## Related examples +- `examples/configs/security/policy.yaml` diff --git a/docs/filters/http/traffic_management/circuit_breaker.md b/docs/filters/http/traffic_management/circuit_breaker.md new file mode 100644 index 0000000..044298f --- /dev/null +++ b/docs/filters/http/traffic_management/circuit_breaker.md @@ -0,0 +1,32 @@ + +# `circuit_breaker` + +Rejects requests to clusters whose circuit is open. + +## Configuration Notes + +Each configured cluster has an independent circuit breaker state machine. Clusters not listed in the config are unaffected (pass-through). + +When consecutive upstream failures reach the threshold, the circuit opens and subsequent requests receive 503 immediately. After the recovery window, a single probe request is forwarded; if it succeeds the circuit closes. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `clusters` | ClusterCircuitBreakerConfig[] | yes | Per-cluster circuit breaker settings. | +| `clusters[].name` | string | yes | Cluster name (must match a cluster in the load balancer). | +| `clusters[].consecutive_failures` | integer | yes | Number of consecutive upstream failures before the circuit trips to Open. | +| `clusters[].recovery_window_secs` | integer | yes | Seconds the circuit stays Open before transitioning to Half-Open. | + +## Example + +```yaml +filter: circuit_breaker +clusters: + - name: backend + consecutive_failures: 5 + recovery_window_secs: 30 +``` + +## Related examples +- `examples/configs/traffic-management/circuit-breaker.yaml` diff --git a/docs/filters/http/traffic_management/endpoint_selector.md b/docs/filters/http/traffic_management/endpoint_selector.md new file mode 100644 index 0000000..8236783 --- /dev/null +++ b/docs/filters/http/traffic_management/endpoint_selector.md @@ -0,0 +1,52 @@ + +# `endpoint_selector` + +Selects an upstream endpoint from a trusted mutation source. + +## Configuration Notes + +Only values set by trusted pre-read mutations (e.g. from an `ext_proc` filter) are considered. Original client-supplied header values are deliberately ignored to prevent SSRF. + +The resolved value must be a single `host:port` authority. If no trusted value is found and `required` is false, the filter does nothing and returns `FilterAction::Continue`. Empty values are rejected as an error. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `connection` | EndpointConnectionConfig | no | Optional connection tuning for selected upstreams. | +| `connection.connection_timeout_ms` | integer | no | TCP connection timeout in milliseconds. | +| `connection.idle_timeout_ms` | integer | no | Idle connection timeout in milliseconds. | +| `connection.read_timeout_ms` | integer | no | Per-read timeout in milliseconds. | +| `connection.total_connection_timeout_ms` | integer | no | Total TCP and TLS connection timeout in milliseconds. | +| `connection.write_timeout_ms` | integer | no | Per-write timeout in milliseconds. | +| `required` | bool | no | Whether the destination header is required (fail-closed). When `true`, requests without a trusted destination header are rejected. Use for compositions where an external processor is expected to always supply a destination. | +| `source_header` | string | yes | The request header to read the upstream endpoint address from. | +| `status_on_required_failure` | integer | no | HTTP status code for required-mode routing failures. Only used when `required: true`. Defaults to 500. Compositions with required external processing typically set 503. | +| `strip_header` | bool | no | Whether to remove the source header after reading it. | +| `tls` | ClusterTls | no | Optional TLS settings for selected upstreams. Certificates and keys are loaded and parsed once when the filter is constructed, never on a request path. | +| `tls.ca` | CaConfig | no | Custom CA. | +| `tls.ca.ca_path` | string | yes | Path to the PEM CA certificate file. | +| `tls.ca.crl_paths` | string[] | no | Paths to PEM-encoded certificate revocation list (CRL) files. When provided, the mTLS client verifier checks presented client certificates against these CRLs and rejects revoked certificates. | +| `tls.client_cert` | CertKeyPair | no | Client certificate for upstream mTLS. | +| `tls.client_cert.cert_path` | string | yes | Path to the PEM certificate file. | +| `tls.client_cert.default` | bool | no | Whether this certificate is the default fallback for unmatched SNI. At most one certificate in a multi-cert config may set this to `true`. The default entry does not need `server_names`. | +| `tls.client_cert.key_path` | string | yes | Path to the PEM private key file. | +| `tls.client_cert.server_names` | string[] | no | SNI hostnames this certificate serves (listener only). | +| `tls.sni` | string | no | SNI hostname. | +| `tls.verify` | bool | no | Verify upstream certificate. | + +## Example + +```yaml +filter: endpoint_selector +source_header: x-gateway-destination-endpoint +strip_header: true # default true +connection: + connection_timeout_ms: 1000 + total_connection_timeout_ms: 2000 +tls: + sni: inference.example.internal +``` + +## Related examples +- `examples/configs/traffic-management/ext-proc-endpoint-selector.yaml` diff --git a/docs/filters/http/traffic_management/grpc_detection.md b/docs/filters/http/traffic_management/grpc_detection.md new file mode 100644 index 0000000..fd3b4a5 --- /dev/null +++ b/docs/filters/http/traffic_management/grpc_detection.md @@ -0,0 +1,19 @@ + +# `grpc_detection` + +Detects gRPC requests from the `content-type` header and promotes the variant to filter metadata and results for downstream routing. + +## Configuration Notes + +Detection values: `grpc` (bare `application/grpc`), `grpc+proto`, `grpc+json`, `grpc+other` (unrecognized sub-protocol), `none` (non-gRPC request). + +Writes `grpc_detection.kind` to both filter metadata and filter results for branch chain conditions. + +## Example + +```yaml +filter: grpc_detection +``` + +## Related examples +- `examples/configs/traffic-management/grpc-detection.yaml` diff --git a/docs/filters/http/traffic_management/load_balancer.md b/docs/filters/http/traffic_management/load_balancer.md new file mode 100644 index 0000000..6c17006 --- /dev/null +++ b/docs/filters/http/traffic_management/load_balancer.md @@ -0,0 +1,122 @@ + +# `load_balancer` + +Selects an upstream endpoint using the cluster's configured strategy. + +## Configuration Notes + +Supported strategies: - `round_robin` (default): cycles through endpoints in order, respecting weights via endpoint expansion. - `least_connections`: picks the endpoint with the fewest active in-flight requests; decrements the counter on `on_response`. - `consistent_hash`: hashes a configurable request header (or the URI path when the header is absent) to pin requests to a stable endpoint. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `clusters` | Cluster[] | no | Cluster definitions. | +| `clusters[].name` | string | yes | Unique name for the cluster. | +| `clusters[].connection_timeout_ms` | integer | no | TCP connection timeout in milliseconds. Applies to the TCP handshake only (before TLS). When exceeded, the connection attempt fails and the load balancer may retry on the next endpoint. `None` (the default) uses Pingora's built-in timeout. | +| `clusters[].endpoints` | (string \| object)[] | yes | List of endpoints for the cluster. Each entry is either a plain `"host:port"` string or a `{ address, weight }` object. | +| `clusters[].endpoints[].address` | string | yes | Socket address as `host:port`. | +| `clusters[].endpoints[].weight` | integer | no | Relative forwarding weight. Higher values receive proportionally more traffic. Defaults to 1. | +| `clusters[].health_check` | HealthCheckConfig | no | Active health check configuration for this cluster. | +| `clusters[].health_check.type` | `http` \| `tcp` \| `grpc` | yes | Probe type: `Http`, `Tcp`, or `Grpc`. | +| `clusters[].health_check.expected_status` | integer | no | Expected HTTP status code for a healthy response. | +| `clusters[].health_check.healthy_threshold` | integer | no | Consecutive successes required to mark an endpoint healthy. | +| `clusters[].health_check.interval_ms` | integer | no | Probe interval in milliseconds. | +| `clusters[].health_check.passive_healthy_threshold` | integer | no | Consecutive successes to mark an endpoint healthy again via passive observation. `None` disables passive recovery (active checks must recover it). | +| `clusters[].health_check.passive_unhealthy_threshold` | integer | no | Consecutive response failures (5xx or connect error) to mark an endpoint unhealthy via passive observation. `None` disables passive checking. | +| `clusters[].health_check.path` | string | no | HTTP path to probe (only used for `http` type). | +| `clusters[].health_check.timeout_ms` | integer | no | Probe timeout in milliseconds. Must be less than `interval_ms`. | +| `clusters[].health_check.unhealthy_threshold` | integer | no | Consecutive failures required to mark an endpoint unhealthy. | +| `clusters[].idle_timeout_ms` | integer | no | Idle connection timeout in milliseconds. Closes pooled upstream connections that have been idle longer than this duration. `None` uses Pingora's default. | +| `clusters[].load_balancer_strategy` | `round_robin` \| `least_connections` \| `p2c` \| `consistent_hash` | no | Load-balancing algorithm for this cluster. Defaults to `round_robin`. | +| `clusters[].max_connections` | integer | no | Maximum concurrent in-flight requests to this cluster. When set, excess requests receive 503. Prevents a single slow upstream from consuming all available capacity. | +| `clusters[].read_timeout_ms` | integer | no | Per-read timeout in milliseconds. Applies to each individual read operation on an established upstream connection. A timeout fires a 502 response to the client. Use `total_connection_timeout_ms` to bound the entire exchange instead. | +| `clusters[].tls` | ClusterTls | no | TLS settings for upstream connections. Presence implies TLS is enabled. Omit for plaintext HTTP. | +| `clusters[].tls.ca` | CaConfig | no | Custom CA. | +| `clusters[].tls.ca.ca_path` | string | yes | Path to the PEM CA certificate file. | +| `clusters[].tls.ca.crl_paths` | string[] | no | Paths to PEM-encoded certificate revocation list (CRL) files. When provided, the mTLS client verifier checks presented client certificates against these CRLs and rejects revoked certificates. | +| `clusters[].tls.client_cert` | CertKeyPair | no | Client certificate for upstream mTLS. | +| `clusters[].tls.client_cert.cert_path` | string | yes | Path to the PEM certificate file. | +| `clusters[].tls.client_cert.default` | bool | no | Whether this certificate is the default fallback for unmatched SNI. At most one certificate in a multi-cert config may set this to `true`. The default entry does not need `server_names`. | +| `clusters[].tls.client_cert.key_path` | string | yes | Path to the PEM private key file. | +| `clusters[].tls.client_cert.server_names` | string[] | no | SNI hostnames this certificate serves (listener only). | +| `clusters[].tls.sni` | string | no | SNI hostname. | +| `clusters[].tls.verify` | bool | no | Verify upstream certificate. | +| `clusters[].total_connection_timeout_ms` | integer | no | Total connection timeout in milliseconds (TCP + TLS). Bounds the combined TCP handshake and TLS negotiation. When exceeded, the connection attempt fails with a 502 response. Prefer this over `connection_timeout_ms` for TLS-enabled clusters where the handshake dominates latency. | +| `clusters[].write_timeout_ms` | integer | no | Per-write timeout in milliseconds. Applies to each individual write operation on an established upstream connection. A timeout fires a 502 response to the client. | + +## Example + +```yaml +filter: load_balancer +clusters: + - name: backend + endpoints: ["10.0.0.1:80"] +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/conditional-terminal.yaml` +- `examples/configs/branching/cross-chain-flat.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/named-chain-ref.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/branching/reentrance.yaml` +- `examples/configs/branching/unconditional-branch.yaml` +- `examples/configs/observability/access-logging.yaml` +- `examples/configs/observability/logging.yaml` +- `examples/configs/operations/admin-interface.yaml` +- `examples/configs/operations/hot-reload.yaml` +- `examples/configs/operations/max-connections.yaml` +- `examples/configs/operations/multi-listener.yaml` +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/payload-processing/body-size-limit-with-extraction.yaml` +- `examples/configs/payload-processing/compression.yaml` +- `examples/configs/payload-processing/conditional-field-extraction.yaml` +- `examples/configs/payload-processing/field-extraction-access-control.yaml` +- `examples/configs/payload-processing/multi-field-extraction.yaml` +- `examples/configs/payload-processing/multi-listener-body-pipeline.yaml` +- `examples/configs/payload-processing/stream-buffer.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/pipeline/composed-chains.yaml` +- `examples/configs/pipeline/conditional-filters.yaml` +- `examples/configs/pipeline/failure-mode.yaml` +- `examples/configs/protocols/mixed-protocol.yaml` +- `examples/configs/protocols/tls-cipher-suites.yaml` +- `examples/configs/protocols/tls-http-reencrypt.yaml` +- `examples/configs/protocols/tls-mtls-both.yaml` +- `examples/configs/protocols/tls-mtls-listener-request.yaml` +- `examples/configs/protocols/tls-mtls-listener.yaml` +- `examples/configs/protocols/tls-mtls-upstream.yaml` +- `examples/configs/protocols/tls-multi-cert.yaml` +- `examples/configs/protocols/tls-termination.yaml` +- `examples/configs/protocols/tls-verify-disabled.yaml` +- `examples/configs/protocols/tls-version-constraint.yaml` +- `examples/configs/protocols/upstream-ca-file.yaml` +- `examples/configs/protocols/upstream-tls.yaml` +- `examples/configs/protocols/websocket.yaml` +- `examples/configs/security/cors.yaml` +- `examples/configs/security/csrf.yaml` +- `examples/configs/security/downstream-read-timeout.yaml` +- `examples/configs/security/forwarded-headers.yaml` +- `examples/configs/security/guardrails.yaml` +- `examples/configs/security/ip-acl.yaml` +- `examples/configs/security/policy.yaml` +- `examples/configs/traffic-management/basic-reverse-proxy.yaml` +- `examples/configs/traffic-management/canary-routing.yaml` +- `examples/configs/traffic-management/circuit-breaker.yaml` +- `examples/configs/traffic-management/grpc-detection.yaml` +- `examples/configs/traffic-management/health-checks.yaml` +- `examples/configs/traffic-management/hostname-upstream.yaml` +- `examples/configs/traffic-management/hosts.yaml` +- `examples/configs/traffic-management/least-connections.yaml` +- `examples/configs/traffic-management/p2c.yaml` +- `examples/configs/traffic-management/path-based-routing.yaml` +- `examples/configs/traffic-management/rate-limiting.yaml` +- `examples/configs/traffic-management/round-robin.yaml` +- `examples/configs/traffic-management/session-affinity.yaml` +- `examples/configs/traffic-management/timeout.yaml` +- `examples/configs/traffic-management/weighted-load-balancing.yaml` +- `examples/configs/transformation/header-manipulation.yaml` +- `examples/configs/transformation/path-rewriting.yaml` +- `examples/configs/transformation/url-rewriting.yaml` diff --git a/docs/filters/http/traffic_management/rate_limit.md b/docs/filters/http/traffic_management/rate_limit.md new file mode 100644 index 0000000..c62e0fe --- /dev/null +++ b/docs/filters/http/traffic_management/rate_limit.md @@ -0,0 +1,30 @@ + +# `rate_limit` + +Token bucket rate limiter that rejects excess traffic with 429. + +## Configuration Notes + +Supports `global` (one shared bucket) and `per_ip` (one bucket per source IP) modes. Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) are injected into both 429 rejections and successful responses. + +State is all managed locally. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `mode` | `global` \| `per_ip` | yes | Whether to use a single global bucket or per-IP buckets. | +| `rate` | number | yes | Tokens replenished per second. | +| `burst` | integer | yes | Maximum bucket capacity. | + +## Example + +```yaml +filter: rate_limit +mode: per_ip # "per_ip" or "global" +rate: 100 # tokens per second +burst: 200 # max bucket capacity +``` + +## Related examples +- `examples/configs/traffic-management/rate-limiting.yaml` diff --git a/docs/filters/http/traffic_management/redirect.md b/docs/filters/http/traffic_management/redirect.md new file mode 100644 index 0000000..19c8e9f --- /dev/null +++ b/docs/filters/http/traffic_management/redirect.md @@ -0,0 +1,26 @@ + +# `redirect` + +Returns a redirect response without contacting any upstream. + +## Configuration Notes + +The `location` template supports `${path}`, `${query}`, `${host}`, and `${scheme}` substitution from the original request. `${query}` includes the leading `?` when a query string is present, and expands to nothing when absent. `${host}` is the `Host` header with port stripped. `${scheme}` is inferred from `X-Forwarded-Proto`, downstream TLS state, or the URI. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `location` | string | yes | Location URL template. Supports `${path}`, `${query}`, `${host}`, and `${scheme}` placeholders. `${query}` expands to `?key=val` (with leading `?`) when a query string is present, or to an empty string when absent. `${host}` expands to the request `Host` header value (port stripped). `${scheme}` expands to the inferred scheme (`http` or `https`). Templates should use `${path}${query}` without a literal `?` separator. | +| `status` | 301 \| 302 \| 307 \| 308 | no | HTTP redirect status code (301, 302, 307, or 308). | + +## Example + +```yaml +filter: redirect +status: 301 +location: "https://example.com${path}${query}" +``` + +## Related examples +- `examples/configs/traffic-management/redirect.yaml` diff --git a/docs/filters/http/traffic_management/router.md b/docs/filters/http/traffic_management/router.md new file mode 100644 index 0000000..4359057 --- /dev/null +++ b/docs/filters/http/traffic_management/router.md @@ -0,0 +1,106 @@ + +# `router` + +Routes requests to clusters based on path prefix and host header. + +## Configuration Notes + +If a preceding filter (such as `path_rewrite` or `url_rewrite`) has set `rewritten_path`, the router matches against the rewritten path. Otherwise, it uses the original request path. + +Sets `ctx.cluster` for downstream filters but does not pick an endpoint or forward the request. The `load_balancer` filter reads `ctx.cluster` to select an endpoint. + +Longest prefix wins. Routes without `host` match any host. Header restrictions use AND semantics with case-sensitive matching. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `json_alias_header` | string | no | Header name for the promoted JSON field value during alias resolution. | +| `json_alias_max_body_bytes` | integer | no | Maximum body bytes to buffer when resolving JSON aliases. | +| `routes` | RouterRouteConfig[] | no | Route table entries. | +| `routes[].path` | string | one of | The exact path to match. | +| `routes[].path_prefix` | string | one of | Path prefix. The longest matching prefix wins. | +| `routes[].cluster` | string | yes | Name of the cluster to route matched requests to. | +| `routes[].headers` | ``object`` | no | Request headers to match. All specified headers must be present with matching values (AND semantics, case-sensitive). | +| `routes[].host` | string | no | Host to match. If set, the route only applies to this host. | +| `routes[].json_aliases` | JsonAlias[] | no | Optional JSON field aliases evaluated for this route. | +| `routes[].json_aliases[].field` | string | yes | Request JSON field whose string value is compared with `pattern`. | +| `routes[].json_aliases[].match` | string | yes | Exact or single-wildcard pattern for the configured field value. | +| `routes[].json_aliases[].target` | string | no | Replacement value; omitted aliases preserve the original value. | +| `multi_level_subdomain_matching` | bool | no | Enable multi-level subdomain matching for wildcard hosts. When `false` (default), `*.example.com` matches only single-level subdomains like `foo.example.com`. When `true`, it also matches multi-level subdomains like `foo.bar.example.com` (suffix match). Some control planes (e.g. Kubernetes Gateway API) require this. | + +## Example + +```yaml +filter: router +routes: + - path_prefix: "/" + cluster: default +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/conditional-terminal.yaml` +- `examples/configs/branching/cross-chain-flat.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/named-chain-ref.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/branching/reentrance.yaml` +- `examples/configs/branching/unconditional-branch.yaml` +- `examples/configs/observability/access-logging.yaml` +- `examples/configs/observability/logging.yaml` +- `examples/configs/operations/admin-interface.yaml` +- `examples/configs/operations/hot-reload.yaml` +- `examples/configs/operations/max-connections.yaml` +- `examples/configs/operations/multi-listener.yaml` +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/payload-processing/body-size-limit-with-extraction.yaml` +- `examples/configs/payload-processing/compression.yaml` +- `examples/configs/payload-processing/conditional-field-extraction.yaml` +- `examples/configs/payload-processing/field-extraction-access-control.yaml` +- `examples/configs/payload-processing/multi-field-extraction.yaml` +- `examples/configs/payload-processing/multi-listener-body-pipeline.yaml` +- `examples/configs/payload-processing/stream-buffer.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/pipeline/composed-chains.yaml` +- `examples/configs/pipeline/conditional-filters.yaml` +- `examples/configs/pipeline/failure-mode.yaml` +- `examples/configs/protocols/mixed-protocol.yaml` +- `examples/configs/protocols/tls-cipher-suites.yaml` +- `examples/configs/protocols/tls-http-reencrypt.yaml` +- `examples/configs/protocols/tls-mtls-both.yaml` +- `examples/configs/protocols/tls-mtls-listener-request.yaml` +- `examples/configs/protocols/tls-mtls-listener.yaml` +- `examples/configs/protocols/tls-mtls-upstream.yaml` +- `examples/configs/protocols/tls-multi-cert.yaml` +- `examples/configs/protocols/tls-termination.yaml` +- `examples/configs/protocols/tls-verify-disabled.yaml` +- `examples/configs/protocols/tls-version-constraint.yaml` +- `examples/configs/protocols/upstream-ca-file.yaml` +- `examples/configs/protocols/upstream-tls.yaml` +- `examples/configs/protocols/websocket.yaml` +- `examples/configs/security/cors.yaml` +- `examples/configs/security/csrf.yaml` +- `examples/configs/security/downstream-read-timeout.yaml` +- `examples/configs/security/forwarded-headers.yaml` +- `examples/configs/security/guardrails.yaml` +- `examples/configs/security/ip-acl.yaml` +- `examples/configs/security/policy.yaml` +- `examples/configs/traffic-management/basic-reverse-proxy.yaml` +- `examples/configs/traffic-management/canary-routing.yaml` +- `examples/configs/traffic-management/circuit-breaker.yaml` +- `examples/configs/traffic-management/grpc-detection.yaml` +- `examples/configs/traffic-management/health-checks.yaml` +- `examples/configs/traffic-management/hostname-upstream.yaml` +- `examples/configs/traffic-management/hosts.yaml` +- `examples/configs/traffic-management/least-connections.yaml` +- `examples/configs/traffic-management/p2c.yaml` +- `examples/configs/traffic-management/path-based-routing.yaml` +- `examples/configs/traffic-management/rate-limiting.yaml` +- `examples/configs/traffic-management/round-robin.yaml` +- `examples/configs/traffic-management/session-affinity.yaml` +- `examples/configs/traffic-management/timeout.yaml` +- `examples/configs/traffic-management/weighted-load-balancing.yaml` +- `examples/configs/transformation/header-manipulation.yaml` +- `examples/configs/transformation/path-rewriting.yaml` +- `examples/configs/transformation/url-rewriting.yaml` diff --git a/docs/filters/http/traffic_management/static_response.md b/docs/filters/http/traffic_management/static_response.md new file mode 100644 index 0000000..3f59760 --- /dev/null +++ b/docs/filters/http/traffic_management/static_response.md @@ -0,0 +1,37 @@ + +# `static_response` + +Returns a fixed response without contacting any upstream. + +## Configuration Notes + +Useful for health checks, status endpoints, or stub routes. Combine with conditions to serve static responses on specific paths. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `body` | string | no | Optional response body string. | +| `headers` | HeaderEntry[] | no | Response headers to include. | +| `headers[].name` | string | yes | Header field name. | +| `headers[].value` | string | yes | Header field value. | +| `status` | integer | yes | HTTP status code to return. | + +## Example + +```yaml +filter: static_response +status: 200 +body: "OK" +``` + +## Related examples +- `examples/configs/branching/conditional-terminal.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/operations/container-default.yaml` +- `examples/configs/operations/hot-reload.yaml` +- `examples/configs/operations/log-overrides.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/traffic-management/grpc-detection.yaml` +- `examples/configs/traffic-management/static-response.yaml` diff --git a/docs/filters/http/traffic_management/timeout.md b/docs/filters/http/traffic_management/timeout.md new file mode 100644 index 0000000..41fb18b --- /dev/null +++ b/docs/filters/http/traffic_management/timeout.md @@ -0,0 +1,26 @@ + +# `timeout` + +Enforces a maximum end-to-end latency from request receipt to response headers. + +## Configuration Notes + +This does not cancel the upstream connection; the upstream has already responded by the time this check runs. It is useful for enforcing SLAs. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `timeout_ms` | integer | yes | Maximum allowed elapsed time from request receipt to response headers, in milliseconds. Requests that exceed this limit receive a 504. | + +## Example + +```yaml +filter: timeout +timeout_ms: 5000 # 5 seconds +``` + +## Related examples +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/pipeline/conditional-filters.yaml` +- `examples/configs/traffic-management/timeout.yaml` diff --git a/docs/filters/http/transformation/headers.md b/docs/filters/http/transformation/headers.md new file mode 100644 index 0000000..e647c44 --- /dev/null +++ b/docs/filters/http/transformation/headers.md @@ -0,0 +1,60 @@ + +# `headers` + +Adds, sets, or removes headers on upstream requests and downstream responses. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `request_add` | HeaderPair[] | no | Headers to append to the upstream request. | +| `request_add[].name` | string | yes | Header field name. | +| `request_add[].value` | string | yes | Header field value. | +| `request_remove` | string[] | no | Header names to remove from the upstream request. | +| `request_set` | HeaderPair[] | no | Headers to set on the upstream request (overwrites existing values). | +| `request_set[].name` | string | yes | Header field name. | +| `request_set[].value` | string | yes | Header field value. | +| `response_add` | HeaderPair[] | no | Headers to append to the downstream response. | +| `response_add[].name` | string | yes | Header field name. | +| `response_add[].value` | string | yes | Header field value. | +| `response_remove` | string[] | no | Header names to remove from the downstream response. | +| `response_set` | HeaderPair[] | no | Headers to set on the downstream response (overwrites existing values). | +| `response_set[].name` | string | yes | Header field name. | +| `response_set[].value` | string | yes | Header field value. | + +## Example + +```yaml +filter: headers +request_add: + - name: X-Forwarded-By + value: praxis +request_set: + - name: X-Custom-Auth + value: bearer-token +request_remove: + - X-Internal-Only +response_add: + - name: X-Frame-Options + value: DENY +response_remove: + - X-Backend-Server +response_set: + - name: Server + value: praxis +``` + +## Related examples +- `examples/configs/branching/conditional-skip-to.yaml` +- `examples/configs/branching/cross-chain-flat.yaml` +- `examples/configs/branching/multiple-branches.yaml` +- `examples/configs/branching/named-chain-ref.yaml` +- `examples/configs/branching/nested-branches.yaml` +- `examples/configs/branching/reentrance.yaml` +- `examples/configs/branching/unconditional-branch.yaml` +- `examples/configs/operations/production-gateway.yaml` +- `examples/configs/pipeline/branch-chains.yaml` +- `examples/configs/pipeline/composed-chains.yaml` +- `examples/configs/pipeline/conditional-filters.yaml` +- `examples/configs/traffic-management/path-based-routing.yaml` +- `examples/configs/transformation/header-manipulation.yaml` diff --git a/docs/filters/http/transformation/path_rewrite.md b/docs/filters/http/transformation/path_rewrite.md new file mode 100644 index 0000000..b5e31a2 --- /dev/null +++ b/docs/filters/http/transformation/path_rewrite.md @@ -0,0 +1,33 @@ + +# `path_rewrite` + +Rewrites the request path before forwarding to the upstream. + +## Configuration Notes + +Supports three operations (one per filter instance): - `strip_prefix`: remove a leading path prefix - `add_prefix`: prepend a path prefix - `replace`: regex find/replace on the path + +Query strings are preserved across all operations. + +Exactly one of the three operation fields must be set. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `strip_prefix` | string | no | Remove this prefix from the request path. | +| `add_prefix` | string | no | Prepend this prefix to the request path. | +| `replace` | ReplaceConfig | no | Regex find/replace on the request path. | +| `replace.pattern` | string | yes | Regex pattern to match. | +| `replace.replacement` | string | yes | Replacement string (supports `$1`, `$name` capture groups). | +| `allow_rewrite_override` | bool | no | When `true`, suppresses the duplicate-rewrite validation error if another rewrite filter precedes this one. Consumed by pipeline validation via the raw YAML config. | + +## Example + +```yaml +filter: path_rewrite +strip_prefix: "/api/v1" +``` + +## Related examples +- `examples/configs/transformation/path-rewriting.yaml` diff --git a/docs/filters/http/transformation/url_rewrite.md b/docs/filters/http/transformation/url_rewrite.md new file mode 100644 index 0000000..a84b826 --- /dev/null +++ b/docs/filters/http/transformation/url_rewrite.md @@ -0,0 +1,35 @@ + +# `url_rewrite` + +Rewrites request URLs using regex substitution and query parameter manipulation before the request reaches upstream. + +## Configuration Notes + +Operations are applied in declaration order, allowing complex multi-step transformations. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `operations` | OperationConfig[] | no | Ordered list of rewrite operations to apply. | +| `operations[].regex_replace` | any | no | Regex-based path replacement. | +| `operations[].strip_query_params` | any | no | Remove named query parameters. | +| `operations[].add_query_params` | any | no | Append query parameters. | +| `allow_rewrite_override` | bool | no | When `true`, suppresses the duplicate-rewrite validation error if another rewrite filter precedes this one. | + +## Example + +```yaml +filter: url_rewrite +operations: + - regex_replace: + pattern: "^/api/v1/(.*)" + replacement: "/api/v2/$1" + - strip_query_params: + - debug + - add_query_params: + source: gateway +``` + +## Related examples +- `examples/configs/transformation/url-rewriting.yaml` diff --git a/docs/filters/tcp/observability/tcp_access_log.md b/docs/filters/tcp/observability/tcp_access_log.md new file mode 100644 index 0000000..ccb50b0 --- /dev/null +++ b/docs/filters/tcp/observability/tcp_access_log.md @@ -0,0 +1,15 @@ + +# `tcp_access_log` + +Logs TCP connection events. + +## Example + +```yaml +filter: tcp_access_log +# no configurable parameters +``` + +## Related examples +- `examples/configs/observability/tcp-access-log.yaml` +- `examples/configs/protocols/tls-sni-routing.yaml` diff --git a/docs/filters/tcp/traffic_management/sni_router.md b/docs/filters/tcp/traffic_management/sni_router.md new file mode 100644 index 0000000..8404b81 --- /dev/null +++ b/docs/filters/tcp/traffic_management/sni_router.md @@ -0,0 +1,36 @@ + +# `sni_router` + +Routes TCP connections by SNI hostname. + +## Configuration Notes + +Performs exact-match lookup first, then longest-suffix wildcard match. Case-insensitive per [RFC 4343]. + +Connections without SNI or with no matching route use `default_upstream` if configured, otherwise receive a TLS alert rejection. + +Bare wildcards (`*`), IP addresses as server names, and duplicate server names across routes are rejected at config validation. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `default_upstream` | string | no | Fallback upstream when no route matches. | +| `routes` | SniRouteEntry[] | yes | Route entries mapping server names to upstreams. | +| `routes[].server_names` | string[] | yes | Server name patterns (exact or wildcard like `*.example.com`). | +| `routes[].upstream` | string | yes | Upstream address for matching connections. | + +## Example + +```yaml +filter: sni_router +routes: + - server_names: ["api.example.com"] + upstream: "10.0.0.1:443" + - server_names: ["*.example.com"] + upstream: "10.0.0.2:443" +default_upstream: "10.0.0.3:443" +``` + +## Related examples +- `examples/configs/protocols/tls-sni-routing.yaml` diff --git a/docs/filters/tcp/traffic_management/tcp_load_balancer.md b/docs/filters/tcp/traffic_management/tcp_load_balancer.md new file mode 100644 index 0000000..86d2a87 --- /dev/null +++ b/docs/filters/tcp/traffic_management/tcp_load_balancer.md @@ -0,0 +1,62 @@ + +# `tcp_load_balancer` + +Selects an upstream TCP endpoint using the cluster's configured strategy. + +## Configuration Notes + +Reads `ctx.cluster` to find the target cluster, selects an endpoint via the configured strategy, and writes the result to `ctx.upstream_addr`. On disconnect, releases the least-connections counter if applicable. + +If all endpoints are unhealthy, the filter enters panic mode and routes to all endpoints. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `clusters` | Cluster[] | no | Cluster definitions. | +| `clusters[].name` | string | yes | Unique name for the cluster. | +| `clusters[].connection_timeout_ms` | integer | no | TCP connection timeout in milliseconds. Applies to the TCP handshake only (before TLS). When exceeded, the connection attempt fails and the load balancer may retry on the next endpoint. `None` (the default) uses Pingora's built-in timeout. | +| `clusters[].endpoints` | (string \| object)[] | yes | List of endpoints for the cluster. Each entry is either a plain `"host:port"` string or a `{ address, weight }` object. | +| `clusters[].endpoints[].address` | string | yes | Socket address as `host:port`. | +| `clusters[].endpoints[].weight` | integer | no | Relative forwarding weight. Higher values receive proportionally more traffic. Defaults to 1. | +| `clusters[].health_check` | HealthCheckConfig | no | Active health check configuration for this cluster. | +| `clusters[].health_check.type` | `http` \| `tcp` \| `grpc` | yes | Probe type: `Http`, `Tcp`, or `Grpc`. | +| `clusters[].health_check.expected_status` | integer | no | Expected HTTP status code for a healthy response. | +| `clusters[].health_check.healthy_threshold` | integer | no | Consecutive successes required to mark an endpoint healthy. | +| `clusters[].health_check.interval_ms` | integer | no | Probe interval in milliseconds. | +| `clusters[].health_check.passive_healthy_threshold` | integer | no | Consecutive successes to mark an endpoint healthy again via passive observation. `None` disables passive recovery (active checks must recover it). | +| `clusters[].health_check.passive_unhealthy_threshold` | integer | no | Consecutive response failures (5xx or connect error) to mark an endpoint unhealthy via passive observation. `None` disables passive checking. | +| `clusters[].health_check.path` | string | no | HTTP path to probe (only used for `http` type). | +| `clusters[].health_check.timeout_ms` | integer | no | Probe timeout in milliseconds. Must be less than `interval_ms`. | +| `clusters[].health_check.unhealthy_threshold` | integer | no | Consecutive failures required to mark an endpoint unhealthy. | +| `clusters[].idle_timeout_ms` | integer | no | Idle connection timeout in milliseconds. Closes pooled upstream connections that have been idle longer than this duration. `None` uses Pingora's default. | +| `clusters[].load_balancer_strategy` | `round_robin` \| `least_connections` \| `p2c` \| `consistent_hash` | no | Load-balancing algorithm for this cluster. Defaults to `round_robin`. | +| `clusters[].max_connections` | integer | no | Maximum concurrent in-flight requests to this cluster. When set, excess requests receive 503. Prevents a single slow upstream from consuming all available capacity. | +| `clusters[].read_timeout_ms` | integer | no | Per-read timeout in milliseconds. Applies to each individual read operation on an established upstream connection. A timeout fires a 502 response to the client. Use `total_connection_timeout_ms` to bound the entire exchange instead. | +| `clusters[].tls` | ClusterTls | no | TLS settings for upstream connections. Presence implies TLS is enabled. Omit for plaintext HTTP. | +| `clusters[].tls.ca` | CaConfig | no | Custom CA. | +| `clusters[].tls.ca.ca_path` | string | yes | Path to the PEM CA certificate file. | +| `clusters[].tls.ca.crl_paths` | string[] | no | Paths to PEM-encoded certificate revocation list (CRL) files. When provided, the mTLS client verifier checks presented client certificates against these CRLs and rejects revoked certificates. | +| `clusters[].tls.client_cert` | CertKeyPair | no | Client certificate for upstream mTLS. | +| `clusters[].tls.client_cert.cert_path` | string | yes | Path to the PEM certificate file. | +| `clusters[].tls.client_cert.default` | bool | no | Whether this certificate is the default fallback for unmatched SNI. At most one certificate in a multi-cert config may set this to `true`. The default entry does not need `server_names`. | +| `clusters[].tls.client_cert.key_path` | string | yes | Path to the PEM private key file. | +| `clusters[].tls.client_cert.server_names` | string[] | no | SNI hostnames this certificate serves (listener only). | +| `clusters[].tls.sni` | string | no | SNI hostname. | +| `clusters[].tls.verify` | bool | no | Verify upstream certificate. | +| `clusters[].total_connection_timeout_ms` | integer | no | Total connection timeout in milliseconds (TCP + TLS). Bounds the combined TCP handshake and TLS negotiation. When exceeded, the connection attempt fails with a 502 response. Prefer this over `connection_timeout_ms` for TLS-enabled clusters where the handshake dominates latency. | +| `clusters[].write_timeout_ms` | integer | no | Per-write timeout in milliseconds. Applies to each individual write operation on an established upstream connection. A timeout fires a 502 response to the client. | + +## Example + +```yaml +filter: tcp_load_balancer +clusters: + - name: db_pool + endpoints: ["10.0.0.1:5432", "10.0.0.2:5432"] +``` + +## Related examples +- `examples/configs/protocols/tcp-consistent-hash.yaml` +- `examples/configs/protocols/tcp-least-connections.yaml` +- `examples/configs/protocols/tcp-round-robin.yaml` diff --git a/docs/getting-started/features.md b/docs/getting-started/features.md index 63f2a72..eb559f7 100644 --- a/docs/getting-started/features.md +++ b/docs/getting-started/features.md @@ -89,14 +89,14 @@ title: Features - **Payload size limits**: global hard ceilings on request and response payload size. -[payload-processing]:../architecture/system-design#payload-processing +[payload-processing]:/docs/architecture/payload-processing ## Security Security is a primary design constraint. Praxis ships with secure defaults and fails closed on ambiguous configuration. See the -[Security Hardening Guide](../security/hardening) for +[Security Hardening Guide](/docs/security/hardening) for deployment guidance. **Build-level guarantees:** @@ -202,123 +202,55 @@ deployment guidance. server instance. See [Protocol Abstraction][protocol-abstraction]. -[http-lifecycle]:../architecture/system-design#http-connection-lifecycle -[tcp-lifecycle]:../architecture/system-design#tcp-connection-lifecycle -[protocol-abstraction]:../architecture/system-design#protocol-adapters -[tls-docs]:../protocols/tls - -## AI Inference - -Praxis is designed as an AI-native proxy. AI inference -capabilities are built on the [filter pipeline][filters] -and [StreamBuffer][payload-processing] body access -pattern, making them composable with all other filters -rather than bolted-on external processors. - -### Current - -- **Model-based routing** (`model_to_header`): extracts - the `model` field from JSON request bodies and - promotes it to an `X-Model` header, enabling - header-based routing to provider-specific clusters. - Uses StreamBuffer to inspect the body before upstream - selection. -- **Credential injection** (`credential_injection`): - per-cluster API key injection with client credential - stripping. Supports inline values and environment - variable sources. Pair with a source discriminator - (IP ACL, client auth) to control which clients get - credential upgrades. - -### Planned - -The following capabilities are on the roadmap. Each -builds on the StreamBuffer body access pattern and the -filter pipeline. - -- **Token counting**: input/output token counts from - request and response bodies -- **Provider routing**: unified routing across LLM - providers with API translation -- **Provider failover**: ordered failover chains with - automatic API translation on failure -- **Token-based rate limiting**: per-client token quotas - with sliding window or token bucket -- **Cost attribution**: token counting mapped to user, - session, model, and endpoint -- **SSE streaming inspection**: per-event filter hooks - for streaming responses -- **Semantic caching**: prompt deduplication via vector - similarity search -- **AI guardrails**: prompt validation, content - filtering, and policy enforcement - -### StreamBuffer as AI Primitive - -StreamBuffer is the key differentiator for AI inference -workloads. Traditional proxies operate on headers only, -requiring external processors for body inspection. -Praxis inspects request bodies inline: - -1. Buffer the first N bytes (typically the JSON - envelope containing the model name, parameters, - and prompt prefix). -2. Extract routing signals (model, provider, token - budget, tool name). -3. Select the upstream based on body content. -4. Forward the buffered prefix, then stream the - remainder with zero additional buffering latency. - -This peek-then-stream pattern avoids the latency and -operational complexity of external processor -architectures while providing full body visibility -where it matters. - -## AI Agentic - -Praxis targets first-class support for AI agent -protocols, positioning MCP and A2A as headline -capabilities alongside HTTP and TCP proxying. - -### JSON-RPC Support - -- **JSON-RPC 2.0 foundation**: request envelope parsing - and method/id extraction for HTTP POST bodies, enabling - method-based routing for MCP/A2A-style traffic via the - `json_rpc` filter - -### Planned - -The following capabilities are on the roadmap and not -yet implemented: - -- **MCP proxying**: session management, tool discovery - and routing, session lifecycle, auth and rate limiting - for Model Context Protocol connections -- **A2A proxying**: agent card discovery, task lifecycle - management, SSE streaming for Agent-to-Agent protocol -- **Stateful agent sessions**: shared session storage, - affinity, and lifecycle hooks for MCP and A2A - -## Build Features - -AI filters are controlled via Cargo features (enabled -by default): - -- `ai-inference`: model routing (`model_to_header` - filter) -- `ai-agentic`: MCP, A2A, agent orchestration (planned) - -To disable AI features: - -```console -cargo build -p praxis --no-default-features -``` +[http-lifecycle]:/docs/architecture/connection-lifecycle +[tcp-lifecycle]:/docs/architecture/connection-lifecycle +[protocol-abstraction]:/docs/architecture/system-design +[tls-docs]:/docs/protocols/tls + +## AI Gateway (praxis-ai) + +Shipped in the [praxis-ai](https://github.com/praxis-proxy/ai) +binary. Full docs: [AI Gateway](/docs/ai/overview). + +### Inference + +- **Model routing** (`model_to_header`) +- **Prompt enrichment** (`prompt_enrich`) +- **Credential injection** (`credential_injection`, core) + +### OpenAI Responses API + +Classification, validation, store, rehydrate, conversations, +proxy, stream events, tool routing + +### Anthropic Messages API + +Classification, validation, protocol headers, OpenAI translation + +### Agentic + +- **JSON-RPC** (`json_rpc`, core) +- **MCP** (`mcp`) and **A2A** (`a2a`) + +### Security and observability + +- **AI guardrails** (`ai_guardrails`): pass-through scaffold + today; buffers request bodies but does not call the NeMo + provider yet. Response-side evaluation is tracked in + [#50](https://github.com/praxis-proxy/ai/issues/50). +- **Token counting** (`token_count`): extracts usage from + provider responses (JSON and SSE) into filter metadata. +- **Token usage headers** (`token_usage_headers`): + injects `Praxis-Token-Input`, `Praxis-Token-Output`, + and `Praxis-Token-Total` when metadata is present. + +StreamBuffer body access enables inline JSON inspection +without external processors. See +[payload processing](/docs/architecture/payload-processing). ## Extensions -- **Rust extensions**: compile-time custom filters with - zero overhead via the `HttpFilter`/`TcpFilter` traits - and `register_filters!` macro. +- **Rust extensions**: `HttpFilter`/`TcpFilter` and + `register_filters!` -[filters]:../filters/filter-model +[filters]:/docs/filters/filter-model diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 89a1c3d..fa41647 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -7,7 +7,7 @@ title: Installation ## Prerequisites -- **Rust** stable 1.94 or later +- **Rust** stable 1.96 or later - **CMake** 3.31 or later (required for building some native dependencies) ## Building from Source @@ -22,6 +22,16 @@ make release The binary will be at `./target/release/praxis`. +For the AI gateway, clone [praxis-proxy/ai](https://github.com/praxis-proxy/ai) +and build `praxis-ai`: + +```console +git clone https://github.com/praxis-proxy/ai.git +cd ai +make release +./target/release/praxis-ai --version +``` + To verify the build: ```console diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index e2e4f63..46c5a2b 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -16,7 +16,7 @@ Praxis is a high-performance and security-first proxy server and framework for A - [Configuration](/docs/configuration/overview) - [Filters](/docs/filters/filter-model) -- [Custom Filters](/docs/filters/custom-filters) +- [Custom Filters](/docs/filters/extensions) - [TLS & mTLS](/docs/protocols/tls) - [Security Hardening](/docs/security/hardening) diff --git a/docs/getting-started/product-map.md b/docs/getting-started/product-map.md new file mode 100644 index 0000000..3922f1a --- /dev/null +++ b/docs/getting-started/product-map.md @@ -0,0 +1,60 @@ +--- +sidebar_position: 1 +title: Product Map +--- + +# Product Map + +Praxis ships as two binaries that share the same YAML config +format and filter pipeline. + +## `praxis` — core proxy + +General-purpose reverse proxy and API gateway. + +- HTTP/1.1, HTTP/2, TCP, WebSocket, TLS +- Routing, load balancing, rate limiting, circuit breakers +- Security filters (CORS, CSRF, IP ACL, guardrails) +- Body-based routing (`json_body_field`, `json_rpc`) + +```console +cargo run -p praxis-proxy -- -c praxis.yaml +``` + +Repo: [praxis-proxy/praxis](https://github.com/praxis-proxy/praxis) + +## `praxis-ai` — AI gateway + +Everything in core, plus AI provider and agentic filters. + +- OpenAI Responses API and Conversations +- Anthropic Messages API +- MCP and A2A agent protocols +- Response store (SQLite/PostgreSQL) +- Token counting and usage headers +- External AI guardrails + +```console +cargo run -p praxis-ai-proxy -- -c praxis-ai.yaml +``` + +Repo: [praxis-proxy/ai](https://github.com/praxis-proxy/ai) + +## When to use which + +| Need | Binary | +| ---- | ------ | +| Generic reverse proxy or API gateway | `praxis` | +| LLM provider routing or unified AI gateway | `praxis-ai` | +| MCP/A2A agent traffic | `praxis-ai` | +| OpenAI Responses stateful flows | `praxis-ai` | + +You can run both side by side on different listeners or +merge AI filters into a custom binary via +[extensions](/docs/filters/extensions). + +## Next steps + +- [Quickstart: praxis](/docs/getting-started/quickstart) +- [Quickstart: praxis-ai](/docs/getting-started/quickstart-ai) +- [Features](/docs/getting-started/features) diff --git a/docs/getting-started/quickstart-ai.md b/docs/getting-started/quickstart-ai.md new file mode 100644 index 0000000..cd53b5a --- /dev/null +++ b/docs/getting-started/quickstart-ai.md @@ -0,0 +1,76 @@ +--- +sidebar_position: 2 +title: Quickstart (praxis-ai) +--- + +# Quickstart: praxis-ai + +Build the release binary from the [ai repository](https://github.com/praxis-proxy/ai): + +```console +make release +``` + +Start Praxis AI: + +```console +./target/release/praxis-ai +``` + +The server starts on `127.0.0.1:8080` with a built-in +default configuration. Verify it: + +```console +curl http://127.0.0.1:8080/ +``` + +```json +{"status": "ok", "server": "praxis-ai"} +``` + +## Route to an AI backend + +Create `praxis-ai.yaml`: + +```yaml +listeners: + - name: ai + address: "127.0.0.1:8080" + filter_chains: [openai] + +filter_chains: + - name: openai + filters: + - filter: openai_responses_format + - filter: router + routes: + - path_prefix: "/v1" + cluster: openai_backend + - filter: load_balancer + clusters: + - name: openai_backend + endpoints: + - "api.openai.com:443" + tls: + sni: "api.openai.com" +``` + +Start with your config: + +```console +./target/release/praxis-ai -c praxis-ai.yaml +``` + +```console +curl http://127.0.0.1:8080/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -d '{"model": "gpt-4o", "input": "Hello"}' +``` + +## Next steps + +- [OpenAI Responses](/docs/ai/openai-responses) +- [Anthropic Messages](/docs/ai/anthropic-messages) +- [AI filter reference](/docs/reference/ai-filters) +- [Examples](/examples) diff --git a/docs/protocols/_category_.json b/docs/protocols/_category_.json index a58e2b3..5c9537d 100644 --- a/docs/protocols/_category_.json +++ b/docs/protocols/_category_.json @@ -1,4 +1 @@ -{ - "label": "Protocols", - "position": 5 -} +{"label":"Protocols","position":7} diff --git a/docs/protocols/tls.md b/docs/protocols/tls.md index 4fdc99a..9f073f4 100644 --- a/docs/protocols/tls.md +++ b/docs/protocols/tls.md @@ -6,7 +6,7 @@ title: TLS & mTLS # TLS Working example configs for every TLS scenario live in -[`examples/configs/protocols/`](https://github.com/praxis-proxy/praxis/tree/main/examples/configs/protocols): +`examples/configs/protocols/`(https://github.com/praxis-proxy/praxis/tree/main/examples/configs/protocols): | Example | Scenario | | ------- | -------- | diff --git a/docs/reference/_category_.json b/docs/reference/_category_.json new file mode 100644 index 0000000..1b337d5 --- /dev/null +++ b/docs/reference/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Reference", + "position": 5 +} diff --git a/docs/reference/ai-filters.md b/docs/reference/ai-filters.md new file mode 100644 index 0000000..4abb35c --- /dev/null +++ b/docs/reference/ai-filters.md @@ -0,0 +1,77 @@ +--- +title: AI Filter Reference +sidebar_position: 2 +--- + +# Filter Reference + +AI filters provided by Praxis AI. For base proxy +filters (router, load balancer, headers, CORS, etc.), +see the [Praxis core filter reference][core-ref]. + +[core-ref]: /docs/reference/core-filters + +## Provider APIs (praxis-ai-apis) + +### Anthropic + +| Filter | Description | +|--------|-------------| +| [`anthropic_messages_format`](/docs/ai/filters/anthropic_messages_format) | Classifies Anthropic Messages API requests and promotes routing facts to headers, metadata, and filter results. | +| [`anthropic_messages_protocol`](/docs/ai/filters/anthropic_messages_protocol) | Normalizes Anthropic Messages protocol headers for native backends. | +| [`anthropic_stream_events`](/docs/ai/filters/anthropic_stream_events) | Transforms streaming SSE responses between `OpenAI` and Anthropic formats, processing each chunk as it arrives. | +| [`anthropic_to_openai`](/docs/ai/filters/anthropic_to_openai) | Transforms Anthropic Messages API requests to Chat Completions-compatible request bodies and transforms compatible responses back. The filter name refers to the OpenAI Chat Completions wire shape, not the Responses API; non-OpenAI compatible backends are valid targets. | +| [`anthropic_validate`](/docs/ai/filters/anthropic_validate) | Validates Anthropic Messages request bodies for proxy-owned JSON envelope requirements. | + +### OpenAI + +| Filter | Description | +|--------|-------------| +| [`openai_conversations`](/docs/ai/filters/openai_conversations) | Handles all `/v1/conversations` endpoints locally. | +| [`openai_response_store`](/docs/ai/filters/openai_response_store) | Persists Responses API responses to the configured response store backend. | +| [`openai_responses_format`](/docs/ai/filters/openai_responses_format) | Classifies AI API request bodies and promotes routing facts to headers, metadata, and filter results without mutating the body. | +| [`openai_responses_model_rewrite`](/docs/ai/filters/openai_responses_model_rewrite) | Rewrites the `model` field in Responses API request bodies. | +| [`openai_responses_rehydrate`](/docs/ai/filters/openai_responses_rehydrate) | Validates `previous_response_id` by fetching the stored response, confirming its status is `"completed"`, and populating `ResponsesState` with the full conversation history (stored turns + current input). | +| [`openai_responses_validate`](/docs/ai/filters/openai_responses_validate) | Validates and enriches Responses API requests. | +| [`openai_stream_events`](/docs/ai/filters/openai_stream_events) | Accumulates state from native Responses API SSE event streams. | +| [`responses_proxy`](/docs/ai/filters/responses_proxy) | Rebuilds the request body from `ResponsesState` when present. | +| [`tool_parse`](/docs/ai/filters/tool_parse) | Parses tool definitions and `tool_choice` from Responses API request bodies and promotes routing facts to metadata and filter results without mutating the body. | + +## Cross-Provider Filters (praxis-ai-filters) + +### Agentic + +| Filter | Description | +|--------|-------------| +| [`a2a`](/docs/ai/filters/a2a) | Extracts A2A protocol metadata from JSON-RPC request bodies and promotes method, family, task ID, streaming detection, and version to request headers, filter results, and durable metadata for routing. | +| [`mcp`](/docs/ai/filters/mcp) | Extracts MCP protocol metadata from JSON-RPC request bodies and promotes method, tool/resource/prompt name, JSON-RPC kind, protocol version, and session presence to request headers/filter results; stores session ID in durable metadata. | + +### Guardrails + +| Filter | Description | +|--------|-------------| +| [`ai_guardrails`](/docs/ai/filters/ai_guardrails) | Pass-through scaffold for external AI guardrail evaluation. | + +### Inference + +| Filter | Description | +|--------|-------------| +| [`model_to_header`](/docs/ai/filters/model_to_header) | Promotes the JSON `"model"` field from the request body to a request header. | + +### Prompt Enrich + +| Filter | Description | +|--------|-------------| +| [`prompt_enrich`](/docs/ai/filters/prompt_enrich) | Injects statically configured messages into the `messages` array of OpenAI-compatible chat completion request bodies. | + +### Token Count + +| Filter | Description | +|--------|-------------| +| [`token_count`](/docs/ai/filters/token_count) | Extracts token usage from AI inference responses and writes unified counts to [filter_metadata](/docs/ai/filters/extensions#filter_metadata). | + +### Token Usage + +| Filter | Description | +|--------|-------------| +| [`token_usage_headers`](/docs/ai/filters/token_usage_headers) | Injects `Praxis-Token-Input`, `Praxis-Token-Output`, and `Praxis-Token-Total` headers into downstream responses when token usage data is present in [filter_metadata](/docs/ai/filters/extensions#filter_metadata). | diff --git a/docs/reference/core-filters.md b/docs/reference/core-filters.md new file mode 100644 index 0000000..2506db5 --- /dev/null +++ b/docs/reference/core-filters.md @@ -0,0 +1,70 @@ +--- +title: Core Filter Reference +sidebar_position: 1 +--- + +# Filter Reference + +Built-in filters organized by protocol and category. + +## HTTP / Observability + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`access_log`](/docs/filters/http/observability/access_log) | - | Logs structured access records for each request and response. | +| [`request_id`](/docs/filters/http/observability/request_id) | - | Ensures every request carries a correlation ID. | + +## HTTP / Payload Processing + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`compression`](/docs/filters/http/payload_processing/compression) | - | Enables Pingora's built-in response compression when present in a filter chain. | +| [`json_body_field`](/docs/filters/http/payload_processing/json_body_field) | - | Extracts top-level fields from a JSON request body and promotes their values to request headers using [`StreamBuffer`] mode. | +| [`json_rpc`](/docs/filters/http/payload_processing/json_rpc) | - | Extracts JSON-RPC 2.0 envelope metadata from request bodies and promotes method, id, and kind to request headers and filter results for routing. | + +## HTTP / Security + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`cors`](/docs/filters/http/security/cors) | - | Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection. | +| [`credential_injection`](/docs/filters/http/security/credential_injection) | - | Injects per-cluster API credentials into upstream requests. | +| [`csrf`](/docs/filters/http/security/csrf) | - | CSRF protection filter that validates request origins against a trusted allowlist. | +| [`forwarded_headers`](/docs/filters/http/security/forwarded_headers) | - | Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers into upstream requests. | +| [`guardrails`](/docs/filters/http/security/guardrails) | - | Rejects requests matching string, regex, or PII rules against headers and/or body content. | +| [`ip_acl`](/docs/filters/http/security/ip_acl) | - | IP-based access control filter. | +| [`policy`](/docs/filters/http/security/policy) | `cpex-policy-engine` | Embeds the CPEX policy engine in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting. | + +## HTTP / Traffic Management + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`circuit_breaker`](/docs/filters/http/traffic_management/circuit_breaker) | - | Rejects requests to clusters whose circuit is open. | +| [`endpoint_selector`](/docs/filters/http/traffic_management/endpoint_selector) | - | Selects an upstream endpoint from a trusted mutation source. | +| [`grpc_detection`](/docs/filters/http/traffic_management/grpc_detection) | - | Detects gRPC requests from the `content-type` header and promotes the variant to filter metadata and results for downstream routing. | +| [`load_balancer`](/docs/filters/http/traffic_management/load_balancer) | - | Selects an upstream endpoint using the cluster's configured strategy. | +| [`rate_limit`](/docs/filters/http/traffic_management/rate_limit) | - | Token bucket rate limiter that rejects excess traffic with 429. | +| [`redirect`](/docs/filters/http/traffic_management/redirect) | - | Returns a redirect response without contacting any upstream. | +| [`router`](/docs/filters/http/traffic_management/router) | - | Routes requests to clusters based on path prefix and host header. | +| [`static_response`](/docs/filters/http/traffic_management/static_response) | - | Returns a fixed response without contacting any upstream. | +| [`timeout`](/docs/filters/http/traffic_management/timeout) | - | Enforces a maximum end-to-end latency from request receipt to response headers. | + +## HTTP / Transformation + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`headers`](/docs/filters/http/transformation/headers) | - | Adds, sets, or removes headers on upstream requests and downstream responses. | +| [`path_rewrite`](/docs/filters/http/transformation/path_rewrite) | - | Rewrites the request path before forwarding to the upstream. | +| [`url_rewrite`](/docs/filters/http/transformation/url_rewrite) | - | Rewrites request URLs using regex substitution and query parameter manipulation before the request reaches upstream. | + +## TCP / Observability + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`tcp_access_log`](/docs/filters/tcp/observability/tcp_access_log) | - | Logs TCP connection events. | + +## TCP / Traffic Management + +| Filter | Feature | Description | +|--------|---------|-------------| +| [`sni_router`](/docs/filters/tcp/traffic_management/sni_router) | - | Routes TCP connections by SNI hostname. | +| [`tcp_load_balancer`](/docs/filters/tcp/traffic_management/tcp_load_balancer) | - | Selects an upstream TCP endpoint using the cluster's configured strategy. | diff --git a/docs/security/_category_.json b/docs/security/_category_.json index 92fc739..a5bea96 100644 --- a/docs/security/_category_.json +++ b/docs/security/_category_.json @@ -1,4 +1 @@ -{ - "label": "Security", - "position": 6 -} +{"label":"Security","position":8} diff --git a/docs/security/hardening.md b/docs/security/hardening.md index 2e1611c..647578b 100644 --- a/docs/security/hardening.md +++ b/docs/security/hardening.md @@ -32,7 +32,7 @@ ambiguous configuration: - Supply chain audited via `cargo audit` and `cargo deny`. - Reserved internal headers (`x-praxis-*`, - `x-mcp-*`, `x-a2a-*`) are rejected from client + `x-ext-protocol-*`, `x-ext-agent-*`) are rejected from client requests, stripped before forwarding to backends, and stripped from backend responses before reaching clients. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index be83883..11aa2a2 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -107,6 +107,12 @@ const config: Config = { label: 'Docs', }, {to: '/examples', label: 'Examples', position: 'left'}, + { + type: 'doc', + docId: 'ai/overview', + position: 'left', + label: 'AI Gateway', + }, {to: '/blog', label: 'Blog', position: 'left'}, { href: 'https://github.com/praxis-proxy/praxis', @@ -121,8 +127,9 @@ const config: Config = { { title: 'Getting Started', items: [ - {label: 'Introduction', to: '/docs/getting-started/introduction'}, - {label: 'Quick Start', to: '/docs/getting-started/quickstart'}, + {label: 'Product Map', to: '/docs/getting-started/product-map'}, + {label: 'Quick Start (Core)', to: '/docs/getting-started/quickstart'}, + {label: 'Quick Start (AI)', to: '/docs/getting-started/quickstart-ai'}, {label: 'Installation', to: '/docs/getting-started/installation'}, {label: 'Examples', to: '/examples'}, ], @@ -130,6 +137,7 @@ const config: Config = { { title: 'Documentation', items: [ + {label: 'AI Gateway', to: '/docs/ai/overview'}, {label: 'Architecture', to: '/docs/architecture/system-design'}, {label: 'Configuration', to: '/docs/configuration/overview'}, {label: 'Filters', to: '/docs/filters/filter-model'}, @@ -140,7 +148,8 @@ const config: Config = { { title: 'Community', items: [ - {label: 'GitHub', href: 'https://github.com/praxis-proxy/praxis'}, + {label: 'Core (GitHub)', href: 'https://github.com/praxis-proxy/praxis'}, + {label: 'AI Gateway (GitHub)', href: 'https://github.com/praxis-proxy/ai'}, {label: 'Discussions', href: 'https://github.com/praxis-proxy/praxis/discussions'}, {label: 'Issues', href: 'https://github.com/praxis-proxy/praxis/issues'}, {label: 'Contributing', to: '/docs/development/contributing'}, diff --git a/scripts/lint-examples-catalog.sh b/scripts/lint-examples-catalog.sh new file mode 100755 index 0000000..d0879e2 --- /dev/null +++ b/scripts/lint-examples-catalog.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Compare examples catalog count with README inventories in source repos. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +count_readme_rows() { + local file="$1" + grep -cE '^\| \[[^]]+\]\(' "$file" || true +} + +PRAXIS_README="${PRAXIS_EXAMPLES_README:-../praxis/examples/README.md}" +AI_README="${AI_EXAMPLES_README:-../ai/examples/README.md}" + +if [[ ! -f "$PRAXIS_README" || ! -f "$AI_README" ]]; then + echo "skip: praxis or ai examples README not found (set PRAXIS_EXAMPLES_README / AI_EXAMPLES_README)" >&2 + exit 0 +fi + +readme_total=$(( $(count_readme_rows "$PRAXIS_README") + $(count_readme_rows "$AI_README") )) +catalog_total=$(grep -cE "path: '" src/data/examples.ts) + +echo "README rows: $readme_total" +echo "examples.ts entries: $catalog_total" + +if [[ "$readme_total" -ne "$catalog_total" ]]; then + echo "examples catalog drift: expected $readme_total entries, found $catalog_total" >&2 + exit 1 +fi + +echo "examples catalog matches README inventories" diff --git a/src/data/examples.ts b/src/data/examples.ts index 6b3692e..a3230e5 100644 --- a/src/data/examples.ts +++ b/src/data/examples.ts @@ -4,6 +4,7 @@ export interface Example { category: string; path: string; filename: string; + repo: 'praxis' | 'ai'; } export const categories = [ @@ -26,428 +27,497 @@ export const examples: Example[] = [ // Traffic Management { name: 'Basic Reverse Proxy', - description: 'Minimal single-listener, single-cluster proxy', + description: 'Minimal config: one listener, one upstream, default filter chain', category: 'Traffic Management', path: 'examples/configs/traffic-management/basic-reverse-proxy.yaml', filename: 'basic-reverse-proxy.yaml', - }, - { - name: 'Path-Based Routing', - description: 'Route by URL path prefix to separate clusters', - category: 'Traffic Management', - path: 'examples/configs/traffic-management/path-based-routing.yaml', - filename: 'path-based-routing.yaml', - }, - { - name: 'Host-Based Routing', - description: 'Route by Host header; one listener, multiple domains', - category: 'Traffic Management', - path: 'examples/configs/traffic-management/hosts.yaml', - filename: 'hosts.yaml', + repo: 'praxis', }, { name: 'Canary Routing', - description: 'Weighted traffic split for canary deployments', + description: 'Sends ~10% of traffic to a canary backend while the stable backend handles the remaining ~90%', category: 'Traffic Management', path: 'examples/configs/traffic-management/canary-routing.yaml', filename: 'canary-routing.yaml', + repo: 'praxis', }, { name: 'Circuit Breaker', - description: 'Per-cluster circuit breaker with closed/open/half-open states', + description: 'Prevents cascading failures by tracking consecutive upstream errors per cluster', category: 'Traffic Management', path: 'examples/configs/traffic-management/circuit-breaker.yaml', filename: 'circuit-breaker.yaml', + repo: 'praxis', }, { - name: 'Round Robin', - description: 'Default strategy: even distribution across backends', + name: 'Ext Proc Endpoint Selector', + description: 'Demonstrates ext_proc full-duplex streaming with endpoint_selector for dynamic upstream routing via an external processing service', category: 'Traffic Management', - path: 'examples/configs/traffic-management/round-robin.yaml', - filename: 'round-robin.yaml', + path: 'examples/configs/traffic-management/ext-proc-endpoint-selector.yaml', + filename: 'ext-proc-endpoint-selector.yaml', + repo: 'praxis', }, { - name: 'Weighted Load Balancing', - description: 'Proportional traffic split via per-endpoint weights', + name: 'gRPC Detection', + description: 'Detects gRPC requests from the content-type header and promotes the variant to filter metadata and results', category: 'Traffic Management', - path: 'examples/configs/traffic-management/weighted-load-balancing.yaml', - filename: 'weighted-load-balancing.yaml', + path: 'examples/configs/traffic-management/grpc-detection.yaml', + filename: 'grpc-detection.yaml', + repo: 'praxis', }, { - name: 'Least Connections', - description: 'Route to backend with fewest in-flight requests', + name: 'Health Checks', + description: 'Per-cluster health checks probe endpoints on a timer and remove unhealthy backends from the load balancer rotation', category: 'Traffic Management', - path: 'examples/configs/traffic-management/least-connections.yaml', - filename: 'least-connections.yaml', + path: 'examples/configs/traffic-management/health-checks.yaml', + filename: 'health-checks.yaml', + repo: 'praxis', }, { - name: 'Session Affinity', - description: 'consistent_hash to pin a user to one backend', + name: 'Hostname Upstream', + description: 'Demonstrates using DNS hostnames instead of IP addresses for upstream endpoints', category: 'Traffic Management', - path: 'examples/configs/traffic-management/session-affinity.yaml', - filename: 'session-affinity.yaml', + path: 'examples/configs/traffic-management/hostname-upstream.yaml', + filename: 'hostname-upstream.yaml', + repo: 'praxis', }, { - name: 'Health Checks', - description: 'Active HTTP and TCP health check probes per cluster', + name: 'Host-Based Routing', + description: 'One listener serves multiple domains', category: 'Traffic Management', - path: 'examples/configs/traffic-management/health-checks.yaml', - filename: 'health-checks.yaml', + path: 'examples/configs/traffic-management/hosts.yaml', + filename: 'hosts.yaml', + repo: 'praxis', }, { - name: 'Timeout', - description: '504 when upstream exceeds a latency SLA', + name: 'Least Connections', + description: 'Routes each request to the backend with the fewest in-flight requests', category: 'Traffic Management', - path: 'examples/configs/traffic-management/timeout.yaml', - filename: 'timeout.yaml', + path: 'examples/configs/traffic-management/least-connections.yaml', + filename: 'least-connections.yaml', + repo: 'praxis', }, { - name: 'Rate Limiting', - description: 'Token bucket rate limiter with per-IP and global modes', + name: 'P2C Load Balancing', + description: 'Samples two random endpoints and picks the one with fewer in-flight requests', category: 'Traffic Management', - path: 'examples/configs/traffic-management/rate-limiting.yaml', - filename: 'rate-limiting.yaml', + path: 'examples/configs/traffic-management/p2c.yaml', + filename: 'p2c.yaml', + repo: 'praxis', }, { - name: 'Static Response', - description: 'Fixed response without upstream', + name: 'Path-Based Routing', + description: 'Routes by URL path prefix', category: 'Traffic Management', - path: 'examples/configs/traffic-management/static-response.yaml', - filename: 'static-response.yaml', + path: 'examples/configs/traffic-management/path-based-routing.yaml', + filename: 'path-based-routing.yaml', + repo: 'praxis', + }, + { + name: 'Rate Limiting', + description: 'Token bucket rate limiter with per-IP or global modes', + category: 'Traffic Management', + path: 'examples/configs/traffic-management/rate-limiting.yaml', + filename: 'rate-limiting.yaml', + repo: 'praxis', }, { name: 'Redirect', - description: '3xx redirects with path/query template substitution', + description: 'Returns a 3xx redirect without contacting any upstream', category: 'Traffic Management', path: 'examples/configs/traffic-management/redirect.yaml', filename: 'redirect.yaml', + repo: 'praxis', }, { - name: 'Hostname Upstream', - description: 'Resolve hostname upstream endpoints such as localhost:9000', + name: 'Round Robin', + description: 'Default strategy', category: 'Traffic Management', - path: 'examples/configs/traffic-management/hostname-upstream.yaml', - filename: 'hostname-upstream.yaml', + path: 'examples/configs/traffic-management/round-robin.yaml', + filename: 'round-robin.yaml', + repo: 'praxis', }, - - // Payload Processing { - name: 'AI Inference Body-Based Routing', - description: 'Route LLM requests by model field in JSON body', - category: 'Payload Processing', - path: 'examples/configs/payload-processing/ai-inference-body-based-routing.yaml', - filename: 'ai-inference-body-based-routing.yaml', + name: 'Session Affinity', + description: 'Hashes a request header to pin a user\'s requests to one backend', + category: 'Traffic Management', + path: 'examples/configs/traffic-management/session-affinity.yaml', + filename: 'session-affinity.yaml', + repo: 'praxis', }, { - name: 'JSON-RPC Routing', - description: 'Route JSON-RPC 2.0 requests by method for MCP and A2A protocols', - category: 'Payload Processing', - path: 'examples/configs/payload-processing/json-rpc-routing.yaml', - filename: 'json-rpc-routing.yaml', + name: 'Static Response', + description: 'Returns a fixed response without contacting any upstream', + category: 'Traffic Management', + path: 'examples/configs/traffic-management/static-response.yaml', + filename: 'static-response.yaml', + repo: 'praxis', }, { - name: 'MCP Classifier Routing', - description: 'Route MCP requests by body-derived method and tool name', + name: 'Timeout', + description: 'Returns 504 if the upstream takes longer than timeout_ms to respond', + category: 'Traffic Management', + path: 'examples/configs/traffic-management/timeout.yaml', + filename: 'timeout.yaml', + repo: 'praxis', + }, + { + name: 'Weighted Load Balancing', + description: 'Traffic split proportional to per-endpoint weights', + category: 'Traffic Management', + path: 'examples/configs/traffic-management/weighted-load-balancing.yaml', + filename: 'weighted-load-balancing.yaml', + repo: 'praxis', + }, + // Payload Processing + { + name: 'MCP Static Catalog', + description: 'Provides a static MCP catalog and broker for initialize, tools/list, ping, and notifications/initialized requests', category: 'Payload Processing', - path: 'examples/configs/payload-processing/mcp-classifier-routing.yaml', - filename: 'mcp-classifier-routing.yaml', + path: 'examples/configs/payload-processing/mcp-static-catalog.yaml', + filename: 'mcp-static-catalog.yaml', + repo: 'ai', }, { - name: 'Stream Buffer', - description: 'Stream-buffered body inspection before forwarding', + name: 'Body Size Limit with Extraction', + description: 'Combines body_limits.max_request_bytes with json_body_field to enforce a global body ceiling while still performing body-based routing', category: 'Payload Processing', - path: 'examples/configs/payload-processing/stream-buffer.yaml', - filename: 'stream-buffer.yaml', + path: 'examples/configs/payload-processing/body-size-limit-with-extraction.yaml', + filename: 'body-size-limit-with-extraction.yaml', + repo: 'praxis', }, { name: 'Compression', - description: 'Gzip, brotli, and zstd response compression', + description: 'Enables transparent response compression using Pingora\'s built-in compression module', category: 'Payload Processing', path: 'examples/configs/payload-processing/compression.yaml', filename: 'compression.yaml', - }, - { - name: 'Multi-Field Extraction', - description: 'Extract multiple JSON fields into headers in one pass', - category: 'Payload Processing', - path: 'examples/configs/payload-processing/multi-field-extraction.yaml', - filename: 'multi-field-extraction.yaml', + repo: 'praxis', }, { name: 'Conditional Field Extraction', - description: 'Apply json_body_field only on matching request paths', + description: 'Uses the condition system to apply json_body_field only on specific request paths', category: 'Payload Processing', path: 'examples/configs/payload-processing/conditional-field-extraction.yaml', filename: 'conditional-field-extraction.yaml', + repo: 'praxis', }, { name: 'Field Extraction Access Control', - description: 'Extract tenant_id from body for header-based routing', + description: 'Extracts the \"tenant_id\" field from the JSON request body and promotes it to an X-Tenant-Id header', category: 'Payload Processing', path: 'examples/configs/payload-processing/field-extraction-access-control.yaml', filename: 'field-extraction-access-control.yaml', + repo: 'praxis', }, { - name: 'Body Size Limit with Extraction', - description: 'Global body size ceiling with json_body_field extraction', + name: 'Multi-Field Extraction', + description: 'A single json_body_field filter extracts multiple top-level JSON fields into separate request headers in one pass', category: 'Payload Processing', - path: 'examples/configs/payload-processing/body-size-limit-with-extraction.yaml', - filename: 'body-size-limit-with-extraction.yaml', + path: 'examples/configs/payload-processing/multi-field-extraction.yaml', + filename: 'multi-field-extraction.yaml', + repo: 'praxis', }, { name: 'Multi-Listener Body Pipeline', - description: 'Three listeners with different body processing strategies', + description: 'Three listeners, each with a different body processing strategy', category: 'Payload Processing', path: 'examples/configs/payload-processing/multi-listener-body-pipeline.yaml', filename: 'multi-listener-body-pipeline.yaml', + repo: 'praxis', + }, + { + name: 'Stream Buffer', + description: 'json_body_field inspects request body chunks as they arrive and defers upstream forwarding until the field is extracted (or end-of-stream)', + category: 'Payload Processing', + path: 'examples/configs/payload-processing/stream-buffer.yaml', + filename: 'stream-buffer.yaml', + repo: 'praxis', }, - // Security + { + name: 'CORS', + description: 'Spec-compliant CORS filter with preflight handling, origin validation, and credential support', + category: 'Security', + path: 'examples/configs/security/cors.yaml', + filename: 'cors.yaml', + repo: 'praxis', + }, { name: 'CSRF Protection', - description: 'CSRF protection via origin validation', + description: 'Cross-site request forgery protection via origin validation', category: 'Security', path: 'examples/configs/security/csrf.yaml', filename: 'csrf.yaml', + repo: 'praxis', + }, + { + name: 'Downstream Read Timeout', + description: 'Protects against slow client attacks by limiting how long the proxy waits for data from downstream clients', + category: 'Security', + path: 'examples/configs/security/downstream-read-timeout.yaml', + filename: 'downstream-read-timeout.yaml', + repo: 'praxis', }, { name: 'Forwarded Headers', - description: 'X-Forwarded-For/Proto/Host with trusted proxies', + description: 'Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host into upstream requests', category: 'Security', path: 'examples/configs/security/forwarded-headers.yaml', filename: 'forwarded-headers.yaml', + repo: 'praxis', }, { name: 'Guardrails', - description: 'Reject requests matching header or body string/regex rules', + description: 'Reject requests that match header or body inspection rules', category: 'Security', path: 'examples/configs/security/guardrails.yaml', filename: 'guardrails.yaml', + repo: 'praxis', }, { name: 'IP ACL', - description: 'Allow or deny by source IP/CIDR', + description: 'Allow or deny requests by source IP/CIDR', category: 'Security', path: 'examples/configs/security/ip-acl.yaml', filename: 'ip-acl.yaml', + repo: 'praxis', }, { - name: 'Downstream Read Timeout', - description: 'Protect against slow client attacks with read timeouts', + name: 'Policy Engine', + description: 'Embeds the CPEX policy engine in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 OAuth 2.0 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting', category: 'Security', - path: 'examples/configs/security/downstream-read-timeout.yaml', - filename: 'downstream-read-timeout.yaml', + path: 'examples/configs/security/policy.yaml', + filename: 'policy.yaml', + repo: 'praxis', }, - { - name: 'CORS', - description: 'CORS preflight handling with origin validation', - category: 'Security', - path: 'examples/configs/security/cors.yaml', - filename: 'cors.yaml', - }, - // Observability { name: 'Access Logging', - description: 'Access log with sampling', + description: 'Structured JSON logging with sampling; logs ~10% of requests. request_id ensures each log line has a correlation ID. access_log emits method, path, status, and timing', category: 'Observability', path: 'examples/configs/observability/access-logging.yaml', filename: 'access-logging.yaml', + repo: 'praxis', }, { name: 'Request ID and Logging', - description: 'request_id + access_log: correlation IDs and structured logs', + description: 'request_id — ensures every request has a correlation ID', category: 'Observability', path: 'examples/configs/observability/logging.yaml', filename: 'logging.yaml', + repo: 'praxis', }, { name: 'TCP Access Log', - description: 'Structured JSON TCP connection logging', + description: 'Structured JSON logging of TCP connection events (connect and disconnect)', category: 'Observability', path: 'examples/configs/observability/tcp-access-log.yaml', filename: 'tcp-access-log.yaml', + repo: 'praxis', }, - // Transformation { name: 'Header Manipulation', - description: 'Add, overwrite, and remove request/response headers', + description: 'Add, overwrite, and remove headers on requests and responses', category: 'Transformation', path: 'examples/configs/transformation/header-manipulation.yaml', filename: 'header-manipulation.yaml', + repo: 'praxis', }, { name: 'Path Rewriting', - description: 'Strip prefix, add prefix, or regex replace on request paths', + description: 'Rewrite request paths before forwarding to upstream', category: 'Transformation', path: 'examples/configs/transformation/path-rewriting.yaml', filename: 'path-rewriting.yaml', + repo: 'praxis', }, { name: 'URL Rewriting', - description: 'Regex path transformation and query string manipulation', + description: 'Regex-based path transformation and query string manipulation', category: 'Transformation', path: 'examples/configs/transformation/url-rewriting.yaml', filename: 'url-rewriting.yaml', + repo: 'praxis', }, - // Protocols { - name: 'TCP Proxy', - description: 'L4 bidirectional TCP forwarding', - category: 'Protocols', - path: 'examples/configs/protocols/tcp-proxy.yaml', - filename: 'tcp-proxy.yaml', - }, - { - name: 'TCP Timeouts', - description: 'TCP proxy with idle and max duration timeouts', + name: 'Mixed Protocol', + description: 'HTTP and TCP listeners run on a single server instance', category: 'Protocols', - path: 'examples/configs/protocols/tcp-timeouts.yaml', - filename: 'tcp-timeouts.yaml', + path: 'examples/configs/protocols/mixed-protocol.yaml', + filename: 'mixed-protocol.yaml', + repo: 'praxis', }, { name: 'TCP Consistent Hash', - description: 'TCP load balancing with consistent-hash client IP affinity', + description: 'TCP consistent-hash load balancing (client IP affinity)', category: 'Protocols', path: 'examples/configs/protocols/tcp-consistent-hash.yaml', filename: 'tcp-consistent-hash.yaml', + repo: 'praxis', }, { name: 'TCP Least Connections', - description: 'TCP load balancing via least-connections strategy', + description: 'TCP least-connections load balancing', category: 'Protocols', path: 'examples/configs/protocols/tcp-least-connections.yaml', filename: 'tcp-least-connections.yaml', + repo: 'praxis', }, { - name: 'TCP Round Robin', - description: 'TCP round-robin load balancing across replicas', - category: 'Protocols', - path: 'examples/configs/protocols/tcp-round-robin.yaml', - filename: 'tcp-round-robin.yaml', - }, - { - name: 'Mixed Protocol', - description: 'HTTP + TCP listeners on one server', - category: 'Protocols', - path: 'examples/configs/protocols/mixed-protocol.yaml', - filename: 'mixed-protocol.yaml', - }, - { - name: 'TLS Termination', - description: 'HTTPS listener; plain HTTP to backends', + name: 'TCP Proxy', + description: 'Bidirectional TCP forwarding', category: 'Protocols', - path: 'examples/configs/protocols/tls-termination.yaml', - filename: 'tls-termination.yaml', + path: 'examples/configs/protocols/tcp-proxy.yaml', + filename: 'tcp-proxy.yaml', + repo: 'praxis', }, { - name: 'TLS Cipher Suites', - description: 'Restrict accepted TLS cipher suites per listener', + name: 'TCP Round Robin', + description: 'TCP round-robin load balancing across database replicas', category: 'Protocols', - path: 'examples/configs/protocols/tls-cipher-suites.yaml', - filename: 'tls-cipher-suites.yaml', + path: 'examples/configs/protocols/tcp-round-robin.yaml', + filename: 'tcp-round-robin.yaml', + repo: 'praxis', }, { - name: 'TLS SNI Routing', - description: 'Route TLS connections by SNI hostname without termination', + name: 'TCP Timeouts', + description: 'TCP proxy with session and max duration timeouts. `tcp_session_timeout_ms` wraps the entire TCP forwarding session in a hard deadline, terminating connections after the threshold regardless of activity. `tcp_max_duration_secs` caps the total session duration in seconds', category: 'Protocols', - path: 'examples/configs/protocols/tls-sni-routing.yaml', - filename: 'tls-sni-routing.yaml', + path: 'examples/configs/protocols/tcp-timeouts.yaml', + filename: 'tcp-timeouts.yaml', + repo: 'praxis', }, { name: 'TCP TLS mTLS', - description: 'TCP proxy with mutual TLS', + description: 'The proxy requires TCP clients to present a valid TLS certificate signed by the trusted CA', category: 'Protocols', path: 'examples/configs/protocols/tcp-tls-mtls.yaml', filename: 'tcp-tls-mtls.yaml', + repo: 'praxis', }, { name: 'TCP TLS Termination', - description: 'TCP proxy with TLS termination', + description: 'TLS on the listener; plain TCP to the upstream backend', category: 'Protocols', path: 'examples/configs/protocols/tcp-tls-termination.yaml', filename: 'tcp-tls-termination.yaml', + repo: 'praxis', + }, + { + name: 'TLS Cipher Suites', + description: 'Restrict accepted cipher suites per listener', + category: 'Protocols', + path: 'examples/configs/protocols/tls-cipher-suites.yaml', + filename: 'tls-cipher-suites.yaml', + repo: 'praxis', }, { name: 'TLS HTTP Re-encrypt', - description: 'TLS termination with re-encryption to upstream', + description: 'HTTPS on the listener; TLS to the upstream backend', category: 'Protocols', path: 'examples/configs/protocols/tls-http-reencrypt.yaml', filename: 'tls-http-reencrypt.yaml', + repo: 'praxis', }, { name: 'TLS mTLS Both', - description: 'mTLS on both listener and upstream', + description: 'Client mTLS to the proxy (client cert required), and proxy mTLS to the upstream backend (proxy presents its own client certificate)', category: 'Protocols', path: 'examples/configs/protocols/tls-mtls-both.yaml', filename: 'tls-mtls-both.yaml', - }, - { - name: 'TLS mTLS Listener', - description: 'mTLS on listener (require client cert)', - category: 'Protocols', - path: 'examples/configs/protocols/tls-mtls-listener.yaml', - filename: 'tls-mtls-listener.yaml', + repo: 'praxis', }, { name: 'TLS mTLS Listener Request', - description: 'mTLS on listener (request client cert)', + description: 'The proxy requests a client certificate but does not require one', category: 'Protocols', path: 'examples/configs/protocols/tls-mtls-listener-request.yaml', filename: 'tls-mtls-listener-request.yaml', + repo: 'praxis', + }, + { + name: 'TLS mTLS Listener', + description: 'The proxy requires clients to present a valid TLS certificate signed by the trusted CA', + category: 'Protocols', + path: 'examples/configs/protocols/tls-mtls-listener.yaml', + filename: 'tls-mtls-listener.yaml', + repo: 'praxis', }, { name: 'TLS mTLS Upstream', - description: 'mTLS to upstream (client cert)', + description: 'Plain HTTP from clients; the proxy presents a client certificate to the upstream backend, which requires mutual TLS authentication', category: 'Protocols', path: 'examples/configs/protocols/tls-mtls-upstream.yaml', filename: 'tls-mtls-upstream.yaml', + repo: 'praxis', }, { name: 'TLS Multi-Certificate', - description: 'SNI-based multi-certificate selection', + description: 'Multiple certificates on one listener; Praxis selects the certificate matching the client\'s SNI hostname', category: 'Protocols', path: 'examples/configs/protocols/tls-multi-cert.yaml', filename: 'tls-multi-cert.yaml', + repo: 'praxis', + }, + { + name: 'TLS SNI Routing', + description: 'Routes TLS connections to different upstreams based on the Server Name Indication (SNI) hostname in the ClientHello', + category: 'Protocols', + path: 'examples/configs/protocols/tls-sni-routing.yaml', + filename: 'tls-sni-routing.yaml', + repo: 'praxis', + }, + { + name: 'TLS Termination', + description: 'HTTPS on the listener; plain HTTP to the backend', + category: 'Protocols', + path: 'examples/configs/protocols/tls-termination.yaml', + filename: 'tls-termination.yaml', + repo: 'praxis', }, { name: 'TLS Verify Disabled', - description: 'Upstream TLS with verification disabled', + description: 'Plain HTTP listener; TLS to the upstream with certificate verification disabled', category: 'Protocols', path: 'examples/configs/protocols/tls-verify-disabled.yaml', filename: 'tls-verify-disabled.yaml', + repo: 'praxis', }, { name: 'TLS Version Constraint', - description: 'Minimum TLS version constraint', + description: 'Restrict accepted TLS versions via `min_version`', category: 'Protocols', path: 'examples/configs/protocols/tls-version-constraint.yaml', filename: 'tls-version-constraint.yaml', + repo: 'praxis', }, { name: 'Upstream CA File', - description: 'Global upstream CA file reference', + description: 'Sets a trusted CA bundle for all upstream TLS connections via `runtime.upstream_ca_file`', category: 'Protocols', path: 'examples/configs/protocols/upstream-ca-file.yaml', filename: 'upstream-ca-file.yaml', + repo: 'praxis', }, { name: 'Upstream TLS', - description: 'Plain HTTP listener; TLS to upstream with SNI', + description: 'Plain HTTP on the listener; TLS to the upstream', category: 'Protocols', path: 'examples/configs/protocols/upstream-tls.yaml', filename: 'upstream-tls.yaml', + repo: 'praxis', }, { name: 'WebSocket', - description: 'Transparent WebSocket upgrade proxying over HTTP', + description: 'HTTP listener that transparently proxies WebSocket upgrade requests', category: 'Protocols', path: 'examples/configs/protocols/websocket.yaml', filename: 'websocket.yaml', + repo: 'praxis', }, - // Pipeline { name: 'Default Config', @@ -455,165 +525,385 @@ export const examples: Example[] = [ category: 'Pipeline', path: 'core/src/config/default.yaml', filename: 'default.yaml', + repo: 'praxis', + }, + { + name: 'Branch Chains', + description: 'Filters write structured results to FilterResultSet', + category: 'Pipeline', + path: 'examples/configs/pipeline/branch-chains.yaml', + filename: 'branch-chains.yaml', + repo: 'praxis', }, { name: 'Composed Chains', - description: 'Multiple named chains composed per listener', + description: 'Multiple named chains are composed per listener', category: 'Pipeline', path: 'examples/configs/pipeline/composed-chains.yaml', filename: 'composed-chains.yaml', + repo: 'praxis', }, { name: 'Conditional Filters', - description: 'when/unless conditions on request and response phase', + description: 'Filters support `conditions` (request phase) and `response_conditions` (response phase) to gate execution', category: 'Pipeline', path: 'examples/configs/pipeline/conditional-filters.yaml', filename: 'conditional-filters.yaml', - }, - { - name: 'Branch Chains', - description: 'All branch chain scenarios in one config (six patterns)', - category: 'Pipeline', - path: 'examples/configs/pipeline/branch-chains.yaml', - filename: 'branch-chains.yaml', + repo: 'praxis', }, { name: 'Failure Mode', - description: 'Failure mode behavior (open continues, closed rejects on error)', + description: 'Demonstrates open and closed failure handling for filters', category: 'Pipeline', path: 'examples/configs/pipeline/failure-mode.yaml', filename: 'failure-mode.yaml', + repo: 'praxis', }, - - // AI / Inference - { - name: 'Credential Injection', - description: 'Inject per-cluster API credentials and strip client tokens', - category: 'AI / Inference', - path: 'examples/configs/ai/credential-injection.yaml', - filename: 'credential-injection.yaml', - }, - { - name: 'Model-to-Header Routing', - description: 'Route by model field in JSON body via X-Model header', - category: 'AI / Inference', - path: 'examples/configs/ai/model-to-header-routing.yaml', - filename: 'model-to-header-routing.yaml', - }, - { - name: 'Prompt Enrichment', - description: 'Inject system messages into chat completion requests', - category: 'AI / Inference', - path: 'examples/configs/ai/prompt-enrichment.yaml', - filename: 'prompt-enrichment.yaml', - }, - // Branching { - name: 'Unconditional Branch', - description: 'Always-fire branch for injecting side-effect chains', + name: 'Conditional Skip-To', + description: 'Skips browser-facing middleware for clean requests', category: 'Branching', - path: 'examples/configs/branching/unconditional-branch.yaml', - filename: 'unconditional-branch.yaml', + path: 'examples/configs/branching/conditional-skip-to.yaml', + filename: 'conditional-skip-to.yaml', + repo: 'praxis', }, { name: 'Conditional Terminal', - description: 'Short-circuit the pipeline with a static response on result match', + description: 'Short-circuits the pipeline when guardrails detects a dangerous request header', category: 'Branching', path: 'examples/configs/branching/conditional-terminal.yaml', filename: 'conditional-terminal.yaml', + repo: 'praxis', }, { - name: 'Conditional Skip-To', - description: 'Skip filters by jumping to a named rejoin point on result match', + name: 'Cross-Chain Flat', + description: 'A listener references two chains: preprocessing and routing', category: 'Branching', - path: 'examples/configs/branching/conditional-skip-to.yaml', - filename: 'conditional-skip-to.yaml', + path: 'examples/configs/branching/cross-chain-flat.yaml', + filename: 'cross-chain-flat.yaml', + repo: 'praxis', }, { name: 'Multiple Branches', - description: 'Multiple branches on one filter with first-match-wins evaluation', + description: 'Multiple branches on a single filter, evaluated in order', category: 'Branching', path: 'examples/configs/branching/multiple-branches.yaml', filename: 'multiple-branches.yaml', + repo: 'praxis', }, { name: 'Named Chain Ref', - description: 'Reference a top-level chain by name instead of inline definition', + description: 'A branch references a top-level chain by name instead of defining filters inline', category: 'Branching', path: 'examples/configs/branching/named-chain-ref.yaml', filename: 'named-chain-ref.yaml', + repo: 'praxis', }, { name: 'Nested Branches', - description: 'Multi-level decision tree with branches inside branches', + description: 'Branch filters that themselves contain branches, forming a multi-level decision tree', category: 'Branching', path: 'examples/configs/branching/nested-branches.yaml', filename: 'nested-branches.yaml', + repo: 'praxis', }, { name: 'Reentrance', - description: 'Loop back to a named filter with max_iterations cap', + description: 'Loops back to a named filter up to N times', category: 'Branching', path: 'examples/configs/branching/reentrance.yaml', filename: 'reentrance.yaml', + repo: 'praxis', }, { - name: 'Cross-Chain Flat', - description: 'Branch across concatenated chains via flat pipeline name index', + name: 'Unconditional Branch', + description: 'Always runs a utility chain before continuing the main pipeline', category: 'Branching', - path: 'examples/configs/branching/cross-chain-flat.yaml', - filename: 'cross-chain-flat.yaml', + path: 'examples/configs/branching/unconditional-branch.yaml', + filename: 'unconditional-branch.yaml', + repo: 'praxis', }, - // Operations { - name: 'Production Gateway', - description: 'Full production setup with composed chains', + name: 'Admin Interface', + description: 'Exposes an admin endpoint for operational health checks, readiness probes, and Prometheus metrics', category: 'Operations', - path: 'examples/configs/operations/production-gateway.yaml', - filename: 'production-gateway.yaml', + path: 'examples/configs/operations/admin-interface.yaml', + filename: 'admin-interface.yaml', + repo: 'praxis', }, { - name: 'Multi-Listener', - description: 'Multiple listeners sharing a filter chain', + name: 'Container Default', + description: 'Default config for containerized deployments', category: 'Operations', - path: 'examples/configs/operations/multi-listener.yaml', - filename: 'multi-listener.yaml', + path: 'examples/configs/operations/container-default.yaml', + filename: 'container-default.yaml', + repo: 'praxis', }, { name: 'Hot Reload', - description: 'Dynamic config reload without restart', + description: 'Filter pipelines are swapped atomically at runtime when the config file changes', category: 'Operations', path: 'examples/configs/operations/hot-reload.yaml', filename: 'hot-reload.yaml', + repo: 'praxis', }, { - name: 'Admin Interface', - description: 'Admin interface with health endpoints', - category: 'Operations', - path: 'examples/configs/operations/admin-interface.yaml', - filename: 'admin-interface.yaml', - }, - { - name: 'Container Default', - description: 'Default containerized deployment with public binding', + name: 'Log Overrides', + description: 'Use `runtime.log_overrides` to raise or lower log verbosity for specific modules without flooding output from every subsystem', category: 'Operations', - path: 'examples/configs/operations/container-default.yaml', - filename: 'container-default.yaml', + path: 'examples/configs/operations/log-overrides.yaml', + filename: 'log-overrides.yaml', + repo: 'praxis', }, { name: 'Max Connections', - description: 'Per-listener connection limit with 503 rejection', + description: 'HTTP listeners return 503 with Retry-After: 1. TCP listeners close the socket immediately', category: 'Operations', path: 'examples/configs/operations/max-connections.yaml', filename: 'max-connections.yaml', + repo: 'praxis', }, { - name: 'Log Overrides', - description: 'Per-module log level tuning via runtime config', + name: 'Multi-Listener', + description: 'Demonstrates multiple HTTP listeners, each with its own filter pipeline', category: 'Operations', - path: 'examples/configs/operations/log-overrides.yaml', - filename: 'log-overrides.yaml', + path: 'examples/configs/operations/multi-listener.yaml', + filename: 'multi-listener.yaml', + repo: 'praxis', + }, + { + name: 'Production Gateway', + description: 'Combines TLS, logging, timeouts, security headers, path routing, and load balancing', + category: 'Operations', + path: 'examples/configs/operations/production-gateway.yaml', + filename: 'production-gateway.yaml', + repo: 'praxis', + }, + // AI / Inference + { + name: 'A2A Agent Card Routing', + description: 'Routes agent card discovery requests to dedicated backends', + category: 'AI / Inference', + path: 'examples/configs/a2a-agent-card-routing.yaml', + filename: 'a2a-agent-card-routing.yaml', + repo: 'ai', + }, + { + name: 'A2A Classifier Routing', + description: 'Routes A2A requests by body-derived method, family, context ID, task ID, and streaming detection', + category: 'AI / Inference', + path: 'examples/configs/a2a-classifier-routing.yaml', + filename: 'a2a-classifier-routing.yaml', + repo: 'ai', + }, + { + name: 'A2A Task Routing', + description: 'Captures task ownership from SendMessage JSON responses and SendStreamingMessage / SubscribeToTask SSE responses, then routes follow-up task operations back to the backend cluster that created the task', + category: 'AI / Inference', + path: 'examples/configs/a2a-task-routing.yaml', + filename: 'a2a-task-routing.yaml', + repo: 'ai', + }, + { + name: 'AI Guardrails', + description: 'AI guardrails pass-through scaffold with NeMo provider config in the request pipeline', + category: 'AI / Inference', + path: 'examples/configs/ai-guardrails.yaml', + filename: 'ai-guardrails.yaml', + repo: 'ai', + }, + { + name: 'AI Inference Body-Based Routing', + description: 'Routes LLM API requests to different backends based on the `model` field in the JSON request body', + category: 'AI / Inference', + path: 'examples/configs/ai-inference-body-based-routing.yaml', + filename: 'ai-inference-body-based-routing.yaml', + repo: 'ai', + }, + { + name: 'Anthropic Messages Protocol', + description: 'Routes Anthropic Messages API requests to a native `/v1/messages` backend', + category: 'AI / Inference', + path: 'examples/configs/anthropic/messages-protocol.yaml', + filename: 'messages-protocol.yaml', + repo: 'ai', + }, + { + name: 'Anthropic to OpenAI', + description: 'Transforms Anthropic Messages API requests and responses for Chat Completions-compatible inference backends', + category: 'AI / Inference', + path: 'examples/configs/anthropic/messages-to-openai.yaml', + filename: 'messages-to-openai.yaml', + repo: 'ai', + }, + { + name: 'Anthropic Request Validate', + description: 'Rejects empty, malformed, or non-object JSON request bodies', + category: 'AI / Inference', + path: 'examples/configs/anthropic/request-validate.yaml', + filename: 'request-validate.yaml', + repo: 'ai', + }, + { + name: 'Unified AI Gateway', + description: 'Routes traffic by classifier-promoted headers so a single listener handles Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses requests', + category: 'AI / Inference', + path: 'examples/configs/anthropic/unified-gateway.yaml', + filename: 'unified-gateway.yaml', + repo: 'ai', + }, + { + name: 'Credential Injection', + description: 'Injects per-cluster API credentials into upstream requests and strips client-provided credentials to prevent forwarding', + category: 'AI / Inference', + path: 'examples/configs/credential-injection.yaml', + filename: 'credential-injection.yaml', + repo: 'ai', + }, + { + name: 'JSON-RPC Routing', + description: 'Routes JSON-RPC 2.0 requests to different backends based on the \"method\" field in the JSON request body', + category: 'AI / Inference', + path: 'examples/configs/json-rpc-routing.yaml', + filename: 'json-rpc-routing.yaml', + repo: 'ai', + }, + { + name: 'MCP Classifier Routing', + description: 'Routes MCP requests by body-derived method and tool name', + category: 'AI / Inference', + path: 'examples/configs/mcp-classifier-routing.yaml', + filename: 'mcp-classifier-routing.yaml', + repo: 'ai', + }, + { + name: 'MCP Stateless Broker', + description: 'Configurable stateless MCP broker using the 2026-07-28 release candidate profile', + category: 'AI / Inference', + path: 'examples/configs/mcp-stateless-broker.yaml', + filename: 'mcp-stateless-broker.yaml', + repo: 'ai', + }, + { + name: 'Model-to-Header Routing', + description: 'Routes LLM API requests to different backends based on the \"model\" field in the JSON request body', + category: 'AI / Inference', + path: 'examples/configs/model-to-header-routing.yaml', + filename: 'model-to-header-routing.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Conversations', + description: 'Local /v1/conversations endpoints for conversation lifecycle, backed by the ConversationItemStore', + category: 'AI / Inference', + path: 'examples/configs/openai/conversations/conversations.yaml', + filename: 'conversations.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Format Routing', + description: 'Routes AI API traffic by detected body format', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/format-routing.yaml', + filename: 'format-routing.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Responses Full Flow', + description: 'Combines format classification, request validation, and backend routing into a single pipeline', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/full-flow.yaml', + filename: 'full-flow.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Model Rewrite', + description: 'Rewrites or injects the top-level `model` field in Responses API request bodies before forwarding to the inference backend', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/model-rewrite.yaml', + filename: 'model-rewrite.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Rehydrate', + description: 'Validates `previous_response_id` by fetching the stored response, confirming its status is completed, and promoting the ID to filter metadata', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/rehydrate.yaml', + filename: 'rehydrate.yaml', + repo: 'ai', + }, + { + name: 'Anthropic Request Validate', + description: 'Validates Responses API requests and rejects invalid parameter combinations', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/request-validate.yaml', + filename: 'request-validate.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Response Store', + description: 'Persists non-streaming Responses API responses to a database and serves stored data via GET endpoints and handles DELETE /v1/responses/{id} locally', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/response-store.yaml', + filename: 'response-store.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Responses Proxy', + description: 'Proxies OpenAI Responses API requests to a native /v1/responses backend', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/responses-proxy.yaml', + filename: 'responses-proxy.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Responses Routing', + description: 'Routes Responses API traffic by detected mode', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/responses-routing.yaml', + filename: 'responses-routing.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Stream Events', + description: 'Accumulates Responses API SSE events from streaming upstream responses', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/stream-events.yaml', + filename: 'stream-events.yaml', + repo: 'ai', + }, + { + name: 'OpenAI Tool Routing', + description: 'Branches request processing by tool composition using filter results from tool_parse', + category: 'AI / Inference', + path: 'examples/configs/openai/responses/tool-routing.yaml', + filename: 'tool-routing.yaml', + repo: 'ai', + }, + { + name: 'Prompt Enrichment', + description: 'Injects system messages into OpenAI-compatible chat completion requests before forwarding to the upstream provider', + category: 'AI / Inference', + path: 'examples/configs/prompt-enrichment.yaml', + filename: 'prompt-enrichment.yaml', + repo: 'ai', + }, + { + name: 'Token Counting', + description: 'Extracts token usage from AI inference responses (streaming SSE and non-streaming JSON) and makes counts available to downstream filters via filter metadata', + category: 'AI / Inference', + path: 'examples/configs/token-counting.yaml', + filename: 'token-counting.yaml', + repo: 'ai', + }, + { + name: 'Token Usage Headers', + description: 'Inject Praxis-Token-Input, Praxis-Token-Output, and Praxis-Token-Total headers into downstream responses when token counts are available in filter metadata', + category: 'AI / Inference', + path: 'examples/configs/token-usage-headers.yaml', + filename: 'token-usage-headers.yaml', + repo: 'ai', }, ]; diff --git a/src/pages/examples/index.module.css b/src/pages/examples/index.module.css index 99fec7f..1b74417 100644 --- a/src/pages/examples/index.module.css +++ b/src/pages/examples/index.module.css @@ -117,6 +117,28 @@ white-space: nowrap; } +.repoBadge { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 4px; + padding: 2px 6px; + white-space: nowrap; +} + +.repoBadgeCore { + color: #8ec8ff; + background: rgba(100, 160, 255, 0.12); + border: 1px solid rgba(100, 160, 255, 0.25); +} + +.repoBadgeAi { + color: #c4a0ff; + background: rgba(160, 120, 255, 0.12); + border: 1px solid rgba(160, 120, 255, 0.25); +} + .cardDesc { font-family: var(--praxis-font-body); font-size: 0.8rem; diff --git a/src/pages/examples/index.tsx b/src/pages/examples/index.tsx index 60ebbe7..44ab2a9 100644 --- a/src/pages/examples/index.tsx +++ b/src/pages/examples/index.tsx @@ -3,17 +3,38 @@ import Layout from '@theme/Layout'; import { examples, categories, type Category, type Example } from '../../data/examples'; import styles from './index.module.css'; +function repoBase(repo: Example['repo']): string { + return repo === 'ai' + ? 'https://raw.githubusercontent.com/praxis-proxy/ai/main/' + : 'https://raw.githubusercontent.com/praxis-proxy/praxis/main/'; +} + +function repoGithub(repo: Example['repo']): string { + return repo === 'ai' + ? 'https://github.com/praxis-proxy/ai' + : 'https://github.com/praxis-proxy/praxis'; +} + function CodeOverlay({ example, onClose }: { example: Example; onClose: () => void }) { const [yaml, setYaml] = useState(null); const [copied, setCopied] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); useEffect(() => { - const url = `https://raw.githubusercontent.com/praxis-proxy/praxis/main/${example.path}`; + const url = `${repoBase(example.repo)}${example.path}`; + setYaml(null); + setLoadFailed(false); fetch(url) - .then((res) => res.text()) + .then((res) => { + if (!res.ok) throw new Error('fetch failed'); + return res.text(); + }) .then(setYaml) - .catch(() => setYaml('# Could not load this example.\n# View it on GitHub instead.')); - }, [example.path]); + .catch(() => { + setLoadFailed(true); + setYaml('# Could not load this example.\n# View it on GitHub instead.'); + }); + }, [example.path, example.repo]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -26,7 +47,7 @@ function CodeOverlay({ example, onClose }: { example: Example; onClose: () => vo }, [onClose]); const handleCopy = async () => { - if (!yaml) return; + if (!yaml || loadFailed) return; try { await navigator.clipboard.writeText(yaml); setCopied(true); @@ -34,6 +55,8 @@ function CodeOverlay({ example, onClose }: { example: Example; onClose: () => vo } catch {} }; + const githubUrl = `${repoGithub(example.repo)}/blob/main/${example.path}`; + return (
e.stopPropagation()}> @@ -41,17 +64,20 @@ function CodeOverlay({ example, onClose }: { example: Example; onClose: () => vo
{example.name} {example.category} + + {example.repo === 'ai' ? 'praxis-ai' : 'core'} +
GitHub -