feat(rust): define typed streaming API contracts

This commit is contained in:
Yujong Lee 2026-09-02 07:03:36 -07:00 committed by GitHub
parent 759740135a
commit 71b6506310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 663 additions and 6 deletions

View file

@ -16,11 +16,14 @@ pub mod response_utils;
pub mod transformation;
pub mod types;
use crate::streaming::OpenedStream;
use serde_json::{Map, Value};
use handler::execute_chat_completions_provider_call;
use prepare::{parse_messages, prepare_chat_completions_call, resolve_provider_config};
use types::{ChatCompletionsRequest, ChatCompletionsResponse};
use types::{
ChatCompletionsRequest, ChatCompletionsResponse, ChatCompletionsStreamRequest, ChatStreamEvent,
};
pub async fn chat_completions(
request: ChatCompletionsRequest<'_>,
@ -54,5 +57,51 @@ pub fn chat_completions_decline_reason(
.map(|reason| reason.0)
}
pub async fn chat_completions_stream(
_request: ChatCompletionsStreamRequest,
) -> Result<OpenedStream<ChatStreamEvent>, Error> {
Err(crate::Error::Unsupported(
"chat completions streaming provider registration",
))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod stream_entrypoint_tests {
use serde_json::json;
use super::*;
use crate::Error;
use crate::streaming::{
ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions,
};
#[tokio::test]
async fn typed_stream_declines_until_a_provider_is_registered() {
let body = serde_json::from_value(json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
}))
.expect("valid chat stream request");
let result = chat_completions_stream(ChatCompletionsStreamRequest {
body,
target: StreamTarget::new(
StreamProviderId::Anthropic,
ProviderCredentials::default(),
None,
),
transport: StreamTransportOptions::default(),
})
.await;
assert!(matches!(
result,
Err(Error::Unsupported(
"chat completions streaming provider registration"
))
));
}
}

View file

@ -5,6 +5,18 @@ use super::types::{
ChatCompletionsResponse, ChatMessage, ChatMessageContent, ProviderChatRequestData,
ProviderChatResponseData,
};
use super::types::{ChatCompletionsStreamRequest, ChatStreamEvent};
use crate::streaming::StreamProvider;
pub trait ChatCompletionsStreamProvider:
StreamProvider<ChatCompletionsStreamRequest, ChatStreamEvent>
{
}
impl<T> ChatCompletionsStreamProvider for T where
T: StreamProvider<ChatCompletionsStreamRequest, ChatStreamEvent>
{
}
/// How the upstream call is authenticated. API-key strategies are resolved in
/// `prepare`; SigV4 needs the serialized body, so the handler signs it.

View file

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::transformation::{ChatCompletionsAuth, ChatCompletionsProviderConfig};
use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions};
/// A `/chat/completions` call as it crosses into the core.
///
@ -110,3 +111,183 @@ pub struct ChatCompletionsResponse {
pub choices: Vec<ChatCompletionsChoice>,
pub usage: ChatCompletionsUsage,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatStreamRole {
Assistant,
Developer,
Function,
System,
Tool,
User,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatStreamMessageContent {
Text(String),
Parts(Vec<JsonObject>),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatStreamMessage {
pub role: ChatStreamRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ChatStreamMessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatStreamStop {
One(String),
Many(Vec<String>),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatStreamStringOrObject {
Name(String),
Definition(JsonObject),
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChatCompletionsStreamParameters {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop: Option<ChatStreamStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_options: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<JsonObject>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ChatStreamStringOrObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_format: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ChatStreamStringOrObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatCompletionsStreamRequestBody {
pub model: String,
pub messages: Vec<ChatStreamMessage>,
#[serde(flatten)]
pub parameters: ChatCompletionsStreamParameters,
}
pub struct ChatCompletionsStreamRequest {
pub body: ChatCompletionsStreamRequestBody,
pub target: StreamTarget,
pub transport: StreamTransportOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatStreamToolFunctionChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub arguments: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<JsonObject>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatStreamToolCallChunk {
pub id: Option<String>,
#[serde(rename = "type")]
pub tool_type: String,
pub function: ChatStreamToolFunctionChunk,
pub index: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatStreamUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens_details: Option<JsonObject>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatStreamEvent {
pub text: String,
pub tool_use: Option<ChatStreamToolCallChunk>,
pub is_finished: bool,
pub finish_reason: String,
pub usage: Option<ChatStreamUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<JsonObject>,
}
#[cfg(test)]
mod stream_contract_tests {
use super::*;
#[test]
fn request_uses_public_chat_completion_parameter_names() {
let request: ChatCompletionsStreamRequestBody = serde_json::from_value(serde_json::json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
"stream": true,
"tool_choice": "auto"
}))
.expect("public request shape");
assert_eq!(request.parameters.max_tokens, Some(32));
assert_eq!(request.parameters.stream, Some(true));
assert!(matches!(
request.parameters.tool_choice,
Some(ChatStreamStringOrObject::Name(ref value)) if value == "auto"
));
}
#[test]
fn event_matches_python_generic_streaming_chunk_shape() {
let event = ChatStreamEvent {
text: "hello".to_string(),
tool_use: None,
is_finished: false,
finish_reason: String::new(),
usage: None,
index: Some(0),
provider_specific_fields: None,
};
assert_eq!(
serde_json::to_value(event).expect("serializable event"),
serde_json::json!({
"text": "hello",
"tool_use": null,
"is_finished": false,
"finish_reason": "",
"usage": null,
"index": 0
})
);
}
}

View file

@ -14,10 +14,13 @@ mod handler;
mod prepare;
pub mod transformation;
pub mod types;
use crate::streaming::OpenedStream;
use handler::{execute_messages_provider_call, execute_messages_provider_stream};
use prepare::prepare_messages_call;
use types::{AnthropicMessagesResponse, MessagesRequest};
use types::{
AnthropicMessagesResponse, MessagesRequest, MessagesStreamEvent, MessagesStreamRequest,
};
pub async fn messages(request: MessagesRequest<'_>) -> Result<AnthropicMessagesResponse, Error> {
execute_messages_provider_call(prepare_messages_call(request)?).await
@ -27,5 +30,52 @@ pub async fn messages_stream(request: MessagesRequest<'_>) -> Result<reqwest::Re
execute_messages_provider_stream(prepare_messages_call(request)?).await
}
pub async fn messages_event_stream(
_request: MessagesStreamRequest,
) -> Result<OpenedStream<MessagesStreamEvent>, Error> {
Err(crate::Error::Unsupported(
"messages event streaming provider registration",
))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod stream_entrypoint_tests {
use serde_json::json;
use super::*;
use crate::Error;
use crate::streaming::{
ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions,
};
#[tokio::test]
async fn typed_event_stream_declines_until_a_provider_is_registered() {
let body = serde_json::from_value(json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
"stream": true
}))
.expect("valid Messages stream request");
let result = messages_event_stream(MessagesStreamRequest {
body,
target: StreamTarget::new(
StreamProviderId::Anthropic,
ProviderCredentials::default(),
None,
),
transport: StreamTransportOptions::default(),
})
.await;
assert!(matches!(
result,
Err(Error::Unsupported(
"messages event streaming provider registration"
))
));
}
}

View file

@ -1,5 +1,19 @@
use super::types::{AnthropicMessagesRequest, AnthropicMessagesResponse};
use crate::Error;
use crate::streaming::StreamProvider;
use super::types::{
AnthropicMessagesRequest, AnthropicMessagesResponse, MessagesStreamEvent, MessagesStreamRequest,
};
pub trait MessagesStreamProvider:
StreamProvider<MessagesStreamRequest, MessagesStreamEvent>
{
}
impl<T> MessagesStreamProvider for T where
T: StreamProvider<MessagesStreamRequest, MessagesStreamEvent>
{
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MessagesAuthStrategy {

View file

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::transformation::AnthropicMessagesProviderConfig;
use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions};
pub struct MessagesRequest<'a> {
pub model: &'a str,
@ -47,6 +48,76 @@ pub struct ContentBlock {
pub extra: Map<String, Value>,
}
pub struct MessagesStreamRequest {
pub body: AnthropicMessagesRequest,
pub target: StreamTarget,
pub transport: StreamTransportOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessagesStreamEvent {
MessageStart {
message: AnthropicMessagesResponse,
},
ContentBlockStart {
index: u64,
content_block: JsonObject,
},
ContentBlockDelta {
index: u64,
delta: JsonObject,
},
ContentBlockStop {
index: u64,
},
MessageDelta {
delta: JsonObject,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<JsonObject>,
},
MessageStop,
Ping,
Error {
error: JsonObject,
},
}
#[cfg(test)]
mod stream_contract_tests {
use super::*;
#[test]
fn message_stop_serializes_as_anthropic_event() {
assert_eq!(
serde_json::to_value(MessagesStreamEvent::MessageStop).expect("serializable event"),
serde_json::json!({"type": "message_stop"})
);
}
#[test]
fn content_delta_keeps_typed_event_fields() {
let event = MessagesStreamEvent::ContentBlockDelta {
index: 0,
delta: JsonObject(
serde_json::json!({"type": "text_delta", "text": "hello"})
.as_object()
.expect("object")
.clone(),
),
};
assert_eq!(
serde_json::to_value(event).expect("serializable event"),
serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "hello"}
})
);
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CacheControl {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]

View file

@ -1,3 +1,81 @@
pub mod instrumentation;
pub mod types;
pub mod websocket;
use crate::error::Error;
use crate::streaming::OpenedStream;
use types::{ResponsesStreamEvent, ResponsesStreamRequest, ResponsesWebSocketRequest};
use websocket::TypedResponsesWebSocketSession;
pub async fn responses_stream(
_request: ResponsesStreamRequest,
) -> Result<OpenedStream<ResponsesStreamEvent>, Error> {
Err(Error::Unsupported(
"responses HTTP streaming provider registration",
))
}
pub async fn responses_websocket(
_request: ResponsesWebSocketRequest,
) -> Result<Box<dyn TypedResponsesWebSocketSession>, Error> {
Err(Error::Unsupported(
"responses WebSocket streaming provider registration",
))
}
#[cfg(test)]
mod stream_entrypoint_tests {
use serde_json::json;
use super::*;
use crate::streaming::{
ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions,
};
fn target() -> StreamTarget {
StreamTarget::new(
StreamProviderId::OpenAi,
ProviderCredentials::default(),
None,
)
}
#[tokio::test]
async fn typed_http_stream_declines_until_a_provider_is_registered() {
let body = serde_json::from_value(json!({
"model": "gpt-5",
"input": "hello",
"stream": true
}))
.expect("valid Responses stream request");
let result = responses_stream(ResponsesStreamRequest {
body,
target: target(),
transport: StreamTransportOptions::default(),
})
.await;
assert!(matches!(
result,
Err(Error::Unsupported(
"responses HTTP streaming provider registration"
))
));
}
#[tokio::test]
async fn typed_websocket_declines_until_a_provider_is_registered() {
let result = responses_websocket(ResponsesWebSocketRequest {
target: target(),
transport: StreamTransportOptions::default(),
})
.await;
assert!(matches!(
result,
Err(Error::Unsupported(
"responses WebSocket streaming provider registration"
))
));
}
}

View file

@ -1,13 +1,46 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ResponsesWsEventType {
ResponseCreate,
ResponseCreated,
ResponseInProgress,
ResponseReasoningSummaryPartAdded,
ResponseReasoningSummaryTextDelta,
ResponseReasoningSummaryTextDone,
ResponseReasoningSummaryPartDone,
ResponseOutputItemAdded,
ResponseOutputTextDelta,
ResponseOutputTextAnnotationAdded,
ResponseOutputTextDone,
ResponseRefusalDelta,
ResponseRefusalDone,
ResponseFunctionCallArgumentsDelta,
ResponseFunctionCallArgumentsDone,
ResponseFileSearchCallInProgress,
ResponseFileSearchCallSearching,
ResponseFileSearchCallCompleted,
ResponseWebSearchCallInProgress,
ResponseWebSearchCallSearching,
ResponseWebSearchCallCompleted,
ResponseMcpListToolsInProgress,
ResponseMcpListToolsCompleted,
ResponseMcpListToolsFailed,
ResponseMcpCallInProgress,
ResponseMcpCallArgumentsDelta,
ResponseMcpCallArgumentsDone,
ResponseMcpCallCompleted,
ResponseMcpCallFailed,
ResponseContentPartAdded,
ResponseContentPartDone,
ResponseOutputItemDone,
ResponseCompleted,
ResponseFailed,
ResponseIncomplete,
ImageGenerationPartialImage,
Error,
Other(String),
}
@ -17,9 +50,40 @@ impl ResponsesWsEventType {
match self {
Self::ResponseCreate => "response.create",
Self::ResponseCreated => "response.created",
Self::ResponseInProgress => "response.in_progress",
Self::ResponseReasoningSummaryPartAdded => "response.reasoning_summary_part.added",
Self::ResponseReasoningSummaryTextDelta => "response.reasoning_summary_text.delta",
Self::ResponseReasoningSummaryTextDone => "response.reasoning_summary_text.done",
Self::ResponseReasoningSummaryPartDone => "response.reasoning_summary_part.done",
Self::ResponseOutputItemAdded => "response.output_item.added",
Self::ResponseOutputTextDelta => "response.output_text.delta",
Self::ResponseOutputTextAnnotationAdded => "response.output_text.annotation.added",
Self::ResponseOutputTextDone => "response.output_text.done",
Self::ResponseRefusalDelta => "response.refusal.delta",
Self::ResponseRefusalDone => "response.refusal.done",
Self::ResponseFunctionCallArgumentsDelta => "response.function_call_arguments.delta",
Self::ResponseFunctionCallArgumentsDone => "response.function_call_arguments.done",
Self::ResponseFileSearchCallInProgress => "response.file_search_call.in_progress",
Self::ResponseFileSearchCallSearching => "response.file_search_call.searching",
Self::ResponseFileSearchCallCompleted => "response.file_search_call.completed",
Self::ResponseWebSearchCallInProgress => "response.web_search_call.in_progress",
Self::ResponseWebSearchCallSearching => "response.web_search_call.searching",
Self::ResponseWebSearchCallCompleted => "response.web_search_call.completed",
Self::ResponseMcpListToolsInProgress => "response.mcp_list_tools.in_progress",
Self::ResponseMcpListToolsCompleted => "response.mcp_list_tools.completed",
Self::ResponseMcpListToolsFailed => "response.mcp_list_tools.failed",
Self::ResponseMcpCallInProgress => "response.mcp_call.in_progress",
Self::ResponseMcpCallArgumentsDelta => "response.mcp_call_arguments.delta",
Self::ResponseMcpCallArgumentsDone => "response.mcp_call_arguments.done",
Self::ResponseMcpCallCompleted => "response.mcp_call.completed",
Self::ResponseMcpCallFailed => "response.mcp_call.failed",
Self::ResponseContentPartAdded => "response.content_part.added",
Self::ResponseContentPartDone => "response.content_part.done",
Self::ResponseOutputItemDone => "response.output_item.done",
Self::ResponseCompleted => "response.completed",
Self::ResponseFailed => "response.failed",
Self::ResponseIncomplete => "response.incomplete",
Self::ImageGenerationPartialImage => "image_generation.partial_image",
Self::Error => "error",
Self::Other(value) => value,
}
@ -44,9 +108,40 @@ impl<'de> Deserialize<'de> for ResponsesWsEventType {
Ok(match value.as_str() {
"response.create" => Self::ResponseCreate,
"response.created" => Self::ResponseCreated,
"response.in_progress" => Self::ResponseInProgress,
"response.reasoning_summary_part.added" => Self::ResponseReasoningSummaryPartAdded,
"response.reasoning_summary_text.delta" => Self::ResponseReasoningSummaryTextDelta,
"response.reasoning_summary_text.done" => Self::ResponseReasoningSummaryTextDone,
"response.reasoning_summary_part.done" => Self::ResponseReasoningSummaryPartDone,
"response.output_item.added" => Self::ResponseOutputItemAdded,
"response.output_text.delta" => Self::ResponseOutputTextDelta,
"response.output_text.annotation.added" => Self::ResponseOutputTextAnnotationAdded,
"response.output_text.done" => Self::ResponseOutputTextDone,
"response.refusal.delta" => Self::ResponseRefusalDelta,
"response.refusal.done" => Self::ResponseRefusalDone,
"response.function_call_arguments.delta" => Self::ResponseFunctionCallArgumentsDelta,
"response.function_call_arguments.done" => Self::ResponseFunctionCallArgumentsDone,
"response.file_search_call.in_progress" => Self::ResponseFileSearchCallInProgress,
"response.file_search_call.searching" => Self::ResponseFileSearchCallSearching,
"response.file_search_call.completed" => Self::ResponseFileSearchCallCompleted,
"response.web_search_call.in_progress" => Self::ResponseWebSearchCallInProgress,
"response.web_search_call.searching" => Self::ResponseWebSearchCallSearching,
"response.web_search_call.completed" => Self::ResponseWebSearchCallCompleted,
"response.mcp_list_tools.in_progress" => Self::ResponseMcpListToolsInProgress,
"response.mcp_list_tools.completed" => Self::ResponseMcpListToolsCompleted,
"response.mcp_list_tools.failed" => Self::ResponseMcpListToolsFailed,
"response.mcp_call.in_progress" => Self::ResponseMcpCallInProgress,
"response.mcp_call_arguments.delta" => Self::ResponseMcpCallArgumentsDelta,
"response.mcp_call_arguments.done" => Self::ResponseMcpCallArgumentsDone,
"response.mcp_call.completed" => Self::ResponseMcpCallCompleted,
"response.mcp_call.failed" => Self::ResponseMcpCallFailed,
"response.content_part.added" => Self::ResponseContentPartAdded,
"response.content_part.done" => Self::ResponseContentPartDone,
"response.output_item.done" => Self::ResponseOutputItemDone,
"response.completed" => Self::ResponseCompleted,
"response.failed" => Self::ResponseFailed,
"response.incomplete" => Self::ResponseIncomplete,
"image_generation.partial_image" => Self::ImageGenerationPartialImage,
"error" => Self::Error,
_ => Self::Other(value),
})
@ -84,6 +179,60 @@ pub struct ResponsesWsTransformResult {
pub events: Vec<ResponsesWsEvent>,
}
pub type ResponsesStreamEvent = ResponsesWsEvent;
pub type ResponseCommand = ResponsesWsEvent;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInput {
Text(String),
Items(Vec<JsonObject>),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesToolChoice {
Name(String),
Definition(JsonObject),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResponsesStreamRequestBody {
pub model: String,
pub input: ResponsesInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<JsonObject>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ResponsesToolChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
pub struct ResponsesStreamRequest {
pub body: ResponsesStreamRequestBody,
pub target: StreamTarget,
pub transport: StreamTransportOptions,
}
pub struct ResponsesWebSocketRequest {
pub target: StreamTarget,
pub transport: StreamTransportOptions,
}
impl ResponsesWsTransformResult {
pub fn passthrough(event: ResponsesWsEvent) -> Self {
Self {
@ -127,11 +276,14 @@ mod tests {
let known: ResponsesWsEventType =
serde_json::from_str("\"response.completed\"").expect("valid event type");
assert_eq!(known, ResponsesWsEventType::ResponseCompleted);
let unknown: ResponsesWsEventType =
let output_delta: ResponsesWsEventType =
serde_json::from_str("\"response.output_text.delta\"").expect("valid event type");
assert_eq!(output_delta, ResponsesWsEventType::ResponseOutputTextDelta);
let unknown: ResponsesWsEventType =
serde_json::from_str("\"response.future_event\"").expect("valid event type");
assert_eq!(
unknown,
ResponsesWsEventType::Other("response.output_text.delta".to_string())
ResponsesWsEventType::Other("response.future_event".to_string())
);
}
@ -163,4 +315,42 @@ mod tests {
assert_eq!(flat.model(), Some("gpt-5"));
assert_eq!(nested.model(), Some("gpt-5-mini"));
}
#[test]
fn stream_request_deserializes_the_public_responses_shape() {
let request: ResponsesStreamRequestBody = serde_json::from_value(serde_json::json!({
"model": "gpt-5",
"input": "hello",
"stream": true,
"max_output_tokens": 32
}))
.expect("public request shape");
assert_eq!(request.model, "gpt-5");
assert_eq!(request.stream, Some(true));
assert!(matches!(request.input, ResponsesInput::Text(ref text) if text == "hello"));
}
#[test]
fn unknown_stream_events_round_trip_for_forward_compatibility() {
let event: ResponsesStreamEvent = serde_json::from_value(serde_json::json!({
"type": "response.future_event",
"sequence_number": 7,
"future_field": "value"
}))
.expect("unknown event");
assert_eq!(
event.event_type,
ResponsesWsEventType::Other("response.future_event".to_string())
);
assert_eq!(
serde_json::to_value(event).expect("serializable event"),
serde_json::json!({
"type": "response.future_event",
"sequence_number": 7,
"future_field": "value"
})
);
}
}

View file

@ -1,6 +1,18 @@
use crate::Error;
use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH};
use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult};
use crate::responses::types::{
ResponseCommand, ResponsesStreamEvent, ResponsesWsEvent, ResponsesWsEventType,
ResponsesWsTransformResult,
};
use futures_util::future::BoxFuture;
pub trait TypedResponsesWebSocketSession: Send + Sync {
fn send(&self, command: ResponseCommand) -> BoxFuture<'_, Result<(), Error>>;
fn recv(&self) -> BoxFuture<'_, Result<Option<ResponsesStreamEvent>, Error>>;
fn close(&self) -> BoxFuture<'_, Result<(), Error>>;
}
pub trait ResponsesWebSocketProviderConfig: Sync {
fn supports_native_websocket(&self) -> bool {