mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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"))
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
(
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user