From e209fa8fd982cf5bdd6864324ea49c15bc4316af Mon Sep 17 00:00:00 2001 From: Charlie Doern Date: Wed, 19 Aug 2026 15:13:13 -0400 Subject: [PATCH] feat(responses): register operations for request-head classification Responses operation identity came from path string matching in the request classifier, with hand-written prefix and suffix checks per endpoint. Declare the supported operations in a family-owned registry instead, so identity comes from method, path, and protocol headers before any payload is read. Register the eight supported operations against the shared operation registry: create, retrieve, delete, cancel, list input items, count input tokens, compact, and the WebSocket handshake. Operation IDs are the official ones from the pinned specification, reproduced verbatim including upstream's casing for `Getinputtokencounts` and `Compactconversation`. Praxis proxies the Responses contract rather than owning it, so every operation declares its runtime request-body shape without an owned OpenAPI contract. This is the first registry to rely on request-body shape being independent of contract ownership. The WebSocket handshake shares its method and path with no HTTP operation the pinned specification represents, so it is declared as an explicit Praxis protocol extension and separated from `POST /v1/responses` by transport. Drift checks skip declared extensions and fail if one later appears in the specification. Add `cargo xtask check-responses-registry`, wired into `make lint`, comparing registered method, path, and operation ID against the pinned specification. The check projects the specification through the existing semantic YAML reader, because the pinned document contains an integer that serde_yaml cannot parse. The registry lives in a subdirectory because non-anchor files in a filter category root are parsed as shared types by the filter doc generator, which would otherwise leak registry type documentation into unrelated filter docs. Closes #743 Signed-off-by: Charlie Doern --- Makefile | 1 + apis/src/openai/mod.rs | 10 +- apis/src/openai/responses/mod.rs | 6 + apis/src/openai/responses/routes/mod.rs | 362 ++++++++++++++++++ xtask/src/main.rs | 5 + xtask/src/openai_conformance.rs | 13 + .../openai_conformance/responses_registry.rs | 95 +++++ 7 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 apis/src/openai/responses/routes/mod.rs create mode 100644 xtask/src/openai_conformance/responses_registry.rs diff --git a/Makefile b/Makefile index ab5a987197..3be12bc975 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ lint: cargo xtask sync-inference-readme cargo xtask sync-responses-readme cargo xtask check-inference + cargo xtask check-responses-registry fmt: cargo +nightly fmt --all diff --git a/apis/src/openai/mod.rs b/apis/src/openai/mod.rs index 94f81f5450..11e6a959b6 100644 --- a/apis/src/openai/mod.rs +++ b/apis/src/openai/mod.rs @@ -30,6 +30,12 @@ pub use operation::{OpenAiHandlingMode, OpenAiOperationSpec, OpenAiRequestBody}; pub use responses::{ AgenticLoopFilter, CompactFilter, DocExtractFilter, FileResolveFilter, FileSearchCalloutFilter, McpDispatchFilter, McpToolResolveFilter, ModelRewriteFilter, OpenaiResponsesValidateFilter, RehydrateFilter, ResponseStoreFilter, - ResponsesFormatFilter, ToolParseFilter, WebSearchFilter, openai_responses_proxy::ResponsesProxyFilter, - responses_to_chat_completions::ResponsesToChatCompletionsFilter, stream_events::OpenaiStreamEventsFilter, + ResponsesFormatFilter, ToolParseFilter, WebSearchFilter, + openai_responses_proxy::ResponsesProxyFilter, + responses_to_chat_completions::ResponsesToChatCompletionsFilter, + routes::{ + PROTOCOL_EXTENSION_OPERATION_IDS as RESPONSES_PROTOCOL_EXTENSION_OPERATION_IDS, ResponsesOperation, + ResponsesOperationSpec, operation_specs as responses_operation_specs, + }, + stream_events::OpenaiStreamEventsFilter, }; diff --git a/apis/src/openai/responses/mod.rs b/apis/src/openai/responses/mod.rs index e52ced22a1..6a7dc53dec 100644 --- a/apis/src/openai/responses/mod.rs +++ b/apis/src/openai/responses/mod.rs @@ -37,6 +37,12 @@ pub(crate) mod openai_mcp_tool_resolve; pub(crate) mod openai_responses_proxy; pub(crate) mod openai_tool_parse; pub(crate) mod responses_to_chat_completions; +#[expect(clippy::allow_attributes, reason = "dead_code expect unfulfilled on module")] +#[allow( + dead_code, + reason = "the Responses operation registry is consumed by the openai_operation classifier" +)] +pub(crate) mod routes; pub(crate) mod state; pub(crate) mod store; pub(crate) mod stream_events; diff --git a/apis/src/openai/responses/routes/mod.rs b/apis/src/openai/responses/routes/mod.rs new file mode 100644 index 0000000000..f4db871c72 --- /dev/null +++ b/apis/src/openai/responses/routes/mod.rs @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Responses operation registry. +//! +//! Operation identity comes from the request head — method, path, and protocol +//! headers — rather than from body heuristics, so a request is recognized before +//! any payload is read. +//! +//! Praxis proxies the Responses contract rather than owning it, so these +//! operations declare their runtime request-body shape without an +//! `OwnedOperationContract`. Operation IDs are the official ones from the pinned +//! OpenAI specification, reproduced verbatim including upstream's casing. + +use std::ops::Deref; + +use crate::openai::operation::{ + OpenAiApiFamily, OpenAiHandlingMode, OpenAiHttpMethod, OpenAiOperationSpec, OpenAiRequestBody, OpenAiTransport, + OperationEntry, RouteParams, match_operation, +}; + +/// Static metadata for one Responses operation. +#[derive(Clone, Copy)] +pub struct ResponsesOperationSpec { + /// Runtime operation. + pub operation: ResponsesOperation, + /// Shared operation metadata. + pub definition: OpenAiOperationSpec, +} + +impl Deref for ResponsesOperationSpec { + type Target = OpenAiOperationSpec; + + fn deref(&self) -> &Self::Target { + &self.definition + } +} + +impl OperationEntry for ResponsesOperationSpec { + fn spec(&self) -> &OpenAiOperationSpec { + &self.definition + } +} + +/// Convert a registry body declaration into a runtime request-body shape. +#[expect( + unused_macro_rules, + reason = "optional-body form is part of the registry API but no Responses operation uses it yet" +)] +macro_rules! request_body_shape { + ([none]) => { + OpenAiRequestBody::None + }; + ([required json]) => { + OpenAiRequestBody::Json { required: true } + }; + ([optional json]) => { + OpenAiRequestBody::Json { required: false } + }; +} + +/// Declare each Responses operation once and derive its runtime metadata. +macro_rules! responses_operations { + ( + $( + $operation:ident { + operation_id: $operation_id:literal, + method: $method:ident, + transport: $transport:ident, + path: $path:literal, + mode: $mode:ident, + body: $body:tt $(,)? + } + ),+ $(,)? + ) => { + /// One Responses operation recognized from the request head. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum ResponsesOperation { + $( + #[doc = concat!(stringify!($method), " /v1", $path)] + $operation, + )+ + } + + /// All Responses operations recognized by Praxis. + pub const OPERATION_SPECS: &[ResponsesOperationSpec] = &[ + $( + ResponsesOperationSpec { + operation: ResponsesOperation::$operation, + definition: OpenAiOperationSpec { + family: OpenAiApiFamily::Responses, + operation_id: $operation_id, + method: OpenAiHttpMethod::$method, + transport: OpenAiTransport::$transport, + spec_path: $path, + runtime_path: concat!("/v1", $path), + mode: OpenAiHandlingMode::$mode, + request_body: request_body_shape!($body), + owned_contract: None, + }, + }, + )+ + ]; + }; +} + +responses_operations! { + CreateResponse { + operation_id: "createResponse", + method: Post, + transport: Http, + path: "/responses", + mode: Inspect, + body: [required json], + }, + CreateResponseWebSocket { + operation_id: "praxis_createResponseWebSocket", + method: Get, + transport: WebSocket, + path: "/responses", + mode: Passthrough, + body: [none], + }, + GetResponse { + operation_id: "getResponse", + method: Get, + transport: Http, + path: "/responses/{response_id}", + mode: Passthrough, + body: [none], + }, + DeleteResponse { + operation_id: "deleteResponse", + method: Delete, + transport: Http, + path: "/responses/{response_id}", + mode: Passthrough, + body: [none], + }, + CancelResponse { + operation_id: "cancelResponse", + method: Post, + transport: Http, + path: "/responses/{response_id}/cancel", + mode: Passthrough, + body: [none], + }, + ListInputItems { + operation_id: "listInputItems", + method: Get, + transport: Http, + path: "/responses/{response_id}/input_items", + mode: Passthrough, + body: [none], + }, + CountInputTokens { + operation_id: "Getinputtokencounts", + method: Post, + transport: Http, + path: "/responses/input_tokens", + mode: Passthrough, + body: [required json], + }, + CompactConversation { + operation_id: "Compactconversation", + method: Post, + transport: Http, + path: "/responses/compact", + mode: Passthrough, + body: [required json], + }, +} + +/// Operation IDs Praxis defines itself because the pinned specification does +/// not represent them as HTTP operations. +/// +/// The Responses `WebSocket` handshake shares its method and path with an HTTP +/// operation and is separated by transport, so it carries a Praxis-owned ID +/// rather than an official one. Drift checks skip these. +pub const PROTOCOL_EXTENSION_OPERATION_IDS: &[&str] = &["praxis_createResponseWebSocket"]; + +/// One matched Responses route. +#[derive(Clone, Copy)] +pub(crate) struct MatchedResponsesRoute<'a> { + /// Matched operation metadata. + pub spec: &'static ResponsesOperationSpec, + /// Borrowed path parameters, captured by the shared matcher. + params: RouteParams<'a>, +} + +impl<'a> MatchedResponsesRoute<'a> { + /// Return the borrowed response ID path segment. + pub(crate) fn response_id(&self) -> Option<&'a str> { + self.params.get("response_id") + } +} + +/// Return all Responses operation specs. +#[must_use] +pub const fn operation_specs() -> &'static [ResponsesOperationSpec] { + OPERATION_SPECS +} + +/// Match a request head to a Responses operation. +/// +/// Matching rules, precedence, and path normalization live in the shared +/// operation module. Transport separates `POST /v1/responses` from the +/// `WebSocket` handshake at the same path. +pub(crate) fn match_route<'a>( + method: &str, + path: &'a str, + transport: OpenAiTransport, +) -> Option> { + match_operation(OPERATION_SPECS, method, path, transport).map(|matched| MatchedResponsesRoute { + spec: matched.spec, + params: matched.params, + }) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, reason = "tests")] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn registry_keys_and_operation_ids_are_unique() { + let keys = OPERATION_SPECS + .iter() + .map(|spec| (spec.method, spec.transport.as_str(), spec.spec_path)) + .collect::>(); + assert_eq!(keys.len(), OPERATION_SPECS.len(), "duplicate method/transport/path key"); + + let ids = OPERATION_SPECS + .iter() + .map(|spec| spec.operation_id) + .collect::>(); + assert_eq!(ids.len(), OPERATION_SPECS.len(), "duplicate operation ID"); + } + + #[test] + fn every_registered_operation_resolves_from_its_own_template() { + for spec in OPERATION_SPECS { + let path = spec.runtime_path.replace("{response_id}", "resp_test"); + let matched = match_route(spec.method.as_str(), &path, spec.transport).unwrap(); + assert_eq!( + matched.spec.operation, + spec.operation, + "{} {path} resolved to the wrong operation", + spec.method.as_str() + ); + } + } + + #[test] + fn static_endpoints_are_not_consumed_as_response_ids() { + for (path, expected) in [ + ("/v1/responses/input_tokens", ResponsesOperation::CountInputTokens), + ("/v1/responses/compact", ResponsesOperation::CompactConversation), + ] { + let matched = match_route("POST", path, OpenAiTransport::Http).unwrap(); + assert_eq!(matched.spec.operation, expected, "{path}"); + assert_eq!(matched.response_id(), None, "{path} must not capture a response ID"); + } + } + + #[test] + fn identifier_paths_still_capture_the_response_id() { + let matched = match_route("GET", "/v1/responses/resp_abc123", OpenAiTransport::Http).unwrap(); + assert_eq!(matched.spec.operation, ResponsesOperation::GetResponse); + assert_eq!(matched.response_id(), Some("resp_abc123")); + + let matched = match_route("POST", "/v1/responses/resp_abc123/cancel", OpenAiTransport::Http).unwrap(); + assert_eq!(matched.spec.operation, ResponsesOperation::CancelResponse); + assert_eq!(matched.response_id(), Some("resp_abc123")); + + let matched = match_route("GET", "/v1/responses/resp_abc123/input_items", OpenAiTransport::Http).unwrap(); + assert_eq!(matched.spec.operation, ResponsesOperation::ListInputItems); + assert_eq!(matched.response_id(), Some("resp_abc123")); + } + + #[test] + fn create_and_websocket_are_separated_without_reading_a_body() { + let create = match_route("POST", "/v1/responses", OpenAiTransport::Http).unwrap(); + assert_eq!(create.spec.operation, ResponsesOperation::CreateResponse); + + let socket = match_route("GET", "/v1/responses", OpenAiTransport::WebSocket).unwrap(); + assert_eq!(socket.spec.operation, ResponsesOperation::CreateResponseWebSocket); + + assert!( + match_route("GET", "/v1/responses", OpenAiTransport::Http).is_none(), + "a plain GET on the collection is not a registered operation" + ); + assert!( + match_route("POST", "/v1/responses", OpenAiTransport::WebSocket).is_none(), + "create is not reachable over a websocket handshake" + ); + } + + #[test] + fn unsupported_methods_and_paths_do_not_match() { + for (method, path) in [ + ("PUT", "/v1/responses"), + ("PATCH", "/v1/responses/resp_abc"), + ("DELETE", "/v1/responses"), + ("GET", "/v1/responses/resp_abc/cancel"), + ("GET", "/v1/responses/resp_abc/other"), + ("POST", "/v1/responses/resp_abc/input_items"), + ] { + assert!( + match_route(method, path, OpenAiTransport::Http).is_none(), + "{method} {path} must not match a Responses operation" + ); + } + } + + #[test] + fn body_bearing_and_bodyless_operations_report_their_shape() { + for spec in OPERATION_SPECS { + let expects_body = matches!( + spec.operation, + ResponsesOperation::CreateResponse + | ResponsesOperation::CountInputTokens + | ResponsesOperation::CompactConversation + ); + assert_eq!( + spec.has_request_body(), + expects_body, + "{:?} reported the wrong body shape", + spec.operation + ); + } + } + + #[test] + fn only_the_websocket_operation_is_a_praxis_extension() { + let extensions = OPERATION_SPECS + .iter() + .filter(|spec| PROTOCOL_EXTENSION_OPERATION_IDS.contains(&spec.operation_id)) + .map(|spec| spec.operation) + .collect::>(); + assert_eq!(extensions, vec![ResponsesOperation::CreateResponseWebSocket]); + + assert!( + OPERATION_SPECS + .iter() + .filter(|spec| spec.transport == OpenAiTransport::WebSocket) + .all(|spec| PROTOCOL_EXTENSION_OPERATION_IDS.contains(&spec.operation_id)), + "every websocket operation must be declared as a Praxis protocol extension" + ); + } + + #[test] + fn registry_declares_no_owned_contract() { + assert!( + OPERATION_SPECS.iter().all(|spec| spec.owned_contract().is_none()), + "Praxis proxies the Responses contract rather than owning it" + ); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 16db3cba44..318bc977dd 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -51,6 +51,10 @@ enum Command { /// Validate inference fixture coverage. CheckInference(inference_fixtures::CheckArgs), + /// Check the runtime Responses operation registry against + /// the pinned OpenAI specification. + CheckResponsesRegistry, + /// Start a quick HTTP test server returning a static /// response to every request. Echo(echo::Args), @@ -118,6 +122,7 @@ fn main() { let cli = Cli::parse(); match cli.command { Command::CheckInference(args) => inference_fixtures::run_check(&args), + Command::CheckResponsesRegistry => openai_conformance::run_responses_registry_check(), Command::Echo(args) => echo::run(args), Command::Debug(args) => debug::run(&args), Command::LintDeps(args) => lint_deps::run(args), diff --git a/xtask/src/openai_conformance.rs b/xtask/src/openai_conformance.rs index 8b38bcca43..c4315fcb6f 100644 --- a/xtask/src/openai_conformance.rs +++ b/xtask/src/openai_conformance.rs @@ -21,6 +21,8 @@ mod oasdiff; mod print; /// Complete reference verification and semantic area projection. mod reference; +/// Responses registry drift check against the pinned specification. +mod responses_registry; /// Semantic YAML tree used for full-spec projection. mod semantic_yaml; /// `OpenAPI` spec loading and operation extraction. @@ -111,6 +113,17 @@ pub(crate) struct Args { // Entry Point // ----------------------------------------------------------------------------- +/// Run the Responses registry drift check and report the outcome. +pub(crate) fn run_responses_registry_check() { + match responses_registry::check() { + Ok(summary) => println!("{summary}"), + Err(failures) => { + eprintln!("{failures}"); + std::process::exit(1); + }, + } +} + /// Run OpenAI API conformance coverage calculation. #[expect(clippy::too_many_lines, reason = "CLI orchestration and threshold handling")] pub(crate) fn run(args: &Args) { diff --git a/xtask/src/openai_conformance/responses_registry.rs b/xtask/src/openai_conformance/responses_registry.rs new file mode 100644 index 0000000000..f41e3b9f8c --- /dev/null +++ b/xtask/src/openai_conformance/responses_registry.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Drift check between the runtime Responses registry and the pinned spec. +//! +//! The registry is the runtime source of truth for Responses operation +//! identity. This check fails when a registered operation's method, path, or +//! operation ID no longer agrees with the pinned OpenAI specification, and when +//! an operation declared as a Praxis protocol extension turns out to exist in +//! the specification after all. + +use super::{ + area::{OPENAI_REFERENCE_MANIFEST, OPENAI_REFERENCE_SPEC}, + model::OperationScope, + spec::{load_reference_source, project_reference}, +}; + +/// Responses operations selected from the pinned specification. +const RESPONSES_SCOPE: OperationScope = OperationScope::new("responses", "Responses", &["/responses"]); + +/// Outcome of comparing one registered operation with the pinned specification. +enum Comparison { + /// Operation agrees with the specification, or is an expected extension. + Agrees, + /// Operation disagrees; carries the human-readable reason. + Drifted(String), +} + +/// Compare one registered operation with the pinned specification. +fn compare( + spec: &praxis_ai_apis::openai::ResponsesOperationSpec, + found: Option<&str>, + is_extension: bool, +) -> Comparison { + let method = spec.method.as_str(); + if is_extension { + return if found == Some(spec.operation_id) { + Comparison::Drifted(format!( + "{method} {} is declared a Praxis protocol extension but the pinned specification defines it", + spec.spec_path + )) + } else { + Comparison::Agrees + }; + } + + match found { + Some(operation_id) if operation_id == spec.operation_id => Comparison::Agrees, + Some(operation_id) => Comparison::Drifted(format!( + "{method} {} registers operation ID {} but the pinned specification says {operation_id}", + spec.spec_path, spec.operation_id + )), + None => Comparison::Drifted(format!( + "{method} {} is registered but absent from the pinned specification", + spec.spec_path + )), + } +} + +/// Compare the runtime Responses registry against the pinned specification. +pub(super) fn check() -> Result { + let reference = load_reference_source(OPENAI_REFERENCE_SPEC, Some(OPENAI_REFERENCE_MANIFEST))?; + let operations = project_reference(&reference, RESPONSES_SCOPE)?.operations; + + let mut checked = 0_usize; + let mut extensions = 0_usize; + let mut failures = Vec::new(); + + for spec in praxis_ai_apis::openai::responses_operation_specs() { + let is_extension = + praxis_ai_apis::openai::RESPONSES_PROTOCOL_EXTENSION_OPERATION_IDS.contains(&spec.operation_id); + if is_extension { + extensions += 1; + } else { + checked += 1; + } + + let found = operations + .iter() + .find(|candidate| candidate.key.method == spec.method.as_str() && candidate.key.path == spec.spec_path) + .and_then(|candidate| candidate.operation_id.as_deref()); + + if let Comparison::Drifted(reason) = compare(spec, found, is_extension) { + failures.push(reason); + } + } + + if failures.is_empty() { + Ok(format!( + "responses registry matches the pinned specification: {checked} operations checked, {extensions} protocol extensions" + )) + } else { + Err(failures.join("\n")) + } +}