diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index 23e54e825..ad9229127 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -21,7 +21,7 @@ gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } gateway-protocol = { path = "../gateway-protocol" } serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" +serde_json = { version = "1.0.150", features = ["preserve_order"] } thiserror = "2.0.18" tokio = { version = "1.52.3", default-features = false, features = ["macros", "time"] } tower-http = { version = "0.6.11", features = ["cors", "fs", "request-id", "timeout", "trace"] } diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index 6105113a3..48331fb34 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -1,6 +1,5 @@ //! OpenAI Responses adapter 的稳定错误 contract。 -use gateway_core::event::EventSequenceError; use serde::Serialize; use serde_json::Value; use thiserror::Error; @@ -147,57 +146,18 @@ impl RequestDecodeError { } } -/// Canonical event 到 OpenAI Responses 的编码错误。 +/// OpenAI Responses 原生 wire 转发错误。 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ResponseEncodeError { - /// Core canonical 顺序错误。 - #[error("canonical event sequence is invalid: {0}")] - Sequence(#[from] EventSequenceError), - /// Core 新增了当前 adapter 尚未声明的事件语义。 - #[error("canonical event is not supported by this OpenAI Responses adapter")] - UnsupportedEvent, - /// `Started` 与 `Completed` 元数据不一致。 - #[error("response metadata changed during canonical stream")] - MetadataChanged, - /// 同一 stream 重复报告 usage。 - #[error("canonical stream contains more than one Usage event")] - DuplicateUsage, - /// 当前 OpenAI Responses adapter 无法表达该 canonical 内容类别。 - #[error("canonical content kind `{kind}` is not supported by OpenAI Responses")] - UnsupportedContentKind { - /// 安全类别名。 - kind: &'static str, - }, - /// Usage 缺少 OpenAI Responses 所需的基础 token 事实。 - #[error("canonical Usage is missing required token totals")] - IncompleteUsage, - /// Tool call 在结束时仍缺少名称。 - #[error("tool call at content index {index} has no name")] - MissingToolName { - /// Canonical content index。 - index: u32, - }, - /// Tool call ID 或名称在增量中发生变化。 - #[error("tool call identity changed at content index {index}")] - ToolIdentityChanged { - /// Canonical content index。 - index: u32, - }, - /// Collector 已终结。 - #[error("responses collector has already completed")] - AlreadyCompleted, - /// Adapter 自身生成的 event framing 无法还原为 WebSocket event JSON。 - #[error("responses event encoder produced an invalid internal framing")] - InvalidEventEncoding, - /// 同一响应混用了协议原生与 canonical 表达。 - #[error("responses stream changed its wire representation")] - MixedWireRepresentation, /// 协议原生流缺少可返回的终态 response object。 #[error("responses wire stream has no terminal response")] MissingWireTerminal, /// 协议原生事件携带了不一致的响应身份。 #[error("responses wire stream changed its response identity")] WireIdentityChanged, + /// 终态 response object 无法序列化为完整 HTTP JSON 响应。 + #[error("responses wire terminal serialization failed")] + Serialization, } impl ResponseEncodeError { @@ -208,7 +168,7 @@ impl ResponseEncodeError { error: ProtocolError { kind: "server_error", code: "invalid_canonical_response", - message: "The gateway could not encode the canonical response.".to_owned(), + message: "The gateway could not forward the upstream response.".to_owned(), param: None, }, } diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index 856b0ae44..48fa62719 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -28,7 +28,6 @@ use crate::openai::{ gateway_error_contract, gateway_error_from_engine, gateway_error_response, protocol_error_response, runtime_unavailable_response, }, - service::created_at_unix_seconds, }; use super::{ @@ -69,9 +68,9 @@ async fn handle_responses( Err(error) => return authentication_error_response(error), }; let decoded = match if review { - decode_review_request_with_headers(&body, &headers, client.policy().provider_kind()) + decode_review_request_with_headers(&body, &headers) } else { - decode_request_with_headers(&body, &headers, client.policy().provider_kind()) + decode_request_with_headers(&body, &headers) } { Ok(decoded) => decoded, Err(error) => { @@ -113,24 +112,13 @@ async fn handle_responses( Err(error) => return gateway_error_response(&error), }; let StartedExecution { - created_at, - stream, - session, - .. + stream, session, .. } = started; - let created_at = match created_at_unix_seconds(created_at) { - Ok(created_at) => created_at, - Err(error) => { - let mut execution = PendingExecution::new(session); - let response = gateway_error_response(&error); - return execution.record_response_status(response).await; - } - }; if stream { - stream_execution_response(session, created_at, connection_guard).await + stream_execution_response(session, connection_guard).await } else { drop(connection_guard); - collect_execution_response(session, created_at).await + collect_execution_response(session).await } } @@ -184,10 +172,7 @@ const fn is_private_or_loopback(address: IpAddr) -> bool { } /// 编码完整 canonical event 集合,并在完整 JSON 成功后提交下游。 -pub async fn collect_execution_response( - session: Box, - created_at: u64, -) -> Response { +pub async fn collect_execution_response(session: Box) -> Response { let mut execution = PendingExecution::new(session); let Some(session) = execution.session_mut() else { return internal_gateway_response("gateway response session is unavailable"); @@ -200,7 +185,7 @@ pub async fn collect_execution_response( } }; - let encoded = match encode_collected_events(created_at, &events) { + let encoded = match encode_collected_events(&events) { Ok(encoded) => encoded, Err(error) => { let response = @@ -242,16 +227,15 @@ impl BufferedResponseEncodeError { fn protocol_body(&self) -> ProtocolErrorBody { match self { Self::Canonical(error) => error.protocol_body(), - Self::Json => ResponseEncodeError::UnsupportedEvent.protocol_body(), + Self::Json => ResponseEncodeError::Serialization.protocol_body(), } } } fn encode_collected_events( - created_at: u64, events: &[ProviderEvent], ) -> Result, BufferedResponseEncodeError> { - let mut encoder = OpenAiResponsesEncoder::new(created_at); + let mut encoder = OpenAiResponsesEncoder::new(); for event in events { encoder .push_sse(event) @@ -293,7 +277,6 @@ fn apply_safe_response_headers( /// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。 pub async fn stream_execution_response( session: Box, - created_at: u64, connection_guard: Option>, ) -> Response { let mut execution = PendingExecution::new(session); @@ -324,7 +307,7 @@ pub async fn stream_execution_response( execution.cancel_and_finalize().await; return response; } - let mut encoder = OpenAiResponsesEncoder::new(created_at); + let mut encoder = OpenAiResponsesEncoder::new(); let mut frames = Vec::new(); for event in &first_events { match encoder.push_sse(event) { diff --git a/backend/crates/gateway-api/src/openai/responses/mod.rs b/backend/crates/gateway-api/src/openai/responses/mod.rs index 25d58eb2f..9b01abbbe 100644 --- a/backend/crates/gateway-api/src/openai/responses/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/mod.rs @@ -13,7 +13,7 @@ pub use request::{ ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata, decode_request_with_headers, }; -pub use response::{OpenAiResponsesEncoder, ResponsesCollector}; +pub use response::OpenAiResponsesEncoder; pub(crate) use websocket::responses_websocket; pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context}; diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 4309425a2..f69819601 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -3,12 +3,7 @@ use std::{fmt, net::IpAddr}; use axum::http::HeaderMap; -use gateway_core::operation::{ - CompactConversationRequest, ContentPart, ContinuationMode, Feature, GenerateRequest, - ImageSource, JsonSchemaFormat, Message, MessageRole, Operation, OutputFormat, ProtocolPayload, - ProviderSessionState, ResponsePersistence, ToolDefinition, -}; -use gateway_core::routing::ProviderKind; +use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState}; use gateway_protocol::openai::{ X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER, }; @@ -17,7 +12,6 @@ use serde_json::{Map, Value}; use super::error::RequestDecodeError; const OPENAI_PROTOCOL: &str = "openai"; -const XAI_PROVIDER: &str = "xai"; const REVIEW_SUBAGENT: &str = "review"; const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent"; @@ -297,35 +291,22 @@ impl fmt::Debug for DecodedResponsesRequest { pub fn decode_request_with_headers( body: &[u8], headers: &HeaderMap, - provider_kind: &ProviderKind, ) -> Result { - decode_request_inner( - body, - false, - &OpenAiRequestHeaders::from_headers(headers), - Some(provider_kind), - ) + decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers)) } /// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。 pub(super) fn decode_review_request_with_headers( body: &[u8], headers: &HeaderMap, - provider_kind: &ProviderKind, ) -> Result { - decode_request_inner( - body, - true, - &OpenAiRequestHeaders::from_headers(headers), - Some(provider_kind), - ) + decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers)) } pub(super) fn decode_request_inner( body: &[u8], review: bool, request_headers: &OpenAiRequestHeaders, - provider_kind: Option<&ProviderKind>, ) -> Result { let value = serde_json::from_slice::(body).map_err(|_| RequestDecodeError::MalformedJson)?; @@ -344,10 +325,6 @@ pub(super) fn decode_request_inner( }); } let model = model.to_owned(); - let compact_conversation = provider_kind - .is_some_and(|provider| provider.as_str() == XAI_PROVIDER) - && consume_compaction_trigger(&mut object); - let stream = object .get("stream") .and_then(Value::as_bool) @@ -366,85 +343,12 @@ pub(super) fn decode_request_inner( request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT)); let protocol_context = request_headers.protocol_context(use_websocket); - let messages = canonical_messages(&object); - let tools = canonical_function_tools(&object); - let tools_requested = object - .get("tools") - .and_then(Value::as_array) - .is_some_and(|tools| !tools.is_empty()); - let image_generation_requested = - object - .get("tools") - .and_then(Value::as_array) - .is_some_and(|tools| { - tools.iter().any(|tool| { - tool.get("type").and_then(Value::as_str) == Some("image_generation") - }) - }); - let vision_requested = contains_type(object.get("input"), "input_image"); - let reasoning_requested = object.get("reasoning").is_some_and(Value::is_object); - let output_format = canonical_output_format(&object); - let json_schema_requested = object - .get("text") - .and_then(|text| text.get("format")) - .and_then(|format| format.get("type")) - .and_then(Value::as_str) - == Some("json_schema"); - let max_output_tokens = object - .get("max_output_tokens") - .and_then(Value::as_u64) - .filter(|tokens| *tokens > 0); - let prompt_cache_key = object - .get("prompt_cache_key") - .and_then(Value::as_str) - .filter(|key| !key.trim().is_empty()) - .map(ToOwned::to_owned); let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object) .map_err(|_| RequestDecodeError::CanonicalContract { field: "request".to_owned(), })? .with_context(protocol_context); - let mut request = GenerateRequest::from_protocol_payload(messages, payload) - .with_image_generation_requested(image_generation_requested) - .with_response_persistence(if store { - ResponsePersistence::Store - } else { - ResponsePersistence::DoNotStore - }); - - if tools_requested { - request = request.require_feature(Feature::Tools); - } - if !tools.is_empty() { - request = request.with_tools(tools); - } - if vision_requested { - request = request.require_feature(Feature::Vision); - } - if reasoning_requested { - request = request.require_feature(Feature::Reasoning); - } - if json_schema_requested { - request = request.require_feature(Feature::JsonSchema); - } - if let Some(format) = output_format { - request = request.with_output_format(format); - } - if let Some(tokens) = max_output_tokens { - request = request.with_max_output_tokens(tokens); - } - if let Some(key) = prompt_cache_key { - request = request.with_prompt_cache_key(key); - } - if continuation.is_continuation() { - request = request.with_continuation(ContinuationMode::Native); - } - - let operation = if compact_conversation { - Operation::CompactConversation(CompactConversationRequest::new(request)) - } else { - Operation::Generate(request) - }; + let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); Ok(DecodedResponsesRequest { operation, metadata: ResponsesRequestMetadata { @@ -483,182 +387,6 @@ fn header_string(headers: &HeaderMap, name: &str) -> Option { .map(ToOwned::to_owned) } -fn canonical_messages(body: &Map) -> Vec { - let mut messages = Vec::new(); - if let Some(instructions) = body - .get("instructions") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - && let Some(message) = message( - MessageRole::Developer, - vec![ContentPart::Text(instructions.to_owned())], - ) - { - messages.push(message); - } - match body.get("input") { - Some(Value::String(input)) if !input.is_empty() => { - if let Some(message) = - message(MessageRole::User, vec![ContentPart::Text(input.clone())]) - { - messages.push(message); - } - } - Some(Value::Array(input)) => { - messages.extend(input.iter().filter_map(canonical_input_item)); - } - _ => {} - } - messages -} - -fn canonical_input_item(value: &Value) -> Option { - let object = value.as_object()?; - match object.get("type").and_then(Value::as_str) { - Some("function_call_output") => message( - MessageRole::User, - vec![ContentPart::ToolResult { - call_id: object.get("call_id")?.as_str()?.to_owned(), - output: object.get("output")?.as_str()?.to_owned(), - }], - ), - Some("function_call") => message( - MessageRole::Assistant, - vec![ContentPart::ToolCall { - call_id: object.get("call_id")?.as_str()?.to_owned(), - name: object.get("name")?.as_str()?.to_owned(), - arguments: object.get("arguments")?.as_str()?.to_owned(), - }], - ), - _ => { - let role = match object.get("role").and_then(Value::as_str)? { - "system" => MessageRole::System, - "developer" => MessageRole::Developer, - "user" => MessageRole::User, - "assistant" => MessageRole::Assistant, - _ => return None, - }; - let content = canonical_content(object.get("content")?, role); - message(role, content) - } - } -} - -fn canonical_content(value: &Value, role: MessageRole) -> Vec { - match value { - Value::String(text) if !text.is_empty() => vec![ContentPart::Text(text.clone())], - Value::Array(parts) => parts - .iter() - .filter_map(|part| canonical_content_part(part, role)) - .collect(), - _ => Vec::new(), - } -} - -fn canonical_content_part(value: &Value, role: MessageRole) -> Option { - let object = value.as_object()?; - match object.get("type").and_then(Value::as_str)? { - "input_text" | "output_text" => object - .get("text") - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - .map(|text| ContentPart::Text(text.to_owned())), - "input_image" if role == MessageRole::User => object - .get("image_url") - .and_then(Value::as_str) - .filter(|url| !url.is_empty()) - .map(|url| ContentPart::Image(ImageSource::Url(url.to_owned()))), - _ => None, - } -} - -fn message(role: MessageRole, content: Vec) -> Option { - (!content.is_empty()) - .then(|| Message::new(role, content).ok()) - .flatten() -} - -fn canonical_function_tools(body: &Map) -> Vec { - body.get("tools") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|value| { - let tool = value.as_object()?; - if tool.get("type").and_then(Value::as_str) != Some("function") { - return None; - } - let name = tool.get("name")?.as_str()?; - let schema = tool.get("parameters")?.as_object()?.clone(); - ToolDefinition::new( - name, - tool.get("description") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - schema, - ) - .ok() - .map(|definition| { - definition.with_strict(tool.get("strict").and_then(Value::as_bool).unwrap_or(false)) - }) - }) - .collect() -} - -fn canonical_output_format(body: &Map) -> Option { - let format = body.get("text")?.get("format")?.as_object()?; - match format.get("type")?.as_str()? { - "text" => Some(OutputFormat::Text), - "json_object" => Some(OutputFormat::JsonObject), - "json_schema" => JsonSchemaFormat::new( - format.get("name")?.as_str()?, - format - .get("description") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - format.get("schema")?.as_object()?.clone(), - format - .get("strict") - .and_then(Value::as_bool) - .unwrap_or(false), - ) - .ok() - .map(OutputFormat::JsonSchema), - _ => None, - } -} - -fn contains_type(value: Option<&Value>, expected: &str) -> bool { - match value { - Some(Value::Array(values)) => values - .iter() - .any(|value| contains_type(Some(value), expected)), - Some(Value::Object(object)) => { - object.get("type").and_then(Value::as_str) == Some(expected) - || object - .values() - .any(|value| contains_type(Some(value), expected)) - } - _ => false, - } -} - -fn consume_compaction_trigger(body: &mut Map) -> bool { - let Some(Value::Array(input)) = body.get_mut("input") else { - return false; - }; - if input - .last() - .and_then(|item| item.get("type")) - .and_then(Value::as_str) - != Some("compaction_trigger") - { - return false; - } - input.pop(); - true -} - fn required_non_empty_string<'a>( object: &'a Map, key: &str, diff --git a/backend/crates/gateway-api/src/openai/responses/response.rs b/backend/crates/gateway-api/src/openai/responses/response.rs index 5454589be..424ad111c 100644 --- a/backend/crates/gateway-api/src/openai/responses/response.rs +++ b/backend/crates/gateway-api/src/openai/responses/response.rs @@ -1,35 +1,21 @@ -//! Canonical event 到 OpenAI Responses SSE/JSON 的单一状态机。 - -use std::collections::BTreeMap; +//! Provider OpenAI Responses wire 到客户端 transport 的透明转发边界。 use bytes::Bytes; -use gateway_core::accounting::Usage; -use gateway_core::event::{ - CompactionOutput, ContentItem, ContentKind, EventSequenceValidator, FinishReason, GatewayEvent, - ProviderEvent, ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta, -}; -use gateway_protocol::openai::sse::{encode_sse_event, encode_sse_event_with_metadata}; -use serde_json::{Map, Value, json}; +use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent}; +use gateway_protocol::openai::sse::encode_sse_event_with_metadata; +use serde_json::Value; use super::error::ResponseEncodeError; const OPENAI_PROTOCOL: &str = "openai"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponseRepresentation { - Undecided, - Canonical, - OpenAiWire, -} - -/// Provider event 封套到 OpenAI Responses wire 的唯一表达边界。 +/// OpenAI Responses wire 的转发器。 /// -/// Provider 已提供 OpenAI wire 时只下发该 wire;否则由 [`ResponsesCollector`] -/// 表达 canonical facts。同一响应不能在两种模式之间切换。 -#[derive(Debug)] +/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。这里仅保留同一 +/// 响应的身份和终态校验,不再从 canonical facts 重建客户端 JSON;因此 +/// OpenAI 上游的未知字段、字段顺序和事件语义不会被 API 层二次解释。 +#[derive(Debug, Default)] pub struct OpenAiResponsesEncoder { - canonical: ResponsesCollector, - representation: ResponseRepresentation, response_id: Option, wire_terminal: Option, wire_failure: bool, @@ -37,12 +23,10 @@ pub struct OpenAiResponsesEncoder { } impl OpenAiResponsesEncoder { - /// 创建响应 encoder。 + /// 创建响应 wire 转发器。 #[must_use] - pub fn new(created_at: u64) -> Self { + pub const fn new() -> Self { Self { - canonical: ResponsesCollector::new(created_at), - representation: ResponseRepresentation::Undecided, response_id: None, wire_terminal: None, wire_failure: false, @@ -54,70 +38,45 @@ impl OpenAiResponsesEncoder { /// /// # Errors /// - /// canonical 顺序无效、响应身份变化或表达模式混用时返回错误。 + /// 同一响应身份变化时返回错误。 pub fn push_sse(&mut self, event: &ProviderEvent) -> Result, ResponseEncodeError> { - let openai_wire = self.select_representation(event)?; - if self.representation == ResponseRepresentation::OpenAiWire { - let Some(wire) = openai_wire else { - return Ok(Vec::new()); - }; - self.observe_wire_terminal(wire); - if let Some(raw_sse_frame) = wire.raw_sse_frame() { - return Ok(vec![raw_sse_frame.clone()]); - } - return Ok(vec![Bytes::from(encode_sse_event_with_metadata( - wire.event_type().unwrap_or_default(), - &wire.data().to_string(), - wire.sse_id(), - wire.sse_retry(), - ))]); + self.observe_identity(event)?; + let Some(wire) = openai_wire(event) else { + return Ok(Vec::new()); + }; + self.observe_wire_terminal(wire); + if let Some(raw_sse_frame) = wire.raw_sse_frame() { + return Ok(vec![raw_sse_frame.clone()]); } - - Ok(self - .push_canonical(event)? - .into_iter() - .map(Bytes::from) - .collect()) + Ok(vec![Bytes::from(encode_sse_event_with_metadata( + wire.event_type().unwrap_or_default(), + &wire.data().to_string(), + wire.sse_id(), + wire.sse_retry(), + ))]) } /// 消费一个 Provider event,并返回 WebSocket JSON messages。 /// /// # Errors /// - /// 继承 [`Self::push_sse`] 的错误。 + /// 同一响应身份变化时返回错误。 pub fn push_websocket( &mut self, event: &ProviderEvent, ) -> Result, ResponseEncodeError> { - let openai_wire = self.select_representation(event)?; - if self.representation == ResponseRepresentation::OpenAiWire { - let Some(wire) = openai_wire else { - return Ok(Vec::new()); - }; - if !wire.has_json_data() { - return Ok(Vec::new()); - } - self.observe_wire_terminal(wire); - return Ok(vec![wire.data().to_string()]); - } - - self.push_canonical(event)? - .into_iter() - .map(|frame| { - frame - .lines() - .find_map(|line| line.strip_prefix("data: ")) - .map(ToOwned::to_owned) - .ok_or(ResponseEncodeError::InvalidEventEncoding) - }) - .collect() + self.observe_identity(event)?; + let Some(wire) = openai_wire(event).filter(|wire| wire.has_json_data()) else { + return Ok(Vec::new()); + }; + self.observe_wire_terminal(wire); + Ok(vec![wire.data().to_string()]) } - /// 返回是否已经观察到 canonical 或 wire 终态。 + /// 返回是否已经看到同一响应的 canonical 与 wire 终态。 #[must_use] pub fn is_completed(&self) -> bool { - (self.wire_terminal.is_some() && self.canonical_completed) - || self.canonical.final_response.is_some() + self.wire_terminal.is_some() && self.canonical_completed } /// 返回是否已把 Provider 原生失败 event 交付给客户端。 @@ -132,56 +91,20 @@ impl OpenAiResponsesEncoder { self.response_id.as_deref() } - /// 校验完整响应并返回非流式 JSON。 + /// 校验完整响应并返回原生终态 response object。 /// /// # Errors /// - /// 流不完整或原生 wire 缺少终态 response object 时返回错误。 + /// 缺少 wire 或 canonical 终态时返回错误。 pub fn finish(self) -> Result { - match self.representation { - ResponseRepresentation::OpenAiWire if self.canonical_completed => self - .wire_terminal - .ok_or(ResponseEncodeError::MissingWireTerminal), - ResponseRepresentation::OpenAiWire => Err(ResponseEncodeError::MissingWireTerminal), - ResponseRepresentation::Undecided | ResponseRepresentation::Canonical => { - self.canonical.finish() - } + if self.canonical_completed { + self.wire_terminal + .ok_or(ResponseEncodeError::MissingWireTerminal) + } else { + Err(ResponseEncodeError::MissingWireTerminal) } } - fn select_representation<'event>( - &mut self, - event: &'event ProviderEvent, - ) -> Result, ResponseEncodeError> { - self.observe_identity(event)?; - let openai_wire = event - .wire_event() - .filter(|wire| wire.protocol() == OPENAI_PROTOCOL); - if openai_wire.is_some() { - if self.representation == ResponseRepresentation::Canonical { - return Err(ResponseEncodeError::MixedWireRepresentation); - } - self.representation = ResponseRepresentation::OpenAiWire; - } else if event.has_canonical_facts() - && self.representation == ResponseRepresentation::Undecided - { - self.representation = ResponseRepresentation::Canonical; - } - - Ok(openai_wire) - } - - fn push_canonical( - &mut self, - event: &ProviderEvent, - ) -> Result, ResponseEncodeError> { - let mut canonical_frames = Vec::new(); - for fact in event.canonical_facts() { - canonical_frames.extend(self.canonical.push(fact)?); - } - Ok(canonical_frames) - } - fn observe_identity(&mut self, event: &ProviderEvent) -> Result<(), ResponseEncodeError> { for fact in event.canonical_facts() { let metadata = match fact { @@ -204,10 +127,7 @@ impl OpenAiResponsesEncoder { Ok(()) } - fn observe_wire_terminal(&mut self, wire: &gateway_core::event::ProtocolWireEvent) { - if !wire.has_json_data() { - return; - } + fn observe_wire_terminal(&mut self, wire: &ProtocolWireEvent) { let effective_type = wire .event_type() .or_else(|| wire.data().get("type").and_then(Value::as_str)); @@ -222,756 +142,8 @@ impl OpenAiResponsesEncoder { } } -/// OpenAI Responses event collector。 -/// -/// Collector 只做协议编码;它不拥有 commit、retry 或 Provider 生命周期。 -#[derive(Debug)] -pub struct ResponsesCollector { - validator: EventSequenceValidator, - created_at: u64, - started: Option, - usage: Option, - output_positions: BTreeMap, - output: Vec, - final_response: Option, -} - -impl ResponsesCollector { - /// 创建 collector。`created_at` 是 logical request 冻结的 Unix 秒时间戳。 - #[must_use] - pub fn new(created_at: u64) -> Self { - Self { - validator: EventSequenceValidator::new(), - created_at, - started: None, - usage: None, - output_positions: BTreeMap::new(), - output: Vec::new(), - final_response: None, - } - } - - /// 消费一个 canonical event,并返回本次新产生的 SSE frames。 - /// - /// # Errors - /// - /// 事件乱序、元数据变化、重复 usage 或出现 OpenAI Responses 无法表达的 - /// canonical 语义时返回 [`ResponseEncodeError`]。 - pub fn push(&mut self, event: &GatewayEvent) -> Result, ResponseEncodeError> { - if self.final_response.is_some() { - return Err(ResponseEncodeError::AlreadyCompleted); - } - self.validator.observe(event)?; - - match event { - GatewayEvent::Started(meta) => self.started(meta), - GatewayEvent::ContentAdded(item) => self.content_added(item), - GatewayEvent::TextDelta(delta) => self.text_delta(delta), - GatewayEvent::ReasoningDelta(delta) => self.reasoning_delta(delta), - GatewayEvent::ToolCallDelta(delta) => self.tool_call_delta(delta), - GatewayEvent::CompactionOutput(output) => self.compaction_output(output), - GatewayEvent::Usage(usage) => self.observe_usage(usage), - GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => Ok(Vec::new()), - GatewayEvent::Completed(meta) => self.completed(meta), - _ => Err(ResponseEncodeError::UnsupportedEvent), - } - } - - /// 校验终态并返回非流式 JSON。 - /// - /// # Errors - /// - /// Stream 未正常完成时返回事件顺序错误。 - pub fn finish(self) -> Result { - self.validator.finish()?; - self.final_response - .ok_or_else(|| gateway_core::event::EventSequenceError::MissingCompleted.into()) - } - - fn started(&mut self, meta: &ResponseMeta) -> Result, ResponseEncodeError> { - self.started = Some(meta.clone()); - let response = response_base( - meta, - self.created_at, - "in_progress", - Value::Null, - Vec::new(), - Value::Null, - ); - Ok(vec![sse_frame( - "response.created", - json!({ - "type": "response.created", - "response": response, - }), - )]) - } - - fn content_added(&mut self, item: &ContentItem) -> Result, ResponseEncodeError> { - let started = self - .started - .as_ref() - .ok_or(gateway_core::event::EventSequenceError::MissingStarted)?; - let output_index = self.output.len(); - let id = deterministic_output_id(started.response_id(), item.index(), item.kind()); - let state = match item.kind() { - ContentKind::Text => OutputState::Text { - id, - text: String::new(), - }, - ContentKind::Reasoning => OutputState::Reasoning { - id, - text: String::new(), - }, - ContentKind::ToolCall => OutputState::ToolCall { - canonical_index: item.index(), - id, - call_id: None, - name: None, - arguments: String::new(), - published: false, - }, - ContentKind::Image => { - return Err(ResponseEncodeError::UnsupportedContentKind { kind: "image" }); - } - ContentKind::Audio => { - return Err(ResponseEncodeError::UnsupportedContentKind { kind: "audio" }); - } - _ => { - return Err(ResponseEncodeError::UnsupportedContentKind { kind: "unknown" }); - } - }; - self.output_positions.insert(item.index(), output_index); - let frames = state.added_frames(output_index); - self.output.push(state); - Ok(frames) - } - - fn text_delta(&mut self, delta: &TextDelta) -> Result, ResponseEncodeError> { - let output_index = self.position(delta.content_index)?; - let OutputState::Text { id, text, .. } = &mut self.output[output_index] else { - return Err( - gateway_core::event::EventSequenceError::InvalidDeltaTarget { - index: delta.content_index, - } - .into(), - ); - }; - text.push_str(&delta.text); - Ok(vec![sse_frame( - "response.output_text.delta", - json!({ - "type": "response.output_text.delta", - "item_id": id, - "output_index": output_index, - "content_index": 0, - "delta": &delta.text, - }), - )]) - } - - fn reasoning_delta( - &mut self, - delta: &ReasoningDelta, - ) -> Result, ResponseEncodeError> { - let output_index = self.position(delta.content_index)?; - let OutputState::Reasoning { id, text, .. } = &mut self.output[output_index] else { - return Err( - gateway_core::event::EventSequenceError::InvalidDeltaTarget { - index: delta.content_index, - } - .into(), - ); - }; - text.push_str(&delta.text); - Ok(vec![sse_frame( - "response.reasoning_summary_text.delta", - json!({ - "type": "response.reasoning_summary_text.delta", - "item_id": id, - "output_index": output_index, - "summary_index": 0, - "delta": &delta.text, - }), - )]) - } - - fn tool_call_delta( - &mut self, - delta: &ToolCallDelta, - ) -> Result, ResponseEncodeError> { - let output_index = self.position(delta.content_index)?; - let OutputState::ToolCall { - canonical_index, - id, - call_id, - name, - arguments, - published, - } = &mut self.output[output_index] - else { - return Err( - gateway_core::event::EventSequenceError::InvalidDeltaTarget { - index: delta.content_index, - } - .into(), - ); - }; - if delta.call_id.is_empty() { - return Err(ResponseEncodeError::ToolIdentityChanged { - index: *canonical_index, - }); - } - match call_id { - Some(existing) if existing != &delta.call_id => { - return Err(ResponseEncodeError::ToolIdentityChanged { - index: *canonical_index, - }); - } - None => *call_id = Some(delta.call_id.clone()), - Some(_) => {} - } - if let Some(delta_name) = &delta.name { - if delta_name.is_empty() { - return Err(ResponseEncodeError::MissingToolName { - index: *canonical_index, - }); - } - match name { - Some(existing) if existing != delta_name => { - return Err(ResponseEncodeError::ToolIdentityChanged { - index: *canonical_index, - }); - } - None => *name = Some(delta_name.clone()), - Some(_) => {} - } - } - - let mut frames = Vec::new(); - if !*published && name.is_some() { - frames.push(sse_frame( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": tool_item(id, call_id.as_deref(), name.as_deref(), "", "in_progress"), - }), - )); - *published = true; - if !arguments.is_empty() { - frames.push(tool_arguments_delta_frame(id, output_index, arguments)); - } - } - arguments.push_str(&delta.arguments_delta); - if *published && !delta.arguments_delta.is_empty() { - frames.push(tool_arguments_delta_frame( - id, - output_index, - &delta.arguments_delta, - )); - } - Ok(frames) - } - - fn compaction_output( - &mut self, - output: &CompactionOutput, - ) -> Result, ResponseEncodeError> { - let output_index = self.output.len(); - let state = OutputState::Compaction { - encrypted_content: output.summary().as_str().to_owned(), - }; - let item = state.completed_item()?; - self.output.push(state); - Ok(vec![sse_frame( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": item, - }), - )]) - } - - fn observe_usage(&mut self, usage: &Usage) -> Result, ResponseEncodeError> { - if self.usage.is_some() { - return Err(ResponseEncodeError::DuplicateUsage); - } - if usage.input_tokens.is_none() - || usage.output_tokens.is_none() - || usage.total_tokens.is_none() - { - return Err(ResponseEncodeError::IncompleteUsage); - } - self.usage = Some(usage.clone()); - Ok(Vec::new()) - } - - fn completed(&mut self, meta: &ResponseMeta) -> Result, ResponseEncodeError> { - let started = self - .started - .as_ref() - .ok_or(gateway_core::event::EventSequenceError::MissingStarted)?; - if started.response_id() != meta.response_id() || started.model() != meta.model() { - return Err(ResponseEncodeError::MetadataChanged); - } - - let mut frames = Vec::new(); - for (output_index, state) in self.output.iter().enumerate() { - frames.extend(state.done_frames(output_index)?); - } - let output = self - .output - .iter() - .map(OutputState::completed_item) - .collect::, _>>()?; - let (status, terminal_event, incomplete_details) = terminal_status(meta.finish_reason()); - let usage = self.usage.as_ref().map_or(Ok(Value::Null), usage_json)?; - let response = response_base( - meta, - self.created_at, - status, - incomplete_details, - output, - usage, - ); - frames.push(sse_frame( - terminal_event, - json!({ - "type": terminal_event, - "response": &response, - }), - )); - self.final_response = Some(response); - Ok(frames) - } - - fn position(&self, canonical_index: u32) -> Result { - self.output_positions - .get(&canonical_index) - .copied() - .ok_or_else(|| { - gateway_core::event::EventSequenceError::InvalidDeltaTarget { - index: canonical_index, - } - .into() - }) - } -} - -#[derive(Debug)] -enum OutputState { - Text { - id: String, - text: String, - }, - Reasoning { - id: String, - text: String, - }, - ToolCall { - canonical_index: u32, - id: String, - call_id: Option, - name: Option, - arguments: String, - published: bool, - }, - Compaction { - encrypted_content: String, - }, -} - -impl OutputState { - fn added_frames(&self, output_index: usize) -> Vec { - match self { - Self::Text { id, .. } => vec![ - sse_frame( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": message_item(id, "in_progress", Vec::new()), - }), - ), - sse_frame( - "response.content_part.added", - json!({ - "type": "response.content_part.added", - "item_id": id, - "output_index": output_index, - "content_index": 0, - "part": output_text_part(""), - }), - ), - ], - Self::Reasoning { id, .. } => vec![ - sse_frame( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": reasoning_item_with_summary(id, Vec::new()), - }), - ), - sse_frame( - "response.reasoning_summary_part.added", - json!({ - "type": "response.reasoning_summary_part.added", - "item_id": id, - "output_index": output_index, - "summary_index": 0, - "part": summary_text_part(""), - }), - ), - ], - Self::ToolCall { .. } => Vec::new(), - Self::Compaction { .. } => Vec::new(), - } - } - - fn done_frames(&self, output_index: usize) -> Result, ResponseEncodeError> { - match self { - Self::Text { id, text, .. } => Ok(vec![ - sse_frame( - "response.output_text.done", - json!({ - "type": "response.output_text.done", - "item_id": id, - "output_index": output_index, - "content_index": 0, - "text": text, - }), - ), - sse_frame( - "response.content_part.done", - json!({ - "type": "response.content_part.done", - "item_id": id, - "output_index": output_index, - "content_index": 0, - "part": output_text_part(text), - }), - ), - sse_frame( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": text_item(id, text, "completed"), - }), - ), - ]), - Self::Reasoning { id, text, .. } => Ok(vec![ - sse_frame( - "response.reasoning_summary_text.done", - json!({ - "type": "response.reasoning_summary_text.done", - "item_id": id, - "output_index": output_index, - "summary_index": 0, - "text": text, - }), - ), - sse_frame( - "response.reasoning_summary_part.done", - json!({ - "type": "response.reasoning_summary_part.done", - "item_id": id, - "output_index": output_index, - "summary_index": 0, - "part": summary_text_part(text), - }), - ), - sse_frame( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": reasoning_item(id, text), - }), - ), - ]), - Self::ToolCall { - canonical_index, - id, - call_id, - name, - arguments, - published, - } => { - let name = name - .as_deref() - .ok_or(ResponseEncodeError::MissingToolName { - index: *canonical_index, - })?; - let call_id = - call_id - .as_deref() - .ok_or(ResponseEncodeError::ToolIdentityChanged { - index: *canonical_index, - })?; - let mut frames = Vec::new(); - if !published { - frames.push(sse_frame( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": tool_item(id, Some(call_id), Some(name), "", "in_progress"), - }), - )); - if !arguments.is_empty() { - frames.push(tool_arguments_delta_frame(id, output_index, arguments)); - } - } - frames.push(sse_frame( - "response.function_call_arguments.done", - json!({ - "type": "response.function_call_arguments.done", - "item_id": id, - "output_index": output_index, - "arguments": arguments, - }), - )); - frames.push(sse_frame( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": tool_item( - id, - Some(call_id), - Some(name), - arguments, - "completed", - ), - }), - )); - Ok(frames) - } - Self::Compaction { .. } => Ok(Vec::new()), - } - } - - fn completed_item(&self) -> Result { - match self { - Self::Text { id, text, .. } => Ok(text_item(id, text, "completed")), - Self::Reasoning { id, text, .. } => Ok(reasoning_item(id, text)), - Self::ToolCall { - canonical_index, - id, - call_id, - name, - arguments, - .. - } => Ok(tool_item( - id, - Some( - call_id - .as_deref() - .ok_or(ResponseEncodeError::ToolIdentityChanged { - index: *canonical_index, - })?, - ), - Some( - name.as_deref() - .ok_or(ResponseEncodeError::MissingToolName { - index: *canonical_index, - })?, - ), - arguments, - "completed", - )), - Self::Compaction { encrypted_content } => Ok(compaction_item(encrypted_content)), - } - } -} - -fn terminal_status(finish_reason: Option) -> (&'static str, &'static str, Value) { - match finish_reason { - Some(FinishReason::Length) => ( - "incomplete", - "response.incomplete", - json!({ "reason": "max_output_tokens" }), - ), - Some(FinishReason::ContentFilter) => ( - "incomplete", - "response.incomplete", - json!({ "reason": "content_filter" }), - ), - _ => ("completed", "response.completed", Value::Null), - } -} - -fn response_base( - meta: &ResponseMeta, - created_at: u64, - status: &str, - incomplete_details: Value, - output: Vec, - usage: Value, -) -> Value { - json!({ - "id": meta.response_id(), - "object": "response", - "created_at": created_at, - "status": status, - "error": null, - "incomplete_details": incomplete_details, - "model": meta.model(), - "output": output, - "usage": usage, - }) -} - -fn usage_json(usage: &Usage) -> Result { - let input_tokens = usage - .input_tokens - .ok_or(ResponseEncodeError::IncompleteUsage)?; - let output_tokens = usage - .output_tokens - .ok_or(ResponseEncodeError::IncompleteUsage)?; - let total_tokens = usage - .total_tokens - .ok_or(ResponseEncodeError::IncompleteUsage)?; - let mut object = Map::new(); - object.insert("input_tokens".to_owned(), json!(input_tokens)); - object.insert("output_tokens".to_owned(), json!(output_tokens)); - object.insert("total_tokens".to_owned(), json!(total_tokens)); - let mut input_details = Map::new(); - if let Some(cached_tokens) = usage.cached_tokens { - input_details.insert("cached_tokens".to_owned(), json!(cached_tokens)); - } - if let Some(cache_write_tokens) = usage.cache_write_tokens { - input_details.insert("cache_write_tokens".to_owned(), json!(cache_write_tokens)); - } - if !input_details.is_empty() { - object.insert( - "input_tokens_details".to_owned(), - Value::Object(input_details), - ); - } - if let Some(reasoning_tokens) = usage.reasoning_tokens { - object.insert( - "output_tokens_details".to_owned(), - json!({ "reasoning_tokens": reasoning_tokens }), - ); - } - Ok(Value::Object(object)) -} - -fn text_item(id: &str, text: &str, status: &str) -> Value { - message_item(id, status, vec![output_text_part(text)]) -} - -fn message_item(id: &str, status: &str, content: Vec) -> Value { - json!({ - "id": id, - "type": "message", - "status": status, - "role": "assistant", - "content": content, - }) -} - -fn output_text_part(text: &str) -> Value { - json!({ - "type": "output_text", - "text": text, - "annotations": [], - }) -} - -fn reasoning_item(id: &str, text: &str) -> Value { - reasoning_item_with_summary(id, vec![summary_text_part(text)]) -} - -fn reasoning_item_with_summary(id: &str, summary: Vec) -> Value { - json!({ - "id": id, - "type": "reasoning", - "summary": summary, - }) -} - -fn compaction_item(encrypted_content: &str) -> Value { - json!({ - "type": "compaction", - "encrypted_content": encrypted_content, - }) -} - -fn summary_text_part(text: &str) -> Value { - json!({ - "type": "summary_text", - "text": text, - }) -} - -fn tool_item( - id: &str, - call_id: Option<&str>, - name: Option<&str>, - arguments: &str, - status: &str, -) -> Value { - json!({ - "id": id, - "type": "function_call", - "status": status, - "call_id": call_id, - "name": name, - "arguments": arguments, - }) -} - -fn tool_arguments_delta_frame(id: &str, output_index: usize, delta: &str) -> String { - sse_frame( - "response.function_call_arguments.delta", - json!({ - "type": "response.function_call_arguments.delta", - "item_id": id, - "output_index": output_index, - "delta": delta, - }), - ) -} - -fn sse_frame(event: &str, payload: Value) -> String { - encode_sse_event(event, &payload.to_string()) -} - -fn deterministic_output_id(response_id: &str, index: u32, kind: ContentKind) -> String { - let prefix = match kind { - ContentKind::Text => "msg", - ContentKind::Reasoning => "rs", - ContentKind::ToolCall => "fc", - ContentKind::Image => "img", - ContentKind::Audio => "audio", - _ => "item", - }; - let mut hash = 0xcbf2_9ce4_8422_2325_u64; - for byte in response_id - .bytes() - .chain(index.to_be_bytes()) - .chain(kind_tag(kind)) - { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - format!("{prefix}_{hash:016x}") -} - -fn kind_tag(kind: ContentKind) -> [u8; 1] { - let tag = match kind { - ContentKind::Text => 1, - ContentKind::Reasoning => 2, - ContentKind::ToolCall => 3, - ContentKind::Image => 4, - ContentKind::Audio => 5, - _ => u8::MAX, - }; - [tag] +fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> { + event + .wire_event() + .filter(|wire| wire.protocol() == OPENAI_PROTOCOL) } diff --git a/backend/crates/gateway-api/src/openai/responses/websocket.rs b/backend/crates/gateway-api/src/openai/responses/websocket.rs index d93d1570e..fe4409095 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket.rs @@ -19,7 +19,6 @@ use gateway_core::error::{GatewayError, GatewayErrorKind}; use gateway_core::event::ProviderResponseHeader; use gateway_core::lifecycle::{ConnectionGuard, ConnectionLifecycle}; use gateway_core::operation::ProviderSessionState; -use gateway_core::routing::ProviderKind; use serde_json::{Map, Value, json}; use thiserror::Error; @@ -29,7 +28,7 @@ use crate::{ auth::{authenticate_client, authentication_error_response}, error::{gateway_error_contract, gateway_error_from_engine, runtime_unavailable_response}, router::MAX_CLIENT_REQUEST_BODY_BYTES, - service::{OpenAiService, created_at_unix_seconds}, + service::OpenAiService, }, }; @@ -158,35 +157,32 @@ async fn serve_responses_websocket(mut socket: WebSocket, session: ResponsesWebS }; request_count = request_count.saturating_add(1); let correlation_id = session.service.next_request_id(); - let decoded = match decode_response_create_with_context( - payload.as_str(), - &session.request_headers, - session.client.policy().provider_kind(), - ) { - Ok(decoded) => { - decoded.with_client_context(session.client_ip, session.user_agent.clone()) - } - Err(error) => { - tracing::info!( - websocket_connection_id = %session.connection_id, - request_id = %correlation_id, - error = %error, - "Responses WebSocket request rejected" - ); - if send_protocol_error( - &mut socket, - StatusCode::BAD_REQUEST, - error.protocol_body(), - &correlation_id, - ) - .await - == ForwardOutcome::Disconnect - { - break; + let decoded = + match decode_response_create_with_context(payload.as_str(), &session.request_headers) { + Ok(decoded) => { + decoded.with_client_context(session.client_ip, session.user_agent.clone()) } - continue; - } - }; + Err(error) => { + tracing::info!( + websocket_connection_id = %session.connection_id, + request_id = %correlation_id, + error = %error, + "Responses WebSocket request rejected" + ); + if send_protocol_error( + &mut socket, + StatusCode::BAD_REQUEST, + error.protocol_body(), + &correlation_id, + ) + .await + == ForwardOutcome::Disconnect + { + break; + } + continue; + } + }; let decoded = replay.prepare(decoded); let started = match session .service @@ -262,10 +258,6 @@ async fn forward_execution( let request_id = started.request_id.to_string(); let streaming = started.stream; let mut execution = PendingExecution::new(started.session); - let created_at = match created_at_unix_seconds(started.created_at) { - Ok(created_at) => created_at, - Err(error) => return send_gateway_error(socket, &error, &request_id).await, - }; if !streaming { let error = GatewayError::new( GatewayErrorKind::Internal, @@ -304,7 +296,7 @@ async fn forward_execution( ); return send_gateway_error(socket, &error, &request_id).await; } - let mut encoder = OpenAiResponsesEncoder::new(created_at); + let mut encoder = OpenAiResponsesEncoder::new(); let mut provider_state = None; let mut first_messages = Vec::new(); for event in &mut first { @@ -537,15 +529,13 @@ async fn next_active_input( pub fn decode_response_create_with_context( payload: &str, request_headers: &OpenAiRequestHeaders, - provider_kind: &ProviderKind, ) -> Result { - decode_response_create_inner(payload, request_headers, Some(provider_kind)) + decode_response_create_inner(payload, request_headers) } fn decode_response_create_inner( payload: &str, request_headers: &OpenAiRequestHeaders, - provider_kind: Option<&ProviderKind>, ) -> Result { let Value::Object(mut body) = serde_json::from_str::(payload) .map_err(|_| ResponseCreateFrameError::InvalidJson)? @@ -561,7 +551,7 @@ fn decode_response_create_inner( } let encoded = serde_json::to_vec(&Value::Object(body)) .map_err(|_| ResponseCreateFrameError::InvalidJson)?; - super::request::decode_request_inner(&encoded, false, request_headers, provider_kind) + super::request::decode_request_inner(&encoded, false, request_headers) .map_err(ResponseCreateFrameError::Request) } diff --git a/backend/crates/gateway-api/src/openai/service.rs b/backend/crates/gateway-api/src/openai/service.rs index 3f9496de0..65c538711 100644 --- a/backend/crates/gateway-api/src/openai/service.rs +++ b/backend/crates/gateway-api/src/openai/service.rs @@ -1,7 +1,6 @@ //! OpenAI wire adapter 到 Core 执行用例的唯一映射。 use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use gateway_core::engine::continuation::PreviousResponseId; use gateway_core::engine::execution::{ @@ -122,13 +121,6 @@ impl OpenAiService { } } -pub(crate) fn created_at_unix_seconds(created_at: SystemTime) -> Result { - created_at - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid")) -} - const fn map_authentication_error(error: ClientAuthenticationError) -> ClientApiKeyAuthError { match error { ClientAuthenticationError::InvalidKey => ClientApiKeyAuthError::InvalidKey, diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 8d5861cdf..97e131148 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -139,27 +139,17 @@ impl ExecutionService for ContextCaptureExecution { let operation_kind = request.operation.kind(); let generation = match &request.operation { Operation::Generate(generation) => Some(generation), - Operation::CompactConversation(compaction) => Some(compaction.generation()), _ => None, }; let (client_metadata, protocol_context, prompt_cache_key, input) = generation.map_or((None, None, None, None), |generation| { + let payload = generation.protocol_payload(); ( - generation - .protocol_payload() - .and_then(|payload| payload.body().get("client_metadata")) - .cloned(), - generation - .protocol_payload() - .map(|payload| payload.context()) - .filter(|context| !context.is_empty()) - .cloned() - .map(Value::Object), + payload.body().get("client_metadata").cloned(), + (!payload.context().is_empty()) + .then(|| Value::Object(payload.context().clone())), generation.prompt_cache_key().map(ToOwned::to_owned), - generation - .protocol_payload() - .and_then(|payload| payload.body().get("input")) - .cloned(), + payload.body().get("input").cloned(), ) }); *self.observed.lock().expect("context capture lock") = Some(CapturedClientContext { @@ -196,10 +186,17 @@ impl FakeSession { } fn buffered(trace: Arc, events: Vec) -> Self { + Self::buffered_provider( + trace, + events.into_iter().map(provider_event_for_fact).collect(), + ) + } + + fn buffered_provider(trace: Arc, events: Vec) -> Self { Self { trace, next: VecDeque::from([NextStep::FinalizeCancelled]), - collected: Some(events.into_iter().map(ProviderEvent::canonical).collect()), + collected: Some(events), collect_error: None, collect_pending: false, finalize_on_commit: true, @@ -342,7 +339,56 @@ fn completed() -> GatewayEvent { } fn delivery(event: GatewayEvent, commit_requirement: CommitRequirement) -> CoordinatedEvent { - CoordinatedEvent::single(ProviderEvent::canonical(event), commit_requirement) + delivery_provider(provider_event_for_fact(event), commit_requirement) +} + +fn delivery_provider( + event: ProviderEvent, + commit_requirement: CommitRequirement, +) -> CoordinatedEvent { + CoordinatedEvent::single(event, commit_requirement) +} + +fn provider_event_for_fact(event: GatewayEvent) -> ProviderEvent { + let (GatewayEvent::Started(meta) | GatewayEvent::Completed(meta)) = &event else { + return ProviderEvent::canonical(event); + }; + let event_type = if matches!(&event, GatewayEvent::Started(_)) { + "response.created" + } else { + "response.completed" + }; + let response = json!({ + "id": meta.response_id(), + "model": meta.model(), + "status": if event_type == "response.created" { "in_progress" } else { "completed" }, + "output": [] + }); + ProviderEvent::canonical_with_wire( + vec![event], + ProtocolWireEvent::json( + "openai", + Some(event_type.to_owned()), + json!({"type": event_type, "response": response}), + ) + .expect("fixture OpenAI wire event"), + ) +} + +fn mismatched_terminal_event() -> ProviderEvent { + let meta = ResponseMeta::new("resp_other", "public-model"); + ProviderEvent::canonical_with_wire( + vec![GatewayEvent::Completed(meta)], + ProtocolWireEvent::json( + "openai", + Some("response.completed".to_owned()), + json!({ + "type": "response.completed", + "response": {"id": "resp_other", "status": "completed", "output": []} + }), + ) + .expect("fixture mismatched terminal"), + ) } async fn captured_client_context( @@ -427,15 +473,16 @@ async fn openai_http_should_preserve_compaction_trigger_as_generate() { } #[tokio::test] -async fn xai_http_should_consume_compaction_trigger_as_typed_operation() { +async fn xai_http_should_leave_compaction_trigger_for_the_xai_provider_adapter() { let captured = captured_http_compaction("xai").await; assert_eq!( (captured.operation_kind, captured.input), ( - OperationKind::CompactConversation, + OperationKind::Generate, Some(json!([ - {"type": "message", "role": "user", "content": "history"} + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} ])), ) ); @@ -596,7 +643,7 @@ async fn streaming_encodes_first_frame_before_commit_and_http_delivery() { ))], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; assert_eq!(response.status(), axum::http::StatusCode::OK); assert_eq!(trace.client_statuses(), vec![200]); @@ -629,7 +676,7 @@ async fn streaming_response_forwards_only_allowlisted_provider_headers() { ) .with_response_headers(headers); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; assert_eq!( response @@ -693,7 +740,7 @@ async fn streaming_commit_batch_preserves_pre_identity_wire_before_committing() vec![NextStep::Event(batch), NextStep::FinalizeSuccess], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read SSE body"); @@ -717,15 +764,15 @@ async fn streaming_first_frame_encode_failure_cancels_before_commit() { let session = FakeSession::streaming( Arc::clone(&trace), vec![ - NextStep::Event(delivery( - completed(), + NextStep::Event(delivery_provider( + ProviderEvent::canonical(completed()), CommitRequirement::CommitBeforeDelivery, )), NextStep::FinalizeCancelled, ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; assert_eq!( response.status(), @@ -747,7 +794,7 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429 ))], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(trace.client_statuses(), vec![429]); @@ -756,17 +803,19 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429 #[tokio::test] async fn streaming_postcommit_encode_failure_cancels_without_second_commit() { let trace = Arc::new(Trace::default()); - let unsupported = GatewayEvent::ContentAdded(ContentItem::new(0, ContentKind::Image)); let session = FakeSession::streaming( Arc::clone(&trace), vec![ NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), - NextStep::Event(delivery(unsupported, CommitRequirement::AlreadyCommitted)), + NextStep::Event(delivery_provider( + mismatched_terminal_event(), + CommitRequirement::AlreadyCommitted, + )), NextStep::FinalizeCancelled, ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read SSE body"); @@ -803,7 +852,7 @@ async fn streaming_success_should_emit_terminal_event_and_done_marker() { ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read complete SSE body"); @@ -838,7 +887,7 @@ async fn streaming_completed_event_without_finalized_execution_should_fail_close ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read failed SSE body"); @@ -873,7 +922,7 @@ async fn streaming_empty_terminal_should_emit_failure_done_and_cancel_execution( ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read failed SSE body"); @@ -909,7 +958,7 @@ async fn streaming_error_should_emit_client_visible_upstream_details() { ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read SSE body"); @@ -983,7 +1032,7 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read SSE body"); @@ -1021,7 +1070,7 @@ async fn streaming_second_commit_request_should_fail_and_finalize_once() { ], ); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read failed SSE body"); @@ -1048,7 +1097,7 @@ async fn streaming_commit_failure_should_not_deliver_the_prepared_first_frame() ) .with_commit_failure(); - let response = stream_execution_response(Box::new(session), 1, None).await; + let response = stream_execution_response(Box::new(session), None).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read error response"); @@ -1070,7 +1119,7 @@ async fn buffered_response_commits_only_after_complete_json_is_encoded() { let trace = Arc::new(Trace::default()); let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]); - let response = collect_execution_response(Box::new(session), 1).await; + let response = collect_execution_response(Box::new(session)).await; let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read JSON body"); @@ -1093,7 +1142,7 @@ async fn buffered_response_forwards_allowlisted_provider_headers() { let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]) .with_response_headers(vec![header]); - let response = collect_execution_response(Box::new(session), 1).await; + let response = collect_execution_response(Box::new(session)).await; assert_eq!( response @@ -1107,9 +1156,12 @@ async fn buffered_response_forwards_allowlisted_provider_headers() { #[tokio::test] async fn buffered_encode_failure_cancels_without_commit() { let trace = Arc::new(Trace::default()); - let session = FakeSession::buffered(Arc::clone(&trace), vec![completed()]); + let session = FakeSession::buffered_provider( + Arc::clone(&trace), + vec![ProviderEvent::canonical(completed())], + ); - let response = collect_execution_response(Box::new(session), 1).await; + let response = collect_execution_response(Box::new(session)).await; assert_eq!( response.status(), @@ -1126,7 +1178,7 @@ async fn buffered_commit_failure_should_cancel_after_encoding_without_returning_ let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]) .with_commit_failure(); - let response = collect_execution_response(Box::new(session), 1).await; + let response = collect_execution_response(Box::new(session)).await; let status = response.status(); assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); @@ -1142,7 +1194,7 @@ async fn buffered_commit_failure_should_cancel_after_encoding_without_returning_ async fn dropping_buffered_handler_before_commit_cancels_execution() { let trace = Arc::new(Trace::default()); let session = FakeSession::pending_buffered(Arc::clone(&trace)); - let task = tokio::spawn(async move { collect_execution_response(Box::new(session), 1).await }); + let task = tokio::spawn(async move { collect_execution_response(Box::new(session)).await }); tokio::task::yield_now().await; task.abort(); @@ -1163,7 +1215,7 @@ async fn buffered_rate_limit_should_persist_the_returned_429_status() { ), ); - let response = collect_execution_response(Box::new(session), 1).await; + let response = collect_execution_response(Box::new(session)).await; assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(trace.client_statuses(), vec![429]); diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index ceb99f6b5..7832b7b51 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -1,1154 +1,50 @@ mod http; mod websocket; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderValue}; use bytes::Bytes; use gateway_api::openai::responses::{ ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, OpenAiResponsesEncoder, - RequestDecodeError, ResponseCreateFrameError, ResponseEncodeError, ResponsesCollector, - decode_request_with_headers, decode_response_create_with_context, + RequestDecodeError, ResponseCreateFrameError, ResponseEncodeError, decode_request_with_headers, + decode_response_create_with_context, }; use gateway_core::{ - accounting::{CalculatedCost, ProviderReportedCost, Usage}, - event::{ - CompactionOutput, CompactionSummary, ContentItem, ContentKind, EventSequenceError, - FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ReasoningDelta, ResponseMeta, - TextDelta, ToolCallDelta, - }, - operation::{ContentPart, Feature, MessageRole, Operation, OutputFormat, ResponsePersistence}, - routing::{ProviderKind, PublicModelId, RoutingContext}, + event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta}, + operation::{Feature, Operation}, + routing::{PublicModelId, RoutingContext}, }; use gateway_protocol::openai::sse::parse_sse_events; use serde_json::{Value, json}; -fn openai_provider() -> ProviderKind { - ProviderKind::new("openai").expect("OpenAI provider kind") -} - fn decode_request(body: &[u8]) -> Result { - decode_request_with_headers(body, &HeaderMap::new(), &openai_provider()) + decode_request_with_headers(body, &HeaderMap::new()) } fn decode_response_create( payload: &str, ) -> Result { - decode_response_create_with_context( - payload, - &OpenAiRequestHeaders::default(), - &openai_provider(), - ) + decode_response_create_with_context(payload, &OpenAiRequestHeaders::default()) } -fn generate_request(body: Value) -> gateway_api::openai::responses::DecodedResponsesRequest { +fn generate_request(body: Value) -> DecodedResponsesRequest { decode_request(body.to_string().as_bytes()).expect("test request should decode") } fn generate_operation( - decoded: &gateway_api::openai::responses::DecodedResponsesRequest, + decoded: &DecodedResponsesRequest, ) -> &gateway_core::operation::GenerateRequest { - match decoded.operation() { - Operation::Generate(request) => request, - _ => panic!("Responses decoder must produce Generate"), - } -} - -fn openai_wire_body( - decoded: &gateway_api::openai::responses::DecodedResponsesRequest, -) -> &serde_json::Map { - generate_operation(decoded) - .protocol_payload() - .expect("OpenAI request should retain wire payload") - .body() -} - -fn openai_protocol_context( - decoded: &gateway_api::openai::responses::DecodedResponsesRequest, -) -> &serde_json::Map { - generate_operation(decoded) - .protocol_payload() - .expect("OpenAI request should retain protocol context") - .context() -} - -fn response_meta() -> ResponseMeta { - ResponseMeta::new("resp_gateway_contract", "smart-code") -} - -fn completed_meta() -> ResponseMeta { - response_meta().with_finish_reason(FinishReason::Stop) -} - -fn usage() -> Usage { - let mut usage = Usage::new(); - usage.input_tokens = Some(10); - usage.output_tokens = Some(4); - usage.cached_tokens = Some(2); - usage.reasoning_tokens = Some(1); - usage.total_tokens = Some(14); - usage -} - -struct CollectedResponses { - response: Value, - sse_frames: Vec, -} - -impl CollectedResponses { - fn response(&self) -> &Value { - &self.response - } - - fn sse_frames(&self) -> &[String] { - &self.sse_frames - } -} - -fn collect_responses( - created_at: u64, - events: &[GatewayEvent], -) -> Result { - let mut collector = ResponsesCollector::new(created_at); - let mut sse_frames = Vec::new(); - for event in events { - sse_frames.extend(collector.push(event)?); - } - Ok(CollectedResponses { - response: collector.finish()?, - sse_frames, - }) -} - -fn text_events() -> Vec { - vec![ - GatewayEvent::Started(response_meta()), - GatewayEvent::ContentAdded(ContentItem::new(7, ContentKind::Text)), - GatewayEvent::TextDelta(TextDelta { - content_index: 7, - text: "hello".to_owned(), - }), - GatewayEvent::Usage(usage()), - GatewayEvent::Completed(completed_meta()), - ] -} - -#[test] -fn decoder_should_preserve_roles_tools_reasoning_schema_and_unknown_provider_options() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": [ - {"role": "system", "content": "system"}, - {"role": "developer", "content": [{"type": "input_text", "text": "developer"}]}, - {"role": "user", "content": "question"}, - {"role": "assistant", "content": [{"type": "output_text", "text": "prior"}]}, - {"type": "function_call_output", "call_id": "call_1", "output": "sunny"} - ], - "tools": [{ - "type": "function", - "name": "weather", - "description": "Weather lookup", - "parameters": {"type": "object"}, - "strict": true - }], - "text": {"format": { - "type": "json_schema", - "name": "weather_result", - "description": "Structured weather", - "schema": {"type": "object"}, - "strict": true - }}, - "reasoning": {"effort": "future-value", "summary": "future-summary"}, - "max_output_tokens": 512, - "stream": true, - "store": false, - "provider_options": { - "version": "v1", - "providers": {"xai": {"search_mode": "off"}} - } - })); - let request = generate_operation(&decoded); - let roles = request - .messages() - .iter() - .map(|message| message.role()) - .collect::>(); - let OutputFormat::JsonSchema(format) = request.output_format() else { - panic!("json schema should be preserved") + let Operation::Generate(request) = decoded.operation() else { + panic!("Responses decoder must produce Generate") }; - - assert_eq!( - ( - roles, - request.tools()[0].strict(), - format.name(), - format.description(), - format.strict(), - request - .protocol_payload() - .and_then(|payload| payload.body().get("reasoning")), - request.max_output_tokens(), - request.response_persistence(), - decoded.metadata().stream(), - decoded.metadata().store(), - request - .protocol_payload() - .and_then(|payload| payload.body().get("provider_options")), - ), - ( - vec![ - MessageRole::System, - MessageRole::Developer, - MessageRole::User, - MessageRole::Assistant, - MessageRole::User, - ], - true, - "weather_result", - Some("Structured weather"), - true, - Some(&json!({"effort": "future-value", "summary": "future-summary"})), - Some(512), - ResponsePersistence::DoNotStore, - true, - false, - Some(&json!({ - "version": "v1", - "providers": {"xai": {"search_mode": "off"}} - })), - ) - ); + request } -#[test] -fn decoder_should_mark_image_generation_tool_intent() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "draw a circuit diagram", - "tools": [{"type": "image_generation"}] - })); - - assert!(generate_operation(&decoded).image_generation_requested()); +fn openai_wire_body(decoded: &DecodedResponsesRequest) -> &serde_json::Map { + generate_operation(decoded).protocol_payload().body() } -#[test] -fn decoder_should_preserve_provider_options_as_an_unknown_wire_field() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "hello", - "provider_options": { - "version": "v1", - "providers": { - "xai": {"schema_version": 1, "turn_index": "7"} - } - } - })); - assert_eq!( - openai_wire_body(&decoded).get("provider_options"), - Some(&json!({ - "version": "v1", - "providers": {"xai": {"schema_version": 1, "turn_index": "7"}} - })) - ); -} - -#[test] -fn decoder_should_preserve_a_bounded_prompt_cache_key_without_debug_exposure() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "cache this prefix", - "prompt_cache_key": "private-cache-route" - })); - let request = generate_operation(&decoded); - - assert_eq!(request.prompt_cache_key(), Some("private-cache-route")); - assert!(!format!("{decoded:?}").contains("private-cache-route")); -} - -#[test] -fn decoder_should_map_string_input_to_user_text() { - let decoded = generate_request(json!({"model": "smart-code", "input": "hello"})); - let message = &generate_operation(&decoded).messages()[0]; - - assert!(matches!( - (message.role(), &message.content()[0]), - (MessageRole::User, ContentPart::Text(text)) if text == "hello" - )); -} - -#[test] -fn decoder_should_preserve_remote_compaction_trigger_for_openai() { - let decoded = decode_request( - &serde_json::to_vec(&json!({ - "model": "smart-code", - "input": [ - {"type": "message", "role": "user", "content": "history"}, - {"type": "compaction_trigger"} - ], - "stream": true - })) - .expect("request JSON"), - ) - .expect("remote compaction v2 request should decode"); - let upstream_input = openai_wire_body(&decoded) - .get("input") - .and_then(Value::as_array) - .expect("OpenAI input should remain an array"); - - assert!( - upstream_input - .iter() - .any(|item| { item.get("type").and_then(Value::as_str) == Some("compaction_trigger") }) - ); -} - -#[test] -fn decoder_should_default_to_streaming_non_stored_response() { - let decoded = generate_request(json!({"model": "smart-code", "input": "hello"})); - - assert_eq!( - ( - decoded.metadata().stream(), - decoded.metadata().store(), - generate_operation(&decoded).response_persistence(), - ), - (true, false, ResponsePersistence::DoNotStore) - ); -} - -#[test] -fn decoder_should_project_boolean_transport_override_without_wire_leak() { - for use_websocket in [true, false] { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "hello", - "use_websocket": use_websocket - })); - - assert!(openai_wire_body(&decoded).get("use_websocket").is_none()); - assert_eq!( - openai_protocol_context(&decoded).get("use_websocket"), - Some(&json!(use_websocket)) - ); - } -} - -#[test] -fn decoder_should_ignore_non_boolean_transport_override_without_disclosure() { - let secret = "private-transport-marker"; - let decoded = decode_request( - json!({"model": "smart-code", "input": "hello", "use_websocket": secret}) - .to_string() - .as_bytes(), - ) - .expect("transport-only value should not become a request schema gate"); - - assert!(openai_wire_body(&decoded).get("use_websocket").is_none()); - assert!( - openai_protocol_context(&decoded) - .get("use_websocket") - .is_none() - ); - assert!(!format!("{decoded:?}").contains(secret)); -} - -#[test] -fn decoder_should_not_interpret_provider_options_as_a_transport_override() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "hello", - "use_websocket": true, - "provider_options": { - "version": "v1", - "providers": { - "openai": {"schema_version": 1, "transport": "http_sse"} - } - } - })); - - assert_eq!( - openai_protocol_context(&decoded).get("use_websocket"), - Some(&json!(true)) - ); - assert!(openai_wire_body(&decoded).contains_key("provider_options")); - assert!(openai_wire_body(&decoded).get("use_websocket").is_none()); -} - -#[test] -fn response_create_should_share_transport_override_projection() { - let decoded = decode_response_create( - &json!({ - "type": "response.create", - "model": "smart-code", - "input": "hello", - "use_websocket": false - }) - .to_string(), - ) - .expect("WebSocket response.create should use the shared request decoder"); - - assert_eq!( - openai_protocol_context(&decoded).get("use_websocket"), - Some(&json!(false)) - ); - assert!(openai_wire_body(&decoded).get("use_websocket").is_none()); -} - -#[test] -fn connection_context_should_attach_without_entering_request_debug() { - let user_agent = "Codex-CLI/private-client-context"; - let decoded = generate_request(json!({"model": "smart-code", "input": "hello"})) - .with_client_context( - Some("203.0.113.9".parse().expect("IP")), - Some(user_agent.to_owned()), - ); - - assert_eq!( - ( - decoded.metadata().client_ip(), - decoded.metadata().user_agent(), - ), - (Some("203.0.113.9".parse().expect("IP")), Some(user_agent)) - ); - assert!(!format!("{decoded:?}").contains(user_agent)); -} - -#[test] -fn decoder_should_prepend_instructions_as_a_developer_message() { - let decoded = generate_request(json!({ - "model": "smart-code", - "instructions": "keep the response concise", - "input": "hello" - })); - let messages = generate_operation(&decoded).messages(); - - assert!(matches!( - (messages[0].role(), &messages[0].content()[0], messages[1].role()), - (MessageRole::Developer, ContentPart::Text(text), MessageRole::User) - if text == "keep the response concise" - )); -} - -#[test] -fn decoder_should_preserve_function_call_output_even_when_empty() { - let decoded = generate_request(json!({ - "model": "smart-code", - "input": [{ - "type": "function_call_output", - "call_id": "call_1", - "output": "" - }] - })); - let part = &generate_operation(&decoded).messages()[0].content()[0]; - - assert!(matches!( - part, - ContentPart::ToolResult { call_id, output } if call_id == "call_1" && output.is_empty() - )); -} - -#[test] -fn decoder_should_require_vision_capability_for_input_image() { - let decoded = generate_request(json!({ - "model": "smart-vision", - "input": [{ - "role": "user", - "content": [ - {"type": "input_text", "text": "describe"}, - {"type": "input_image", "image_url": "https://example.invalid/image.png"} - ] - }] - })); - - assert!( - decoded - .operation() - .capability_requirements() - .features() - .contains(&Feature::Vision) - ); -} - -#[test] -fn decoder_should_route_a_large_wire_body_without_local_context_rejection_for_each_provider() { - let body = json!({ - "model": "model-a", - "input": "x".repeat(128_001) - }) - .to_string(); - - for provider_name in ["openai", "xai"] { - let provider = ProviderKind::new(provider_name).expect("provider"); - let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new(), &provider) - .expect("large request should decode"); - let plan = super::snapshot("sk_context_test", provider_name) - .plan( - &PublicModelId::new(decoded.metadata().public_model()).expect("public model"), - decoded.operation(), - &RoutingContext { - provider_kind: Some(provider), - ..RoutingContext::default() - }, - ) - .expect("large wire body should not be locally context-gated"); - - assert_eq!(plan.candidates()[0].upstream_model().as_str(), "model-a"); - } -} - -#[test] -fn decoder_should_preserve_previous_response_intent_without_exposing_it_in_debug() { - let response_id = "resp_private_continuation"; - let decoded = generate_request(json!({ - "model": "smart-code", - "input": "continue", - "previous_response_id": response_id - })); - - assert!( - matches!(decoded.metadata().continuation(), ContinuationIntent::PreviousResponseId(value) if value == response_id) - && !format!("{decoded:?}").contains(response_id) - && decoded - .operation() - .capability_requirements() - .features() - .contains(&Feature::NativeContinuation) - ); -} - -#[test] -fn decoder_should_reject_malformed_json_without_retaining_the_body() { - let secret = br#"{"model":"smart-code","input":"secret-prompt"#; - let error = decode_request(secret).expect_err("truncated JSON must fail"); - - assert_eq!(error, RequestDecodeError::MalformedJson); - assert!(!format!("{error:?}\n{error}").contains("secret-prompt")); -} - -#[test] -fn decoder_should_reject_a_non_object_body() { - let error = - decode_request(br#"["smart-code", "hello"]"#).expect_err("top-level array must fail"); - - assert_eq!(error, RequestDecodeError::ExpectedObject); -} - -#[test] -fn decoder_should_require_model() { - let error = decode_request(json!({"input": "hello"}).to_string().as_bytes()) - .expect_err("missing model must fail"); - - assert_eq!( - error, - RequestDecodeError::MissingField { - field: "model".to_owned() - } - ); -} - -#[test] -fn decoder_should_preserve_missing_input_for_upstream_validation() { - let decoded = decode_request(json!({"model": "smart-code"}).to_string().as_bytes()) - .expect("missing input must not become a gateway schema gate"); - - assert!(openai_wire_body(&decoded).get("input").is_none()); -} - -#[test] -fn decoder_should_preserve_zero_max_output_tokens_for_upstream_validation() { - let decoded = decode_request( - json!({"model": "smart-code", "input": "hello", "max_output_tokens": 0}) - .to_string() - .as_bytes(), - ) - .expect("opaque OpenAI field should pass through"); - - assert_eq!( - ( - generate_operation(&decoded).max_output_tokens(), - openai_wire_body(&decoded).get("max_output_tokens"), - ), - (None, Some(&json!(0))) - ); -} - -#[test] -fn decoder_should_preserve_fractional_max_output_tokens_for_upstream_validation() { - let decoded = decode_request( - json!({"model": "smart-code", "input": "hello", "max_output_tokens": 1.5}) - .to_string() - .as_bytes(), - ) - .expect("opaque OpenAI field should pass through"); - - assert_eq!( - openai_wire_body(&decoded).get("max_output_tokens"), - Some(&json!(1.5)) - ); -} - -#[test] -fn decoder_should_accept_an_external_previous_response_id() { - let decoded = decode_request( - json!({ - "model": "smart-code", - "input": "continue", - "previous_response_id": "upstream-response-id" - }) - .to_string() - .as_bytes(), - ) - .expect("external response ID should pass through"); - - assert!(matches!( - decoded.metadata().continuation(), - ContinuationIntent::PreviousResponseId(value) if value == "upstream-response-id" - )); -} - -#[test] -fn decoder_should_preserve_a_response_id_with_control_characters_for_upstream_validation() { - let response_id = "resp_safe_prefix\nforged_suffix"; - let decoded = decode_request( - json!({ - "model": "smart-code", - "input": "continue", - "previous_response_id": response_id - }) - .to_string() - .as_bytes(), - ) - .expect("response ID shape belongs to the upstream"); - - assert_eq!( - openai_wire_body(&decoded).get("previous_response_id"), - Some(&json!(response_id)) - ); -} - -#[test] -fn decoder_should_preserve_an_oversized_response_id_for_upstream_validation() { - let response_id = format!("resp_{}", "a".repeat(252)); - let decoded = decode_request( - json!({ - "model": "smart-code", - "input": "continue", - "previous_response_id": response_id - }) - .to_string() - .as_bytes(), - ) - .expect("response ID length belongs to the upstream"); - - assert_eq!( - openai_wire_body(&decoded).get("previous_response_id"), - Some(&json!(response_id)) - ); -} - -#[test] -fn decoder_should_preserve_inline_image_data_without_decoding_it() { - let decoded = decode_request( - json!({ - "model": "smart-vision", - "input": [{ - "role": "user", - "content": [{ - "type": "input_image", - "image_url": "data:image/png;base64,cHJpdmF0ZQ==" - }] - }] - }) - .to_string() - .as_bytes(), - ) - .expect("Provider should own OpenAI image validation"); - - assert!( - decoded - .operation() - .capability_requirements() - .features() - .contains(&Feature::Vision) - ); - assert_eq!( - openai_wire_body(&decoded) - .get("input") - .and_then(|input| input.pointer("/0/content/0/image_url")), - Some(&json!("data:image/png;base64,cHJpdmF0ZQ==")) - ); -} - -#[test] -fn decoder_should_preserve_nested_unknown_fields_without_debug_exposure() { - let secret = "nested-private-value"; - let decoded = decode_request( - json!({ - "model": "smart-code", - "input": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "hello", - "secret_value": secret - }] - }] - }) - .to_string() - .as_bytes(), - ) - .expect("unknown OpenAI fields should pass through"); - let rendered = format!("{decoded:?}"); - - assert_eq!( - openai_wire_body(&decoded) - .get("input") - .and_then(|input| input.pointer("/0/content/0/secret_value")), - Some(&json!(secret)) - ); - assert!(!rendered.contains(secret)); -} - -#[test] -fn decoder_should_preserve_unknown_top_level_field() { - let decoded = decode_request( - json!({"model": "smart-code", "input": "hello", "mystery": true}) - .to_string() - .as_bytes(), - ) - .expect("unknown OpenAI fields should pass through"); - - assert_eq!( - openai_wire_body(&decoded).get("mystery"), - Some(&json!(true)) - ); -} - -#[test] -fn decoder_should_preserve_new_official_semantic_without_gateway_enumeration() { - let decoded = decode_request( - json!({"model": "smart-code", "input": "hello", "background": true}) - .to_string() - .as_bytes(), - ) - .expect("Provider should own OpenAI request evolution"); - - assert_eq!( - openai_wire_body(&decoded).get("background"), - Some(&json!(true)) - ); -} - -#[test] -fn decoder_should_preserve_unknown_provider_options_version() { - let decoded = decode_request( - json!({ - "model": "smart-code", - "input": "hello", - "provider_options": {"version": "v2", "providers": {}} - }) - .to_string() - .as_bytes(), - ) - .expect("provider_options is an upstream-owned unknown field"); - - assert_eq!( - openai_wire_body(&decoded).get("provider_options"), - Some(&json!({"version": "v2", "providers": {}})) - ); -} - -#[test] -fn decoder_should_preserve_unknown_stream_value_without_disclosing_prompt() { - let secret = "private prompt that must not appear"; - let decoded = decode_request( - json!({"model": "smart-code", "input": secret, "stream": "private-invalid-value"}) - .to_string() - .as_bytes(), - ) - .expect("upstream owns stream field validation"); - let rendered = format!("{decoded:?}"); - - assert_eq!( - openai_wire_body(&decoded).get("stream"), - Some(&json!("private-invalid-value")) - ); - assert!(!rendered.contains(secret) && !rendered.contains("private prompt")); -} - -#[test] -fn stream_terminal_response_should_equal_non_stream_response() { - let events = text_events(); - let collected = - collect_responses(1_700_000_000, &events).expect("canonical events should encode"); - let sse = collected.sse_frames().join(""); - let parsed = parse_sse_events(&sse).expect("generated SSE should parse"); - let terminal = parsed - .iter() - .find(|event| event.event.as_deref() == Some("response.completed")) - .expect("terminal event should exist"); - let terminal_json: Value = serde_json::from_str(&terminal.data).expect("event data is JSON"); - - assert_eq!(terminal_json.get("response"), Some(collected.response())); -} - -#[test] -fn websocket_events_should_reuse_the_exact_sse_event_json() { - let events = text_events(); - let mut sse_encoder = OpenAiResponsesEncoder::new(1_700_000_000); - let mut websocket_encoder = OpenAiResponsesEncoder::new(1_700_000_000); - - for event in &events { - let provider_event = ProviderEvent::canonical(event.clone()); - let sse_frames = sse_encoder - .push_sse(&provider_event) - .expect("SSE event encoding"); - let websocket_events = websocket_encoder - .push_websocket(&provider_event) - .expect("WebSocket event encoding"); - let sse_body = sse_frames - .iter() - .flat_map(|frame| frame.as_ref().iter().copied()) - .collect::>(); - let parsed = parse_sse_events( - std::str::from_utf8(&sse_body).expect("generated SSE frames remain UTF-8"), - ) - .expect("generated SSE frames remain parseable") - .into_iter() - .map(|event| event.data) - .collect::>(); - - assert_eq!(websocket_events, parsed); - } -} - -#[test] -fn stream_should_emit_first_terminal_and_usage_once() { - let events = text_events(); - let collected = - collect_responses(1_700_000_000, &events).expect("canonical events should encode"); - let parsed = - parse_sse_events(&collected.sse_frames().join("")).expect("generated SSE should parse"); - let first = parsed - .iter() - .filter(|event| event.event.as_deref() == Some("response.created")) - .count(); - let terminal = parsed - .iter() - .filter(|event| event.event.as_deref() == Some("response.completed")) - .count(); - let non_null_usage = parsed - .iter() - .filter_map(|event| serde_json::from_str::(&event.data).ok()) - .filter(|event| { - event - .pointer("/response/usage") - .is_some_and(|usage| !usage.is_null()) - }) - .count(); - - assert_eq!((first, terminal, non_null_usage), (1, 1, 1)); -} - -#[test] -fn collector_should_encode_reasoning_and_whole_function_call() { - let events = vec![ - GatewayEvent::Started(response_meta()), - GatewayEvent::ContentAdded(ContentItem::new(0, ContentKind::Reasoning)), - GatewayEvent::ReasoningDelta(ReasoningDelta { - content_index: 0, - text: "checked constraints".to_owned(), - }), - GatewayEvent::ContentAdded(ContentItem::new(1, ContentKind::ToolCall)), - GatewayEvent::ToolCallDelta(ToolCallDelta { - content_index: 1, - call_id: "call_weather".to_owned(), - name: Some("weather".to_owned()), - arguments_delta: "{\"city\":\"Shanghai\"}".to_owned(), - }), - GatewayEvent::Completed(response_meta().with_finish_reason(FinishReason::ToolCall)), - ]; - let collected = collect_responses(1_700_000_000, &events) - .expect("reasoning and function call should encode"); - - assert_eq!( - ( - collected.response().pointer("/output/0/type"), - collected.response().pointer("/output/0/summary/0/text"), - collected.response().pointer("/output/1/type"), - collected.response().pointer("/output/1/name"), - collected.response().pointer("/output/1/arguments"), - ), - ( - Some(&json!("reasoning")), - Some(&json!("checked constraints")), - Some(&json!("function_call")), - Some(&json!("weather")), - Some(&json!("{\"city\":\"Shanghai\"}")), - ) - ); -} - -#[test] -fn collector_should_generate_deterministic_output_ids() { - let events = text_events(); - let first = collect_responses(1_700_000_000, &events).expect("first encoding should work"); - let second = collect_responses(1_700_000_000, &events).expect("second encoding should work"); - - assert_eq!( - first.response().pointer("/output/0/id"), - second.response().pointer("/output/0/id") - ); -} - -#[test] -fn collector_should_encode_length_termination_as_incomplete() { - let mut events = text_events(); - let terminal = events.last_mut().expect("terminal event"); - *terminal = GatewayEvent::Completed(response_meta().with_finish_reason(FinishReason::Length)); - let collected = - collect_responses(1_700_000_000, &events).expect("length termination should encode"); - let parsed = - parse_sse_events(&collected.sse_frames().join("")).expect("generated SSE should parse"); - - assert_eq!( - ( - collected.response().pointer("/status"), - collected.response().pointer("/incomplete_details/reason"), - parsed.last().and_then(|event| event.event.as_deref()), - ), - ( - Some(&json!("incomplete")), - Some(&json!("max_output_tokens")), - Some("response.incomplete"), - ) - ); -} - -#[test] -fn collector_should_reject_duplicate_usage() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - collector - .push(&GatewayEvent::Usage(usage())) - .expect("first usage should be accepted"); - let error = collector - .push(&GatewayEvent::Usage(usage())) - .expect_err("duplicate usage must fail"); - - assert_eq!(error, ResponseEncodeError::DuplicateUsage); -} - -#[test] -fn collector_should_keep_accounting_costs_out_of_the_responses_wire() { - let cost = ProviderReportedCost::from_usd_ticks(42).expect("valid provider cost"); - let events = vec![ - GatewayEvent::Started(response_meta()), - GatewayEvent::CalculatedCost( - CalculatedCost::from_usd_ticks(24).expect("valid calculated cost"), - ), - GatewayEvent::ProviderCost(cost), - GatewayEvent::Completed(completed_meta()), - ]; - let collected = collect_responses(1_700_000_000, &events) - .expect("provider cost is accounting-only metadata"); - - assert!(collected.response().get("cost").is_none()); - assert_eq!( - parse_sse_events(&collected.sse_frames().join("")) - .expect("generated SSE should parse") - .len(), - 2 - ); -} - -#[test] -fn collector_should_reject_incomplete_usage() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - let mut incomplete = Usage::new(); - incomplete.input_tokens = Some(1); - incomplete.output_tokens = Some(1); - let error = collector - .push(&GatewayEvent::Usage(incomplete)) - .expect_err("usage without total must fail"); - - assert_eq!(error, ResponseEncodeError::IncompleteUsage); -} - -#[test] -fn collector_should_reject_terminal_metadata_changes() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - let error = collector - .push(&GatewayEvent::Completed( - ResponseMeta::new("resp_gateway_contract", "different-model") - .with_finish_reason(FinishReason::Stop), - )) - .expect_err("frozen response metadata must not change"); - - assert_eq!(error, ResponseEncodeError::MetadataChanged); -} - -#[test] -fn collector_should_reject_tool_identity_changes() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - collector - .push(&GatewayEvent::ContentAdded(ContentItem::new( - 1, - ContentKind::ToolCall, - ))) - .expect("tool item should encode"); - collector - .push(&GatewayEvent::ToolCallDelta(ToolCallDelta { - content_index: 1, - call_id: "call_first".to_owned(), - name: Some("weather".to_owned()), - arguments_delta: "{".to_owned(), - })) - .expect("first tool delta should encode"); - let error = collector - .push(&GatewayEvent::ToolCallDelta(ToolCallDelta { - content_index: 1, - call_id: "call_rebound".to_owned(), - name: Some("weather".to_owned()), - arguments_delta: "}".to_owned(), - })) - .expect_err("tool call identity must remain frozen"); - - assert_eq!(error, ResponseEncodeError::ToolIdentityChanged { index: 1 }); -} - -#[test] -fn collector_should_reject_a_stream_without_terminal_event() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - let error = collector.finish().expect_err("unfinished stream must fail"); - - assert_eq!( - error, - ResponseEncodeError::Sequence(EventSequenceError::MissingCompleted) - ); -} - -#[test] -fn collector_should_reject_event_before_started_via_core_validator() { - let mut collector = ResponsesCollector::new(1_700_000_000); - let error = collector - .push(&GatewayEvent::TextDelta(TextDelta { - content_index: 0, - text: "out of order".to_owned(), - })) - .expect_err("out-of-order event must fail"); - - assert_eq!( - error, - ResponseEncodeError::Sequence(EventSequenceError::MissingStarted) - ); -} - -#[test] -fn collector_should_reject_unrepresentable_media_output() { - let mut collector = ResponsesCollector::new(1_700_000_000); - collector - .push(&GatewayEvent::Started(response_meta())) - .expect("started should encode"); - let error = collector - .push(&GatewayEvent::ContentAdded(ContentItem::new( - 0, - ContentKind::Image, - ))) - .expect_err("media output must fail explicitly"); - - assert_eq!( - error, - ResponseEncodeError::UnsupportedContentKind { kind: "image" } - ); -} - -#[test] -fn openai_wire_encoder_should_preserve_unknown_media_events_and_upstream_response_id() { - let upstream_id = "resp_upstream_private"; - let started = ResponseMeta::new(upstream_id, "gpt-test"); - let completed = - ResponseMeta::new(upstream_id, "gpt-test").with_finish_reason(FinishReason::Stop); - let raw_partial_image = Bytes::from_static( - b"id: evt_partial_image\r\nevent: response.image_generation_call.partial_image\r\nretry: 2000\r\ndata: { \"type\": \"response.image_generation_call.partial_image\", \"response_id\": \"resp_upstream_private\", \"partial_image_b64\": \"opaque-image-fragment\" }\r\n\r\n", - ); - let events = [ - openai_wire_event( - vec![GatewayEvent::Started(started)], - "response.created", - json!({ - "type": "response.created", - "response": {"id": upstream_id, "status": "in_progress"} - }), - ), - ProviderEvent::wire( - ProtocolWireEvent::json_with_raw_sse_metadata( - "openai", - Some("response.image_generation_call.partial_image".to_owned()), - json!({ - "type": "response.image_generation_call.partial_image", - "response_id": upstream_id, - "partial_image_b64": "opaque-image-fragment" - }), - raw_partial_image.clone(), - Some("evt_partial_image".to_owned()), - Some(2_000), - ) - .expect("valid OpenAI wire event"), - ), - openai_wire_event( - vec![GatewayEvent::Completed(completed)], - "response.completed", - json!({ - "type": "response.completed", - "response": { - "id": upstream_id, - "status": "completed", - "output": [{"type": "image_generation_call", "result": "opaque-image"}] - } - }), - ), - ]; - let mut encoder = OpenAiResponsesEncoder::new(1_700_000_000); - let frames = events - .iter() - .flat_map(|event| encoder.push_sse(event).expect("wire event should encode")) - .collect::>(); - let response = encoder.finish().expect("wire response should finish"); - let body = frames - .iter() - .flat_map(|frame| frame.as_ref().iter().copied()) - .collect::>(); - let body_text = std::str::from_utf8(&body).expect("wire SSE should be UTF-8"); - let parsed = parse_sse_events(body_text).expect("wire SSE should parse"); - - assert_eq!(parsed.len(), 3); - assert_eq!(parsed[1].id.as_deref(), Some("evt_partial_image")); - assert_eq!(parsed[1].retry, Some(2_000)); - assert_eq!( - serde_json::from_str::(&parsed[1].data) - .expect("unknown event JSON") - .get("partial_image_b64"), - Some(&json!("opaque-image-fragment")) - ); - assert_eq!(response.get("id"), Some(&json!(upstream_id))); - assert!(body_text.contains(upstream_id)); - assert!( - body.windows(raw_partial_image.len()) - .any(|frame| frame == raw_partial_image.as_ref()) - ); +fn openai_protocol_context(decoded: &DecodedResponsesRequest) -> &serde_json::Map { + generate_operation(decoded).protocol_payload().context() } fn openai_wire_event(canonical: Vec, event_type: &str, data: Value) -> ProviderEvent { @@ -1162,46 +58,379 @@ fn openai_wire_event(canonical: Vec, event_type: &str, data: Value } #[test] -fn typed_compaction_output_should_encode_the_codex_remote_compaction_v2_contract() { - let events = [ - GatewayEvent::Started(response_meta()), - GatewayEvent::CompactionOutput(CompactionOutput::new( - CompactionSummary::new("plain Grok conversation summary").expect("valid summary"), - )), - GatewayEvent::Completed(completed_meta()), - ]; - let collected = collect_responses(1_700_000_000, &events) - .expect("typed compaction output should be representable as OpenAI Responses"); - let parsed = - parse_sse_events(&collected.sse_frames().join("")).expect("compaction SSE should be valid"); - let done_items = parsed - .iter() - .filter_map(|event| serde_json::from_str::(&event.data).ok()) - .filter(|event| { - event.get("type").and_then(Value::as_str) == Some("response.output_item.done") - }) - .filter_map(|event| event.get("item").cloned()) - .collect::>(); +fn decoder_should_preserve_the_openai_body_and_only_derive_stable_routing_facts() { + let request_body = json!({ + "model": "smart-code", + "input": [{ + "role": "user", + "content": [ + {"type": "input_text", "text": "describe"}, + {"type": "input_image", "image_url": "https://example.invalid/image.png"} + ] + }], + "tools": [{"type": "function", "name": "weather", "parameters": {"type": "object"}}], + "text": {"format": {"type": "json_schema", "name": "weather", "schema": {"type": "object"}}}, + "reasoning": {"effort": "future-value"}, + "max_output_tokens": 512, + "stream": false, + "store": true, + "background": true, + "future_official_field": {"keep": true} + }); + let decoded = generate_request(request_body.clone()); + let requirements = decoded.operation().capability_requirements(); assert_eq!( - ( - done_items, - collected.response().pointer("/output").cloned(), - parsed - .last() - .and_then(|event| serde_json::from_str::(&event.data).ok()) - .and_then(|event| event.get("type").cloned()), + Value::Object(openai_wire_body(&decoded).clone()), + request_body + ); + assert_eq!( + openai_wire_body(&decoded) + .keys() + .map(String::as_str) + .collect::>(), + [ + "model", + "input", + "tools", + "text", + "reasoning", + "max_output_tokens", + "stream", + "store", + "background", + "future_official_field" + ] + ); + assert_eq!(requirements.requested_output_tokens(), Some(512)); + assert!( + requirements + .features() + .is_superset(&std::collections::BTreeSet::from([ + Feature::Tools, + Feature::Vision, + Feature::Reasoning, + Feature::JsonSchema, + ])) + ); + assert!(!decoded.metadata().stream()); + assert!(decoded.metadata().store()); +} + +#[test] +fn decoder_should_preserve_compaction_trigger_for_the_openai_provider() { + let decoded = generate_request(json!({ + "model": "smart-code", + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ] + })); + + assert_eq!( + Value::Object(openai_wire_body(&decoded).clone()).pointer("/input/1/type"), + Some(&json!("compaction_trigger")) + ); + assert!(matches!(decoded.operation(), Operation::Generate(_))); +} + +#[test] +fn decoder_should_keep_transport_override_out_of_the_openai_wire_body() { + let decoded = generate_request(json!({ + "model": "smart-code", + "input": "hello", + "use_websocket": true + })); + + assert!(!openai_wire_body(&decoded).contains_key("use_websocket")); + assert_eq!( + openai_protocol_context(&decoded).get("use_websocket"), + Some(&json!(true)) + ); +} + +#[test] +fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() { + let mut headers = HeaderMap::new(); + headers.insert("x-codex-turn-state", HeaderValue::from_static("turn-state")); + headers.insert( + "x-codex-turn-metadata", + HeaderValue::from_static("{\"kind\":\"review\"}"), + ); + headers.insert("conversation-id", HeaderValue::from_static("conversation")); + let body = json!({"model": "smart-code", "input": "hello"}); + + let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers) + .expect("request should decode"); + + assert_eq!(Value::Object(openai_wire_body(&decoded).clone()), body); + assert_eq!( + openai_protocol_context(&decoded), + &serde_json::Map::from_iter([ + ("turn_state".to_owned(), json!("turn-state")), + ("turn_metadata".to_owned(), json!("{\"kind\":\"review\"}")), + ("conversation_id".to_owned(), json!("conversation")), + ]) + ); +} + +#[test] +fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() { + let secret = "nested-private-value"; + let decoded = generate_request(json!({ + "model": "smart-code", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello", "future": secret}]}], + "future_top_level": {"secret": secret} + })); + + assert_eq!( + Value::Object(openai_wire_body(&decoded).clone()).pointer("/input/0/content/0/future"), + Some(&json!(secret)) + ); + assert!(!format!("{decoded:?}").contains(secret)); +} + +#[test] +fn decoder_should_preserve_continuation_and_mark_the_routing_requirement() { + let decoded = generate_request(json!({ + "model": "smart-code", + "input": "continue", + "previous_response_id": "resp_private_continuation" + })); + + assert!(matches!( + decoded.metadata().continuation(), + ContinuationIntent::PreviousResponseId(value) if value == "resp_private_continuation" + )); + assert!( + decoded + .operation() + .capability_requirements() + .features() + .contains(&Feature::NativeContinuation) + ); + assert!(!format!("{decoded:?}").contains("resp_private_continuation")); +} + +#[test] +fn decoder_should_leave_openai_semantic_validation_to_the_upstream() { + let decoded = generate_request(json!({ + "model": "smart-code", + "stream": "future-invalid-value", + "max_output_tokens": 0, + "future_official_field": [1, 2, 3] + })); + + assert_eq!( + Value::Object(openai_wire_body(&decoded).clone()), + json!({ + "model": "smart-code", + "stream": "future-invalid-value", + "max_output_tokens": 0, + "future_official_field": [1, 2, 3] + }) + ); + assert_eq!(generate_operation(&decoded).max_output_tokens(), None); +} + +#[test] +fn decoder_should_not_reject_large_bodies_using_catalog_context_limits() { + let body = json!({ + "model": "model-a", + "input": "x".repeat(128_001) + }) + .to_string(); + let decoded = decode_request(body.as_bytes()).expect("large request should decode"); + + let plan = super::snapshot("sk_context_test", "openai") + .plan( + &PublicModelId::new(decoded.metadata().public_model()).expect("public model"), + decoded.operation(), + &RoutingContext::default(), + ) + .expect("large body must not be locally context-gated"); + + assert_eq!(plan.candidates()[0].upstream_model().as_str(), "model-a"); +} + +#[test] +fn decoder_should_return_safe_errors_for_invalid_envelopes() { + let malformed = decode_request(br#"{"model":"smart-code","input":"private-prompt"#) + .expect_err("truncated JSON must fail"); + let non_object = decode_request(br#"["smart-code"]"#).expect_err("array must fail"); + let missing_model = decode_request(br#"{"input":"hello"}"#).expect_err("model is required"); + + assert_eq!(malformed, RequestDecodeError::MalformedJson); + assert_eq!(non_object, RequestDecodeError::ExpectedObject); + assert_eq!( + missing_model, + RequestDecodeError::MissingField { + field: "model".to_owned() + } + ); + assert!(!format!("{malformed:?} {malformed}").contains("private-prompt")); +} + +#[test] +fn transparent_encoder_should_forward_raw_sse_frames_unknown_events_and_terminal_response() { + let response_id = "resp_upstream"; + let started = ResponseMeta::new(response_id, "gpt-test"); + let completed = ResponseMeta::new(response_id, "gpt-test"); + let raw_partial = Bytes::from_static( + b"id: evt_partial\r\nevent: response.image_generation_call.partial_image\r\nretry: 2000\r\ndata: {\"type\":\"response.image_generation_call.partial_image\",\"response_id\":\"resp_upstream\",\"opaque\":true}\r\n\r\n", + ); + let partial_data = json!({ + "type": "response.image_generation_call.partial_image", + "response_id": response_id, + "opaque": true + }); + let terminal_response = json!({ + "id": response_id, + "status": "completed", + "output": [{"type": "image_generation_call", "result": "opaque-image"}], + "future_terminal_field": {"keep": true} + }); + let events = [ + openai_wire_event( + vec![GatewayEvent::Started(started)], + "response.created", + json!({"type": "response.created", "response": {"id": response_id, "status": "in_progress"}}), + ), + ProviderEvent::wire( + ProtocolWireEvent::json_with_raw_sse_metadata( + "openai", + Some("response.image_generation_call.partial_image".to_owned()), + partial_data.clone(), + raw_partial.clone(), + Some("evt_partial".to_owned()), + Some(2_000), + ) + .expect("valid raw OpenAI event"), + ), + openai_wire_event( + vec![GatewayEvent::Completed(completed)], + "response.completed", + json!({"type": "response.completed", "response": terminal_response}), ), - ( - vec![json!({ - "type": "compaction", - "encrypted_content": "plain Grok conversation summary", - })], - Some(json!([{ - "type": "compaction", - "encrypted_content": "plain Grok conversation summary", - }])), - Some(json!("response.completed")), - ) + ]; + let mut encoder = OpenAiResponsesEncoder::new(); + let frames = events + .iter() + .flat_map(|event| encoder.push_sse(event).expect("wire event should encode")) + .collect::>(); + let response = encoder.finish().expect("wire response should finish"); + let body = frames + .iter() + .flat_map(|frame| frame.as_ref().iter().copied()) + .collect::>(); + let body_text = std::str::from_utf8(&body).expect("wire SSE should be UTF-8"); + let parsed = parse_sse_events(body_text).expect("wire SSE should parse"); + + assert_eq!(frames[1], raw_partial); + assert_eq!(parsed[1].id.as_deref(), Some("evt_partial")); + assert_eq!(parsed[1].retry, Some(2_000)); + assert_eq!( + serde_json::from_str::(&parsed[1].data).expect("unknown event JSON"), + partial_data + ); + assert_eq!(response, terminal_response); +} + +#[test] +fn transparent_encoder_should_use_identical_json_for_sse_and_websocket() { + let response_id = "resp_transport_match"; + let created_data = json!({ + "type": "response.created", + "response": {"id": response_id, "status": "in_progress", "future": true} + }); + let completed_data = json!({ + "type": "response.completed", + "response": {"id": response_id, "status": "completed", "output": []} + }); + let events = [ + openai_wire_event( + vec![GatewayEvent::Started(ResponseMeta::new( + response_id, + "gpt-test", + ))], + "response.created", + created_data.clone(), + ), + openai_wire_event( + vec![GatewayEvent::Completed(ResponseMeta::new( + response_id, + "gpt-test", + ))], + "response.completed", + completed_data.clone(), + ), + ]; + let mut sse_encoder = OpenAiResponsesEncoder::new(); + let mut websocket_encoder = OpenAiResponsesEncoder::new(); + + let sse = events + .iter() + .flat_map(|event| sse_encoder.push_sse(event).expect("SSE wire event")) + .collect::>(); + let websocket = events + .iter() + .flat_map(|event| { + websocket_encoder + .push_websocket(event) + .expect("WebSocket wire event") + }) + .collect::>(); + let sse_text = String::from_utf8( + sse.iter() + .flat_map(|frame| frame.as_ref().iter().copied()) + .collect(), + ) + .expect("SSE is UTF-8"); + let sse_data = parse_sse_events(&sse_text) + .expect("SSE parses") + .into_iter() + .map(|event| event.data) + .collect::>(); + + assert_eq!(websocket, sse_data); + assert_eq!( + websocket, + vec![created_data.to_string(), completed_data.to_string()] + ); +} + +#[test] +fn transparent_encoder_should_require_matching_canonical_and_wire_terminals() { + let mut encoder = OpenAiResponsesEncoder::new(); + let started = openai_wire_event( + vec![GatewayEvent::Started(ResponseMeta::new( + "resp_1", "gpt-test", + ))], + "response.created", + json!({"type": "response.created", "response": {"id": "resp_1"}}), + ); + encoder.push_sse(&started).expect("started event"); + let changed_identity = openai_wire_event( + vec![GatewayEvent::Completed(ResponseMeta::new( + "resp_2", "gpt-test", + ))], + "response.completed", + json!({"type": "response.completed", "response": {"id": "resp_2"}}), + ); + + assert_eq!( + encoder + .push_sse(&changed_identity) + .expect_err("identity changes must fail"), + ResponseEncodeError::WireIdentityChanged + ); + + let mut missing_terminal = OpenAiResponsesEncoder::new(); + missing_terminal.push_sse(&started).expect("started event"); + assert_eq!( + missing_terminal + .finish() + .expect_err("terminal response is required"), + ResponseEncodeError::MissingWireTerminal ); } diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket.rs b/backend/crates/gateway-api/tests/openai/responses/websocket.rs index a02ad90cb..434db0b91 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket.rs @@ -22,12 +22,7 @@ fn response_create_should_default_to_the_websocket_streaming_contract() { let Operation::Generate(request) = decoded.operation() else { panic!("Responses must map to Generate"); }; - assert!( - request - .protocol_payload() - .and_then(|payload| payload.body().get("stream")) - .is_none() - ); + assert!(request.protocol_payload().body().get("stream").is_none()); } #[test] @@ -51,7 +46,7 @@ fn response_create_should_preserve_provider_options_as_opaque_wire_body() { let Operation::Generate(request) = decoded.operation() else { panic!("Responses must map to Generate"); }; - let payload = request.protocol_payload().expect("OpenAI wire payload"); + let payload = request.protocol_payload(); assert_eq!( payload.body().get("provider_options"), @@ -86,7 +81,8 @@ fn response_create_should_preserve_compaction_trigger_for_openai() { assert_eq!( request .protocol_payload() - .and_then(|payload| payload.body().get("input")) + .body() + .get("input") .and_then(|input| input.pointer("/1/type")), Some(&json!("compaction_trigger")) ); diff --git a/backend/crates/gateway-core/Cargo.toml b/backend/crates/gateway-core/Cargo.toml index 0cb416a0e..29ba7dce0 100644 --- a/backend/crates/gateway-core/Cargo.toml +++ b/backend/crates/gateway-core/Cargo.toml @@ -18,7 +18,7 @@ chrono = "0.4.45" futures = "0.3.32" futures-timer = "3.0.3" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" +serde_json = { version = "1.0.150", features = ["preserve_order"] } thiserror = "2.0.18" uuid = { version = "1.23.3", features = ["v7"] } diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index 1ddf4cc6d..f6071c860 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -245,7 +245,6 @@ impl ProviderStreamAccountFeedback { crate::event::GatewayEvent::TextDelta(_) | crate::event::GatewayEvent::ReasoningDelta(_) | crate::event::GatewayEvent::ToolCallDelta(_) - | crate::event::GatewayEvent::CompactionOutput(_) ) }) { @@ -350,8 +349,7 @@ impl Stream for ProviderStream { Poll::Pending => Poll::Pending, Poll::Ready(Some(Ok(event))) => { // 带 wire 的 canonical facts 只是旁路观测:wire 才是客户端协议的 - // 权威表达。只有 canonical-only Provider 输出(例如 xAI 的内部 - // compaction)需要以状态机作为交付条件。 + // 权威表达。只有 canonical-only Provider 输出需要以状态机作为交付条件。 if event.wire_event().is_none() && !event.canonical_facts().is_empty() { this.strict_canonical_seen = true; for fact in event.canonical_facts() { diff --git a/backend/crates/gateway-core/src/event.rs b/backend/crates/gateway-core/src/event.rs index cdb12d7c0..1764bfbae 100644 --- a/backend/crates/gateway-core/src/event.rs +++ b/backend/crates/gateway-core/src/event.rs @@ -179,77 +179,6 @@ impl fmt::Debug for ToolCallDelta { } } -/// Provider 生成的明文对话摘要。 -#[derive(Clone, PartialEq, Eq)] -pub struct CompactionSummary(String); - -impl CompactionSummary { - /// 创建非空明文摘要。 - /// - /// # Errors - /// - /// 摘要为空时返回错误。 - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() { - return Err(CompactionSummaryError::Empty); - } - Ok(Self(value)) - } - - /// 返回明文摘要。 - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for CompactionSummary { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("CompactionSummary") - .field("bytes", &self.0.len()) - .finish() - } -} - -/// 明文摘要不满足 canonical compaction 约束。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] -pub enum CompactionSummaryError { - /// 摘要为空。 - #[error("compaction summary is empty")] - Empty, -} - -/// Provider 生成、等待客户端协议投影的明文压缩结果。 -#[derive(Clone, PartialEq, Eq)] -pub struct CompactionOutput { - summary: CompactionSummary, -} - -impl CompactionOutput { - /// 创建明文压缩输出。 - #[must_use] - pub const fn new(summary: CompactionSummary) -> Self { - Self { summary } - } - - /// 返回 Provider 生成的明文摘要。 - #[must_use] - pub const fn summary(&self) -> &CompactionSummary { - &self.summary - } -} - -impl fmt::Debug for CompactionOutput { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("CompactionOutput") - .field("summary", &self.summary) - .finish() - } -} - /// Provider 返回的协议原生 JSON event。 /// /// Core 不解释 `data`,只把它与同一上游事件产生的 canonical facts 一起 @@ -641,8 +570,6 @@ pub enum GatewayEvent { ReasoningDelta(ReasoningDelta), /// 工具调用增量。 ToolCallDelta(ToolCallDelta), - /// 对话压缩产生的明文摘要。 - CompactionOutput(CompactionOutput), /// 规范化用量。 Usage(Usage), /// Provider 域依据实际模型和终态用量计算的费用。 @@ -806,7 +733,6 @@ impl GatewayEvent { Self::TextDelta(_) | Self::ReasoningDelta(_) | Self::ToolCallDelta(_) - | Self::CompactionOutput(_) | Self::Completed(_) ) } @@ -827,9 +753,6 @@ pub enum EventSequenceError { /// 重复索引。 index: u32, }, - /// 同一响应产生了多个压缩结果。 - #[error("canonical stream contains more than one CompactionOutput event")] - DuplicateCompactionOutput, /// Delta 引用了不存在或类别错误的内容项。 #[error("canonical delta does not match content index {index}")] InvalidDeltaTarget { @@ -849,7 +772,6 @@ pub enum EventSequenceError { pub struct EventSequenceValidator { started: bool, completed: bool, - compaction_output: bool, content: BTreeMap, } @@ -860,7 +782,6 @@ impl EventSequenceValidator { Self { started: false, completed: false, - compaction_output: false, content: BTreeMap::new(), } } @@ -901,12 +822,6 @@ impl EventSequenceValidator { GatewayEvent::ToolCallDelta(delta) => { self.require_content(delta.content_index, ContentKind::ToolCall)?; } - GatewayEvent::CompactionOutput(_) => { - if self.compaction_output { - return Err(EventSequenceError::DuplicateCompactionOutput); - } - self.compaction_output = true; - } GatewayEvent::Usage(_) | GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => {} diff --git a/backend/crates/gateway-core/src/operation/mod.rs b/backend/crates/gateway-core/src/operation/mod.rs index dc839db5a..920ea0b86 100644 --- a/backend/crates/gateway-core/src/operation/mod.rs +++ b/backend/crates/gateway-core/src/operation/mod.rs @@ -19,8 +19,6 @@ use crate::error::{OperationError, validate_text}; pub enum OperationKind { /// 文本、多模态和工具生成。 Generate, - /// 对话历史压缩。 - CompactConversation, /// 向量嵌入。 Embed, /// 文档重排。 @@ -37,7 +35,6 @@ impl OperationKind { pub const fn as_str(self) -> &'static str { match self { Self::Generate => "generate", - Self::CompactConversation => "compact_conversation", Self::Embed => "embed", Self::Rerank => "rerank", Self::GenerateImage => "generate_image", @@ -55,15 +52,6 @@ pub enum RetrySafety { NonIdempotent, } -/// 客户端对生成结果的持久化意图。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ResponsePersistence { - /// 允许所选 Provider 保存响应状态。 - Store, - /// 要求所选 Provider 不保存响应状态。 - DoNotStore, -} - /// Router 理解的稳定能力。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] @@ -251,476 +239,33 @@ impl fmt::Debug for ProtocolPayload { } } -/// 生成输入的角色。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum MessageRole { - /// 系统指令。 - System, - /// 开发者指令;与系统角色分开保留,避免协议 adapter 丢失优先级语义。 - Developer, - /// 用户输入。 - User, - /// 助手历史输出。 - Assistant, -} - -/// 图像输入来源。 -#[derive(Clone, PartialEq, Eq)] -pub enum ImageSource { - /// 外部 URL;协议 adapter 必须先完成长度与 scheme 校验。 - Url(String), - /// 已解码的二进制图像。 - Bytes { - /// MIME type。 - media_type: String, - /// 图像内容。 - data: Vec, - }, -} - -impl fmt::Debug for ImageSource { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Url(_) => formatter.write_str("ImageSource::Url()"), - Self::Bytes { media_type, data } => formatter - .debug_struct("ImageSource::Bytes") - .field("media_type", media_type) - .field("bytes", &data.len()) - .finish(), - } - } -} - -/// 生成输入的稳定内容片段。 -#[derive(Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum ContentPart { - /// 文本内容。 - Text(String), - /// 图像内容。 - Image(ImageSource), - /// 工具结果。 - ToolResult { - /// 对应 tool call ID。 - call_id: String, - /// 工具输出文本。 - output: String, - }, - /// 助手发起的工具调用;portable history 用它在跨 Provider 时恢复调用上下文。 - ToolCall { - /// 稳定 tool call ID。 - call_id: String, - /// 工具名称。 - name: String, - /// 完整 JSON arguments 文本。 - arguments: String, - }, -} - -impl fmt::Debug for ContentPart { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Text(text) => formatter - .debug_tuple("Text") - .field(&format_args!("<{} bytes>", text.len())) - .finish(), - Self::Image(source) => formatter.debug_tuple("Image").field(source).finish(), - Self::ToolResult { call_id, output } => formatter - .debug_struct("ToolResult") - .field("call_id", call_id) - .field("output", &format_args!("<{} bytes>", output.len())) - .finish(), - Self::ToolCall { - call_id, - name, - arguments, - } => formatter - .debug_struct("ToolCall") - .field("call_id", call_id) - .field("name", name) - .field("arguments", &format_args!("<{} bytes>", arguments.len())) - .finish(), - } - } -} - -/// 一项有角色的生成输入。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Message { - role: MessageRole, - content: Vec, -} - -impl Message { - /// 创建消息。 - /// - /// # Errors - /// - /// 内容为空时返回错误。 - pub fn new(role: MessageRole, content: Vec) -> Result { - if content.is_empty() { - return Err(OperationError::EmptyField { field: "content" }); - } - Ok(Self { role, content }) - } - - /// 返回消息角色。 - #[must_use] - pub const fn role(&self) -> MessageRole { - self.role - } - - /// 返回内容片段。 - #[must_use] - pub fn content(&self) -> &[ContentPart] { - &self.content - } -} - -/// 工具声明。 -#[derive(Clone, PartialEq)] -pub struct ToolDefinition { - name: String, - description: Option, - input_schema: Map, - strict: bool, -} - -impl ToolDefinition { - /// 创建工具声明。 - /// - /// # Errors - /// - /// 名称为空时返回错误。 - pub fn new( - name: impl Into, - description: Option, - input_schema: Map, - ) -> Result { - let name = name.into(); - if name.is_empty() { - return Err(OperationError::EmptyField { field: "tool.name" }); - } - Ok(Self { - name, - description, - input_schema, - strict: false, - }) - } - - /// 设置是否要求 Provider 严格遵守输入 schema。 - #[must_use] - pub const fn with_strict(mut self, strict: bool) -> Self { - self.strict = strict; - self - } - - /// 返回工具名。 - #[must_use] - pub fn name(&self) -> &str { - &self.name - } - - /// 返回工具说明。 - #[must_use] - pub fn description(&self) -> Option<&str> { - self.description.as_deref() - } - - /// 返回工具输入 schema。 - #[must_use] - pub const fn input_schema(&self) -> &Map { - &self.input_schema - } - - /// 返回是否启用严格 schema。 - #[must_use] - pub const fn strict(&self) -> bool { - self.strict - } -} - -impl fmt::Debug for ToolDefinition { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ToolDefinition") - .field("name", &self.name) - .field( - "description", - &self.description.as_ref().map(|_| ""), - ) - .field("input_schema", &"") - .field("strict", &self.strict) - .finish() - } -} - -/// 结构化 JSON 输出的稳定约束。 -#[derive(Clone, PartialEq)] -pub struct JsonSchemaFormat { - name: String, - description: Option, - schema: Map, - strict: bool, -} - -impl JsonSchemaFormat { - /// 创建 JSON Schema 输出约束。 - /// - /// # Errors - /// - /// Schema 名称为空时返回错误。 - pub fn new( - name: impl Into, - description: Option, - schema: Map, - strict: bool, - ) -> Result { - let name = name.into(); - if name.is_empty() { - return Err(OperationError::EmptyField { - field: "text.format.name", - }); - } - Ok(Self { - name, - description, - schema, - strict, - }) - } - - /// 返回 schema 名称。 - #[must_use] - pub fn name(&self) -> &str { - &self.name - } - - /// 返回可选说明。 - #[must_use] - pub fn description(&self) -> Option<&str> { - self.description.as_deref() - } - - /// 返回 JSON Schema object。 - #[must_use] - pub const fn schema(&self) -> &Map { - &self.schema - } - - /// 返回是否要求严格结构化输出。 - #[must_use] - pub const fn strict(&self) -> bool { - self.strict - } -} - -impl fmt::Debug for JsonSchemaFormat { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("JsonSchemaFormat") - .field("name", &self.name) - .field( - "description", - &self.description.as_ref().map(|_| ""), - ) - .field("schema", &"") - .field("strict", &self.strict) - .finish() - } -} - -/// 生成输出格式。 -#[derive(Clone, PartialEq)] -pub enum OutputFormat { - /// 普通文本。 - Text, - /// 任意 JSON object。 - JsonObject, - /// 指定 JSON Schema。 - JsonSchema(JsonSchemaFormat), -} - -impl fmt::Debug for OutputFormat { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Text => formatter.write_str("Text"), - Self::JsonObject => formatter.write_str("JsonObject"), - Self::JsonSchema(_) => formatter.write_str("JsonSchema()"), - } - } -} - -/// 推理强度。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ReasoningEffort { - /// 最低推理预算。 - Minimal, - /// 低。 - Low, - /// 中。 - Medium, - /// 高。 - High, - /// Provider 支持时使用最高推理预算。 - ExtraHigh, -} - -/// 可见推理摘要要求。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ReasoningSummary { - /// 由模型选择摘要粒度。 - Auto, - /// 简短摘要。 - Concise, - /// 详细摘要。 - Detailed, - /// 显式关闭摘要。 - None, -} - -/// 推理要求。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ReasoningRequirement { - /// 推理强度。 - pub effort: Option, - /// 可见推理摘要要求;`None` 表示客户端未指定。 - pub summary: Option, -} - -/// 对话延续模式。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ContinuationMode { - /// Provider 原生绑定。 - Native, -} - /// 通用生成请求。 #[derive(Clone, PartialEq)] pub struct GenerateRequest { payload: Arc, - reasoning: Option, - continuation: Option, - max_output_tokens: Option, - image_generation_requested: bool, - response_persistence: ResponsePersistence, } #[derive(Clone, PartialEq)] struct GeneratePayload { - messages: Vec, - tools: Vec, - output_format: OutputFormat, - prompt_cache_key: Option, - required_features: BTreeSet, - protocol_payload: Option, + protocol_payload: ProtocolPayload, provider_session_state: Option, } impl GenerateRequest { - /// 创建最小生成请求。 - /// - /// # Errors - /// - /// 消息为空时返回错误。 - pub fn new(messages: Vec) -> Result { - if messages.is_empty() { - return Err(OperationError::EmptyField { field: "messages" }); - } - Ok(Self::from_parts(messages, None)) - } - /// 创建携带协议不透明正文的生成请求。 /// - /// `messages` 是 Router 使用的已知语义投影,允许为空;完整请求始终由 - /// `protocol_payload` 保留并交给对应 Provider 解释。 + /// 完整请求只以原始 wire object 保存;Core 仅局部只读已知字段以推导路由能力, + /// Provider 负责解释与转换正文。 #[must_use] - pub fn from_protocol_payload( - messages: Vec, - protocol_payload: ProtocolPayload, - ) -> Self { - Self::from_parts(messages, Some(protocol_payload)) - } - - fn from_parts(messages: Vec, protocol_payload: Option) -> Self { + pub fn from_protocol_payload(protocol_payload: ProtocolPayload) -> Self { Self { payload: Arc::new(GeneratePayload { - messages, - tools: Vec::new(), - output_format: OutputFormat::Text, - prompt_cache_key: None, - required_features: BTreeSet::new(), protocol_payload, provider_session_state: None, }), - reasoning: None, - continuation: None, - max_output_tokens: None, - image_generation_requested: false, - response_persistence: ResponsePersistence::Store, } } - /// 设置工具。 - #[must_use] - pub fn with_tools(mut self, tools: Vec) -> Self { - Arc::make_mut(&mut self.payload).tools = tools; - self - } - - /// 设置输出格式。 - #[must_use] - pub fn with_output_format(mut self, output_format: OutputFormat) -> Self { - Arc::make_mut(&mut self.payload).output_format = output_format; - self - } - - /// 设置推理要求。 - #[must_use] - pub const fn with_reasoning(mut self, reasoning: ReasoningRequirement) -> Self { - self.reasoning = Some(reasoning); - self - } - - /// 设置对话延续模式。 - #[must_use] - pub const fn with_continuation(mut self, continuation: ContinuationMode) -> Self { - self.continuation = Some(continuation); - self - } - - /// 设置输出 token 上限。 - #[must_use] - pub const fn with_max_output_tokens(mut self, tokens: u64) -> Self { - self.max_output_tokens = Some(tokens); - self - } - - /// 设置客户端提供的稳定 prompt cache 路由键。 - #[must_use] - pub fn with_prompt_cache_key(mut self, key: impl Into) -> Self { - Arc::make_mut(&mut self.payload).prompt_cache_key = Some(key.into()); - self - } - - /// 记录协议 adapter 已识别的图片生成工具意图。 - #[must_use] - pub const fn with_image_generation_requested(mut self, requested: bool) -> Self { - self.image_generation_requested = requested; - self - } - - /// 冻结客户端的响应持久化意图。 - #[must_use] - pub const fn with_response_persistence( - mut self, - response_persistence: ResponsePersistence, - ) -> Self { - self.response_persistence = response_persistence; - self - } - /// 附着同一客户端连接上一轮由 Provider 返回的不透明状态。 #[must_use] pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self { @@ -728,67 +273,44 @@ impl GenerateRequest { self } - /// 增加从不透明协议正文识别出的稳定能力要求。 - #[must_use] - pub fn require_feature(mut self, feature: Feature) -> Self { - Arc::make_mut(&mut self.payload) - .required_features - .insert(feature); - self - } - - /// 返回消息。 - #[must_use] - pub fn messages(&self) -> &[Message] { - &self.payload.messages - } - - /// 返回工具。 - #[must_use] - pub fn tools(&self) -> &[ToolDefinition] { - &self.payload.tools - } - - /// 返回输出格式。 - #[must_use] - pub fn output_format(&self) -> &OutputFormat { - &self.payload.output_format - } - - /// 返回推理要求。 - #[must_use] - pub const fn reasoning(&self) -> Option { - self.reasoning - } - - /// 返回对话延续模式。 - #[must_use] - pub const fn continuation(&self) -> Option { - self.continuation - } - /// 返回最大输出 token 数。 #[must_use] - pub const fn max_output_tokens(&self) -> Option { - self.max_output_tokens + pub fn max_output_tokens(&self) -> Option { + self.body() + .get("max_output_tokens") + .and_then(Value::as_u64) + .filter(|tokens| *tokens > 0) } /// 返回客户端提供的 prompt cache 路由键。 #[must_use] pub fn prompt_cache_key(&self) -> Option<&str> { - self.payload.prompt_cache_key.as_deref() + self.body() + .get("prompt_cache_key") + .and_then(Value::as_str) + .filter(|key| !key.trim().is_empty()) + } + + /// 返回原始请求是否要求 Provider 原生 continuation。 + #[must_use] + pub fn native_continuation_requested(&self) -> bool { + self.body() + .get("previous_response_id") + .and_then(Value::as_str) + .is_some_and(|response_id| !response_id.is_empty()) } /// 返回客户端是否请求了图片生成工具。 #[must_use] - pub const fn image_generation_requested(&self) -> bool { - self.image_generation_requested - } - - /// 返回客户端的响应持久化意图。 - #[must_use] - pub const fn response_persistence(&self) -> ResponsePersistence { - self.response_persistence + pub fn image_generation_requested(&self) -> bool { + self.body() + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| { + tools.iter().any(|tool| { + tool.get("type").and_then(Value::as_str) == Some("image_generation") + }) + }) } /// 返回指定 Provider 的连接内会话状态。 @@ -802,64 +324,55 @@ impl GenerateRequest { /// 返回协议不透明正文。 #[must_use] - pub fn protocol_payload(&self) -> Option<&ProtocolPayload> { - self.payload.protocol_payload.as_ref() + pub fn protocol_payload(&self) -> &ProtocolPayload { + &self.payload.protocol_payload } fn requirements(&self) -> CapabilityRequirements { - self.requirements_for(OperationKind::Generate) - } - - fn requirements_for(&self, operation: OperationKind) -> CapabilityRequirements { - let mut requirements = CapabilityRequirements::new(operation) - .with_requested_output_tokens(self.max_output_tokens); - for feature in &self.payload.required_features { - requirements = requirements.require(*feature); - } - if !self.payload.tools.is_empty() { + let mut requirements = CapabilityRequirements::new(OperationKind::Generate) + .with_requested_output_tokens(self.max_output_tokens()); + if self + .body() + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()) + { requirements = requirements.require(Feature::Tools); } - if self.payload.messages.iter().any(|message| { - message - .content() - .iter() - .any(|part| matches!(part, ContentPart::Image(_))) - }) { + if contains_type(self.body().get("input"), "input_image") { requirements = requirements.require(Feature::Vision); } - if self.reasoning.is_some() { + if self.body().get("reasoning").is_some_and(Value::is_object) { requirements = requirements.require(Feature::Reasoning); } - if matches!(self.payload.output_format, OutputFormat::JsonSchema(_)) { + if self + .body() + .get("text") + .and_then(Value::as_object) + .and_then(|text| text.get("format")) + .and_then(Value::as_object) + .and_then(|format| format.get("type")) + .and_then(Value::as_str) + == Some("json_schema") + { requirements = requirements.require(Feature::JsonSchema); } - if self.continuation.is_some() { + if self.native_continuation_requested() { requirements.require(Feature::NativeContinuation) } else { requirements } } + + fn body(&self) -> &Map { + self.protocol_payload().body() + } } impl fmt::Debug for GenerateRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("GenerateRequest") - .field("messages", &self.payload.messages.len()) - .field("tools", &self.payload.tools.len()) - .field("output_format", &self.payload.output_format) - .field("reasoning", &self.reasoning) - .field("continuation", &self.continuation) - .field("max_output_tokens", &self.max_output_tokens) - .field( - "prompt_cache_key", - &self.payload.prompt_cache_key.as_ref().map(|_| ""), - ) - .field( - "image_generation_requested", - &self.image_generation_requested, - ) - .field("response_persistence", &self.response_persistence) .field( "provider_session_state", &self @@ -868,57 +381,23 @@ impl fmt::Debug for GenerateRequest { .as_ref() .map(|_| ""), ) - .field("required_features", &self.payload.required_features) - .field("protocol_payload", &self.payload.protocol_payload) + .field("protocol_payload", self.protocol_payload()) .finish() } } -/// 请求 Provider 将完整对话历史压缩为可在后续轮次继续使用的摘要。 -/// -/// 请求沿用生成调用的 canonical history、工具和推理约束,但作为独立 operation -/// 路由,Provider 不得通过扫描客户端 wire payload 判断压缩语义。 -#[derive(Clone, PartialEq)] -pub struct CompactConversationRequest { - generation: GenerateRequest, -} - -impl CompactConversationRequest { - /// 从协议 adapter 已规范化的生成上下文创建压缩请求。 - #[must_use] - pub const fn new(generation: GenerateRequest) -> Self { - Self { generation } - } - - /// 返回需要压缩的 canonical 生成上下文。 - #[must_use] - pub const fn generation(&self) -> &GenerateRequest { - &self.generation - } - - /// 拆出需要压缩的 canonical 生成上下文。 - #[must_use] - pub fn into_generation(self) -> GenerateRequest { - self.generation - } - - fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self { - self.generation = self.generation.with_provider_session_state(state); - self - } - - fn requirements(&self) -> CapabilityRequirements { - self.generation - .requirements_for(OperationKind::CompactConversation) - } -} - -impl fmt::Debug for CompactConversationRequest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("CompactConversationRequest") - .field("generation", &self.generation) - .finish() +fn contains_type(value: Option<&Value>, expected: &str) -> bool { + match value { + Some(Value::Array(values)) => values + .iter() + .any(|value| contains_type(Some(value), expected)), + Some(Value::Object(object)) => { + object.get("type").and_then(Value::as_str) == Some(expected) + || object + .values() + .any(|value| contains_type(Some(value), expected)) + } + _ => false, } } @@ -1098,8 +577,6 @@ impl fmt::Debug for SpeechRequest { pub enum Operation { /// 生成。 Generate(GenerateRequest), - /// 对话历史压缩。 - CompactConversation(CompactConversationRequest), /// Embedding。 Embed(EmbedRequest), /// Rerank。 @@ -1116,9 +593,6 @@ impl Operation { pub fn with_provider_session_state(self, state: ProviderSessionState) -> Self { match self { Self::Generate(request) => Self::Generate(request.with_provider_session_state(state)), - Self::CompactConversation(request) => { - Self::CompactConversation(request.with_provider_session_state(state)) - } operation => operation, } } @@ -1128,7 +602,6 @@ impl Operation { pub const fn kind(&self) -> OperationKind { match self { Self::Generate(_) => OperationKind::Generate, - Self::CompactConversation(_) => OperationKind::CompactConversation, Self::Embed(_) => OperationKind::Embed, Self::Rerank(_) => OperationKind::Rerank, Self::GenerateImage(_) => OperationKind::GenerateImage, @@ -1141,7 +614,6 @@ impl Operation { pub fn capability_requirements(&self) -> CapabilityRequirements { match self { Self::Generate(request) => request.requirements(), - Self::CompactConversation(request) => request.requirements(), Self::Embed(_) => CapabilityRequirements::new(OperationKind::Embed), Self::Rerank(_) => CapabilityRequirements::new(OperationKind::Rerank), Self::GenerateImage(_) => CapabilityRequirements::new(OperationKind::GenerateImage), @@ -1154,23 +626,18 @@ impl Operation { pub const fn retry_safety(&self) -> RetrySafety { match self { Self::Embed(_) | Self::Rerank(_) => RetrySafety::Idempotent, - Self::Generate(_) - | Self::CompactConversation(_) - | Self::GenerateImage(_) - | Self::Speech(_) => RetrySafety::NonIdempotent, + Self::Generate(_) | Self::GenerateImage(_) | Self::Speech(_) => { + RetrySafety::NonIdempotent + } } } /// 返回该 operation 是否代表一次图片生成请求。 #[must_use] - pub const fn image_generation_requested(&self) -> bool { + pub fn image_generation_requested(&self) -> bool { match self { Self::Generate(request) => request.image_generation_requested(), - Self::CompactConversation(_) - | Self::Embed(_) - | Self::Rerank(_) - | Self::GenerateImage(_) - | Self::Speech(_) => false, + Self::Embed(_) | Self::Rerank(_) | Self::GenerateImage(_) | Self::Speech(_) => false, } } @@ -1179,9 +646,6 @@ impl Operation { pub fn provider_session_state(&self, provider: &str) -> Option<&ProviderSessionState> { match self { Self::Generate(request) => request.provider_session_state(provider), - Self::CompactConversation(request) => { - request.generation.provider_session_state(provider) - } Self::Embed(_) | Self::Rerank(_) | Self::GenerateImage(_) | Self::Speech(_) => None, } } diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 534ba963b..b32e5b2e9 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -33,15 +33,14 @@ use gateway_core::event::{ ProviderResponseTimings, ResponseMeta, UpstreamHttpVersion, WebSocketPoolKind, }; use gateway_core::operation::{ - ContentPart, EmbedRequest, GenerateRequest, Message, MessageRole, Operation, - ProviderSessionState, RetrySafety, + EmbedRequest, GenerateRequest, Operation, ProtocolPayload, ProviderSessionState, RetrySafety, }; use gateway_core::policy::ClientApiKeyId; use gateway_core::routing::{ ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext, RoutingPlan, RuntimeSnapshot, UpstreamModelId, }; -use serde_json::{Map, Value}; +use serde_json::{Map, Value, json}; #[derive(Debug, Clone, PartialEq, Eq)] struct FinalState { @@ -317,19 +316,26 @@ fn operation(retry_safety: RetrySafety) -> Operation { } fn generate_operation() -> Operation { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("hello".to_owned())], - ) - .expect("message"); - Operation::Generate(GenerateRequest::new(vec![message]).expect("generate request")) + let body = json!({ + "model": "gpt-5", + "input": [{"type": "message", "role": "user", "content": "hello"}], + }); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"), + )) } fn image_generate_operation() -> Operation { - let Operation::Generate(request) = generate_operation() else { - unreachable!("generate_operation must return Generate") - }; - Operation::Generate(request.with_image_generation_requested(true)) + let body = json!({ + "model": "gpt-5", + "input": [{"type": "message", "role": "user", "content": "draw"}], + "tools": [{"type": "image_generation"}], + }); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"), + )) } fn complete_stream(total_tokens: Option) -> Vec> { @@ -373,7 +379,8 @@ fn image_stream(image_output_tokens: Option) -> Vec RoutingPlan { let provider = ProviderKind::new("openai").expect("provider"); let public_model = PublicModelId::new("gpt-5").expect("public model"); - let capabilities = ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000)); + let capabilities = ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000)) + .with_upstream_feature_validation(); RuntimeSnapshot::new( ConfigRevision::new(1).expect("config revision"), AccountSelectionPolicy::new( diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 050d713eb..04cd93cf3 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -30,15 +30,14 @@ use gateway_core::engine::{ NewModelRequest, RecoveryReport, UpstreamSendState, }; use gateway_core::error::{GatewayErrorKind, ProviderErrorKind, StoreError}; -use gateway_core::operation::{ - ContentPart, GenerateRequest, Message, MessageRole, Operation, OperationKind, -}; +use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProtocolPayload}; use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}; use gateway_core::routing::snapshot::RuntimeSnapshotHandle; use gateway_core::routing::{ ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot, UpstreamModelId, }; +use serde_json::json; #[test] fn only_provider_attributable_failures_should_affect_circuit() { @@ -296,10 +295,12 @@ fn client_snapshot() -> RuntimeSnapshot { } fn probe_operation() -> Operation { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("ping".to_owned())], - ) - .expect("probe message"); - Operation::Generate(GenerateRequest::new(vec![message]).expect("probe request")) + let body = json!({ + "model": "gpt-probe", + "input": [{"type": "message", "role": "user", "content": "ping"}], + }); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"), + )) } diff --git a/backend/crates/gateway-core/tests/event.rs b/backend/crates/gateway-core/tests/event.rs index 4e9e7bea0..e60b55c3a 100644 --- a/backend/crates/gateway-core/tests/event.rs +++ b/backend/crates/gateway-core/tests/event.rs @@ -1,8 +1,8 @@ use gateway_core::engine::provider::UpstreamTransport; use gateway_core::event::{ - CompactionOutput, CompactionSummary, ContentItem, ContentKind, EventSequenceError, - EventSequenceValidator, GatewayEvent, ProtocolWireEvent, ProviderEvent, - ProviderResponseObservation, ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta, + ContentItem, ContentKind, EventSequenceError, EventSequenceValidator, GatewayEvent, + ProtocolWireEvent, ProviderEvent, ProviderResponseObservation, ReasoningDelta, ResponseMeta, + TextDelta, ToolCallDelta, }; use serde_json::json; @@ -274,29 +274,3 @@ fn validator_should_accept_reasoning_and_tool_call_stream() { assert_eq!(validator.finish(), Ok(())); } - -#[test] -fn validator_should_reject_duplicate_compaction_output() { - let mut validator = EventSequenceValidator::new(); - validator - .observe(&GatewayEvent::Started(ResponseMeta::new( - "resp_1", - "smart-code", - ))) - .expect("Started is valid"); - validator - .observe(&GatewayEvent::CompactionOutput(compaction_output())) - .expect("first compaction output is valid"); - - let error = validator - .observe(&GatewayEvent::CompactionOutput(compaction_output())) - .expect_err("compaction output must be unique"); - - assert_eq!(error, EventSequenceError::DuplicateCompactionOutput); -} - -fn compaction_output() -> CompactionOutput { - CompactionOutput::new( - CompactionSummary::new("plain Grok conversation summary").expect("valid summary"), - ) -} diff --git a/backend/crates/gateway-core/tests/operation/mod.rs b/backend/crates/gateway-core/tests/operation/mod.rs index b2a8eacfe..49babf239 100644 --- a/backend/crates/gateway-core/tests/operation/mod.rs +++ b/backend/crates/gateway-core/tests/operation/mod.rs @@ -1,193 +1,76 @@ -use serde_json::{Map, Value}; - use gateway_core::error::OperationError; use gateway_core::operation::{ - CompactConversationRequest, ContentPart, ContinuationMode, EmbedRequest, Feature, - GenerateRequest, ImageRequest, ImageSource, JsonSchemaFormat, Message, MessageRole, Operation, - OperationKind, OutputFormat, ProtocolPayload, ReasoningEffort, ReasoningRequirement, - ReasoningSummary, RerankRequest, ResponsePersistence, RetrySafety, SpeechRequest, - ToolDefinition, + EmbedRequest, Feature, GenerateRequest, ImageRequest, Operation, OperationKind, + ProtocolPayload, ProviderSessionState, RerankRequest, RetrySafety, SpeechRequest, }; +use serde_json::{Map, Value, json}; -fn text_message(role: MessageRole, text: &str) -> Message { - Message::new(role, vec![ContentPart::Text(text.to_owned())]).expect("message is valid") +fn generate(body: Value) -> GenerateRequest { + let body = body.as_object().expect("request object").clone(); + GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body).expect("OpenAI payload"), + ) } #[test] -fn capability_requirements_should_include_vision_for_image_input() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Image(ImageSource::Url( - "https://example.invalid/image.png".to_owned(), - ))], - ) - .expect("test message is valid"); - let request = GenerateRequest::new(vec![message]).expect("test request is valid"); - let operation = Operation::Generate(request); - - assert!( - operation - .capability_requirements() - .features() - .contains(&Feature::Vision) - ); -} - -#[test] -fn capability_requirements_should_include_json_schema() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("input".to_owned())], - ) - .expect("test message is valid"); - let format = JsonSchemaFormat::new("result", None, serde_json::Map::new(), true) - .expect("test schema format is valid"); - let request = GenerateRequest::new(vec![message]) - .expect("test request is valid") - .with_output_format(OutputFormat::JsonSchema(format)); - let operation = Operation::Generate(request); - - assert!( - operation - .capability_requirements() - .features() - .contains(&Feature::JsonSchema) - ); -} - -#[test] -fn operation_debug_should_not_include_prompt_text() { - let secret_prompt = "private prompt body"; - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text(secret_prompt.to_owned())], - ) - .expect("test message is valid"); - let operation = - Operation::Generate(GenerateRequest::new(vec![message]).expect("test request is valid")); - - assert!(!format!("{operation:?}").contains(secret_prompt)); -} - -#[test] -fn response_persistence_should_be_explicit_in_generate_request() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("input".to_owned())], - ) - .expect("test message is valid"); - let request = GenerateRequest::new(vec![message]) - .expect("test request is valid") - .with_response_persistence(ResponsePersistence::DoNotStore); +fn generate_request_should_keep_client_body_opaque_and_redacted() { + let secret = "private prompt body"; + let body = json!({ + "model": "gpt-test", + "input": [{"type": "message", "role": "user", "content": secret}], + "future_provider_field": {"keep": true}, + }); + let request = generate(body.clone()); assert_eq!( - request.response_persistence(), - ResponsePersistence::DoNotStore + request.protocol_payload().body(), + body.as_object().expect("request object") + ); + assert!(!format!("{request:?}").contains(secret)); +} + +#[test] +fn capability_requirements_should_read_known_openai_fields_without_rewriting_body() { + let request = generate(json!({ + "model": "gpt-test", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_image", "image_url": "https://example.invalid/image.png"}], + }], + "tools": [{"type": "function", "name": "lookup"}], + "reasoning": {"effort": "future-level"}, + "text": {"format": {"type": "json_schema", "name": "result", "schema": {}}}, + "previous_response_id": "resp_previous", + "max_output_tokens": 1024, + })); + let requirements = Operation::Generate(request).capability_requirements(); + + assert_eq!(requirements.requested_output_tokens(), Some(1024)); + assert!(requirements.features().contains(&Feature::Tools)); + assert!(requirements.features().contains(&Feature::Vision)); + assert!(requirements.features().contains(&Feature::Reasoning)); + assert!(requirements.features().contains(&Feature::JsonSchema)); + assert!( + requirements + .features() + .contains(&Feature::NativeContinuation) ); } #[test] -fn prompt_cache_key_should_be_explicit_and_redacted_in_generate_request() { - let request = GenerateRequest::new(vec![text_message(MessageRole::User, "input")]) - .expect("test request is valid") - .with_prompt_cache_key("private-cache-route"); +fn prompt_cache_and_image_generation_should_be_read_from_raw_body() { + let request = generate(json!({ + "model": "gpt-test", + "prompt_cache_key": "private-cache-route", + "tools": [{"type": "image_generation"}], + })); assert_eq!(request.prompt_cache_key(), Some("private-cache-route")); + assert!(request.image_generation_requested()); assert!(!format!("{request:?}").contains("private-cache-route")); } -#[test] -fn message_should_reject_empty_content() { - let error = Message::new(MessageRole::User, Vec::new()).expect_err("content is required"); - - assert_eq!(error, OperationError::EmptyField { field: "content" }); -} - -#[test] -fn generate_request_should_reject_empty_messages() { - let error = GenerateRequest::new(Vec::new()).expect_err("messages are required"); - - assert_eq!(error, OperationError::EmptyField { field: "messages" }); -} - -#[test] -fn embed_request_should_reject_empty_input() { - let error = EmbedRequest::new(Vec::new()).expect_err("input is required"); - - assert_eq!(error, OperationError::EmptyField { field: "input" }); -} - -#[test] -fn embed_request_should_reject_empty_item() { - let error = EmbedRequest::new(vec!["valid".to_owned(), String::new()]) - .expect_err("every embedding input is required"); - - assert_eq!(error, OperationError::EmptyField { field: "input" }); -} - -#[test] -fn rerank_request_should_reject_empty_query() { - let error = RerankRequest::new("", vec!["document".to_owned()]).expect_err("query is required"); - - assert_eq!(error, OperationError::EmptyField { field: "query" }); -} - -#[test] -fn rerank_request_should_reject_empty_documents() { - let error = RerankRequest::new("query", Vec::new()).expect_err("documents are required"); - - assert_eq!(error, OperationError::EmptyField { field: "documents" }); -} - -#[test] -fn rerank_request_should_reject_empty_document_item() { - let error = - RerankRequest::new("query", vec![String::new()]).expect_err("every document is required"); - - assert_eq!(error, OperationError::EmptyField { field: "documents" }); -} - -#[test] -fn image_request_should_reject_empty_prompt() { - let error = ImageRequest::new("").expect_err("prompt is required"); - - assert_eq!(error, OperationError::EmptyField { field: "prompt" }); -} - -#[test] -fn speech_request_should_reject_empty_input() { - let error = SpeechRequest::new("", "alloy").expect_err("speech input is required"); - - assert_eq!(error, OperationError::EmptyField { field: "input" }); -} - -#[test] -fn speech_request_should_reject_empty_voice() { - let error = SpeechRequest::new("hello", "").expect_err("voice is required"); - - assert_eq!(error, OperationError::EmptyField { field: "voice" }); -} - -#[test] -fn tool_definition_should_reject_empty_name() { - let error = ToolDefinition::new("", None, Map::new()).expect_err("tool name is required"); - - assert_eq!(error, OperationError::EmptyField { field: "tool.name" }); -} - -#[test] -fn json_schema_format_should_reject_empty_name() { - let error = - JsonSchemaFormat::new("", None, Map::new(), true).expect_err("schema name is required"); - - assert_eq!( - error, - OperationError::EmptyField { - field: "text.format.name" - } - ); -} - #[test] fn protocol_payload_context_should_be_opaque_and_separate_from_wire_body() { let secret = "connection-only-value"; @@ -207,84 +90,42 @@ fn protocol_payload_context_should_be_opaque_and_separate_from_wire_body() { } #[test] -fn client_supplied_history_should_preserve_message_order() { - let request = GenerateRequest::new(vec![ - text_message(MessageRole::Assistant, "previous"), - text_message(MessageRole::User, "next"), - ]) - .expect("client history is valid"); - - assert_eq!(request.messages()[0].role(), MessageRole::Assistant); -} - -#[test] -fn capability_requirements_should_include_reasoning() { - let request = GenerateRequest::new(vec![text_message(MessageRole::User, "solve")]) - .expect("request is valid") - .with_reasoning(ReasoningRequirement { - effort: Some(ReasoningEffort::High), - summary: Some(ReasoningSummary::Concise), - }); - - let requirements = Operation::Generate(request).capability_requirements(); - - assert!(requirements.features().contains(&Feature::Reasoning)); -} - -#[test] -fn capability_requirements_should_distinguish_native_continuation() { - let request = GenerateRequest::new(vec![text_message(MessageRole::User, "continue")]) - .expect("request is valid") - .with_continuation(ContinuationMode::Native); - - let requirements = Operation::Generate(request).capability_requirements(); - - assert!( - requirements - .features() - .contains(&Feature::NativeContinuation) - ); -} - -#[test] -fn capability_requirements_should_preserve_output_bound() { - let request = GenerateRequest::new(vec![text_message(MessageRole::User, "bounded")]) - .expect("request is valid") - .with_max_output_tokens(1_024); - - let requirements = Operation::Generate(request).capability_requirements(); - - assert_eq!(requirements.requested_output_tokens(), Some(1_024)); -} - -#[test] -fn embed_and_rerank_operations_should_be_idempotent() { - let embed = Operation::Embed( - EmbedRequest::new(vec!["input".to_owned()]).expect("embedding request is valid"), - ); - let rerank = Operation::Rerank( - RerankRequest::new("query", vec!["document".to_owned()]).expect("rerank request is valid"), +fn provider_session_state_should_only_be_visible_to_its_provider() { + let request = generate(json!({"model": "gpt-test"})).with_provider_session_state( + ProviderSessionState::new( + "xai", + Map::from_iter([("session_id".to_owned(), json!("s"))]), + ) + .expect("xAI state"), ); - assert_eq!(embed.retry_safety(), RetrySafety::Idempotent); - assert_eq!(rerank.retry_safety(), RetrySafety::Idempotent); + assert!(request.provider_session_state("xai").is_some()); + assert!(request.provider_session_state("openai").is_none()); } #[test] -fn generate_and_compact_conversation_should_be_non_idempotent() { - let generate = GenerateRequest::new(vec![text_message(MessageRole::User, "history")]) - .expect("compaction history is valid"); +fn non_generate_requests_should_reject_missing_required_fields() { assert_eq!( - Operation::Generate(generate.clone()).retry_safety(), - RetrySafety::NonIdempotent + EmbedRequest::new(Vec::new()).expect_err("input is required"), + OperationError::EmptyField { field: "input" } + ); + assert_eq!( + RerankRequest::new("", vec!["document".to_owned()]).expect_err("query is required"), + OperationError::EmptyField { field: "query" } + ); + assert_eq!( + ImageRequest::new("").expect_err("prompt is required"), + OperationError::EmptyField { field: "prompt" } + ); + assert_eq!( + SpeechRequest::new("hello", "").expect_err("voice is required"), + OperationError::EmptyField { field: "voice" } ); - let compact = Operation::CompactConversation(CompactConversationRequest::new(generate)); - - assert_eq!(compact.retry_safety(), RetrySafety::NonIdempotent); } #[test] -fn operation_kind_should_match_each_payload() { +fn operation_kind_and_retry_safety_should_remain_stable() { + let generate = Operation::Generate(generate(json!({"model": "gpt-test"}))); let embed = Operation::Embed( EmbedRequest::new(vec!["input".to_owned()]).expect("embedding request is valid"), ); @@ -294,22 +135,10 @@ fn operation_kind_should_match_each_payload() { let image = Operation::GenerateImage(ImageRequest::new("draw").expect("image request")); let speech = Operation::Speech(SpeechRequest::new("hello", "alloy").expect("speech request")); - assert_eq!(embed.kind(), OperationKind::Embed); - assert_eq!(rerank.kind(), OperationKind::Rerank); + assert_eq!(generate.kind(), OperationKind::Generate); + assert_eq!(generate.retry_safety(), RetrySafety::NonIdempotent); + assert_eq!(embed.retry_safety(), RetrySafety::Idempotent); + assert_eq!(rerank.retry_safety(), RetrySafety::Idempotent); assert_eq!(image.kind(), OperationKind::GenerateImage); assert_eq!(speech.kind(), OperationKind::Speech); } - -#[test] -fn content_debug_should_redact_text_and_tool_payloads() { - let text_secret = "private-text"; - let tool_secret = "private-tool-output"; - let text = ContentPart::Text(text_secret.to_owned()); - let tool = ContentPart::ToolResult { - call_id: "call_1".to_owned(), - output: tool_secret.to_owned(), - }; - - assert!(!format!("{text:?}").contains(text_secret)); - assert!(!format!("{tool:?}").contains(tool_secret)); -} diff --git a/backend/crates/gateway-core/tests/routing/mod.rs b/backend/crates/gateway-core/tests/routing/mod.rs index 9c41c6e3f..ab2b0e161 100644 --- a/backend/crates/gateway-core/tests/routing/mod.rs +++ b/backend/crates/gateway-core/tests/routing/mod.rs @@ -4,8 +4,7 @@ use std::time::Duration; use gateway_core::engine::credential::{AccountSelectionPolicy, RotationStrategy}; use gateway_core::operation::{ - CapabilityRequirements, ContentPart, Feature, GenerateRequest, Message, MessageRole, Operation, - OperationKind, + CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload, }; use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}; use gateway_core::routing::{ @@ -36,12 +35,14 @@ fn model(provider: &str, name: &str, capabilities: ModelCapabilities) -> Provide } fn operation() -> Operation { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("hello".to_owned())], - ) - .expect("valid message"); - Operation::Generate(GenerateRequest::new(vec![message]).expect("valid request")) + let body = serde_json::json!({ + "model": "gpt-5.5", + "input": [{"type": "message", "role": "user", "content": "hello"}], + }); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"), + )) } fn client_policy(id: &str, plaintext: &str, enabled: bool) -> ClientPolicy { @@ -269,7 +270,7 @@ fn upstream_feature_validation_should_preserve_operation_and_limit_gates() { .require(Feature::JsonSchema); let oversized_output = CapabilityRequirements::new(OperationKind::Generate) .with_requested_output_tokens(Some(16_001)); - let unsupported_operation = CapabilityRequirements::new(OperationKind::CompactConversation); + let unsupported_operation = CapabilityRequirements::new(OperationKind::Speech); assert_eq!( ( diff --git a/backend/crates/gateway-protocol/Cargo.toml b/backend/crates/gateway-protocol/Cargo.toml index eaaf0064b..81f23ff91 100644 --- a/backend/crates/gateway-protocol/Cargo.toml +++ b/backend/crates/gateway-protocol/Cargo.toml @@ -13,7 +13,7 @@ path = "tests/main.rs" [dependencies] serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" +serde_json = { version = "1.0.150", features = ["preserve_order"] } thiserror = "2.0.18" uuid = { version = "1.23.3", features = ["v4"] } diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 81254f295..543101849 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -32,10 +32,7 @@ use gateway_core::engine::credential::{ ProviderAccountStore, }; use gateway_core::error::StoreErrorKind; -use gateway_core::operation::{ - ContentPart, GenerateRequest, Message, MessageRole, Operation, ProtocolPayload, - ResponsePersistence, -}; +use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload}; use gateway_core::provider_ports::{ NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingTakeOutcome, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind, @@ -1172,11 +1169,6 @@ fn build_connection_test_operation( upstream_model: &UpstreamModelId, input_text: &str, ) -> Result { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text(input_text.to_owned())], - ) - .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; let mut body = Map::new(); body.insert( "model".to_owned(), @@ -1194,10 +1186,9 @@ fn build_connection_test_operation( body.insert("store".to_owned(), Value::Bool(false)); let payload = ProtocolPayload::json_object("openai", body) .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; - Ok(Operation::Generate( - GenerateRequest::from_protocol_payload(vec![message], payload) - .with_response_persistence(ResponsePersistence::DoNotStore), - )) + Ok(Operation::Generate(GenerateRequest::from_protocol_payload( + payload, + ))) } fn currency_cost(money: Money) -> Result { diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 303b10446..da67cc84d 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -235,7 +235,7 @@ impl Provider for CodexProvider { )); }; let previous_session = decode_openai_session_state(generate)?; - let continuation_requested = generate.continuation().is_some(); + let continuation_requested = generate.native_continuation_requested(); let mut upstream_request = encode_generate_request(generate, candidate.upstream_model().as_str()) .map_err(map_request_error)?; @@ -1284,7 +1284,6 @@ fn valid_cookie_name(name: &str) -> bool { fn map_request_error(error: CodexRequestEncodeError) -> ProviderError { let kind = match error { CodexRequestEncodeError::InvalidProtocolPayload => ProviderErrorKind::InvalidRequest, - CodexRequestEncodeError::UnsupportedContent => ProviderErrorKind::Unsupported, }; provider_error(kind, UpstreamSendState::NotSent) } diff --git a/backend/crates/providers/openai/src/transport/catalog.rs b/backend/crates/providers/openai/src/transport/catalog.rs index 95898300c..b8d5b0086 100644 --- a/backend/crates/providers/openai/src/transport/catalog.rs +++ b/backend/crates/providers/openai/src/transport/catalog.rs @@ -266,7 +266,6 @@ pub enum CodexModelCatalogError { } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] struct CodexModelsWire { models: Vec, } @@ -294,18 +293,20 @@ struct CodexReasoningEffortWire { effort: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] enum CodexInputModalityWire { Text, Image, + #[serde(other)] + Unknown, } /// 解析唯一受支持的 Codex 官方完整快照。 /// /// # Errors /// -/// 任一条目、分页信号、重复 slug、未知顶层字段或 ETag 不合法时整轮失败。 +/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;上游新增的未知字段会忽略。 pub fn parse_codex_model_catalog( body: &[u8], etag: Option<&str>, @@ -453,21 +454,33 @@ fn modality_evidence( CodexCatalogCapabilityEvidence::Unknown, )); }; - let mut unique = BTreeSet::new(); + let mut has_text = false; + let mut has_image = false; + let mut has_unknown = false; for value in values { - if !unique.insert(value) { - return Err(CodexModelCatalogError::InvalidCapabilities); + match value { + CodexInputModalityWire::Text if has_text => { + return Err(CodexModelCatalogError::InvalidCapabilities); + } + CodexInputModalityWire::Image if has_image => { + return Err(CodexModelCatalogError::InvalidCapabilities); + } + CodexInputModalityWire::Text => has_text = true, + CodexInputModalityWire::Image => has_image = true, + CodexInputModalityWire::Unknown => has_unknown = true, } } Ok(( - declared_presence(unique.contains(&CodexInputModalityWire::Text)), - declared_presence(unique.contains(&CodexInputModalityWire::Image)), + modality_presence(has_text, has_unknown), + modality_presence(has_image, has_unknown), )) } -fn declared_presence(value: bool) -> CodexCatalogCapabilityEvidence { - if value { +fn modality_presence(declared: bool, has_unknown: bool) -> CodexCatalogCapabilityEvidence { + if declared { CodexCatalogCapabilityEvidence::DeclaredNative + } else if has_unknown { + CodexCatalogCapabilityEvidence::Unknown } else { CodexCatalogCapabilityEvidence::DeclaredUnsupported } diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index fb5e45b62..c90a2f3a3 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -1,11 +1,7 @@ //! 核心 Generate operation 到 Codex Responses wire request 的严格编码。 -use base64::Engine as _; -use gateway_core::operation::{ - ContentPart, GenerateRequest, ImageSource, MessageRole, OutputFormat, ReasoningEffort, - ReasoningSummary, ResponsePersistence, -}; -use serde_json::{Map, Value, json}; +use gateway_core::operation::GenerateRequest; +use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::transport::protocol::responses::CodexResponsesRequest; @@ -73,199 +69,26 @@ const INSTALLATION_ID_KEYS: &[&str] = &[ pub enum CodexRequestEncodeError { #[error("Codex request is missing its OpenAI protocol payload")] InvalidProtocolPayload, - #[error("Codex request contains unsupported content")] - UnsupportedContent, } pub fn encode_generate_request( request: &GenerateRequest, upstream_model: &str, ) -> Result { - let (mut body, protocol_context) = match request.protocol_payload() { - Some(payload) if payload.protocol() == "openai" => { - (payload.body().clone(), Some(payload.context())) - } - Some(_) => return Err(CodexRequestEncodeError::InvalidProtocolPayload), - None => (Map::new(), None), - }; - let protocol_payload = protocol_context.is_some(); + let payload = request.protocol_payload(); + if payload.protocol() != "openai" { + return Err(CodexRequestEncodeError::InvalidProtocolPayload); + } + let mut body = payload.body().clone(); body.insert("model".to_owned(), Value::String(upstream_model.to_owned())); - if !protocol_payload { - body.insert("input".to_owned(), Value::Array(encode_messages(request)?)); - } - if !protocol_payload { - body.insert("stream".to_owned(), Value::Bool(true)); - body.insert( - "store".to_owned(), - Value::Bool(matches!( - request.response_persistence(), - ResponsePersistence::Store - )), - ); - } - - if !protocol_payload && !request.tools().is_empty() { - body.insert( - "tools".to_owned(), - Value::Array( - request - .tools() - .iter() - .map(|tool| { - let mut value = Map::new(); - value.insert("type".to_owned(), Value::String("function".to_owned())); - value.insert("name".to_owned(), Value::String(tool.name().to_owned())); - if let Some(description) = tool.description() { - value.insert( - "description".to_owned(), - Value::String(description.to_owned()), - ); - } - value.insert( - "parameters".to_owned(), - Value::Object(tool.input_schema().clone()), - ); - value.insert("strict".to_owned(), Value::Bool(tool.strict())); - Value::Object(value) - }) - .collect(), - ), - ); - } - - if !protocol_payload && !matches!(request.output_format(), OutputFormat::Text) { - body.insert( - "text".to_owned(), - encode_output_format(request.output_format()), - ); - } - if !protocol_payload && let Some(reasoning) = request.reasoning() { - let mut value = Map::new(); - if let Some(effort) = reasoning.effort { - value.insert( - "effort".to_owned(), - Value::String(reasoning_effort(effort).to_owned()), - ); - } - if let Some(summary) = reasoning.summary { - value.insert( - "summary".to_owned(), - Value::String(reasoning_summary(summary).to_owned()), - ); - } - body.insert("reasoning".to_owned(), Value::Object(value)); - } - if !protocol_payload && let Some(tokens) = request.max_output_tokens() { - body.insert("max_output_tokens".to_owned(), Value::from(tokens)); - } - if !protocol_payload && let Some(key) = request.prompt_cache_key() { - body.insert("prompt_cache_key".to_owned(), Value::String(key.to_owned())); - } let mut encoded = CodexResponsesRequest::from_body(body); encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some(); extract_request_context(&mut encoded); - if let Some(context) = protocol_context { - apply_protocol_context(&mut encoded, context); - } + apply_protocol_context(&mut encoded, payload.context()); Ok(encoded) } -fn encode_messages(request: &GenerateRequest) -> Result, CodexRequestEncodeError> { - let mut input = Vec::new(); - for message in request.messages() { - let mut content = Vec::new(); - for part in message.content() { - match part { - ContentPart::Text(text) => content.push(json!({ - "type": match message.role() { - MessageRole::Assistant => "output_text", - MessageRole::System - | MessageRole::Developer - | MessageRole::User => "input_text", - }, - "text": text, - })), - ContentPart::Image(source) => { - if message.role() != MessageRole::User { - return Err(CodexRequestEncodeError::UnsupportedContent); - } - content.push(json!({ - "type": "input_image", - "image_url": image_url(source), - })); - } - ContentPart::ToolResult { call_id, output } => { - input.push(json!({ - "type": "function_call_output", - "call_id": call_id, - "output": output, - })); - } - ContentPart::ToolCall { - call_id, - name, - arguments, - } => { - if message.role() != MessageRole::Assistant { - return Err(CodexRequestEncodeError::UnsupportedContent); - } - input.push(json!({ - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments, - })); - } - _ => return Err(CodexRequestEncodeError::UnsupportedContent), - } - } - if !content.is_empty() { - input.push(json!({ - "type": "message", - "role": message_role(message.role()), - "content": content, - })); - } - } - if input.is_empty() { - return Err(CodexRequestEncodeError::UnsupportedContent); - } - Ok(input) -} - -fn image_url(source: &ImageSource) -> String { - match source { - ImageSource::Url(url) => url.clone(), - ImageSource::Bytes { media_type, data } => format!( - "data:{media_type};base64,{}", - base64::engine::general_purpose::STANDARD.encode(data) - ), - } -} - -fn encode_output_format(format: &OutputFormat) -> Value { - let format = match format { - OutputFormat::Text => json!({"type": "text"}), - OutputFormat::JsonObject => json!({"type": "json_object"}), - OutputFormat::JsonSchema(schema) => { - let mut value = Map::new(); - value.insert("type".to_owned(), Value::String("json_schema".to_owned())); - value.insert("name".to_owned(), Value::String(schema.name().to_owned())); - if let Some(description) = schema.description() { - value.insert( - "description".to_owned(), - Value::String(description.to_owned()), - ); - } - value.insert("schema".to_owned(), Value::Object(schema.schema().clone())); - value.insert("strict".to_owned(), Value::Bool(schema.strict())); - Value::Object(value) - } - }; - json!({"format": format}) -} - fn extract_request_context(request: &mut CodexResponsesRequest) { let context = ExtractedRequestContext::from_body(request.body()); request.turn_state = context.turn_state; @@ -749,31 +572,3 @@ fn context_string(context: &Map, field: &str) -> Option { .filter(|value| !value.trim().is_empty()) .map(ToOwned::to_owned) } - -const fn message_role(role: MessageRole) -> &'static str { - match role { - MessageRole::System => "system", - MessageRole::Developer => "developer", - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - } -} - -const fn reasoning_effort(effort: ReasoningEffort) -> &'static str { - match effort { - ReasoningEffort::Minimal => "minimal", - ReasoningEffort::Low => "low", - ReasoningEffort::Medium => "medium", - ReasoningEffort::High => "high", - ReasoningEffort::ExtraHigh => "xhigh", - } -} - -const fn reasoning_summary(summary: ReasoningSummary) -> &'static str { - match summary { - ReasoningSummary::Auto => "auto", - ReasoningSummary::Concise => "concise", - ReasoningSummary::Detailed => "detailed", - ReasoningSummary::None => "none", - } -} diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 85598bf93..7a2acf8da 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -296,8 +296,7 @@ async fn openai_core_provider_projects_codex_request_observation_without_routing "turn_metadata".to_owned(), Value::String(r#"{"request_kind":"compaction","subagent_kind":"review"}"#.to_owned()), )])); - let operation = - Operation::Generate(GenerateRequest::from_protocol_payload(Vec::new(), payload)); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); let observation = bundle.core_provider().request_observation(&operation); diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 685b5fb69..ab0aaf34b 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -11,10 +11,7 @@ use gateway_core::engine::{ RequestAttemptContext, UpstreamSendState, }; use gateway_core::error::ProviderErrorKind; -use gateway_core::operation::{ - CompactConversationRequest, ContentPart, GenerateRequest, Message, MessageRole, Operation, - ProtocolPayload, -}; +use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload}; use gateway_core::policy::ClientApiKeyId; use gateway_core::routing::{ ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext, @@ -120,16 +117,16 @@ async fn create_account(store: &Arc, id: &str) { } fn generate_operation() -> Operation { - Operation::Generate( - GenerateRequest::new(vec![ - Message::new( - MessageRole::User, - vec![ContentPart::Text("hello".to_owned())], - ) - .expect("message"), - ]) - .expect("generate request"), - ) + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), json!("hello")), + ]), + ) + .expect("OpenAI payload"), + )) } fn planned_request(provider_name: &str, operation: Operation) -> ProviderRequest { @@ -207,12 +204,24 @@ async fn cancelled_attempt_fails_before_account_selection_or_upstream_send() { } #[tokio::test] -async fn openai_provider_rejects_compaction_as_an_unsupported_operation() { +async fn openai_provider_keeps_a_compaction_trigger_as_a_regular_generate_request() { let store = Arc::new(MemoryAccountStore::default()); - let Operation::Generate(generation) = generate_operation() else { - unreachable!("fixture is generate") - }; - let operation = Operation::CompactConversation(CompactConversationRequest::new(generation)); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ( + "input".to_owned(), + json!([ + {"type": "message", "role": "user", "content": "hello"}, + {"type": "compaction_trigger"} + ]), + ), + ]), + ) + .expect("OpenAI payload"), + )); let result = provider(&store) .execute( planned_request("openai", operation), @@ -220,10 +229,10 @@ async fn openai_provider_rejects_compaction_as_an_unsupported_operation() { ) .await; let Err(error) = result else { - panic!("OpenAI compaction must remain unsupported") + panic!("missing OpenAI account must fail") }; - assert_eq!(error.kind(), ProviderErrorKind::Unsupported); + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); assert_eq!(error.send_state(), UpstreamSendState::NotSent); } @@ -259,7 +268,7 @@ async fn opaque_provider_options_do_not_change_openai_account_selection() { ]), ) .expect("OpenAI payload"); - let generation = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let generation = GenerateRequest::from_protocol_payload(payload); let result = provider(&store) .execute( planned_request("openai", Operation::Generate(generation)), @@ -279,11 +288,17 @@ async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key() let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_affinity").await; let affinity = Arc::new(MemorySessionAffinity::default()); - let mut generation = match generate_operation() { - Operation::Generate(generation) => generation, - _ => unreachable!("fixture is generate"), - }; - generation = generation.with_prompt_cache_key("raw-prompt-cache-key"); + let generation = GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), json!("hello")), + ("prompt_cache_key".to_owned(), json!("raw-prompt-cache-key")), + ]), + ) + .expect("OpenAI payload"), + ); let stream = provider_with_affinity(&store, Arc::clone(&affinity)) .execute( @@ -318,7 +333,7 @@ fn request_observation_reads_openai_metadata_without_changing_the_operation() { r#"{"request_kind":"review","subagent_kind":"worker"}"#.to_owned(), ), )])); - let generation = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let generation = GenerateRequest::from_protocol_payload(payload); let operation = Operation::Generate(generation); let observation = provider(&store).request_observation(&operation); @@ -337,8 +352,7 @@ fn request_observation_preserves_the_raw_reasoning_effort() { Map::from_iter([("reasoning".to_owned(), json!({"effort": "future-value"}))]), ) .expect("protocol payload"); - let operation = - Operation::Generate(GenerateRequest::from_protocol_payload(Vec::new(), payload)); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); let observation = provider(&store).request_observation(&operation); diff --git a/backend/crates/providers/openai/tests/transport/catalog.rs b/backend/crates/providers/openai/tests/transport/catalog.rs index 3da33b1da..9232ca2d9 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -144,13 +144,32 @@ fn duplicate_request_slugs_should_fail_the_entire_snapshot() { } #[test] -fn pagination_signal_should_fail_the_entire_snapshot() { - let result = parse_codex_model_catalog( +fn unknown_top_level_pagination_fields_should_not_reject_the_snapshot() { + let snapshot = parse_codex_model_catalog( br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4"}],"has_more":true,"cursor":"next"}"#, None, - ); + ) + .expect("unknown pagination fields are not part of the model contract"); - assert!(matches!(result, Err(CodexModelCatalogError::InvalidWire))); + assert_eq!(snapshot.models()[0].request_model().as_str(), "gpt-5.4"); +} + +#[test] +fn unknown_input_modality_should_degrade_capability_evidence_to_unknown() { + let snapshot = parse_codex_model_catalog( + br#"{"models":[{"slug":"gpt-future","display_name":"Future","input_modalities":["audio"]}]}"#, + None, + ) + .expect("future modality should not reject the snapshot"); + let capabilities = snapshot.models()[0].capabilities(); + + assert_eq!( + (capabilities.text_input(), capabilities.image_input()), + ( + CodexCatalogCapabilityEvidence::Unknown, + CodexCatalogCapabilityEvidence::Unknown + ) + ); } #[test] diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index ce69bb60d..ae64e441c 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -1,11 +1,14 @@ -use gateway_core::operation::{ - ContentPart, GenerateRequest, Message, MessageRole, ProtocolPayload, ReasoningEffort, - ReasoningRequirement, ReasoningSummary, ResponsePersistence, ToolDefinition, -}; +use gateway_core::operation::{GenerateRequest, ProtocolPayload}; use serde_json::{Map, Value, json}; use provider_openai::encode_generate_request; +fn request(body: Map) -> GenerateRequest { + GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body).expect("OpenAI payload"), + ) +} + #[test] fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() { let body = Map::from_iter([ @@ -25,8 +28,7 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_ ("turnState".to_owned(), json!("private-turn-state")), ("future_official_field".to_owned(), json!({"enabled": true})), ]); - let payload = ProtocolPayload::json_object("openai", body).expect("OpenAI payload"); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = request(body); let encoded = encode_generate_request(&request, "gpt-routed").expect("encode wire payload"); @@ -54,15 +56,10 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_ #[test] fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity() { let request = |input: &str| { - let payload = ProtocolPayload::json_object( - "openai", - Map::from_iter([ - ("model".to_owned(), json!("client-model")), - ("input".to_owned(), Value::String(input.to_owned())), - ]), - ) - .expect("OpenAI payload"); - GenerateRequest::from_protocol_payload(Vec::new(), payload) + request(Map::from_iter([ + ("model".to_owned(), json!("client-model")), + ("input".to_owned(), Value::String(input.to_owned())), + ])) }; for input in ["private stable prompt", "different private prompt"] { @@ -73,35 +70,25 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity() } #[test] -fn encoder_should_project_typed_generate_semantics() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("secret prompt".to_owned())], - ) - .expect("message"); - let tool = ToolDefinition::new( - "lookup", - Some("Lookup a value".to_owned()), - json!({"type":"object"}) - .as_object() - .cloned() - .expect("object"), - ) - .expect("tool") - .with_strict(true); - let request = GenerateRequest::new(vec![message]) - .expect("generate") - .with_tools(vec![tool]) - .with_max_output_tokens(512) - .with_response_persistence(ResponsePersistence::DoNotStore) - .with_reasoning(ReasoningRequirement { - effort: Some(ReasoningEffort::High), - summary: Some(ReasoningSummary::Concise), - }); +fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() { + let request = request(Map::from_iter([ + ("model".to_owned(), json!("client-model")), + ("input".to_owned(), json!("secret prompt")), + ( + "tools".to_owned(), + json!([{"type": "function", "name": "lookup", "strict": true}]), + ), + ("max_output_tokens".to_owned(), json!(512)), + ("store".to_owned(), json!(false)), + ( + "reasoning".to_owned(), + json!({"effort": "high", "summary": "concise"}), + ), + ])); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test"))); - assert_eq!(encoded.body().get("stream"), Some(&json!(true))); + assert!(encoded.body().get("stream").is_none()); assert_eq!(encoded.body().get("max_output_tokens"), Some(&json!(512))); assert_eq!(encoded.body().get("store"), Some(&json!(false))); let body = Value::Object(encoded.body().clone()); @@ -111,15 +98,12 @@ fn encoder_should_project_typed_generate_semantics() { } #[test] -fn downstream_store_intent_should_not_enable_upstream_storage() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("persist inside gateway".to_owned())], - ) - .expect("message"); - let request = GenerateRequest::new(vec![message]) - .expect("generate") - .with_response_persistence(ResponsePersistence::Store); +fn encoder_should_preserve_client_store_intent() { + let request = request(Map::from_iter([ + ("model".to_owned(), json!("client-model")), + ("input".to_owned(), json!("persist inside gateway")), + ("store".to_owned(), json!(true)), + ])); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); @@ -127,15 +111,12 @@ fn downstream_store_intent_should_not_enable_upstream_storage() { } #[test] -fn encoder_should_forward_the_common_prompt_cache_key() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("cache prefix".to_owned())], - ) - .expect("message"); - let request = GenerateRequest::new(vec![message]) - .expect("generate") - .with_prompt_cache_key("cache-route"); +fn encoder_should_forward_the_client_prompt_cache_key() { + let request = request(Map::from_iter([ + ("model".to_owned(), json!("client-model")), + ("input".to_owned(), json!("cache prefix")), + ("prompt_cache_key".to_owned(), json!("cache-route")), + ])); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); @@ -159,7 +140,7 @@ fn encoder_should_project_explicit_websocket_transport_without_touching_body() { "use_websocket".to_owned(), Value::Bool(true), )])); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); @@ -182,7 +163,7 @@ fn encoder_should_project_explicit_http_transport_without_touching_body() { "use_websocket".to_owned(), Value::Bool(false), )])); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); @@ -208,7 +189,7 @@ fn encoder_should_preserve_opaque_provider_options_without_interpreting_them() { ]), ) .expect("OpenAI payload"); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let encoded = encode_generate_request(&request, "gpt-test").expect("opaque options encode"); @@ -245,7 +226,7 @@ fn encoder_should_project_lite_and_memgen_options_to_transport_state_only() { Value::String("true".to_owned()), ), ])); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let encoded = encode_generate_request(&request, "gpt-test").expect("encode"); @@ -269,7 +250,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() { "turn_metadata".to_owned(), Value::String(r#"{"request_kind":"compaction","subagent_kind":"review"}"#.to_owned()), )])); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let semantics = encode_generate_request(&request, "observability") .expect("observability request should encode") @@ -304,7 +285,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() { .clone(), ) .expect("OpenAI payload"); - let request = GenerateRequest::from_protocol_payload(Vec::new(), payload); + let request = GenerateRequest::from_protocol_payload(payload); let semantics = encode_generate_request(&request, "observability") .expect("transparent OpenAI request should encode") diff --git a/backend/crates/providers/xai/Cargo.toml b/backend/crates/providers/xai/Cargo.toml index 73936ceeb..a09034d73 100644 --- a/backend/crates/providers/xai/Cargo.toml +++ b/backend/crates/providers/xai/Cargo.toml @@ -20,7 +20,7 @@ getrandom = "0.3.4" jsonwebtoken = { version = "=10.3.0", default-features = false, features = ["rust_crypto"] } reqwest = { version = "=0.12.28", default-features = false, features = ["stream", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] } serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" +serde_json = { version = "1.0.150", features = ["preserve_order"] } semver = "1.0.27" sha2 = "0.11.0" thiserror = "2.0.18" diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 1270a675f..6e86e8640 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -33,10 +33,7 @@ use gateway_core::engine::credential::{ ProviderAccountStore, }; use gateway_core::error::StoreErrorKind; -use gateway_core::operation::{ - ContentPart, GenerateRequest, Message, MessageRole, Operation, ProtocolPayload, - ResponsePersistence, -}; +use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload}; use gateway_core::provider_ports::{ NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingTakeOutcome, ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, @@ -1253,11 +1250,6 @@ fn build_connection_test_operation( upstream_model: &UpstreamModelId, input_text: &str, ) -> Result { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text(input_text.to_owned())], - ) - .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; let mut body = Map::new(); body.insert( "model".to_owned(), @@ -1275,10 +1267,9 @@ fn build_connection_test_operation( body.insert("store".to_owned(), Value::Bool(false)); let payload = ProtocolPayload::json_object("openai", body) .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; - Ok(Operation::Generate( - GenerateRequest::from_protocol_payload(vec![message], payload) - .with_response_persistence(ResponsePersistence::DoNotStore), - )) + Ok(Operation::Generate(GenerateRequest::from_protocol_payload( + payload, + ))) } fn map_failure_class(class: FailureClass) -> ProviderAdminError { diff --git a/backend/crates/providers/xai/src/provider.rs b/backend/crates/providers/xai/src/provider.rs index f821dae64..b21371b1d 100644 --- a/backend/crates/providers/xai/src/provider.rs +++ b/backend/crates/providers/xai/src/provider.rs @@ -18,12 +18,9 @@ use gateway_core::engine::provider::{ }; use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendState}; use gateway_core::error::{ContinuationFailure, ProviderError, ProviderErrorKind}; -use gateway_core::event::{ - CompactionOutput, GatewayEvent, ProviderEvent, ProviderResponseObservation, ResponseMeta, -}; +use gateway_core::event::{GatewayEvent, ProviderEvent, ProviderResponseObservation, ResponseMeta}; use gateway_core::operation::{ - CompactConversationRequest, Feature, GenerateRequest, Operation, OperationKind, - ProviderSessionState, + Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState, }; use gateway_core::routing::{ ModelCapabilities, ProviderCandidate, ProviderKind, SupportLevel, UpstreamModelId, @@ -131,7 +128,6 @@ impl Provider for GrokBuildProvider { == GrokCatalogCapabilityEvidence::DeclaredNative { operations.insert(OperationKind::Generate); - operations.insert(OperationKind::CompactConversation); } let capabilities = ModelCapabilities::new( operations, @@ -174,9 +170,6 @@ impl Provider for GrokBuildProvider { Operation::Generate(generate) => { self.execute_generate(generate, candidate, context).await } - Operation::CompactConversation(compact) => { - self.execute_compaction(compact, candidate, context).await - } _ => Err(provider_error( ProviderErrorKind::Unsupported, UpstreamSendState::NotSent, @@ -192,6 +185,9 @@ impl GrokBuildProvider { candidate: &ProviderCandidate, context: AttemptContext, ) -> Result { + if crate::transport::compaction::has_terminal_compaction_trigger(generate) { + return self.execute_compaction(generate, candidate, context).await; + } let previous_session = decode_xai_session_state(generate)?; let continuation_account = continuation_account(&context, previous_session.as_ref())?; let mut upstream_request = GrokResponsesRequest::encode( @@ -253,7 +249,7 @@ impl GrokBuildProvider { async fn execute_compaction( &self, - compact: &CompactConversationRequest, + generate: &GenerateRequest, candidate: &ProviderCandidate, context: AttemptContext, ) -> Result { @@ -265,7 +261,7 @@ impl GrokBuildProvider { UpstreamSendState::NotSent, )); } - let previous_session = decode_xai_session_state(compact.generation())?; + let previous_session = decode_xai_session_state(generate)?; let operation_account = previous_session .as_ref() .map(|previous| ProviderAccountId::new(previous.account_id.clone())) @@ -275,7 +271,7 @@ impl GrokBuildProvider { .as_ref() .and_then(|previous| previous.session_id.clone()); let upstream_request = GrokCompactionRequest::encode( - compact, + generate, candidate.upstream_model().as_str(), context.client_api_key_ref(), ) @@ -959,9 +955,7 @@ fn cold_compaction_http_sse_stream( let mut body = accepted.response.into_body(); let mut canonical = GrokCanonicalDecoder::new(upstream_model.as_str()); let mut summary = GrokCompactionSummaryDecoder::new(); - let mut started = None; - let mut completed = None; - let mut accounting = Vec::new(); + let mut facts = CompactionFacts::default(); 'stream: while let Some(chunk) = next_grok_chunk( &mut body, @@ -977,81 +971,104 @@ fn cold_compaction_http_sse_stream( })?; for event in events { summary.observe(&event).map_err(map_compaction_decode_error)?; - collect_compaction_facts( - &event, - &mut started, - &mut completed, - &mut accounting, - )?; - if completed.is_some() { + facts.observe(&event); + if facts.completed.is_some() { break 'stream; } } } - if completed.is_none() { + if facts.completed.is_none() { for event in canonical.finish_without_terminal().map_err(|error| { mark_transient_compaction_failure(map_continuation_failure(&context, error)) })? { summary.observe(&event).map_err(map_compaction_decode_error)?; - collect_compaction_facts( - &event, - &mut started, - &mut completed, - &mut accounting, - )?; - if completed.is_some() { + facts.observe(&event); + if facts.completed.is_some() { break; } } } let summary = summary.finish().map_err(map_compaction_decode_error)?; - let started = started.ok_or_else(protocol_sent)?; - let completed = completed.unwrap_or_else(|| started.clone()); + let started = facts.started.ok_or_else(protocol_sent)?; + let completed = facts.completed.unwrap_or_else(|| started.clone()); + let (created, output_done, terminal) = crate::transport::compaction::compaction_wire_events( + &started, + &completed, + &summary, + facts.created_response.as_ref(), + facts.terminal_response.as_ref(), + facts.terminal_was_incomplete, + ) + .map_err(|_| protocol_sent())? + .into_parts(); ensure_sent_context(&context)?; - yield ProviderEvent::canonical(GatewayEvent::Started(started)); - yield ProviderEvent::canonical(GatewayEvent::CompactionOutput( - CompactionOutput::new(summary), - )); - for fact in accounting { - yield ProviderEvent::canonical(fact); - } - yield ProviderEvent::canonical(GatewayEvent::Completed(completed)); + yield ProviderEvent::canonical_with_wire(vec![GatewayEvent::Started(started)], created); + yield ProviderEvent::wire(output_done); + let mut terminal_facts = facts.accounting; + terminal_facts.push(GatewayEvent::Completed(completed)); + yield ProviderEvent::canonical_with_wire(terminal_facts, terminal); }) } -fn collect_compaction_facts( - event: &ProviderEvent, - started: &mut Option, - completed: &mut Option, - accounting: &mut Vec, -) -> Result<(), ProviderError> { - for fact in event.canonical_facts() { - match fact { - GatewayEvent::Started(meta) => { - if started.is_none() { - *started = Some(meta.clone()); +#[derive(Default)] +struct CompactionFacts { + started: Option, + completed: Option, + accounting: Vec, + created_response: Option, + terminal_response: Option, + terminal_was_incomplete: bool, +} + +impl CompactionFacts { + fn observe(&mut self, event: &ProviderEvent) { + self.capture_wire_response(event); + for fact in event.canonical_facts() { + match fact { + GatewayEvent::Started(meta) if self.started.is_none() => { + self.started = Some(meta.clone()); } - } - GatewayEvent::Completed(meta) => { - if completed.is_none() { - *completed = Some(meta.clone()); + GatewayEvent::Completed(meta) if self.completed.is_none() => { + self.completed = Some(meta.clone()); } + GatewayEvent::Usage(_) + | GatewayEvent::CalculatedCost(_) + | GatewayEvent::ProviderCost(_) => self.accounting.push(fact.clone()), + _ => {} + } + } + } + + fn capture_wire_response(&mut self, event: &ProviderEvent) { + let Some(wire) = event.wire_event().filter(|wire| wire.has_json_data()) else { + return; + }; + let event_type = wire + .event_type() + .or_else(|| wire.data().get("type").and_then(Value::as_str)); + let response = wire.data().get("response").cloned(); + match event_type { + Some("response.created" | "response.in_progress") + if self.created_response.is_none() => + { + self.created_response = response; + } + Some("response.completed" | "response.incomplete") + if self.terminal_response.is_none() => + { + self.terminal_was_incomplete = event_type == Some("response.incomplete"); + self.terminal_response = response; } - GatewayEvent::Usage(_) - | GatewayEvent::CalculatedCost(_) - | GatewayEvent::ProviderCost(_) => accounting.push(fact.clone()), _ => {} } } - Ok(()) } fn map_compaction_decode_error(error: GrokCompactionDecodeError) -> ProviderError { match error { GrokCompactionDecodeError::Degenerate => mark_transient_compaction_failure(protocol_sent()), - GrokCompactionDecodeError::InvalidSummary(_) => protocol_sent(), } } diff --git a/backend/crates/providers/xai/src/transport/catalog.rs b/backend/crates/providers/xai/src/transport/catalog.rs index 3908289ea..4937f2170 100644 --- a/backend/crates/providers/xai/src/transport/catalog.rs +++ b/backend/crates/providers/xai/src/transport/catalog.rs @@ -584,6 +584,9 @@ pub enum GrokCatalogApiBackend { ChatCompletions, /// Anthropic Messages wire。 Messages, + /// 上游新增、当前 adapter 尚未识别的 backend。 + #[serde(other)] + Unknown, } /// Grok 目录中允许进入控制面的能力证据。 @@ -815,7 +818,6 @@ impl fmt::Debug for GrokModelCatalogClient { } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] struct GrokModelsWire { object: GrokModelsObject, data: Vec, @@ -859,7 +861,7 @@ struct GrokModelWire { /// /// # Errors /// -/// 任一条目、分页信号、重复 slug、未知顶层字段或 ETag 不合法时整轮失败。 +/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;上游新增的未知字段会忽略。 pub fn parse_grok_model_catalog( body: &[u8], etag: Option<&str>, @@ -916,7 +918,10 @@ fn normalize_model(wire: GrokModelWire) -> Result Result, affinity: Option, @@ -47,12 +51,12 @@ impl GrokCompactionRequest { /// /// 完整历史无法按 Grok Responses contract 规范化时返回错误。 pub fn encode( - request: &CompactConversationRequest, + request: &GenerateRequest, upstream_model: &str, client_api_key_ref: &ClientApiKeyId, ) -> Result { let normalized = GrokResponsesRequest::encode_compaction_source( - request.generation(), + request, upstream_model, client_api_key_ref, )?; @@ -116,6 +120,25 @@ impl GrokCompactionRequest { } } +/// 返回 xAI 专用全历史压缩是否由客户端请求。 +/// +/// `compaction_trigger` 是 Codex Responses wire 的控制项。它只在 xAI adapter +/// 内被识别,并且只接受处在 `input` 最末尾的触发器;OpenAI 透明路径不会调用 +/// 本函数,因此会保留该项原样上游。 +#[must_use] +pub(crate) fn has_terminal_compaction_trigger(request: &GenerateRequest) -> bool { + request + .protocol_payload() + .body() + .get("input") + .and_then(Value::as_array) + .and_then(|input| input.last()) + .and_then(Value::as_object) + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + == Some("compaction_trigger") +} + impl fmt::Debug for GrokCompactionRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -127,9 +150,9 @@ impl fmt::Debug for GrokCompactionRequest { } } -/// 从一次专用 Grok 摘要响应中提取 Core-owned [`CompactionSummary`]。 +/// 从一次专用 Grok 摘要响应中提取 xAI adapter 私有的摘要文本。 /// -/// Decoder 不产出客户端事件;协议投影属于 API 边界。 +/// xAI 随后自行投影为 OpenAI Responses wire,Core 不再承载 compaction 语义。 #[derive(Default)] pub struct GrokCompactionSummaryDecoder { text: String, @@ -158,20 +181,17 @@ impl GrokCompactionSummaryDecoder { Ok(()) } - /// 完成摘要解码并返回有界的 typed summary。 + /// 完成摘要解码并返回清理后的摘要文本。 /// /// # Errors /// /// 摘要为空或过短时返回错误。 - pub fn finish(self) -> Result { + pub fn finish(self) -> Result { let summary = clean_summary(&self.text); - if summary.is_empty() { + if summary.is_empty() || summary.chars().count() < MIN_GROK_COMPACTION_SUMMARY_CHARS { return Err(GrokCompactionDecodeError::Degenerate); } - if summary.chars().count() < MIN_GROK_COMPACTION_SUMMARY_CHARS { - return Err(GrokCompactionDecodeError::Degenerate); - } - CompactionSummary::new(summary).map_err(GrokCompactionDecodeError::InvalidSummary) + Ok(summary) } } @@ -189,8 +209,159 @@ impl fmt::Debug for GrokCompactionSummaryDecoder { pub enum GrokCompactionDecodeError { #[error("Grok compaction response summary was too short")] Degenerate, - #[error("Grok compaction response summary is invalid")] - InvalidSummary(#[source] CompactionSummaryError), +} + +pub(crate) struct GrokCompactionWireEvents { + created: ProtocolWireEvent, + output_done: ProtocolWireEvent, + terminal: ProtocolWireEvent, +} + +impl GrokCompactionWireEvents { + #[must_use] + pub(crate) fn into_parts(self) -> (ProtocolWireEvent, ProtocolWireEvent, ProtocolWireEvent) { + (self.created, self.output_done, self.terminal) + } +} + +/// 把 xAI 的专用摘要结果投影为 Codex 需要的 OpenAI Responses 事件。 +/// +/// 这是 xAI adapter 的局部兼容职责,不经过 API 层的通用 canonical 重建器。 +pub(crate) fn compaction_wire_events( + started: &ResponseMeta, + completed: &ResponseMeta, + summary: &str, + created_source: Option<&Value>, + terminal_source: Option<&Value>, + terminal_was_incomplete: bool, +) -> Result { + let created_response = compaction_response( + started, + created_source, + "in_progress", + Value::Null, + Vec::new(), + Value::Null, + ); + let item = compaction_item(summary); + let (status, terminal_event, incomplete_details) = terminal_shape( + completed.finish_reason(), + terminal_was_incomplete, + terminal_source, + ); + let terminal_response = compaction_response( + completed, + terminal_source.or(created_source), + status, + incomplete_details, + vec![item.clone()], + terminal_source + .and_then(|source| source.get("usage")) + .cloned() + .unwrap_or(Value::Null), + ); + Ok(GrokCompactionWireEvents { + created: ProtocolWireEvent::json_with_sse_metadata( + "openai", + Some("response.created".to_owned()), + json!({ + "type": "response.created", + "response": created_response, + }), + None, + None, + )?, + output_done: ProtocolWireEvent::json_with_sse_metadata( + "openai", + Some("response.output_item.done".to_owned()), + json!({ + "type": "response.output_item.done", + "output_index": 0, + "item": item, + }), + None, + None, + )?, + terminal: ProtocolWireEvent::json_with_sse_metadata( + "openai", + Some(terminal_event.to_owned()), + json!({ + "type": terminal_event, + "response": terminal_response, + }), + None, + None, + )?, + }) +} + +fn compaction_response( + meta: &ResponseMeta, + source: Option<&Value>, + status: &str, + incomplete_details: Value, + output: Vec, + usage: Value, +) -> Value { + let mut response = source + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + response.insert( + "id".to_owned(), + Value::String(meta.response_id().to_owned()), + ); + response + .entry("object".to_owned()) + .or_insert_with(|| Value::String("response".to_owned())); + response + .entry("created_at".to_owned()) + .or_insert_with(|| json!(unix_seconds())); + response.insert("status".to_owned(), Value::String(status.to_owned())); + response.insert("error".to_owned(), Value::Null); + response.insert("incomplete_details".to_owned(), incomplete_details); + response.insert("model".to_owned(), Value::String(meta.model().to_owned())); + response.insert("output".to_owned(), Value::Array(output)); + response.insert("usage".to_owned(), usage); + Value::Object(response) +} + +fn compaction_item(summary: &str) -> Value { + json!({ + "type": "compaction", + "encrypted_content": summary, + }) +} + +fn terminal_shape( + finish_reason: Option, + terminal_was_incomplete: bool, + terminal_source: Option<&Value>, +) -> (&'static str, &'static str, Value) { + let incomplete = terminal_was_incomplete + || matches!( + finish_reason, + Some(FinishReason::Length | FinishReason::ContentFilter) + ); + if !incomplete { + return ("completed", "response.completed", Value::Null); + } + let details = terminal_source + .and_then(|source| source.get("incomplete_details")) + .cloned() + .unwrap_or_else(|| match finish_reason { + Some(FinishReason::Length) => json!({ "reason": "max_output_tokens" }), + Some(FinishReason::ContentFilter) => json!({ "reason": "content_filter" }), + _ => json!({ "reason": "other" }), + }); + ("incomplete", "response.incomplete", details) +} + +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() } fn summary_prompt_item() -> Value { diff --git a/backend/crates/providers/xai/src/transport/request.rs b/backend/crates/providers/xai/src/transport/request.rs index 6f525a002..10e9aa43e 100644 --- a/backend/crates/providers/xai/src/transport/request.rs +++ b/backend/crates/providers/xai/src/transport/request.rs @@ -163,7 +163,7 @@ impl GrokResponsesRequest { upstream_model: &str, client_api_key_ref: &ClientApiKeyId, ) -> Result { - Self::encode_inner(request, upstream_model, client_api_key_ref, true) + Self::encode_inner(request, upstream_model, client_api_key_ref, true, false) } pub(super) fn encode_compaction_source( @@ -171,7 +171,7 @@ impl GrokResponsesRequest { upstream_model: &str, client_api_key_ref: &ClientApiKeyId, ) -> Result { - Self::encode_inner(request, upstream_model, client_api_key_ref, false) + Self::encode_inner(request, upstream_model, client_api_key_ref, false, true) } fn encode_inner( @@ -179,12 +179,16 @@ impl GrokResponsesRequest { upstream_model: &str, client_api_key_ref: &ClientApiKeyId, allow_cache_route: bool, + should_consume_terminal_compaction_trigger: bool, ) -> Result { - let payload = request - .protocol_payload() - .filter(|payload| payload.protocol() == "openai") - .ok_or(GrokRequestEncodeError::InvalidProtocolPayload)?; + let payload = request.protocol_payload(); + if payload.protocol() != "openai" { + return Err(GrokRequestEncodeError::InvalidProtocolPayload); + } let mut body = payload.body().clone(); + if should_consume_terminal_compaction_trigger { + consume_terminal_compaction_trigger(&mut body)?; + } // `provider_options` 是已废弃的网关私有 envelope,不是 xAI 上游协议字段。 // OpenAI 透明路径会保留未知字段;这里只在 xAI adapter 内做最小剥离。 body.remove("provider_options"); @@ -231,6 +235,22 @@ impl GrokResponsesRequest { } } +fn consume_terminal_compaction_trigger( + body: &mut Map, +) -> Result<(), GrokRequestEncodeError> { + let Some(Value::Array(input)) = body.get_mut("input") else { + return Err(GrokRequestEncodeError::InvalidRequestNormalization); + }; + let Some(Value::Object(last)) = input.last() else { + return Err(GrokRequestEncodeError::InvalidRequestNormalization); + }; + if last.get("type").and_then(Value::as_str) != Some("compaction_trigger") { + return Err(GrokRequestEncodeError::InvalidRequestNormalization); + } + input.pop(); + Ok(()) +} + impl fmt::Debug for GrokResponsesRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index e488bf4f6..0fff8be98 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -22,12 +22,11 @@ use gateway_core::error::{ }; use gateway_core::event::{GatewayEvent, UpstreamHttpVersion}; use gateway_core::operation::{ - CompactConversationRequest, Feature, GenerateRequest, Operation, OperationKind, - ProtocolPayload, ProviderSessionState, + Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload, ProviderSessionState, }; use gateway_core::routing::{ ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext, - RuntimeSnapshot, UpstreamModelId, + RuntimeSnapshot, SupportLevel, UpstreamModelId, }; use provider_xai::{ GrokBuildProvider, GrokCredentialCatalogCache, GrokCredentialFailure, @@ -491,7 +490,7 @@ fn operation() -> Operation { ]), ) .expect("OpenAI payload"); - Operation::Generate(GenerateRequest::from_protocol_payload(Vec::new(), payload)) + Operation::Generate(GenerateRequest::from_protocol_payload(payload)) } fn compaction_operation() -> Operation { @@ -501,15 +500,16 @@ fn compaction_operation() -> Operation { ("model".to_owned(), json!("client-model")), ( "input".to_owned(), - json!([{"type": "message", "role": "user", "content": "history"}]), + json!([ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ]), ), ("stream".to_owned(), json!(true)), ]), ) .expect("OpenAI payload"); - Operation::CompactConversation(CompactConversationRequest::new( - GenerateRequest::from_protocol_payload(Vec::new(), payload), - )) + Operation::Generate(GenerateRequest::from_protocol_payload(payload)) } fn compaction_operation_with_state(state: ProviderSessionState) -> Operation { @@ -519,24 +519,27 @@ fn compaction_operation_with_state(state: ProviderSessionState) -> Operation { ("model".to_owned(), json!("client-model")), ( "input".to_owned(), - json!([{ - "type": "reasoning", - "summary": [], - "encrypted_content": "account-bound-reasoning" - }, { - "type": "message", - "role": "user", - "content": "complete history" - }]), + json!([ + { + "type": "reasoning", + "summary": [], + "encrypted_content": "account-bound-reasoning" + }, + { + "type": "message", + "role": "user", + "content": "complete history" + }, + {"type": "compaction_trigger"} + ]), ), ("stream".to_owned(), json!(true)), ]), ) .expect("OpenAI payload"); - Operation::CompactConversation(CompactConversationRequest::new( - GenerateRequest::from_protocol_payload(Vec::new(), payload) - .with_provider_session_state(state), - )) + Operation::Generate( + GenerateRequest::from_protocol_payload(payload).with_provider_session_state(state), + ) } fn provider_request(provider_kind: &str) -> ProviderRequest { @@ -548,10 +551,9 @@ fn provider_request_with_operation(provider_kind: &str, operation: Operation) -> let provider_model = ProviderModel::new( provider.clone(), UpstreamModelId::new(MODEL).expect("model"), - ModelCapabilities::new( - BTreeSet::from([OperationKind::Generate, OperationKind::CompactConversation]), - Some(131_072), - ), + ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(131_072)) + .with_feature(Feature::Tools, SupportLevel::Native) + .with_feature(Feature::NativeContinuation, SupportLevel::Native), ); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), @@ -631,7 +633,6 @@ fn operation_with_state(body: serde_json::Value, state: ProviderSessionState) -> panic!("request body must be an object"); }; let request = GenerateRequest::from_protocol_payload( - Vec::new(), ProtocolPayload::json_object("openai", body).expect("OpenAI payload"), ) .with_provider_session_state(state); @@ -701,7 +702,7 @@ async fn execute_returns_cold_stream_and_records_selected_account() { } #[tokio::test] -async fn compaction_stream_should_publish_only_typed_summary_and_accounting() { +async fn compaction_stream_should_emit_openai_wire_and_keep_only_accounting_canonical() { let summary = valid_compaction_summary("validated summary"); let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse( &summary, @@ -717,21 +718,33 @@ async fn compaction_stream_should_publish_only_typed_summary_and_accounting() { .expect("compaction stream"); let events = stream.by_ref().collect::>().await; let facts = events - .into_iter() - .map(|event| event.expect("successful compaction event")) - .flat_map(|event| event.into_parts().0) + .iter() + .map(|event| event.as_ref().expect("successful compaction event")) + .flat_map(|event| event.canonical_facts()) + .collect::>(); + let wire_types = events + .iter() + .map(|event| event.as_ref().expect("successful compaction event")) + .filter_map(|event| event.wire_event().and_then(|wire| wire.event_type())) .collect::>(); assert!(matches!( facts.as_slice(), [ GatewayEvent::Started(_), - GatewayEvent::CompactionOutput(_), GatewayEvent::Usage(_), GatewayEvent::CalculatedCost(_), GatewayEvent::Completed(_), ] )); + assert_eq!( + wire_types, + [ + "response.created", + "response.output_item.done", + "response.completed" + ] + ); } #[tokio::test] @@ -801,25 +814,36 @@ async fn compaction_should_accept_summary_when_upstream_stream_ends_without_term ) .await .expect("compaction stream"); - let facts = stream + let events = stream .by_ref() .map(|event| event.expect("successful compaction event")) - .flat_map(|event| stream::iter(event.into_parts().0)) .collect::>() .await; + let facts = events + .iter() + .flat_map(|event| event.canonical_facts()) + .collect::>(); + let wire_types = events + .iter() + .filter_map(|event| event.wire_event().and_then(|wire| wire.event_type())) + .collect::>(); assert!(matches!( facts.as_slice(), - [ - GatewayEvent::Started(_), - GatewayEvent::CompactionOutput(_), - GatewayEvent::Completed(_), - ] + [GatewayEvent::Started(_), GatewayEvent::Completed(_)] )); + assert_eq!( + wire_types, + [ + "response.created", + "response.output_item.done", + "response.completed" + ] + ); } #[tokio::test] -async fn compaction_stream_should_exclude_reasoning_from_plaintext_summary() { +async fn compaction_wire_should_exclude_reasoning_from_summary_content() { let summary = valid_compaction_summary("continuation marker"); let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse( &summary, @@ -834,18 +858,17 @@ async fn compaction_stream_should_exclude_reasoning_from_plaintext_summary() { .await .expect("compaction stream"); let events = stream.by_ref().collect::>().await; - let output = events + let summary = events .iter() .map(|event| event.as_ref().expect("successful compaction event")) - .flat_map(|event| event.canonical_facts()) - .find_map(|event| match event { - GatewayEvent::CompactionOutput(output) => Some(output), - _ => None, - }) - .expect("typed compaction output"); + .filter_map(|event| event.wire_event()) + .find(|wire| wire.event_type() == Some("response.output_item.done")) + .and_then(|wire| wire.data().pointer("/item/encrypted_content")) + .and_then(serde_json::Value::as_str) + .expect("compaction output wire"); - assert!(output.summary().as_str().contains("continuation marker")); - assert!(!output.summary().as_str().contains("private reasoning")); + assert!(summary.contains("continuation marker")); + assert!(!summary.contains("private reasoning")); } #[tokio::test] @@ -1285,7 +1308,6 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco let selector = StubSelector::success(); let provider = provider(selector.clone(), transport.clone()).await; let first_operation = Operation::Generate(GenerateRequest::from_protocol_payload( - Vec::new(), ProtocolPayload::json_object( "openai", Map::from_iter([ @@ -1408,7 +1430,6 @@ async fn replay_owner_should_reencode_custom_apply_patch_call_for_grok() { ]); let provider = provider(StubSelector::success(), transport.clone()).await; let first_operation = Operation::Generate(GenerateRequest::from_protocol_payload( - Vec::new(), ProtocolPayload::json_object( "openai", Map::from_iter([ diff --git a/backend/crates/providers/xai/tests/transport/canonical.rs b/backend/crates/providers/xai/tests/transport/canonical.rs index d51fb1047..384d30aff 100644 --- a/backend/crates/providers/xai/tests/transport/canonical.rs +++ b/backend/crates/providers/xai/tests/transport/canonical.rs @@ -55,7 +55,6 @@ fn tool_request(body: Value) -> GrokResponsesRequest { panic!("request fixture must be an object"); }; let request = GenerateRequest::from_protocol_payload( - Vec::new(), ProtocolPayload::json_object("openai", body).expect("OpenAI payload"), ); GrokResponsesRequest::encode( diff --git a/backend/crates/providers/xai/tests/transport/catalog.rs b/backend/crates/providers/xai/tests/transport/catalog.rs index f448bc7a1..8ba2ccc6b 100644 --- a/backend/crates/providers/xai/tests/transport/catalog.rs +++ b/backend/crates/providers/xai/tests/transport/catalog.rs @@ -415,13 +415,30 @@ fn duplicate_actual_models_should_fail_the_entire_snapshot() { } #[test] -fn pagination_signal_should_fail_the_entire_snapshot() { - let result = parse_grok_model_catalog( +fn unknown_top_level_pagination_fields_should_not_reject_the_snapshot() { + let snapshot = parse_grok_model_catalog( br#"{"object":"list","data":[{"id":"grok-4"}],"has_more":true,"cursor":"next"}"#, None, - ); + ) + .expect("unknown pagination fields are not part of the model contract"); - assert!(matches!(result, Err(GrokModelCatalogError::InvalidWire))); + assert_eq!(snapshot.models()[0].request_model().as_str(), "grok-4"); +} + +#[test] +fn unknown_api_backend_should_degrade_to_unknown_without_rejecting_the_snapshot() { + let snapshot = parse_grok_model_catalog( + br#"{"object":"list","future_top_level":true,"data":[{"id":"grok-future","apiBackend":"future_backend","future_field":{"keep":true}}]}"#, + None, + ) + .expect("future backend should not reject the snapshot"); + let capabilities = snapshot.models()[0].capabilities(); + + assert_eq!(capabilities.api_backend(), None); + assert_eq!( + capabilities.responses_api(), + GrokCatalogCapabilityEvidence::Unknown + ); } #[test] diff --git a/backend/crates/providers/xai/tests/transport/compaction.rs b/backend/crates/providers/xai/tests/transport/compaction.rs index dfdf5dcbd..3d13b6db1 100644 --- a/backend/crates/providers/xai/tests/transport/compaction.rs +++ b/backend/crates/providers/xai/tests/transport/compaction.rs @@ -1,7 +1,5 @@ -use gateway_core::event::{ - CompactionSummary, GatewayEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta, -}; -use gateway_core::operation::{CompactConversationRequest, GenerateRequest, ProtocolPayload}; +use gateway_core::event::{GatewayEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta}; +use gateway_core::operation::{GenerateRequest, ProtocolPayload}; use gateway_core::policy::ClientApiKeyId; use provider_xai::{ GrokCompactionDecodeError, GrokCompactionRequest, GrokCompactionSummaryDecoder, @@ -11,15 +9,15 @@ use serde_json::{Value, json}; const GROK_COMPACTION_REQUEST_FIXTURE: &str = include_str!("fixtures/grok_compaction_request.json"); -fn compact_request(body: Value) -> CompactConversationRequest { +fn compaction_request(body: Value) -> GenerateRequest { let body = body.as_object().expect("request object").clone(); let payload = ProtocolPayload::json_object("openai", body).expect("OpenAI payload"); - CompactConversationRequest::new(GenerateRequest::from_protocol_payload(Vec::new(), payload)) + GenerateRequest::from_protocol_payload(payload) } fn encode(body: Value) -> Result { GrokCompactionRequest::encode( - &compact_request(body), + &compaction_request(body), "grok-4.5", &ClientApiKeyId::new("key_compaction").expect("client API key ID"), ) @@ -32,7 +30,7 @@ fn valid_summary(secret: &str) -> String { ) } -fn decode_summary(raw: &str) -> Result { +fn decode_summary(raw: &str) -> Result { let mut decoder = GrokCompactionSummaryDecoder::new(); decoder.observe(&ProviderEvent::canonical(GatewayEvent::TextDelta( TextDelta { @@ -51,7 +49,8 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() { {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "first"}]}, {"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"}, {"type": "function_call_output", "call_id": "call_1", "output": "result"}, - {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "last"}]} + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "last"}]}, + {"type": "compaction_trigger"} ], "stream": true })) @@ -78,7 +77,10 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() { fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() { let request = encode(json!({ "model": "client-model", - "input": [{"type": "message", "role": "user", "content": "history"}], + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ], "tools": [{"type": "function", "name": "lookup", "parameters": {"type": "object"}}], "tool_choice": "required", "parallel_tool_calls": true, @@ -120,7 +122,10 @@ fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() { fn encoder_should_match_grok_compaction_fixture_shape() { let request = encode(json!({ "model": "client-model", - "input": [{"type": "message", "role": "user", "content": "history"}], + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ], "tools": [{"type": "function", "name": "lookup", "parameters": {"type": "object"}}] })) .expect("compaction request"); @@ -138,7 +143,10 @@ fn encoder_should_match_grok_compaction_fixture_shape() { fn encoder_should_append_structured_full_replace_prompt() { let request = encode(json!({ "model": "client-model", - "input": [{"type": "message", "role": "user", "content": "history"}] + "input": [ + {"type": "message", "role": "user", "content": "history"}, + {"type": "compaction_trigger"} + ] })) .expect("compaction request"); let prompt = request.body()["input"][1]["content"][0]["text"] @@ -153,19 +161,23 @@ fn encoder_should_append_structured_full_replace_prompt() { } #[test] -fn encoder_should_fail_closed_when_raw_trigger_reaches_provider() { - let error = match encode(json!({ +fn encoder_should_consume_only_the_terminal_compaction_trigger() { + let request = encode(json!({ "model": "client-model", "input": [ {"type": "message", "role": "user", "content": "history"}, {"type": "compaction_trigger"} ] - })) { - Ok(_) => panic!("raw trigger must not cross the typed operation boundary"), - Err(error) => error, - }; + })) + .expect("compaction request"); - assert_eq!(error, GrokRequestEncodeError::InvalidRequestNormalization); + assert!( + request.body()["input"] + .as_array() + .expect("input array") + .iter() + .all(|item| item["type"] != "compaction_trigger") + ); } #[test] @@ -334,7 +346,10 @@ fn decoder_should_accept_a_valid_summary_without_interpreting_terminal_state() { fn debug_output_should_not_contain_private_conversation_or_summary() { let request = encode(json!({ "model": "client-model", - "input": [{"type": "message", "role": "user", "content": "request-private-secret"}] + "input": [ + {"type": "message", "role": "user", "content": "request-private-secret"}, + {"type": "compaction_trigger"} + ] })) .expect("compaction request"); let mut decoder = GrokCompactionSummaryDecoder::new(); diff --git a/backend/crates/providers/xai/tests/transport/request.rs b/backend/crates/providers/xai/tests/transport/request.rs index 256c2ccda..122d37851 100644 --- a/backend/crates/providers/xai/tests/transport/request.rs +++ b/backend/crates/providers/xai/tests/transport/request.rs @@ -1,6 +1,4 @@ -use gateway_core::operation::{ - ContentPart, GenerateRequest, Message, MessageRole, ProtocolPayload, -}; +use gateway_core::operation::{GenerateRequest, ProtocolPayload}; use gateway_core::policy::ClientApiKeyId; use serde_json::{Map, Value, json}; @@ -11,7 +9,6 @@ fn raw_request(body: Value) -> GenerateRequest { panic!("request fixture must be an object"); }; GenerateRequest::from_protocol_payload( - Vec::new(), ProtocolPayload::json_object("openai", body).expect("OpenAI payload"), ) } @@ -149,22 +146,6 @@ fn account_identity_should_be_removed_without_touching_prompt_content() { ); } -#[test] -fn typed_projection_should_not_exist_as_a_second_request_path() { - let message = Message::new( - MessageRole::User, - vec![ContentPart::Text("private prompt".to_owned())], - ) - .expect("message"); - let request = GenerateRequest::new(vec![message]).expect("typed operation"); - - assert_eq!( - GrokResponsesRequest::encode(&request, "grok-routed", &client_key()) - .expect_err("missing raw payload must fail"), - GrokRequestEncodeError::InvalidProtocolPayload - ); -} - #[test] fn request_debug_should_not_expose_prompt_or_unknown_values() { let request = raw_request(json!({