refactor(gateway): restore provider-owned protocol boundaries

Keep OpenAI Responses bodies and wire events transparent, remove the generic typed compaction and response reconstruction path, and localize Grok compaction adaptation to the xAI provider.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-25 00:33:12 +08:00
co-authored by Codex
parent 98250268b0
commit 5364508219
40 changed files with 1375 additions and 4036 deletions
+1 -1
View File
@@ -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"] }
@@ -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,
},
}
@@ -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<dyn ExecutionSession>,
created_at: u64,
) -> Response {
pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> 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<Vec<u8>, 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<dyn ExecutionSession>,
created_at: u64,
connection_guard: Option<Box<dyn ConnectionGuard>>,
) -> 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) {
@@ -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};
@@ -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<DecodedResponsesRequest, RequestDecodeError> {
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<DecodedResponsesRequest, RequestDecodeError> {
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<DecodedResponsesRequest, RequestDecodeError> {
let value =
serde_json::from_slice::<Value>(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<String> {
.map(ToOwned::to_owned)
}
fn canonical_messages(body: &Map<String, Value>) -> Vec<Message> {
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<Message> {
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<ContentPart> {
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<ContentPart> {
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<ContentPart>) -> Option<Message> {
(!content.is_empty())
.then(|| Message::new(role, content).ok())
.flatten()
}
fn canonical_function_tools(body: &Map<String, Value>) -> Vec<ToolDefinition> {
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<String, Value>) -> Option<OutputFormat> {
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<String, Value>) -> 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<String, Value>,
key: &str,
@@ -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<String>,
wire_terminal: Option<Value>,
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<Vec<Bytes>, 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<Vec<String>, 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<Value, ResponseEncodeError> {
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<Option<&'event gateway_core::event::ProtocolWireEvent>, 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<Vec<String>, 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<ResponseMeta>,
usage: Option<Usage>,
output_positions: BTreeMap<u32, usize>,
output: Vec<OutputState>,
final_response: Option<Value>,
}
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<Vec<String>, 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<Value, ResponseEncodeError> {
self.validator.finish()?;
self.final_response
.ok_or_else(|| gateway_core::event::EventSequenceError::MissingCompleted.into())
}
fn started(&mut self, meta: &ResponseMeta) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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::<Result<Vec<_>, _>>()?;
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<usize, ResponseEncodeError> {
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<String>,
name: Option<String>,
arguments: String,
published: bool,
},
Compaction {
encrypted_content: String,
},
}
impl OutputState {
fn added_frames(&self, output_index: usize) -> Vec<String> {
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<Vec<String>, 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<Value, ResponseEncodeError> {
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<FinishReason>) -> (&'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<Value>,
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<Value, ResponseEncodeError> {
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>) -> 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>) -> 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)
}
@@ -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<DecodedResponsesRequest, ResponseCreateFrameError> {
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<DecodedResponsesRequest, ResponseCreateFrameError> {
let Value::Object(mut body) = serde_json::from_str::<Value>(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)
}
@@ -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<u64, GatewayError> {
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,
@@ -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<Trace>, events: Vec<GatewayEvent>) -> Self {
Self::buffered_provider(
trace,
events.into_iter().map(provider_event_for_fact).collect(),
)
}
fn buffered_provider(trace: Arc<Trace>, events: Vec<ProviderEvent>) -> 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]);
File diff suppressed because it is too large Load Diff
@@ -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"))
);
+1 -1
View File
@@ -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"] }
@@ -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() {
-85
View File
@@ -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<String>) -> Result<Self, CompactionSummaryError> {
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<u32, ContentKind>,
}
@@ -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(_) => {}
+76 -612
View File
@@ -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<u8>,
},
}
impl fmt::Debug for ImageSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Url(_) => formatter.write_str("ImageSource::Url(<redacted>)"),
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<ContentPart>,
}
impl Message {
/// 创建消息。
///
/// # Errors
///
/// 内容为空时返回错误。
pub fn new(role: MessageRole, content: Vec<ContentPart>) -> Result<Self, OperationError> {
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<String>,
input_schema: Map<String, Value>,
strict: bool,
}
impl ToolDefinition {
/// 创建工具声明。
///
/// # Errors
///
/// 名称为空时返回错误。
pub fn new(
name: impl Into<String>,
description: Option<String>,
input_schema: Map<String, Value>,
) -> Result<Self, OperationError> {
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<String, Value> {
&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(|_| "<present>"),
)
.field("input_schema", &"<validated JSON object>")
.field("strict", &self.strict)
.finish()
}
}
/// 结构化 JSON 输出的稳定约束。
#[derive(Clone, PartialEq)]
pub struct JsonSchemaFormat {
name: String,
description: Option<String>,
schema: Map<String, Value>,
strict: bool,
}
impl JsonSchemaFormat {
/// 创建 JSON Schema 输出约束。
///
/// # Errors
///
/// Schema 名称为空时返回错误。
pub fn new(
name: impl Into<String>,
description: Option<String>,
schema: Map<String, Value>,
strict: bool,
) -> Result<Self, OperationError> {
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<String, Value> {
&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(|_| "<present>"),
)
.field("schema", &"<validated JSON object>")
.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(<validated>)"),
}
}
}
/// 推理强度。
#[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<ReasoningEffort>,
/// 可见推理摘要要求;`None` 表示客户端未指定。
pub summary: Option<ReasoningSummary>,
}
/// 对话延续模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContinuationMode {
/// Provider 原生绑定。
Native,
}
/// 通用生成请求。
#[derive(Clone, PartialEq)]
pub struct GenerateRequest {
payload: Arc<GeneratePayload>,
reasoning: Option<ReasoningRequirement>,
continuation: Option<ContinuationMode>,
max_output_tokens: Option<u64>,
image_generation_requested: bool,
response_persistence: ResponsePersistence,
}
#[derive(Clone, PartialEq)]
struct GeneratePayload {
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
output_format: OutputFormat,
prompt_cache_key: Option<String>,
required_features: BTreeSet<Feature>,
protocol_payload: Option<ProtocolPayload>,
protocol_payload: ProtocolPayload,
provider_session_state: Option<ProviderSessionState>,
}
impl GenerateRequest {
/// 创建最小生成请求。
///
/// # Errors
///
/// 消息为空时返回错误。
pub fn new(messages: Vec<Message>) -> Result<Self, OperationError> {
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<Message>,
protocol_payload: ProtocolPayload,
) -> Self {
Self::from_parts(messages, Some(protocol_payload))
}
fn from_parts(messages: Vec<Message>, protocol_payload: Option<ProtocolPayload>) -> 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<ToolDefinition>) -> 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<String>) -> 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<ReasoningRequirement> {
self.reasoning
}
/// 返回对话延续模式。
#[must_use]
pub const fn continuation(&self) -> Option<ContinuationMode> {
self.continuation
}
/// 返回最大输出 token 数。
#[must_use]
pub const fn max_output_tokens(&self) -> Option<u64> {
self.max_output_tokens
pub fn max_output_tokens(&self) -> Option<u64> {
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<String, Value> {
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(|_| "<present>"),
)
.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(|_| "<present>"),
)
.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,
}
}
@@ -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<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
@@ -373,7 +379,8 @@ fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, Pr
fn plan(operation: &Operation) -> 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(
@@ -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"),
))
}
+3 -29
View File
@@ -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"),
)
}
@@ -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));
}
@@ -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!(
(
+1 -1
View File
@@ -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"] }
+4 -13
View File
@@ -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<Operation, ProviderAdminError> {
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<CurrencyCost, ProviderAdminError> {
@@ -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)
}
@@ -266,7 +266,6 @@ pub enum CodexModelCatalogError {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CodexModelsWire {
models: Vec<CodexModelWire>,
}
@@ -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
}
@@ -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<CodexResponsesRequest, CodexRequestEncodeError> {
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<Vec<Value>, 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<String, Value>, field: &str) -> Option<String> {
.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",
}
}
@@ -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);
@@ -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<MemoryAccountStore>, 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);
@@ -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]
@@ -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<String, Value>) -> 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")
+1 -1
View File
@@ -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"
+4 -13
View File
@@ -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<Operation, ProviderAdminError> {
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 {
+77 -60
View File
@@ -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<ProviderStream, ProviderError> {
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<ProviderStream, ProviderError> {
@@ -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<ResponseMeta>,
completed: &mut Option<ResponseMeta>,
accounting: &mut Vec<GatewayEvent>,
) -> 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<ResponseMeta>,
completed: Option<ResponseMeta>,
accounting: Vec<GatewayEvent>,
created_response: Option<Value>,
terminal_response: Option<Value>,
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(),
}
}
@@ -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<GrokModelWire>,
@@ -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<GrokCatalogModel, GrokModelCat
let context_window_tokens = optional_positive(wire.context_window)?;
let max_output_tokens = optional_positive(wire.max_completion_tokens)?;
let responses_api = responses_evidence(wire.supported_in_api, wire.api_backend);
let api_backend = wire
.api_backend
.filter(|backend| *backend != GrokCatalogApiBackend::Unknown);
let responses_api = responses_evidence(wire.supported_in_api, api_backend);
Ok(GrokCatalogModel {
request_model,
@@ -928,7 +933,7 @@ fn normalize_model(wire: GrokModelWire) -> Result<GrokCatalogModel, GrokModelCat
),
backend_search: GrokCatalogCapabilityEvidence::from_wire(wire.supports_backend_search),
streaming_tool_calls: GrokCatalogCapabilityEvidence::from_wire(wire.stream_tool_calls),
api_backend: wire.api_backend,
api_backend,
},
limits: GrokCatalogLimits {
context_window_tokens,
@@ -1,7 +1,11 @@
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
use gateway_core::event::{CompactionSummary, CompactionSummaryError, GatewayEvent, ProviderEvent};
use gateway_core::operation::CompactConversationRequest;
use gateway_core::error::IdentifierError;
use gateway_core::event::{
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta,
};
use gateway_core::operation::GenerateRequest;
use gateway_core::policy::ClientApiKeyId;
use serde_json::{Map, Value, json};
@@ -33,8 +37,8 @@ If the prior conversation contains a note about files at /tmp/compaction/segment
/// xAI 上游专用的全历史摘要请求。
///
/// 该类型只接受 Core 已分类的压缩 operation。它复用常规 Grok request encoder
/// 规范化完整历史,但不会从客户端 wire 中推断压缩语义。
/// 该类型只接收已由 xAI adapter 识别出末尾 `compaction_trigger` 的生成请求。
/// 它复用常规 Grok request encoder 规范化完整历史,再生成 xAI 专用摘要请求。
pub struct GrokCompactionRequest {
body: Map<String, Value>,
affinity: Option<GrokSessionAffinityKey>,
@@ -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<Self, GrokRequestEncodeError> {
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<CompactionSummary, GrokCompactionDecodeError> {
pub fn finish(self) -> Result<String, GrokCompactionDecodeError> {
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<GrokCompactionWireEvents, IdentifierError> {
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<Value>,
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<FinishReason>,
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 {
@@ -163,7 +163,7 @@ impl GrokResponsesRequest {
upstream_model: &str,
client_api_key_ref: &ClientApiKeyId,
) -> Result<Self, GrokRequestEncodeError> {
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, GrokRequestEncodeError> {
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<Self, GrokRequestEncodeError> {
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<String, Value>,
) -> 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
@@ -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::<Vec<_>>().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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.await;
let facts = events
.iter()
.flat_map(|event| event.canonical_facts())
.collect::<Vec<_>>();
let wire_types = events
.iter()
.filter_map(|event| event.wire_event().and_then(|wire| wire.event_type()))
.collect::<Vec<_>>();
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::<Vec<_>>().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([
@@ -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(
@@ -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]
@@ -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, GrokRequestEncodeError> {
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<CompactionSummary, GrokCompactionDecodeError> {
fn decode_summary(raw: &str) -> Result<String, GrokCompactionDecodeError> {
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();
@@ -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!({