Skip to content

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

Draft
cdoern wants to merge 1 commit into
praxis-proxy:mainfrom
cdoern:feat/openai-operation-classifier
Draft

feat(openai): add a request-head operation classifier#890
cdoern wants to merge 1 commit 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
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(openai): add a request-head operation classifier

1 participant