Skip to content

feat(openai): add a request-head operation classifier - #890

Open
cdoern wants to merge 4 commits into
praxis-proxy:mainfrom
cdoern:feat/openai-operation-classifier
Open

feat(openai): add a request-head operation classifier#890
cdoern wants to merge 4 commits into
praxis-proxy:mainfrom
cdoern:feat/openai-operation-classifier

Conversation

@cdoern

@cdoern cdoern commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #744

Stacked on #788

The first commit, e209fa8f, is the #743 Responses registry under review in #788.
It is included because the classifier consults that registry. Merge #788 first;
this branch then contains only its own commit. Review just 35951360 here.

Summary

Operation identity was derived per filter from path string matching, so every
consumer re-implemented the same method and path checks and no single component
owned the answer.

openai_operation classifies supported operations from the request head alone
and publishes the result four ways:

Output Consumer
typed OpenAiOperationMatch in request extensions downstream filters (#742, #741)
openai_operation.family, openai_operation.operation_id metadata logging, tracing
filter results family, operation_id on_result branch conditions
x-praxis-ai-family, x-praxis-ai-operation headers see the note below

The filter takes the default BodyAccess::None and BodyMode::Stream, so it
declares no body access and no buffering — the classification is available
before any body-handling decision is made.

Transport comes from the opening handshake headers (RFC 6455 §4.1, with
Connection treated as a token list per RFC 9110 §7.6.1). That is what separates
the Responses WebSocket operation from POST /v1/responses at the same path
without inspecting a payload.

Note on consuming the classification

Two findings from wiring this up, both worth knowing before #745 starts.

x-praxis-ai-* is a reserved prefix. The protocol layer rejects a client
request carrying one with a 400, and strips them before forwarding upstream. So
the "client-supplied headers cannot spoof the classifier" criterion is already
enforced at the ingress boundary, ahead of any filter. This filter's
overwrite-on-match and remove-on-miss remain as defense in depth.

Header-phase mutations are not visible to router. The router matches the
downstream request headers, while a header-phase filter's changes are pending
until the request is forwarded. openai_responses_format only routes on
x-praxis-ai-format because it runs in the pre-read body phase, which lands
before the header-phase pipeline. A head-only classifier has no equivalent.

Consequently the published headers have no in-pipeline consumer today. The
working mechanisms are request extensions for downstream filters and filter
results for branch chains. The header code is kept because it is correct, cheap,
and becomes useful with a non-reserved header name or a future consumer — but
#745's plan to route on these headers needs revisiting before it is picked up.

Testing

  • cargo test --workspace — all suites pass, including 2594 in praxis-ai-apis
    and 479 integration tests
  • make lint — passes end to end
  • make doc — clean

15 unit tests cover the published outputs: both families classified, WebSocket
separated from create by transport, static endpoints not consumed as
identifiers, unsupported methods publishing nothing, configurable and disabled
header names, invalid header names rejected at startup, and the
Connection/Upgrade token-list edge cases.

6 functional tests cover the example config's observable boundary behaviour:
classified and unclassified traffic forwarded unharmed, the classifier's own
headers never crossing to upstream, and client-supplied reserved headers
rejected at ingress on both classified and unclassified paths.

Not in scope

Switching the existing classifier and Responses filters to consume this match is
#742 and #741. Registry-driven routing is #745.

Operation identity was derived per filter from path string matching, so every
consumer re-implemented the same method and path checks and no single component
owned the answer.

Add `openai_operation`, which classifies supported operations from the request
head alone and publishes the result four ways: a typed `OpenAiOperationMatch` in
request extensions, `openai_operation.family` and `openai_operation.operation_id`
metadata, matching filter results, and configurable proxy-owned routing headers.

The filter takes the default `BodyAccess::None` and `BodyMode::Stream`, so it
declares no body access and no buffering. Transport is read from the opening
handshake headers, which is what separates the Responses WebSocket operation
from `POST /v1/responses` at the same path without inspecting a payload.

Routing headers are overwritten on a match and removed when nothing matches, so
a client-supplied value cannot survive. Unmatched requests are otherwise left
unchanged; whether they are rejected, forwarded to a fallback, or handled some
other way stays a routing policy decision.

Note on consuming the classification: `x-praxis-ai-*` is a reserved prefix, so
the protocol layer rejects client requests carrying one and strips them before
forwarding. Header-phase mutations are also still pending while the `router`
filter matches the downstream request, so routing on these headers is not
available in the same phase. Downstream filters read the typed match from
request extensions, and pipelines branch on the published filter results.
Registry-driven routing is tracked separately.

Conversations exposes its route module to the crate so the classifier can
consult both registries through one entry point.

Closes praxis-proxy#744

Signed-off-by: Charlie Doern <cdoern@redhat.com>
@cdoern
cdoern force-pushed the feat/openai-operation-classifier branch from d8dcf7c to f4331dc Compare September 4, 2026 14:41
@cdoern
cdoern marked this pull request as ready for review September 8, 2026 18:09
@cdoern
cdoern requested review from a team and aslakknutsen September 8, 2026 18:09

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praxis-bot review: feat(openai): add a request-head operation classifier

Clean, well-structured filter with strong security properties and thorough test coverage. The classification logic is correct, the anti-spoofing headers are properly handled (overwrite on match, remove on miss), and the WebSocket transport detection follows the RFC precisely. 15 unit tests and 6 functional integration tests cover the published contract end to end.

One medium finding on the generated filter documentation.

Severity Findings
Critical 0
Large 0
Medium 1

Comment thread docs/filters/openai_operation.md Outdated
Addresses review feedback on praxis-proxy#890.

`generate-filter-docs` takes the first `pub` type it encounters in the module,
and `OpenAiOperationMatch` was declared before `OpenaiOperationFilter`. The
generated page therefore described the match type rather than the filter, and
its configuration notes carried implementation details about `'static` fields
and borrowed path parameters that are irrelevant to configuring the filter.
The reference table inherited the same description.

Declare the filter and its inherent impl before the match type it publishes, so
the generator reads the intended doc comment. The generated page and reference
entry are regenerated.

Signed-off-by: Charlie Doern <cdoern@redhat.com>
@cdoern
cdoern requested a review from a team as a code owner September 9, 2026 18:29
@leseb leseb self-assigned this Sep 10, 2026

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Unsupported upgrade headers suppress HTTP classification. mod.rs:118 selects WebSocket transport without requiring GET. Adding upgrade headers to a normal operation such as POST /v1/responses leaves it unclassified. This is currently latent because WebSocket and classifier-based policy routing are not supported yet, but should be fixed before consumers land.

P2 — Unsafe header targets are accepted. config.rs:87 permits targets such as authorization, host, and content-length, potentially overwriting or removing authentication and transport state. Use the repository’s shared promotion-header validation.

P2 — Duplicate output headers are accepted. The same validation permits family and operation to use the same header name. The operation value then overwrites the family value. Reject duplicates case-insensitively.

P2 — Functional tests do not observe classification. operation_classifier.rs:47 tests forwarding and core reserved-header handling, but never consumes the classifier result. The suite would pass with a no-op classifier. Route distinct operations through on_result to distinct backends and assert the selected backend.

pub(crate) struct OperationHeaders {
/// Header name for the API family. `null` disables the header.
#[serde(default = "default_family_header")]
pub family: Option<String>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use application_protocol instead so this matches praxis-proxy/praxis#1106 - sorry the change, just realized now this would be better

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ai): add a request-head operation classifier

3 participants