diff --git a/Makefile b/Makefile index ab5a98719..3be12bc97 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 94f81f545..11e6a959b 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 e52ced22a..6a7dc53de 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 000000000..f4db871c7 --- /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 16db3cba4..318bc977d 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 8b38bcca4..c4315fcb6 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 000000000..f41e3b9f8 --- /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")) + } +}