Operation Classifier

Identifies supported AI operations from the request head — method, normalized path, and protocol headers — and publishes the result so a pipeline can branch on a proxy-owned fact instead of a path prefix

Category: Setup-dependent integration
Task: Identifies supported AI operations from the request head — method, normalized path, and protocol headers — and publishes the result so a pipeline can branch on a proxy-owned fact instead of a path prefix

Prerequisites: The external service, credentials, or certificates referenced by this configuration.

Run it: Use ghcr.io/praxis-proxy/ai:0.8.0 and follow the container quickstart to mount and start the configuration.

This configuration comes from the selected release. The example has not been run here; external services are not bundled.

Download the source file.

# AI Operation Classifier
#
# Identifies supported AI operations from the request head — method,
# normalized path, and protocol headers — and publishes the result so a
# pipeline can branch on a proxy-owned fact instead of a path prefix.
#
# The filter reads no request body and declares no buffering, so the
# classification is available before any body-handling decision is made.
#
# Published per matched request:
#   - a typed AiOperationMatch in request extensions
#   - metadata: ai_operation.application_protocol,
#     ai_operation.operation_id
#   - filter results: application_protocol, operation_id
#   - headers: x-praxis-ai-application-protocol, x-praxis-ai-operation
#
# The application protocol is provider-qualified (openai_responses,
# openai_conversations, openai_chat_completions, anthropic_messages, ...)
# so one identifier space can describe every AI application protocol the
# proxy classifies. This matches the cluster-side `application_protocol`
# vocabulary in praxis-proxy/praxis#1106.
#
# One filter serves every provider: each protocol declares its own
# operation registry and the classifier aggregates them. This example
# branches the OpenAI protocols and Anthropic Messages.
#
# Consuming the classification:
#   The classifier runs in the request-head phase, before any filter's
#   buffered body pre-read. Its headers are pending mutations applied when
#   the request is forwarded, so the `router` filter — which matches the
#   *downstream* request headers — does not see them in the request phase.
#   Branch on the published filter results
#   with `on_result`, which are visible immediately; downstream filters
#   read the typed match from request extensions; the backend reads the
#   forwarded headers.
#
# Anti-spoofing:
#   Both headers are proxy-owned. On a match they are overwritten with the
#   classifier's values; when nothing matches they are removed. A client
#   cannot supply either name and have it survive to the backend. Header
#   targets carrying authentication or framing state (authorization, host,
#   content-length, ...) are rejected at config load, as is pointing both
#   outputs at one header name.
#
# Unmatched requests are forwarded to the default backend unchanged apart
# from the stripped headers. Whether they are rejected, sent to a fallback
# backend, or handled some other way is a routing policy decision this
# filter does not make.
#
# Example requests:
#
#   # classified openai_responses/createResponse -> responses backend
#   curl -X POST http://localhost:8080/v1/responses \
#     -H "Content-Type: application/json" \
#     -d '{"model":"gpt-4.1","input":"Hello"}'
#
#   # classified openai_responses/Getinputtokencounts — a static endpoint,
#   # not a response whose ID is "input_tokens"
#   curl -X POST http://localhost:8080/v1/responses/input_tokens \
#     -H "Content-Type: application/json" -d '{"model":"gpt-4.1"}'
#
#   # classified openai_conversations/getConversation -> conversations backend
#   curl http://localhost:8080/v1/conversations/conv_123
#
#   # classified openai_chat_completions/createChatCompletion -> chat backend
#   curl -X POST http://localhost:8080/v1/chat/completions \
#     -H "Content-Type: application/json" \
#     -d '{"model":"gpt-4.1","messages":[{"role":"user","content":"Hi"}]}'
#
#   # classified openai_chat_completions/listChatCompletions -> chat backend
#   curl http://localhost:8080/v1/chat/completions
#
#   # classified openai_files/createFile -> files backend. The upload is
#   # multipart, so only head classification can recognize it.
#   curl -X POST http://localhost:8080/v1/files \\
#     -F purpose=assistants -F file=@./example.txt
#
#   # classified openai_vector_stores/getVectorStoreFile -> vector stores
#   # backend. {file_id} here names a vector-store file, not a Files upload.
#   curl http://localhost:8080/v1/vector_stores/vs_1/files/file_1
#
#   # unknown subresource: no operation matches, so it falls through rather
#   # than entering the vector stores route a path prefix would have matched
#   curl http://localhost:8080/v1/vector_stores/vs_1/unknown
#
#   # unsupported method: no operation matches, headers stripped,
#   # falls through to the default backend
#   curl -X PUT http://localhost:8080/v1/responses
#
#   # classified anthropic_messages/messages_post -> anthropic backend
#   curl -X POST http://localhost:8080/v1/messages \
#     -H "Content-Type: application/json" \
#     -d '{"model":"claude-opus-4-8","max_tokens":16,"messages":[]}'
#
#   # spoofing attempt: the supplied header is replaced, not honored
#   curl -X POST http://localhost:8080/v1/responses \
#     -H "x-praxis-ai-application-protocol: anthropic_messages" \
#     -H "Content-Type: application/json" \
#     -d '{"model":"gpt-4.1","input":"Hello"}'
#
# Build:

listeners:
  - name: ai-gateway
    address: "127.0.0.1:8080"
    filter_chains: [operation-pipeline]

filter_chains:
  - name: operation-pipeline
    filters:
      # Classify from the request head. Header names are configurable;
      # set either to null to publish metadata and results without a
      # header.
      - filter: ai_operation
        headers:
          application_protocol: x-praxis-ai-application-protocol
          operation: x-praxis-ai-operation
        # Branch on the published filter result, not on a path prefix.
        # Conversations and Chat Completions each have their own backend;
        # every other classified or unclassified request falls through.
        branch_chains:
          - name: conversations
            on_result:
              filter: ai_operation
              key: application_protocol
              result: openai_conversations
            chains:
              - name: conversations-chain
                filters:
                  - filter: router
                    routes:
                      - path_prefix: "/"
                        cluster: "conversations-backend"
            rejoin: shared_load_balancer
          - name: chat-completions
            on_result:
              filter: ai_operation
              key: application_protocol
              result: openai_chat_completions
            chains:
              - name: chat-chain
                filters:
                  - filter: router
                    routes:
                      - path_prefix: "/"
                        cluster: "chat-backend"
            rejoin: shared_load_balancer
          - name: files
            on_result:
              filter: ai_operation
              key: application_protocol
              result: openai_files
            chains:
              - name: files-chain
                filters:
                  - filter: router
                    routes:
                      - path_prefix: "/"
                        cluster: "files-backend"
            rejoin: shared_load_balancer
          - name: vector-stores
            on_result:
              filter: ai_operation
              key: application_protocol
              result: openai_vector_stores
            chains:
              - name: vector-stores-chain
                filters:
                  - filter: router
                    routes:
                      - path_prefix: "/"
                        cluster: "vector-stores-backend"
            rejoin: shared_load_balancer
          - name: anthropic-messages
            on_result:
              filter: ai_operation
              key: application_protocol
              result: anthropic_messages
            chains:
              - name: anthropic-chain
                filters:
                  - filter: router
                    routes:
                      - path_prefix: "/"
                        cluster: "anthropic-backend"
            rejoin: shared_load_balancer

      # Default: anything that did not branch, classified or not.
      - filter: router
        routes:
          - path_prefix: "/"
            cluster: "responses-backend"

      - name: shared_load_balancer
        filter: load_balancer
        clusters:
          - name: "responses-backend"
            endpoints:
              - "127.0.0.1:3001"
          - name: "conversations-backend"
            endpoints:
              - "127.0.0.1:3002"
          - name: "chat-backend"
            endpoints:
              - "127.0.0.1:3003"
          - name: "files-backend"
            endpoints:
              - "127.0.0.1:3004"
          - name: "vector-stores-backend"
            endpoints:
              - "127.0.0.1:3005"
          - name: "anthropic-backend"
            endpoints:
              - "127.0.0.1:3006"

insecure_options:
  allow_private_endpoints: true # example proxies to local backends