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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Configuration examples organized by category.
## Running an Example

```console
cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/full-flow.yaml
cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/full-flow-agentic.yaml
curl http://localhost:8080/
```

Expand Down Expand Up @@ -80,8 +80,7 @@ before sending requests.
| [file-search-chat-completions-fixture.yaml](configs/openai/responses/file-search-chat-completions-fixture.yaml) | Single-upstream fixture configuration for recording the private Chat Completions function representation of a Responses file_search tool |
| [file-search-chat-completions.yaml](configs/openai/responses/file-search-chat-completions.yaml) | Accepts finite OpenAI Responses requests with hosted file search while targeting a backend that only implements /v1/chat/completions |
| [format-routing.yaml](configs/openai/responses/format-routing.yaml) | Routes AI API traffic by detected body format |
| [full-flow-agentic.yaml](configs/openai/responses/full-flow-agentic.yaml) | Extends the full-flow pipeline with an iterative_request_router (IRR) around the inference step, enabling server-side file search execution |
| [full-flow.yaml](configs/openai/responses/full-flow.yaml) | Combines conversations, format classification, request validation, file resolution, and backend routing into a single pipeline |
| [full-flow-agentic.yaml](configs/openai/responses/full-flow-agentic.yaml) | Unified Responses API gateway routing POST /v1/responses through the iterative_request_router for server-side file search execution, with the Responses WebSocket and non-Responses paths routed around the IRR |
| [irr-terminal-streaming.yaml](configs/openai/responses/irr-terminal-streaming.yaml) | Demonstrates a single-step iterative_request_router pipeline that exposes a native OpenAI Responses SSE body incrementally. `terminal_streaming: true` lets the final serializer select Praxis's typed streaming transport for an effective `"stream": true` request |
| [mcp-dispatch.yaml](configs/openai/responses/mcp-dispatch.yaml) | Demonstrates the `openai_mcp_dispatch` filter configuration |
| [mcp-tool-resolve.yaml](configs/openai/responses/mcp-tool-resolve.yaml) | Demonstrates the `openai_mcp_tool_resolve` filter, which resolves MCP tool entries in the Responses API `tools` array into concrete tool definitions by calling `tools/list` on each upstream MCP server |
Expand Down
211 changes: 168 additions & 43 deletions examples/configs/openai/responses/full-flow-agentic.yaml
Original file line number Diff line number Diff line change
@@ -1,51 +1,84 @@
# Responses API Full Flow — File Search Callout (Non-Streaming)
#
# Extends the full-flow pipeline with an iterative_request_router (IRR)
# around the inference step, enabling server-side file search execution.
# The IRR sends the request to the model, executes any returned
# file_search_call through the vector store, and feeds the search
# context back for a second model inference before returning the
# final Responses API object.
#
# Because IRR fully buffers responses, this config does NOT support
# streaming or WebSocket. For streaming without the agentic loop,
# see full-flow.yaml. For MCP-driven agentic tool loops, see
# agentic-loop.yaml (these two loop types cannot share an IRR step
# because agentic_loop and file_search_callout use incompatible
# response-body accumulation strategies).
#
# Pre-IRR filters run once on the client request to set up shared
# state (ResponsesState) which persists across all IRR iterations
# via extension swapping.
#
# Pre-IRR pipeline (runs once):
# 1. openai_conversations — CRUD for the Conversations API
# 2. openai_responses_format — classifies body, promotes metadata
# Responses API Full Flow — Unified Gateway with File Search Callout
#
# Unified Responses API gateway routing POST /v1/responses through the
# iterative_request_router for server-side file search execution, with
# the Responses WebSocket and non-Responses paths routed around the IRR.
#
# ⚠️ DRAFT — depends on issue #313 (file_search SSE streaming lifecycle).
# This config merges the standalone `full-flow.yaml` gateway into the
# agentic (IRR) pipeline so a single deployment serves BOTH:
# - POST /v1/responses → runs through the iterative_request_router
# (IRR), enabling server-side file_search execution across rounds.
# - GET /v1/responses (WebSocket) and the non-Responses paths
# (/v1/prompts, /v1/embeddings, /v1/files, /v1/vector_stores)
# → routed AROUND the IRR (the IRR buffers full responses and must
# not sit in the path of a streaming/WebSocket upgrade).
# The old `full-flow.yaml` is deleted once #313 removes the stream=true
# rejection and validates the complete file-search/IRR streaming
# lifecycle. Until then treat this as a stacked draft.
#
# Architecture (B-shaped):
#
# client ─▶ [ pre-IRR prefix, runs once ]
# │
# ├─ NOT (POST /v1/responses) ─▶ bypass branch
# │ router + load_balancer, rejoin: terminal
# │ (GET /v1/responses WebSocket + non-Responses paths)
# │
# └─ POST /v1/responses ───────▶ iterative_request_router
# (terminal filter; single-pass or multi-round
# file_search execution, then final response)
#
# The bypass gate is a `headers` no-op host carrying `conditions` +
# `branch_chains`. Its condition (`unless POST /v1/responses`) skips the
# host — and therefore its branch — for POST /v1/responses, which falls
# through to the terminal IRR. Every other request runs the branch,
# whose router+load_balancer select an upstream and `rejoin: terminal`
# forwards it, stopping the pipeline before the IRR.
#
# Why routing lives in a branch (not a top-level router before the IRR):
# the IRR must be the terminal filter, and a top-level router or
# load_balancer in the same flat chain as the IRR is rejected by the
# pipeline builder. Routing filters nested inside a branch (or inside
# the IRR step) are allowed.
#
# Chat-body guard:
# A Chat Completions or Anthropic Messages body POSTed to /v1/responses
# is a client error. The standalone full-flow.yaml rejected it via a
# top-level router that required x-praxis-ai-format=openai_responses
# (route-miss → 404). Here the equivalent guard is an on_result branch
# on openai_responses_format: when the classifier promotes a non-
# Responses format for the body, a static_response returns 404 before
# the request reaches the IRR or any backend. The IRR's own inner
# router matches only `path: /v1/responses` because the IRR rebuilds
# its sub-request from the raw client headers and does NOT carry the
# promoted x-praxis-ai-format header into the inner pipeline.
#
# Pre-IRR pipeline (runs once, before routing):
# 1. openai_responses_format — classifies body, promotes routing
# metadata (format/model/stream/mode) + filter results. Hosts the
# chat-body guard. Placed first so its promoted `format` result is
# still live when its own branch is evaluated in the header phase.
# 2. openai_conversations — CRUD for the Conversations API
# 3. openai_responses_validate — validates parameters, generates IDs
# 4. openai_tool_parse — parses tools array, promotes summary facts
# 5. openai_response_store — persists responses, registers store backend
# 6. openai_stream_events — accumulates SSE events (no-op here since
# IRR buffers the response; retained for pipeline consistency)
# 6. openai_stream_events — accumulates SSE events
# 7. openai_responses_rehydrate — loads previous_response_id / conversation
# 8. openai_file_resolve — resolves file_id references via Files API
# 9. openai_doc_extract — converts input_file to input_text for backends
# that do not natively support input_file
# 10. openai_mcp_tool_resolve — resolves MCP tool entries via tools/list
#
# IRR inference filter chain (all routing lives inside the IRR step
# because the IRR must be the terminal filter in the chain):
# IRR inference step (all routing lives inside the IRR step because the
# IRR must be the terminal filter):
# Request phase (forward):
# openai_file_search_callout → openai_responses_proxy
# → headers → router → load_balancer
# Response phase (reverse):
# load_balancer → router → headers → openai_responses_proxy
# → openai_file_search_callout
#
# Only Responses API traffic (/v1/responses) is routed by this
# config. Non-Responses paths (/v1/prompts, /v1/embeddings, etc.)
# receive a route-miss error. For a full gateway that also routes
# non-Responses traffic, see full-flow.yaml (without IRR).
#
# Transition rules (evaluated after each step's response-body):
# openai_file_search_callout.pending = "true" → next: inference
# default → done (exit to client)
Expand All @@ -66,11 +99,6 @@
# -H "Content-Type: application/json" \
# -d '{"model":"gpt-4.1","input":"Hello, world!"}'
#
# # Multi-turn with previous_response_id
# curl -X POST http://localhost:8080/v1/responses \
# -H "Content-Type: application/json" \
# -d '{"model":"gpt-4.1","input":"What next?","previous_response_id":"resp_abc"}'
#
# # File search — model issues file_search_call, executed via vector store
# curl -X POST http://localhost:8080/v1/responses \
# -H "Content-Type: application/json" \
Expand All @@ -80,6 +108,15 @@
# "tools": [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
# }'
#
# # Responses WebSocket (routed around the IRR to a WS-capable backend)
# websocat -H="Authorization: Bearer $OPENAI_API_KEY" \
# ws://localhost:8080/v1/responses
#
# # Non-Responses path (routed around the IRR to a dedicated service)
# curl http://localhost:8080/v1/embeddings \
# -H "Content-Type: application/json" \
# -d '{"model":"text-embedding-3-small","input":"hello"}'
#
# Build:
# cargo build -p praxis-ai-proxy

Expand All @@ -91,19 +128,51 @@ listeners:
filter_chains:
- name: full-flow-agentic-pipeline
filters:
- filter: openai_conversations
backend: sqlite
database_url: "sqlite://responses.db?mode=rwc"
conversations_table: openai_conversations
items_table: openai_conversation_items

# Classify first so the promoted `format` result is still live when
# this filter's own guard branch is evaluated (branch conditions
# read the host's filter results, which the pipeline clears after
# each filter in the header phase).
- filter: openai_responses_format
on_invalid: continue
headers:
format: x-praxis-ai-format
model: x-praxis-ai-model
stream: x-praxis-ai-stream
mode: x-praxis-responses-mode
branch_chains:
# A Chat Completions body on /v1/responses is a client error;
# reject it before it reaches the IRR or any backend.
- name: reject-chat-completions-body
on_result:
filter: openai_responses_format
key: format
result: openai_chat_completions
rejoin: terminal
chains:
- name: reject-chat-completions
filters:
- filter: static_response
status: 404
body: '{"error":{"message":"Chat Completions request body is not valid on the Responses endpoint","type":"invalid_request_error","code":"unsupported_body"}}'
# Likewise an Anthropic Messages body on /v1/responses.
- name: reject-anthropic-messages-body
on_result:
filter: openai_responses_format
key: format
result: anthropic_messages
rejoin: terminal
chains:
- name: reject-anthropic-messages
filters:
- filter: static_response
status: 404
body: '{"error":{"message":"Anthropic Messages request body is not valid on the Responses endpoint","type":"invalid_request_error","code":"unsupported_body"}}'

- filter: openai_conversations
backend: sqlite
database_url: "sqlite://responses.db?mode=rwc"
conversations_table: openai_conversations
items_table: openai_conversation_items

- filter: openai_responses_validate

Expand Down Expand Up @@ -135,6 +204,60 @@ filter_chains:

- filter: openai_mcp_tool_resolve

# Bypass gate: route everything EXCEPT POST /v1/responses around the
# terminal IRR. GET /v1/responses (WebSocket) and the non-Responses
# paths must not enter the IRR. POST /v1/responses fails this
# condition, so this host (and its branch) is skipped and the
# request falls through to the IRR below.
- filter: headers
conditions:
- unless:
path_prefix: "/v1/responses"
methods: [POST]
branch_chains:
- name: non-irr-gateway
rejoin: terminal
chains:
- name: gateway
filters:
- filter: router
routes:
- path_prefix: "/v1/prompts"
cluster: "prompts-api"
- path_prefix: "/v1/embeddings"
cluster: "embeddings-api"
- path_prefix: "/v1/files"
cluster: "files-api"
- path_prefix: "/v1/vector_stores"
cluster: "vector-stores-backend"
# GET /v1/responses WebSocket upgrade → inference
# backend, gated on the classifier's promoted format
# so a bare non-Responses GET does not slip through.
- path: "/v1/responses"
headers:
x-praxis-ai-format: "openai_responses"
cluster: "inference-backend"
- filter: load_balancer
clusters:
- name: "prompts-api"
endpoints:
- "127.0.0.1:9998"
- name: "embeddings-api"
endpoints:
- "127.0.0.1:9997"
- name: "files-api"
endpoints:
- "127.0.0.1:9999"
- name: "vector-stores-backend"
endpoints:
- "127.0.0.1:3002"
- name: "inference-backend"
endpoints:
- "127.0.0.1:3001"

# Terminal filter: POST /v1/responses (valid Responses body) runs
# here. Single-pass or multi-round file_search execution, then the
# final Responses object is returned.
- filter: iterative_request_router
initial_step: inference
max_iterations: 8
Expand All @@ -161,6 +284,8 @@ filter_chains:
request_set:
- name: Content-Type
value: application/json
# Inner router matches path only: the IRR sub-request carries
# the raw client headers, not the promoted x-praxis-ai-format.
- filter: router
routes:
- path: "/v1/responses"
Expand Down
Loading
Loading