use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use futures::stream::Stream;
use serde::Deserialize;
use serde_json::Value;
use crate::api::ApiClient;
use crate::api::error::ApiError;
use crate::message::{Message, MessagePart, Role};
use crate::stream::{
DeltaPart, IndexedDelta, MessageDelta, MessageDeltaPayload, MessageMetadata, MessageStart,
PartStart, StreamEvent, StreamStopReason, Usage,
};
use crate::structured::ToolConstraint;
use crate::structured::tighten_json_schema;
use crate::tool::ToolSchema;
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_MODEL: &str = "gpt-4o";
const SSE_DONE: &str = "[DONE]";
const TEXT_PART_INDEX: usize = 0;
const THINKING_PART_INDEX: usize = 1;
pub struct OpenAiClient {
http: reqwest::Client,
api_key: String,
base_url: String,
model: std::sync::Mutex<String>,
stream_usage: bool,
}
impl OpenAiClient {
#[must_use]
pub fn builder() -> OpenAiClientBuilder {
OpenAiClientBuilder::default()
}
pub fn from_env() -> Result<Self, ApiError> {
let api_key = std::env::var("OPENAI_API_KEY")
.or_else(|_| std::env::var("API_KEY"))
.map_err(|_| ApiError::auth_invalid_key("OPENAI_API_KEY not set"))?;
let base_url = std::env::var("OPENAI_BASE_URL")
.or_else(|_| std::env::var("BASE_URL"))
.unwrap_or_else(|_| DEFAULT_BASE_URL.into());
let model = std::env::var("OPENAI_MODEL")
.or_else(|_| std::env::var("MODEL"))
.unwrap_or_else(|_| DEFAULT_MODEL.into());
Self::builder()
.with_api_key(api_key)
.with_base_url(base_url)
.with_model(model)
.build()
}
fn completions_url(&self) -> String {
format!("{}/chat/completions", self.base_url)
}
fn build_response(raw: &Value) -> Result<crate::api::NonStreamingResponse, ApiError> {
let choice = raw.get("choices").and_then(|c| c.get(0));
let message = choice.and_then(|c| c.get("message"));
let mut parts: Vec<MessagePart> = Vec::new();
if let Some(msg) = message {
if let Some(text) = msg.get("content").and_then(|t| t.as_str()) {
parts.push(MessagePart::text(text));
}
if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or("");
let function = tc.get("function");
let name = function
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let input = match function
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
{
None | Some("") => serde_json::json!({}),
Some(s) => serde_json::from_str::<Value>(s).map_err(|e| {
ApiError::http(format!("tool_call arguments is not valid JSON: {e}"))
})?,
};
parts.push(MessagePart::tool_call(id, name, input));
}
}
}
let reason = choice
.and_then(|c| c.get("finish_reason"))
.and_then(|r| r.as_str())
.unwrap_or("stop");
let stop_reason = match reason {
"tool_calls" => StreamStopReason::ToolCall,
"length" => StreamStopReason::MaxTokens,
other => StreamStopReason::from_api_str(other).unwrap_or(StreamStopReason::EndTurn),
};
let usage = raw
.get("usage")
.and_then(|u| OpenAiUsage::deserialize(u).ok())
.map(|u| Usage::from(&u))
.filter(|u| u.input_tokens > 0 || u.output_tokens > 0);
Ok(crate::api::NonStreamingResponse {
message: Message::new(Role::Assistant, parts),
stop_reason,
usage,
})
}
async fn post_completions(
http: &reqwest::Client,
url: &str,
api_key: &str,
body: &Value,
) -> Result<reqwest::Response, ApiError> {
let mut bearer = reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}"))
.map_err(|e| ApiError::auth_invalid_key(format!("invalid bearer token: {e}")))?;
bearer.set_sensitive(true);
super::post_json_checked(http, url, &[(reqwest::header::AUTHORIZATION, bearer)], body).await
}
}
impl ApiClient for OpenAiClient {
fn model(&self) -> String {
crate::error::recover_guard(self.model.lock()).clone()
}
fn base_url(&self) -> String {
self.base_url.clone()
}
fn set_model(&self, model: &str) -> bool {
if model.trim().is_empty() {
return false;
}
*crate::error::recover_guard(self.model.lock()) = model.to_string();
true
}
fn stream_messages(
&self,
request: &crate::api::StreamRequest,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
let system = request.system.clone();
let tools = request.tools.clone();
let model = crate::error::recover_guard(self.model.lock()).clone();
let body = RequestBody::build(
&model,
&request.messages,
system.as_deref(),
tools.as_deref(),
None,
&ToolConstraint::None,
)
.with_stream_usage(self.stream_usage);
let url = self.completions_url();
let api_key = self.api_key.clone();
let http = self.http.clone();
Box::pin(async_stream::try_stream! {
let resp = Self::post_completions(&http, &url, &api_key, &body.to_json(true)).await?;
let mut sse = SseReader::from_response(resp);
let mut emitter = StreamEmitter::default();
while let Some(data) = sse.next_openai_data().await? {
let Some(chunk) = OpenAiChunk::parse(&data) else {
if let Some(err) = OpenAiStreamError::parse(&data) {
emitter.record_error(&err);
break;
}
continue;
};
emitter.process_chunk(&chunk);
for ev in emitter.drain() {
yield ev;
}
}
if sse.done_marker_seen() {
emitter.mark_done();
}
for ev in emitter.finish()? {
yield ev;
}
})
}
fn create_message(
&self,
request: &crate::api::StreamRequest,
) -> Pin<Box<dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_>>
{
let system = request.system.clone();
let tools = request.tools.clone();
let model = crate::error::recover_guard(self.model.lock()).clone();
let body = RequestBody::build(
&model,
&request.messages,
system.as_deref(),
tools.as_deref(),
None,
&ToolConstraint::None,
);
let url = self.completions_url();
Box::pin(async move {
let resp =
Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false))
.await?;
let resp = super::read_bounded_body(resp).await?;
let raw = serde_json::from_slice::<Value>(&resp)
.map_err(|e| ApiError::http(e.to_string()))?;
Self::build_response(&raw)
})
}
fn stream_messages_with_options(
&self,
request: &crate::api::StreamRequest,
options: crate::structured::RequestOptions,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
let system = request.system.clone();
let tools = request.tools.clone();
let model = options
.model
.clone()
.unwrap_or_else(|| crate::error::recover_guard(self.model.lock()).clone());
let rf = options.response_format.as_ref();
let body = RequestBody::build(
&model,
&request.messages,
system.as_deref(),
tools.as_deref(),
rf,
&options.tool_constraint,
)
.with_stream_usage(self.stream_usage);
let url = self.completions_url();
let api_key = self.api_key.clone();
let http = self.http.clone();
Box::pin(async_stream::try_stream! {
let resp = Self::post_completions(&http, &url, &api_key, &body.to_json(true)).await?;
let mut sse = SseReader::from_response(resp);
let mut emitter = StreamEmitter::default();
while let Some(data) = sse.next_openai_data().await? {
let Some(chunk) = OpenAiChunk::parse(&data) else {
if let Some(err) = OpenAiStreamError::parse(&data) {
emitter.record_error(&err);
break;
}
continue;
};
emitter.process_chunk(&chunk);
for ev in emitter.drain() {
yield ev;
}
}
if sse.done_marker_seen() {
emitter.mark_done();
}
for ev in emitter.finish()? {
yield ev;
}
})
}
fn create_message_with_options(
&self,
request: &crate::api::StreamRequest,
options: crate::structured::RequestOptions,
) -> Pin<Box<dyn Future<Output = Result<crate::api::NonStreamingResponse, ApiError>> + Send + '_>>
{
let system = request.system.clone();
let tools = request.tools.clone();
let model = options
.model
.clone()
.unwrap_or_else(|| crate::error::recover_guard(self.model.lock()).clone());
let rf = options.response_format.as_ref();
let body = RequestBody::build(
&model,
&request.messages,
system.as_deref(),
tools.as_deref(),
rf,
&options.tool_constraint,
);
let url = self.completions_url();
Box::pin(async move {
let resp =
Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false))
.await?;
let resp = super::read_bounded_body(resp).await?;
let raw = serde_json::from_slice::<Value>(&resp)
.map_err(|e| ApiError::http(e.to_string()))?;
Self::build_response(&raw)
})
}
}
pub struct OpenAiClientBuilder {
api_key: Option<String>,
base_url: String,
model: String,
http: super::HttpClientConfig,
stream_usage: bool,
}
impl Default for OpenAiClientBuilder {
fn default() -> Self {
Self {
api_key: None,
base_url: DEFAULT_BASE_URL.into(),
model: DEFAULT_MODEL.into(),
http: super::HttpClientConfig::default(),
stream_usage: true,
}
}
}
impl OpenAiClientBuilder {
#[must_use]
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into().trim_end_matches('/').to_string();
self
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http = self.http.with_timeout(timeout);
self
}
#[must_use]
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.http = self.http.with_connect_timeout(timeout);
self
}
#[must_use]
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = self.http.with_http_client(client);
self
}
#[must_use]
pub fn with_stream_usage(mut self, enabled: bool) -> Self {
self.stream_usage = enabled;
self
}
#[must_use]
pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
self.http = self.http.with_pool_max_idle_per_host(n);
self
}
#[must_use]
pub fn with_pool_idle_timeout(mut self, d: Duration) -> Self {
self.http = self.http.with_pool_idle_timeout(d);
self
}
#[must_use]
pub fn with_tcp_keepalive(mut self, d: Duration) -> Self {
self.http = self.http.with_tcp_keepalive(d);
self
}
#[must_use]
pub fn with_tcp_nodelay(mut self, enabled: bool) -> Self {
self.http = self.http.with_tcp_nodelay(enabled);
self
}
pub fn build(self) -> Result<OpenAiClient, ApiError> {
let api_key = self
.api_key
.ok_or_else(|| ApiError::auth_invalid_key("API key not provided"))?;
let http = self.http.build()?;
Ok(OpenAiClient {
http,
api_key,
base_url: self.base_url,
model: std::sync::Mutex::new(self.model),
stream_usage: self.stream_usage,
})
}
}
struct RequestBody {
model: String,
messages: Vec<Value>,
tools: Option<Vec<Value>>,
response_format: Option<Value>,
guided_json: Option<String>,
stream_usage: bool,
}
impl RequestBody {
fn build(
model: &str,
messages: &[Message],
system: Option<&str>,
tools: Option<&[ToolSchema]>,
response_format: Option<&crate::structured::ResponseFormat>,
tool_constraint: &ToolConstraint,
) -> Self {
let tools = tools.filter(|t| !t.is_empty());
let mut msgs = Vec::with_capacity(messages.len().saturating_add(1));
if let Some(sys) = system {
msgs.push(serde_json::json!({ "role": "system", "content": sys }));
}
for m in messages {
msgs.extend(convert_message(m));
}
let (tools, guided_json) = if response_format.is_some() {
(None, None)
} else {
match tool_constraint {
ToolConstraint::None => (tools.map(convert_tools), None),
ToolConstraint::Strict => (tools.map(convert_tools_strict), None),
#[cfg(feature = "grammar")]
ToolConstraint::Grammar(provider) => {
let has_tools = tools.is_some_and(|t| !t.is_empty());
(
tools.map(convert_tools),
has_tools.then(|| provider.grammar().to_string()),
)
}
}
};
let rf = response_format.map(|rf| {
serde_json::json!({
"type": "json_schema",
"json_schema": {
"name": rf.name,
"schema": rf.schema,
"strict": rf.strict
}
})
});
Self {
model: model.into(),
messages: msgs,
tools,
response_format: rf,
guided_json,
stream_usage: true,
}
}
#[must_use]
fn with_stream_usage(mut self, enabled: bool) -> Self {
self.stream_usage = enabled;
self
}
fn to_json(&self, stream: bool) -> Value {
let mut body = serde_json::json!({
"model": self.model,
"messages": self.messages,
"stream": stream,
});
if let Some(obj) = body.as_object_mut() {
if stream && self.stream_usage {
obj.insert(
"stream_options".to_string(),
serde_json::json!({"include_usage": true}),
);
}
if let Some(tools) = &self.tools {
obj.insert("tools".to_string(), Value::Array(tools.clone()));
}
if let Some(rf) = &self.response_format {
obj.insert("response_format".to_string(), rf.clone());
}
if let Some(grammar) = &self.guided_json {
obj.insert("guided_json".to_string(), Value::String(grammar.clone()));
}
}
body
}
}
fn convert_message(m: &Message) -> Vec<Value> {
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
};
let mut text_parts: Vec<&str> = Vec::new();
let mut tool_calls: Vec<Value> = Vec::new();
let mut tool_results: Vec<Value> = Vec::new();
for p in &m.parts {
match p {
MessagePart::Text { text } => text_parts.push(text.as_str()),
MessagePart::ToolCall { id, name, input } => {
tool_calls.push(serde_json::json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input_to_string(input),
}
}));
}
MessagePart::ToolResult {
call_id, output, ..
} => {
tool_results.push(serde_json::json!({
"role": "tool",
"tool_call_id": call_id,
"content": output.to_string(),
}));
}
MessagePart::Image { .. } => {} }
}
if !tool_calls.is_empty() {
vec![build_assistant_message(role, &tool_calls, &text_parts)]
} else if !tool_results.is_empty() {
if !text_parts.is_empty() {
tool_results.push(serde_json::json!({
"role": "user",
"content": text_parts.join(""),
}));
}
tool_results
} else {
vec![serde_json::json!({ "role": role, "content": text_parts.join("") })]
}
}
fn build_assistant_message(role: &str, tool_calls: &[Value], text_parts: &[&str]) -> Value {
let text = text_parts.join("");
let content = if text.is_empty() {
Value::Null
} else {
Value::String(text)
};
serde_json::json!({
"role": role,
"content": content,
"tool_calls": tool_calls,
})
}
fn convert_tools(tools: &[ToolSchema]) -> Vec<Value> {
tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.tool,
"description": &t.description,
"parameters": t.input_schema.clone(),
}
})
})
.collect()
}
fn convert_tools_strict(tools: &[ToolSchema]) -> Vec<Value> {
tools
.iter()
.map(|t| {
let parameters = tighten_json_schema(&t.input_schema);
serde_json::json!({
"type": "function",
"function": {
"name": t.tool,
"description": &t.description,
"parameters": parameters,
"strict": true,
}
})
})
.collect()
}
fn input_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
use super::sse::SseReader;
impl SseReader {
async fn next_openai_data(&mut self) -> Result<Option<String>, ApiError> {
loop {
while let Some(line) = self.take_line()? {
let Some(data) = super::sse_data_payload(&line) else {
continue;
};
if data == SSE_DONE {
self.mark_done_marker_seen();
return Ok(None);
}
return Ok(Some(data.into()));
}
if self.next_chunk().await?.is_none() {
return Ok(None);
}
}
}
}
#[derive(Deserialize)]
struct OpenAiChunk {
id: String,
model: String,
choices: Vec<OpenAiChoice>,
#[serde(default)]
usage: Option<OpenAiUsage>,
}
impl OpenAiChunk {
fn parse(data: &str) -> Option<Self> {
match serde_json::from_str(data) {
Ok(chunk) => Some(chunk),
Err(e) => {
tracing::warn!(
error = %e,
data_len = data.len(),
"failed to parse OpenAI SSE chunk, skipping"
);
None
}
}
}
}
#[derive(Deserialize)]
struct OpenAiStreamError {
message: String,
#[serde(rename = "type", default)]
kind: Option<String>,
#[serde(default)]
code: Option<serde_json::Value>,
}
impl OpenAiStreamError {
fn parse(data: &str) -> Option<Self> {
let value: serde_json::Value = serde_json::from_str(data).ok()?;
match value.get("error")? {
serde_json::Value::String(message) => Some(Self {
message: message.clone(),
kind: None,
code: None,
}),
serde_json::Value::Object(fields) => {
serde_json::from_value(serde_json::Value::Object(fields.clone()))
.map_err(|e| {
tracing::warn!(
error = %e,
"failed to parse OpenAI mid-stream error payload"
);
e
})
.ok()
}
_ => None,
}
}
fn classify(&self) -> ApiError {
let numeric_status = self.code.as_ref().and_then(serde_json::Value::as_u64);
let rate_limited = self.kind.as_deref() == Some("rate_limit_error")
|| numeric_status.is_some_and(|status| matches!(status, 429 | 503 | 529))
|| self.code.as_ref() == Some(&serde_json::json!("rate_limit_exceeded"));
let mut detail = String::new();
if let Some(kind) = &self.kind {
detail.push_str(kind);
detail.push_str(": ");
}
detail.push_str(&self.message);
if let Some(status) = numeric_status {
detail.push_str(" (HTTP ");
detail.push_str(&status.to_string());
detail.push(')');
}
if rate_limited {
ApiError::RateLimit {
retry_after: None,
message: detail,
}
} else {
ApiError::api(detail)
}
}
}
#[derive(Deserialize)]
struct OpenAiChoice {
delta: Option<OpenAiDelta>,
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
}
impl From<&OpenAiUsage> for Usage {
fn from(u: &OpenAiUsage) -> Self {
Usage::new(
u32::try_from(u.prompt_tokens).unwrap_or(u32::MAX),
u32::try_from(u.completion_tokens).unwrap_or(u32::MAX),
)
}
}
#[derive(Deserialize)]
struct OpenAiDelta {
content: Option<String>,
#[serde(alias = "reasoning")]
reasoning_content: Option<String>,
tool_calls: Option<Vec<OpenAiToolCallDelta>>,
}
#[derive(Deserialize)]
struct OpenAiToolCallDelta {
#[serde(default)]
id: Option<String>,
index: usize,
#[serde(default)]
function: Option<OpenAiToolCallFunction>,
}
#[derive(Deserialize, Default)]
struct OpenAiToolCallFunction {
#[serde(default)]
arguments: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Default)]
enum PartLane {
#[default]
Closed,
Open,
}
#[derive(Default)]
struct StreamEmitter {
started: bool,
text: PartLane,
thinking: PartLane,
seen_tool_indices: Vec<usize>,
open_tool_count: usize,
finished: bool,
done: bool,
pending_stop_reason: Option<StreamStopReason>,
pending_usage: Option<Usage>,
error: Option<ApiError>,
pending: Vec<StreamEvent>,
}
impl StreamEmitter {
fn process_chunk(&mut self, chunk: &OpenAiChunk) {
if !self.started {
self.started = true;
self.push(StreamEvent::MessageStart(MessageStart {
message: MessageMetadata {
id: chunk.id.clone(),
role: "assistant".into(),
model: chunk.model.clone(),
},
}));
}
if let Some(usage) = &chunk.usage {
let typed = Usage::from(usage);
if typed.input_tokens > 0 || typed.output_tokens > 0 {
self.pending_usage = Some(typed);
}
}
if let Some(choice) = chunk.choices.first() {
if let Some(delta) = &choice.delta {
self.process_delta(delta);
}
if let Some(reason) = &choice.finish_reason {
self.process_finish(reason);
}
}
if chunk.usage.is_some() {
self.flush_message_delta();
}
}
fn process_delta(&mut self, delta: &OpenAiDelta) {
if let Some(text) = &delta.content
&& !text.is_empty()
{
if matches!(self.thinking, PartLane::Open) {
self.thinking = PartLane::Closed;
self.push(StreamEvent::PartStop {
index: Some(THINKING_PART_INDEX),
});
}
if matches!(self.text, PartLane::Closed) {
self.text = PartLane::Open;
self.push(StreamEvent::PartStart(PartStart {
index: TEXT_PART_INDEX,
part: Some(MessagePart::text("")),
}));
}
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: TEXT_PART_INDEX,
delta: DeltaPart::Text { text: text.clone() },
}));
}
if let Some(reasoning) = &delta.reasoning_content
&& !reasoning.is_empty()
{
if matches!(self.text, PartLane::Open) {
self.text = PartLane::Closed;
self.push(StreamEvent::PartStop {
index: Some(TEXT_PART_INDEX),
});
}
if matches!(self.thinking, PartLane::Closed) {
self.thinking = PartLane::Open;
self.push(StreamEvent::PartStart(PartStart {
index: THINKING_PART_INDEX,
part: None,
}));
}
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: THINKING_PART_INDEX,
delta: DeltaPart::Thinking {
text: reasoning.clone(),
},
}));
}
if let Some(tool_calls) = &delta.tool_calls {
self.close_content_lanes();
for tc in tool_calls {
self.process_tool_call(tc);
}
}
}
fn close_content_lanes(&mut self) {
if matches!(self.text, PartLane::Open) {
self.text = PartLane::Closed;
self.push(StreamEvent::PartStop {
index: Some(TEXT_PART_INDEX),
});
}
if matches!(self.thinking, PartLane::Open) {
self.thinking = PartLane::Closed;
self.push(StreamEvent::PartStop {
index: Some(THINKING_PART_INDEX),
});
}
}
fn process_tool_call(&mut self, tc: &OpenAiToolCallDelta) {
if tc.function.is_some() && !self.seen_tool_indices.contains(&tc.index) {
self.seen_tool_indices.push(tc.index);
self.push(StreamEvent::PartStart(PartStart {
index: tc.index,
part: Some(MessagePart::ToolCall {
id: tc.id.clone().unwrap_or_default(),
name: tc
.function
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or_default(),
input: Value::Null,
}),
}));
self.open_tool_count = self.open_tool_count.saturating_add(1);
}
if let Some(func) = &tc.function
&& !func.arguments.is_empty()
{
self.push(StreamEvent::IndexedDelta(IndexedDelta {
index: tc.index,
delta: DeltaPart::InputJson {
partial_json: func.arguments.clone(),
},
}));
}
}
fn process_finish(&mut self, reason: &str) {
if self.finished {
return;
}
self.finished = true;
self.close_content_lanes();
let open_tool_indices: Vec<usize> = self
.seen_tool_indices
.iter()
.take(self.open_tool_count)
.copied()
.collect();
for index in open_tool_indices {
self.push(StreamEvent::PartStop { index: Some(index) });
}
let stop_reason = match reason {
"tool_calls" => StreamStopReason::ToolCall,
"length" => StreamStopReason::MaxTokens,
other => StreamStopReason::from_api_str(other).unwrap_or(StreamStopReason::EndTurn),
};
self.pending_stop_reason = Some(stop_reason);
}
fn flush_message_delta(&mut self) {
if let Some(stop_reason) = self.pending_stop_reason.take() {
self.push(StreamEvent::MessageDelta(MessageDelta {
delta: MessageDeltaPayload {
stop_reason: Some(stop_reason.to_api_str().into()),
},
usage: self.pending_usage,
}));
}
}
fn finish(&mut self) -> Result<Vec<StreamEvent>, ApiError> {
if let Some(err) = self.error.take() {
return Err(err);
}
if self.done && !self.finished {
self.process_finish("stop");
}
self.flush_message_delta();
let mut out = self.drain();
if self.started && (self.finished || self.done) {
out.push(StreamEvent::MessageStop);
}
Ok(out)
}
fn mark_done(&mut self) {
self.done = true;
}
fn record_error(&mut self, payload: &OpenAiStreamError) {
if self.error.is_none() {
self.error = Some(payload.classify());
}
}
fn drain(&mut self) -> Vec<StreamEvent> {
std::mem::take(&mut self.pending)
}
fn push(&mut self, ev: StreamEvent) {
self.pending.push(ev);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessagePart, Role, ToolContent};
use crate::tool::ToolSchema;
#[test]
fn openai_emitter_part_lane_default_closed() {
let em = StreamEmitter::default();
assert!(
matches!(em.text, PartLane::Closed) && matches!(em.thinking, PartLane::Closed),
"both content lanes must start closed"
);
}
#[test]
fn request_body_includes_system_message_first() {
let msgs = vec![Message::user("hello")];
let body = RequestBody::build(
"gpt-4o",
&msgs,
Some("be brief"),
None,
None,
&ToolConstraint::None,
);
let json = body.to_json(true);
let messages = json["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "be brief");
assert_eq!(messages[1]["role"], "user");
}
#[test]
fn request_body_without_system() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
let json = body.to_json(false);
let messages = json["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
}
#[test]
fn request_body_stream_flag_toggles() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
assert_eq!(body.to_json(true)["stream"], true);
assert_eq!(body.to_json(false)["stream"], false);
}
#[test]
fn request_body_streaming_includes_usage_option() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
assert_eq!(body.to_json(true)["stream_options"]["include_usage"], true);
}
#[test]
fn request_body_non_streaming_omits_usage_option() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
assert!(
body.to_json(false).get("stream_options").is_none(),
"stream_options should only be present when streaming"
);
}
#[test]
fn request_body_stream_usage_disabled_omits_stream_options() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None)
.with_stream_usage(false);
assert!(
body.to_json(true).get("stream_options").is_none(),
"stream_options should be absent when stream_usage is disabled"
);
}
#[test]
#[cfg(feature = "ollama")]
fn ollama_constructor_disables_stream_usage() {
let client = crate::provider::ollama("test-model").unwrap();
assert!(
!client.stream_usage,
"ollama() should disable stream_usage for compatibility"
);
}
#[test]
fn default_builder_enables_stream_usage() {
let client = OpenAiClient::builder()
.with_api_key("test")
.build()
.unwrap();
assert!(
client.stream_usage,
"default builder should enable stream_usage"
);
}
#[test]
fn emitter_usage_chunk_after_finish_carries_usage_in_delta() {
let mut em = StreamEmitter::default();
let text = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&text);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
let usage_chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
)
.unwrap();
em.process_chunk(&usage_chunk);
let events = em.drain();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta from usage chunk");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn"));
let usage = delta.usage.expect("usage should be present");
assert_eq!(usage.input_tokens, 10);
assert_eq!(usage.output_tokens, 5);
}
#[test]
fn emitter_finish_without_usage_chunk_emits_delta_with_none_usage() {
let mut em = StreamEmitter::default();
let text = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&text);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
let events = em.finish().unwrap();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta from finish");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn"));
assert!(delta.usage.is_none());
}
#[test]
fn request_body_model_and_tools() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let body = RequestBody::build(
"my-model",
&msgs,
None,
Some(&tools),
None,
&ToolConstraint::None,
);
let json = body.to_json(true);
assert_eq!(json["model"], "my-model");
let tools_arr = json["tools"].as_array().unwrap();
assert_eq!(tools_arr.len(), 1);
assert_eq!(tools_arr[0]["type"], "function");
assert_eq!(tools_arr[0]["function"]["name"], "echo");
}
#[test]
fn request_body_tools_absent_when_none() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
let json = body.to_json(false);
assert!(
json.get("tools").is_none(),
"tools key should be absent when no tools are set"
);
}
#[test]
fn convert_message_user_text() {
let m = Message::user("hello world");
let v = convert_message(&m).remove(0);
assert_eq!(v["role"], "user");
assert_eq!(v["content"], "hello world");
}
#[test]
fn convert_message_assistant_text() {
let m = Message::new(Role::Assistant, vec![MessagePart::text("hi there")]);
let v = convert_message(&m).remove(0);
assert_eq!(v["role"], "assistant");
assert_eq!(v["content"], "hi there");
}
#[test]
fn convert_message_assistant_tool_calls() {
let m = Message::new(
Role::Assistant,
vec![MessagePart::ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({"message": "hi"}),
}],
);
let v = convert_message(&m).remove(0);
assert_eq!(v["role"], "assistant");
assert!(v["content"].is_null());
let calls = v["tool_calls"].as_array().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["id"], "call_1");
assert_eq!(calls[0]["type"], "function");
assert_eq!(calls[0]["function"]["name"], "echo");
assert_eq!(
calls[0]["function"]["arguments"].as_str().unwrap(),
r#"{"message":"hi"}"#
);
}
#[test]
fn convert_message_tool_result() {
let m = Message::new(
Role::User,
vec![MessagePart::ToolResult {
call_id: "call_1".into(),
name: "echo".into(),
output: ToolContent::from_string("result text"),
is_error: None,
}],
);
let v = convert_message(&m).remove(0);
assert_eq!(v["role"], "tool");
assert_eq!(v["tool_call_id"], "call_1");
assert!(v["content"].is_string());
}
#[test]
fn convert_message_multiple_tool_results_expand() {
let m = Message::new(
Role::User,
vec![
MessagePart::ToolResult {
call_id: "call_1".into(),
name: "echo".into(),
output: ToolContent::from_string("a"),
is_error: None,
},
MessagePart::ToolResult {
call_id: "call_2".into(),
name: "echo".into(),
output: ToolContent::from_string("b"),
is_error: None,
},
],
);
let vs = convert_message(&m);
assert_eq!(vs.len(), 2, "two tool results expand to two messages");
assert_eq!(vs[0]["role"], "tool");
assert_eq!(vs[0]["tool_call_id"], "call_1");
assert_eq!(vs[1]["role"], "tool");
assert_eq!(vs[1]["tool_call_id"], "call_2");
}
#[test]
fn convert_tools_shape() {
let tools = vec![
ToolSchema {
tool: "search".into(),
description: "Search the web".into(),
input_schema: serde_json::json!({"type": "object"}),
},
ToolSchema {
tool: "calc".into(),
description: "Calculate".into(),
input_schema: serde_json::json!({"type": "object"}),
},
];
let out = convert_tools(&tools);
assert_eq!(out.len(), 2);
assert_eq!(out[0]["function"]["name"], "search");
assert_eq!(out[1]["function"]["name"], "calc");
}
#[test]
fn input_to_string_passes_through_strings() {
assert_eq!(input_to_string(&Value::String("raw".into())), "raw");
}
#[test]
fn input_to_string_serializes_objects() {
let v = serde_json::json!({"a": 1});
let s = input_to_string(&v);
assert_eq!(s, r#"{"a":1}"#);
}
#[test]
fn input_to_string_serializes_numbers() {
let s = input_to_string(&Value::from(42));
assert_eq!(s, "42");
}
#[test]
fn parse_valid_chunk() {
let data = r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#;
let chunk = OpenAiChunk::parse(data).unwrap();
assert_eq!(chunk.id, "chatcmpl-1");
assert_eq!(chunk.model, "gpt-4o");
assert_eq!(chunk.choices.len(), 1);
}
#[test]
fn parse_malformed_returns_none() {
assert!(OpenAiChunk::parse("not json").is_none());
assert!(OpenAiChunk::parse("").is_none());
}
#[test]
fn parse_malformed_partial_json_returns_none() {
assert!(OpenAiChunk::parse(r#"{"id":"chatcmpl-1","choices":[{"delta":{"con"#).is_none());
}
#[test]
fn parse_valid_chunk_with_all_fields() {
let data = r#"{"id":"abc","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":null}],"usage":null}"#;
let chunk = OpenAiChunk::parse(data).unwrap();
assert_eq!(chunk.id, "abc");
assert_eq!(chunk.model, "gpt-4o");
assert_eq!(chunk.choices.len(), 1);
assert!(chunk.usage.is_none());
}
#[test]
fn parse_chunk_missing_usage_defaults_to_none() {
let data = r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#;
let chunk = OpenAiChunk::parse(data).unwrap();
assert!(chunk.usage.is_none());
}
#[test]
fn parse_final_chunk_with_partial_usage_defaults_missing_fields() {
let data = r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":15}}"#;
let chunk = OpenAiChunk::parse(data).unwrap();
let usage = chunk.usage.as_ref().expect("usage should parse");
assert_eq!(usage.prompt_tokens, 15);
assert_eq!(usage.completion_tokens, 0);
}
#[test]
fn parse_final_chunk_with_usage() {
let data = r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":42,"completion_tokens":7,"total_tokens":49}}"#;
let chunk = OpenAiChunk::parse(data).unwrap();
assert!(chunk.choices.is_empty());
let usage = chunk.usage.as_ref().expect("usage");
assert_eq!(usage.prompt_tokens, 42);
assert_eq!(usage.completion_tokens, 7);
let typed: Usage = usage.into();
assert_eq!(typed.input_tokens, 42);
assert_eq!(typed.output_tokens, 7);
}
#[test]
fn emitter_usage_and_finish_in_same_chunk() {
let mut em = StreamEmitter::default();
let text = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&text);
em.drain();
let combined = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":3}}"#,
)
.unwrap();
em.process_chunk(&combined);
let events = em.drain();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta from combined chunk");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn"));
let usage = delta.usage.expect("usage");
assert_eq!(usage.input_tokens, 8);
assert_eq!(usage.output_tokens, 3);
}
#[test]
fn emitter_tool_call_stream_with_usage_chunk() {
let mut em = StreamEmitter::default();
let open = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&open);
em.drain();
let args = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":\"rust\"}"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&args);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
let usage_chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":50,"completion_tokens":20}}"#,
)
.unwrap();
em.process_chunk(&usage_chunk);
let events = em.drain();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta after usage chunk");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("tool_call"));
let usage = delta.usage.expect("usage");
assert_eq!(usage.input_tokens, 50);
assert_eq!(usage.output_tokens, 20);
}
#[test]
fn emitter_emits_message_start_on_first_chunk() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":""},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::MessageStart(_)))
);
}
#[test]
fn emitter_text_delta_starts_part_then_deltas() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"Hel"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
assert_eq!(events.len(), 3);
assert!(matches!(
events[1],
StreamEvent::PartStart(ref p) if p.index == TEXT_PART_INDEX
));
assert!(matches!(
events[2],
StreamEvent::IndexedDelta(ref d) if d.index == TEXT_PART_INDEX
));
let chunk2 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"lo"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk2);
let events2 = em.drain();
assert_eq!(events2.len(), 1);
assert!(matches!(events2[0], StreamEvent::IndexedDelta(_)));
}
#[test]
fn emitter_tool_call_emits_part_start_and_delta() {
let mut em = StreamEmitter::default();
let chunk0 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":null},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk0);
em.drain();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
assert_eq!(events.len(), 2);
assert!(matches!(
events[0],
StreamEvent::PartStart(ref p) if p.index == 1
));
assert!(matches!(
events[1],
StreamEvent::IndexedDelta(ref d) if d.index == 1
));
}
#[test]
fn emitter_multi_chunk_tool_call_emits_part_start_once() {
let mut em = StreamEmitter::default();
let chunk0 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":null},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk0);
em.drain();
let header = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&header);
em.drain();
let fragment = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"hi\"}"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&fragment);
let events = em.drain();
let deltas: Vec<_> = events
.iter()
.filter(|e| matches!(e, StreamEvent::IndexedDelta(_)))
.collect();
assert_eq!(
deltas.len(),
1,
"follow-up chunk must emit only an argument delta, not a PartStart"
);
assert!(
events
.iter()
.all(|e| !matches!(e, StreamEvent::PartStart(_)))
);
assert_eq!(em.open_tool_count, 1);
}
#[test]
fn emitter_multi_chunk_tool_call_accumulates_through_accumulator() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":null},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"hi\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).unwrap();
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).unwrap();
}
}
for ev in em.finish().unwrap() {
acc.process(&ev).unwrap();
}
let msg = acc.build();
assert_eq!(msg.parts.len(), 1);
match &msg.parts[0] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "echo");
assert_eq!(input, &serde_json::json!({"msg": "hi"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn emitter_two_interleaved_multi_chunk_tool_calls_accumulate() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":null},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"search","arguments":"{\"q\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"b\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).unwrap();
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).unwrap();
}
}
for ev in em.finish().unwrap() {
acc.process(&ev).unwrap();
}
let msg = acc.build();
assert_eq!(msg.parts.len(), 2, "two tool calls expected");
match &msg.parts[0] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "echo");
assert_eq!(input, &serde_json::json!({"msg": "a"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
match &msg.parts[1] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "search");
assert_eq!(input, &serde_json::json!({"q": "b"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn text_then_tool_call_stream_preserves_tool_arguments() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"Let me check that."},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"hi\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).unwrap();
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).unwrap();
}
}
for ev in em.finish().unwrap() {
acc.process(&ev).unwrap();
}
let msg = acc.build();
assert_eq!(
msg.parts.len(),
2,
"the text part and the tool part both flush"
);
match &msg.parts[0] {
MessagePart::Text { text } => assert_eq!(text, "Let me check that."),
other => panic!("expected Text, got {other:?}"),
}
match &msg.parts[1] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "echo");
assert_eq!(
input,
&serde_json::json!({"msg": "hi"}),
"the tool arguments must survive the text-lane index collision"
);
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn tool_call_then_text_reopen_preserves_both() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"hi\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"Done, here is what I found."},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).unwrap();
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).unwrap();
}
}
for ev in em.finish().unwrap() {
acc.process(&ev).unwrap();
}
let msg = acc.build();
let tool = msg
.parts
.iter()
.find_map(|p| match p {
MessagePart::ToolCall { name, input, .. } => Some((name.clone(), input.clone())),
_ => None,
})
.expect("the tool call must survive the reopened text lane");
assert_eq!(tool.0, "echo");
assert_eq!(tool.1, serde_json::json!({"msg": "hi"}));
let text = msg
.parts
.iter()
.find_map(|p| match p {
MessagePart::Text { text } => Some(text.clone()),
_ => None,
})
.expect("the trailing text must survive the reopened lane");
assert_eq!(text, "Done, here is what I found.");
}
#[test]
fn thinking_then_two_tool_calls_preserve_arguments() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"reasoning_content":"thinking hard"},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"echo","arguments":"{\"a\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"search","arguments":"{\"q\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"rust\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).unwrap();
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).unwrap();
}
}
for ev in em.finish().unwrap() {
acc.process(&ev).unwrap();
}
let msg = acc.build();
assert_eq!(msg.parts.len(), 2, "both tool calls flush");
match &msg.parts[0] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "echo");
assert_eq!(input, &serde_json::json!({"a": 1}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
match &msg.parts[1] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "search");
assert_eq!(
input,
&serde_json::json!({"q": "rust"}),
"the index-1 call must survive the thinking-lane collision"
);
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn emitter_real_continuation_chunks_omit_id_and_name() {
use crate::stream::StreamAccumulator;
let mut em = StreamEmitter::default();
let mut acc = StreamAccumulator::new();
let chunks = [
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":null},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{\"msg\":"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"hi\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
];
for raw in chunks {
let chunk = OpenAiChunk::parse(raw).expect("real chunk shape must deserialize");
em.process_chunk(&chunk);
for ev in em.drain() {
acc.process(&ev).expect("accumulator accepts events");
}
}
for ev in em.finish().expect("clean finish") {
acc.process(&ev).expect("accumulator accepts finish events");
}
let msg = acc.build();
assert_eq!(msg.parts.len(), 1, "one tool call expected");
match &msg.parts[0] {
MessagePart::ToolCall { name, input, .. } => {
assert_eq!(name, "echo");
assert_eq!(
input,
&serde_json::json!({"msg": "hi"}),
"continuation fragment must accumulate, not be dropped"
);
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn tool_open_closes_text_lane_with_addressed_stop() {
let mut em = StreamEmitter::default();
let text = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"let me look"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&text);
em.drain();
let tool = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&tool);
let events = em.drain();
assert!(
matches!(
events.first(),
Some(StreamEvent::PartStop { index: Some(0) })
),
"the open text lane must close with an addressed stop before the tool part opens: {events:?}"
);
assert!(
matches!(events.get(1), Some(StreamEvent::PartStart(ps))
if matches!(ps.part, Some(MessagePart::ToolCall { .. }))),
"the tool PartStart follows the lane close: {events:?}"
);
}
#[test]
fn tool_open_closes_thinking_lane_with_addressed_stop() {
let mut em = StreamEmitter::default();
let reasoning = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"reasoning_content":"deliberating"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&reasoning);
em.drain();
let tool = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"search","arguments":"{}"}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&tool);
let events = em.drain();
assert!(
matches!(
events.first(),
Some(StreamEvent::PartStop { index: Some(1) })
),
"the open thinking lane must close with an addressed stop naming part index 1 \
before a tool call at the same wire index opens: {events:?}"
);
assert!(
matches!(events.get(1), Some(StreamEvent::PartStart(ps))
if matches!(ps.part, Some(MessagePart::ToolCall { .. }))),
"the tool PartStart follows the lane close: {events:?}"
);
}
#[test]
fn emitter_finish_emits_part_stops_and_message_delta() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
let part_stop_events = em.drain();
assert_eq!(part_stop_events.len(), 1);
assert!(matches!(part_stop_events[0], StreamEvent::PartStop { .. }));
let events = em.finish().unwrap();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::MessageDelta(_)))
);
}
#[test]
fn emitter_finish_closes_lanes_so_late_delta_reopens() {
let mut em = StreamEmitter::default();
let text_chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&text_chunk);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
assert!(
matches!(em.text, PartLane::Closed),
"process_finish must close the text lane"
);
let late_delta = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"more"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&late_delta);
let events = em.drain();
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::PartStart(_))),
"a delta after finish must re-open the text lane with PartStart"
);
}
#[test]
fn emitter_finish_with_tool_calls_stop_reason() {
let mut em = StreamEmitter::default();
let chunk0 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk0);
em.drain();
let tool_chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"echo","arguments":""}}]},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&tool_chunk);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
let part_stop_events = em.drain();
assert_eq!(part_stop_events.len(), 1);
assert!(matches!(part_stop_events[0], StreamEvent::PartStop { .. }));
let events = em.finish().unwrap();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("tool_call"));
}
#[test]
fn emitter_finish_appends_message_stop() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
let final_events = em.finish().unwrap();
assert!(matches!(
final_events.last(),
Some(StreamEvent::MessageStop)
));
}
#[test]
fn emitter_finish_without_start_is_empty() {
let mut em = StreamEmitter::default();
let events = em.finish().unwrap();
assert!(events.is_empty());
}
#[test]
fn midstream_error_payload_is_recognized_and_classified() {
let payload = r#"{"error":{"message":"Rate limit reached","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#;
let parsed = OpenAiStreamError::parse(payload)
.expect("an error object payload must parse as a mid-stream error");
assert!(
matches!(parsed.classify(), ApiError::RateLimit { .. }),
"a rate_limit_error payload must classify as RateLimit"
);
let server_error = r#"{"error":{"message":"upstream melted","type":"server_error"}}"#;
let parsed = OpenAiStreamError::parse(server_error)
.expect("an error object payload must parse as a mid-stream error");
let err = parsed.classify();
assert!(
!matches!(err, ApiError::RateLimit { .. }),
"non-rate-limit payloads stay provider errors"
);
assert!(
err.to_string().contains("server_error"),
"the error must name the provider's type: {err}"
);
let overloaded = OpenAiStreamError::parse(
r#"{"error":{"message":"upstream overloaded","type":"server_error","code":503}}"#,
)
.unwrap();
let err = overloaded.classify();
assert!(
matches!(&err, ApiError::RateLimit { message, .. } if message.contains("503")),
"a 503 error chunk must classify as RateLimit with the status in the \
detail so downstream overload detection reads Overloaded: {err}"
);
}
#[test]
fn malformed_non_error_payloads_are_still_skipped() {
assert!(
OpenAiStreamError::parse(r#"{"id":123}"#).is_none(),
"a chunk-shaped payload that merely failed strict parsing must stay a skip"
);
assert!(
OpenAiStreamError::parse("not json").is_none(),
"garbage must stay a skip"
);
assert!(
OpenAiStreamError::parse(r#"{"error":{"message":"x","object":"error"}}"#).is_some(),
"an error object with sibling fields inside it must still parse"
);
let string_shaped = OpenAiStreamError::parse(r#"{"error":"internal server error"}"#)
.expect("a bare string error payload must parse as a mid-stream error");
assert_eq!(string_shaped.message, "internal server error");
assert!(
!matches!(string_shaped.classify(), ApiError::RateLimit { .. }),
"a string error carries no class and stays a provider error"
);
}
#[test]
fn midstream_error_chunk_surfaces_as_an_error() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"par"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
em.drain();
let err_payload = OpenAiStreamError::parse(
r#"{"error":{"message":"server overloaded","type":"server_error"}}"#,
)
.unwrap();
em.record_error(&err_payload);
let drained = em.drain();
assert!(
drained
.iter()
.all(|e| !matches!(e, StreamEvent::MessageStop)),
"no clean MessageStop may be emitted alongside the failure: {drained:?}"
);
let err = em
.finish()
.expect_err("finish must surface the recorded error");
assert!(
err.to_string().contains("server overloaded"),
"the terminal error must carry the provider's message: {err}"
);
}
#[test]
fn emitter_finish_reason_length_maps_to_max_tokens() {
let mut em = StreamEmitter::default();
let chunk0 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk0);
em.drain();
let finish = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"length"}]}"#,
)
.unwrap();
em.process_chunk(&finish);
em.drain();
let events = em.finish().unwrap();
let delta = events.iter().find_map(|e| match e {
StreamEvent::MessageDelta(md) => Some(md),
_ => None,
});
let delta = delta.expect("MessageDelta");
assert_eq!(delta.delta.stop_reason.as_deref(), Some("max_tokens"));
}
#[test]
fn emitter_empty_content_does_not_open_text_part() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":""},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
}
#[test]
fn emitter_double_finish_ignored() {
let mut em = StreamEmitter::default();
let chunk0 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk0);
em.drain();
let finish1 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish1);
em.drain();
let finish2 = OpenAiChunk::parse(
r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#,
)
.unwrap();
em.process_chunk(&finish2);
let events = em.drain();
assert!(events.is_empty());
}
#[test]
fn sse_reader_take_line_extracts_newline_terminated() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "data: hello\n".into(),
done_marker_seen: false,
};
let line = reader.take_line().unwrap().unwrap();
assert_eq!(line, "data: hello");
assert!(reader.buf.is_empty());
}
#[test]
fn sse_reader_take_line_returns_none_without_newline() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "partial".into(),
done_marker_seen: false,
};
assert!(reader.take_line().unwrap().is_none());
}
#[test]
fn sse_reader_take_line_handles_multiple_lines() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "line1\nline2\n".into(),
done_marker_seen: false,
};
assert_eq!(reader.take_line().unwrap().unwrap(), "line1");
assert_eq!(reader.take_line().unwrap().unwrap(), "line2");
}
#[test]
fn sse_reader_take_line_trims_cr() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "data: hi\r\n".into(),
done_marker_seen: false,
};
let line = reader.take_line().unwrap().unwrap();
assert_eq!(line, "data: hi");
}
#[test]
fn builder_timeouts_applied_on_build() {
let client = OpenAiClient::builder()
.with_api_key("sk-test")
.with_timeout(Duration::from_mins(3))
.with_connect_timeout(Duration::from_secs(15))
.build();
assert!(client.is_ok(), "build should succeed with valid timeouts");
}
#[tokio::test]
async fn sse_reader_take_line_splits_on_newline() {
let mut reader = SseReader {
bytes: Box::pin(futures::stream::empty()),
buf: "data: hello\ndata: world\n".to_string().into_bytes(),
done_marker_seen: false,
};
assert_eq!(reader.take_line().unwrap(), Some("data: hello".to_string()));
assert_eq!(reader.take_line().unwrap(), Some("data: world".to_string()));
assert_eq!(reader.take_line().unwrap(), None);
}
#[tokio::test]
async fn sse_reader_next_data_extracts_payload() {
let data = "data: {\"id\":\"c1\",\"model\":\"gpt-4o\",\"choices\":[]}\n\n";
let stream =
futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(data.to_string().into())]);
let mut reader = SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
done_marker_seen: false,
};
let result = reader.next_openai_data().await.unwrap();
assert!(result.is_some());
assert!(result.unwrap().contains("c1"));
}
#[tokio::test]
async fn sse_reader_next_data_done_returns_none() {
let stream = futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
"data: [DONE]\n\n".into(),
)]);
let mut reader = SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
done_marker_seen: false,
};
let result = reader.next_openai_data().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn sse_reader_buffer_overflow_returns_error() {
let huge = "x".repeat(2 * 1024 * 1024);
let stream = futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(huge.into())]);
let mut reader = super::SseReader {
bytes: Box::pin(stream),
buf: Vec::new(),
done_marker_seen: false,
};
let result = reader.next_openai_data().await;
assert!(result.is_err(), "should error on buffer overflow");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("SSE buffer"),
"error should mention SSE buffer: {err_msg}"
);
}
#[test]
fn max_response_body_is_ten_mb() {
assert_eq!(super::super::MAX_RESPONSE_BODY, 10 * 1024 * 1024);
}
#[test]
fn request_body_response_format_emitted() {
let msgs = vec![Message::user("hi")];
let rf =
crate::structured::ResponseFormat::new("action", serde_json::json!({"type": "object"}));
let body = RequestBody::build(
"gpt-4o",
&msgs,
None,
None,
Some(&rf),
&ToolConstraint::None,
);
let json = body.to_json(false);
assert_eq!(json["response_format"]["type"], "json_schema");
assert_eq!(json["response_format"]["json_schema"]["name"], "action");
assert_eq!(
json["response_format"]["json_schema"]["schema"],
serde_json::json!({"type": "object"})
);
assert_eq!(json["response_format"]["json_schema"]["strict"], true);
}
#[test]
fn request_body_response_format_absent_when_none() {
let msgs = vec![Message::user("hi")];
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None);
let json = body.to_json(false);
assert!(
json.get("response_format").is_none(),
"response_format should be absent (not null) when not set"
);
}
#[test]
fn request_body_response_format_suppresses_tools() {
let msgs = vec![Message::user("hi")];
let caller_tool = ToolSchema {
tool: "read".into(),
description: "Read".into(),
input_schema: serde_json::json!({"type": "object"}),
};
let rf =
crate::structured::ResponseFormat::new("result", serde_json::json!({"type": "object"}));
let body = RequestBody::build(
"gpt-4o",
&msgs,
None,
Some(&[caller_tool]),
Some(&rf),
&ToolConstraint::None,
);
let json = body.to_json(false);
assert!(
json.get("tools").is_none(),
"tools key should be absent when response_format is set"
);
assert!(json.get("response_format").is_some());
}
#[test]
fn extract_structured_from_text_part() {
let client = OpenAiClient::builder()
.with_api_key("test")
.build()
.unwrap();
let message = Message::assistant(r#"{"tool": "write", "args": {}}"#);
let value = client.extract_structured(&message);
assert_eq!(value["tool"], "write");
}
#[test]
fn extract_structured_from_tool_call_part() {
let client = OpenAiClient::builder()
.with_api_key("test")
.build()
.unwrap();
let message = Message::new(
Role::Assistant,
vec![MessagePart::tool_call(
"tc_1",
"action",
serde_json::json!({"tool": "read", "args": {}}),
)],
);
let value = client.extract_structured(&message);
assert_eq!(value["tool"], "read");
}
#[test]
fn extract_structured_prose_falls_back_to_string() {
let client = OpenAiClient::builder()
.with_api_key("test")
.build()
.unwrap();
let message = Message::assistant("I cannot produce that.");
let value = client.extract_structured(&message);
assert_eq!(value, serde_json::json!("I cannot produce that."));
}
#[test]
fn build_response_maps_text_and_stop_finish_reason() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "hello"},
"finish_reason": "stop"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.message.role, Role::Assistant);
assert_eq!(response.message.text_content(), "hello");
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_maps_tool_calls_finish_reason() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"id": "call_1",
"function": {"name": "search", "arguments": "{\"q\": \"x\"}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.message.parts.len(), 1);
match &response.message.parts[0] {
MessagePart::ToolCall { id, name, input } => {
assert_eq!(id, "call_1");
assert_eq!(name, "search");
assert_eq!(input, &serde_json::json!({"q": "x"}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
assert_eq!(response.stop_reason, StreamStopReason::ToolCall);
}
#[test]
fn build_response_maps_length_to_max_tokens() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "truncated"},
"finish_reason": "length"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.stop_reason, StreamStopReason::MaxTokens);
}
#[test]
fn build_response_extracts_usage() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 42, "completion_tokens": 7}
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.usage.expect("usage").input_tokens, 42);
assert_eq!(response.usage.expect("usage").output_tokens, 7);
assert_eq!(response.usage.expect("usage").total_tokens(), 49);
}
#[test]
fn build_response_missing_usage_is_none() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "hi"},
"finish_reason": "stop"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert!(response.usage.is_none());
}
#[test]
fn build_response_zero_usage_collapses_to_none() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0}
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert!(
response.usage.is_none(),
"all-zero usage must collapse to None"
);
}
#[test]
fn build_response_text_and_tool_calls_combined() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": "Let me search",
"tool_calls": [{
"id": "call_1",
"function": {"name": "search", "arguments": "{\"q\": \"x\"}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.message.parts.len(), 2);
assert!(response.message.parts[0].is_text());
assert!(response.message.parts[1].is_tool_call());
assert_eq!(response.stop_reason, StreamStopReason::ToolCall);
}
#[test]
fn build_response_multiple_tool_calls_preserve_order() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": null,
"tool_calls": [
{"id": "a", "function": {"name": "first", "arguments": "{}"}},
{"id": "b", "function": {"name": "second", "arguments": "{\"n\": 2}"}}
]
},
"finish_reason": "tool_calls"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.message.parts.len(), 2);
match &response.message.parts[0] {
MessagePart::ToolCall { id, name, .. } => {
assert_eq!(id, "a");
assert_eq!(name, "first");
}
other => panic!("expected ToolCall, got {other:?}"),
}
match &response.message.parts[1] {
MessagePart::ToolCall { id, name, .. } => {
assert_eq!(id, "b");
assert_eq!(name, "second");
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn build_response_malformed_arguments_returns_error() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"id": "call_1",
"function": {"name": "search", "arguments": "not valid json{"}
}]
},
"finish_reason": "tool_calls"
}]
});
let result = OpenAiClient::build_response(&raw);
assert!(
result.is_err(),
"malformed non-empty arguments must surface as an error, not silently default to {{}}"
);
}
#[test]
fn build_response_empty_arguments_defaults_to_empty_object() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"id": "call_1",
"function": {"name": "search", "arguments": ""}
}]
},
"finish_reason": "tool_calls"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
match &response.message.parts[0] {
MessagePart::ToolCall { input, .. } => {
assert_eq!(input, &serde_json::json!({}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn build_response_missing_arguments_defaults_to_empty_object() {
let raw = serde_json::json!({
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"id": "call_1",
"function": {"name": "search"}
}]
},
"finish_reason": "tool_calls"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
match &response.message.parts[0] {
MessagePart::ToolCall { input, .. } => {
assert_eq!(input, &serde_json::json!({}));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn build_response_missing_choices_yields_empty_message() {
let raw = serde_json::json!({});
let response = OpenAiClient::build_response(&raw).unwrap();
assert!(response.message.parts.is_empty());
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn build_response_unrecognized_finish_reason_defaults_to_end_turn() {
let raw = serde_json::json!({
"choices": [{
"message": {"content": "hi"},
"finish_reason": "content_filter"
}]
});
let response = OpenAiClient::build_response(&raw).unwrap();
assert_eq!(response.stop_reason, StreamStopReason::EndTurn);
}
#[test]
fn openai_strict_sets_flag_and_tightens() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {"msg": {"type": "string"}}
}),
}];
let body = RequestBody::build(
"gpt-4o",
&msgs,
None,
Some(&tools),
None,
&ToolConstraint::Strict,
);
let json = body.to_json(false);
let tools_arr = json["tools"].as_array().unwrap();
assert_eq!(tools_arr.len(), 1);
assert_eq!(tools_arr[0]["function"]["strict"], true);
let params = &tools_arr[0]["function"]["parameters"];
assert_eq!(params["additionalProperties"], false);
let required = params["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "msg");
}
#[test]
fn openai_none_constraint_unchanged_shape() {
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let body = RequestBody::build(
"gpt-4o",
&msgs,
None,
Some(&tools),
None,
&ToolConstraint::None,
);
let json = body.to_json(false);
let tools_arr = json["tools"].as_array().unwrap();
assert!(
tools_arr[0]["function"].get("strict").is_none(),
"strict must not appear under ToolConstraint::None"
);
assert!(
json.get("guided_json").is_none(),
"guided_json must not appear under ToolConstraint::None"
);
assert_eq!(
tools_arr[0]["function"]["parameters"],
serde_json::json!({"type": "object"})
);
}
#[test]
fn openai_strict_suppressed_when_response_format_set() {
let msgs = vec![Message::user("hi")];
let caller_tool = ToolSchema {
tool: "read".into(),
description: "Read".into(),
input_schema: serde_json::json!({"type": "object"}),
};
let rf =
crate::structured::ResponseFormat::new("result", serde_json::json!({"type": "object"}));
let body = RequestBody::build(
"gpt-4o",
&msgs,
None,
Some(&[caller_tool]),
Some(&rf),
&ToolConstraint::Strict,
);
let json = body.to_json(false);
assert!(
json.get("tools").is_none(),
"tools must be absent when response_format is set"
);
assert!(
json.get("guided_json").is_none(),
"guided_json must be absent when response_format is set"
);
assert!(json.get("response_format").is_some());
}
#[cfg(feature = "grammar")]
#[test]
fn openai_grammar_injects_guided_json() {
use crate::provider::grammar::JsonSchemaGrammar;
use crate::structured::ToolConstraint;
let msgs = vec![Message::user("hi")];
let tools = vec![ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let grammar = std::sync::Arc::new(JsonSchemaGrammar::from_schemas(&tools));
let constraint = ToolConstraint::Grammar(grammar);
let body = RequestBody::build("gpt-4o", &msgs, None, Some(&tools), None, &constraint);
let json = body.to_json(false);
let guided = json["guided_json"].as_str().unwrap();
assert!(
guided.contains("echo"),
"guided_json should reference the tool: {guided}"
);
assert!(json.get("tools").is_some());
let tools_arr = json["tools"].as_array().unwrap();
assert!(tools_arr[0]["function"].get("strict").is_none());
}
#[cfg(feature = "grammar")]
#[test]
fn openai_grammar_without_tools_omits_guided_json() {
use crate::provider::grammar::JsonSchemaGrammar;
use crate::structured::ToolConstraint;
let msgs = vec![Message::user("hi")];
let grammar = std::sync::Arc::new(JsonSchemaGrammar::from_schemas(&[]));
let constraint = ToolConstraint::Grammar(grammar);
let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &constraint);
let json = body.to_json(false);
assert!(
json.get("guided_json").is_none(),
"guided_json must be absent when no tools are registered"
);
assert!(
json.get("tools").is_none(),
"tools must be absent when none were supplied"
);
let body = RequestBody::build("gpt-4o", &msgs, None, Some(&[]), None, &constraint);
let json = body.to_json(false);
assert!(
json.get("guided_json").is_none(),
"guided_json must be absent for an empty tool slice"
);
}
#[test]
fn convert_message_system_role_emitted_inline() {
let msg = Message::new(Role::System, vec![MessagePart::text("stay on task")]);
let value = convert_message(&msg).remove(0);
assert_eq!(value["role"], "system");
assert_eq!(value["content"], "stay on task");
}
#[test]
fn openai_delta_reasoning_content_emits_thinking() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"reasoning_content":"thinking…"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
let thinking = events.iter().find_map(|e| match e {
StreamEvent::IndexedDelta(d) => match &d.delta {
DeltaPart::Thinking { text } => Some(text.clone()),
_ => None,
},
_ => None,
});
assert_eq!(thinking.as_deref(), Some("thinking…"));
assert!(
events
.iter()
.any(|e| matches!(e, StreamEvent::PartStart(_))),
"a PartStart should fire for the reasoning lane"
);
}
#[test]
fn openai_delta_reasoning_alias_works() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"reasoning":"via alias"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
let thinking = events.iter().find_map(|e| match e {
StreamEvent::IndexedDelta(d) => match &d.delta {
DeltaPart::Thinking { text } => Some(text.clone()),
_ => None,
},
_ => None,
});
assert_eq!(
thinking.as_deref(),
Some("via alias"),
"#[serde(alias = \"reasoning\")] must accept the field"
);
}
#[test]
fn openai_reasoning_does_not_open_text_part() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"reasoning_content":"reasoning only"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
let has_text = events.iter().any(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Text { .. })
)
});
assert!(!has_text, "reasoning-only chunk must not emit Text deltas");
assert!(
matches!(em.text, PartLane::Closed),
"reasoning must not open the text lane"
);
}
#[test]
fn openai_reasoning_and_text_interleave() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"reasoning_content":"think"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
let has_thinking = events.iter().any(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Thinking { .. })
)
});
let has_text = events.iter().any(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Text { .. })
)
});
assert!(has_thinking, "a Thinking delta should have fired");
assert!(has_text, "a Text delta should have fired");
let lane_switch_stops = events
.iter()
.filter(|e| matches!(e, StreamEvent::PartStop { .. }))
.count();
assert_eq!(
lane_switch_stops, 1,
"lane switch must close the reasoning lane with one PartStop"
);
}
#[test]
fn openai_combined_content_and_reasoning_in_one_delta() {
let mut em = StreamEmitter::default();
let chunk = OpenAiChunk::parse(
r#"{"id":"c1","model":"o1","choices":[{"delta":{"content":"answer","reasoning_content":"why"},"finish_reason":null}]}"#,
)
.unwrap();
em.process_chunk(&chunk);
let events = em.drain();
let has_text = events.iter().any(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Text { ref text } if text == "answer")
)
});
let has_thinking = events.iter().any(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Thinking { ref text } if text == "why")
)
});
assert!(has_text, "text delta must fire");
assert!(has_thinking, "thinking delta must fire");
let stops = events
.iter()
.filter(|e| matches!(e, StreamEvent::PartStop { .. }))
.count();
assert_eq!(stops, 1, "exactly one PartStop for the text lane");
let text_idx = events
.iter()
.position(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Text { .. })
)
})
.expect("text delta present");
let stop_idx = events
.iter()
.position(|e| matches!(e, StreamEvent::PartStop { .. }))
.expect("PartStop present");
let thinking_idx = events
.iter()
.rposition(|e| {
matches!(
e,
StreamEvent::IndexedDelta(d) if matches!(d.delta, DeltaPart::Thinking { .. })
)
})
.expect("thinking delta present");
assert!(text_idx < stop_idx, "text delta before PartStop");
assert!(stop_idx < thinking_idx, "PartStop before thinking delta");
}
#[test]
fn request_body_omits_tools_for_empty_slice() {
let body = RequestBody::build(
"m",
&[crate::message::Message::user("hi")],
None,
Some(&[]),
None,
&ToolConstraint::None,
)
.to_json(false);
assert!(
body.get("tools").is_none(),
"an empty tool list must be omitted, not sent as []; got {}",
body.get("tools").unwrap_or(&serde_json::Value::Null)
);
}
#[test]
fn completions_url_has_no_double_slash_for_trailing_slash_base() {
let bare = OpenAiClient::builder()
.with_api_key("k")
.with_base_url("https://api.example.com/v1")
.build()
.expect("client builds");
let slashed = OpenAiClient::builder()
.with_api_key("k")
.with_base_url("https://api.example.com/v1/")
.build()
.expect("client builds");
assert_eq!(
slashed.completions_url(),
bare.completions_url(),
"a trailing-slash base URL must join to the same request URL as the bare one"
);
}
#[test]
fn convert_message_keeps_text_alongside_tool_results() {
let msg = crate::message::Message::new(
crate::message::Role::User,
vec![
crate::message::MessagePart::text("stale results, search again"),
crate::message::MessagePart::tool_result(
"c1",
"search",
crate::message::ToolContent::from_string("[]"),
false,
),
],
);
let json = serde_json::to_string(&convert_message(&msg)).unwrap_or_default();
assert!(
json.contains("stale results, search again"),
"text parts accompanying tool results must reach the model; got {json}"
);
let messages = convert_message(&msg);
assert_eq!(
messages.len(),
2,
"one tool message plus the trailing user text message: {messages:?}"
);
assert_eq!(messages[0]["role"], "tool", "the tool result comes first");
assert_eq!(messages[0]["tool_call_id"], "c1");
assert_eq!(
messages[1]["role"], "user",
"the preserved text rides as a trailing user message"
);
assert_eq!(messages[1]["content"], "stale results, search again");
}
#[tokio::test]
async fn request_model_override_replaces_the_body_model_on_stream() {
use futures::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let head = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
String::from_utf8_lossy(&buf[..n]).into_owned()
});
let client = OpenAiClient::builder()
.with_api_key("k")
.with_base_url(format!("http://{addr}"))
.build()
.unwrap();
let options = crate::structured::RequestOptions::new().with_model("override-model");
let mut stream =
client.stream_messages_with_options(&crate::api::StreamRequest::new(vec![]), options);
let _ = stream.next().await;
let request = server.await.unwrap();
assert!(
request.contains("\"model\":\"override-model\""),
"the streaming path must honor the per-request model override: {request}"
);
}
#[tokio::test]
async fn request_model_override_replaces_the_body_model() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let head = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
String::from_utf8_lossy(&buf[..n]).into_owned()
});
let client = OpenAiClient::builder()
.with_api_key("k")
.with_base_url(format!("http://{addr}"))
.build()
.unwrap();
let options = crate::structured::RequestOptions::new().with_model("override-model");
drop(
client
.create_message_with_options(&crate::api::StreamRequest::new(vec![]), options)
.await,
);
let request = server.await.unwrap();
assert!(
request.contains("\"model\":\"override-model\""),
"the per-request override must replace the body's model field: {request}"
);
}
#[tokio::test]
async fn sse_data_line_without_space_is_parsed() {
let data = "data:{\"ok\":true}\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
done_marker_seen: false,
};
let parsed = reader
.next_openai_data()
.await
.expect("reader must not err");
assert!(
parsed.is_some(),
"spec-legal 'data:' line must yield the payload, not be skipped"
);
}
#[test]
fn finish_without_done_marker_emits_no_message_stop() {
let mut emitter = super::StreamEmitter::default();
let chunk = super::OpenAiChunk::parse(
r#"{"id":"c1","model":"m","choices":[{"delta":{"content":"partial"},"finish_reason":null}]}"#,
)
.unwrap();
emitter.process_chunk(&chunk);
let _ = emitter.drain();
let events = emitter
.finish()
.expect("finish without a recorded error must be Ok");
assert!(
!events.iter().any(|e| matches!(e, StreamEvent::MessageStop)),
"a bare EOF never masquerades as a clean completion"
);
}
#[tokio::test]
async fn compact_done_marker_terminates_the_stream() {
let data = "data:[DONE]\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
done_marker_seen: false,
};
let parsed = reader
.next_openai_data()
.await
.expect("reader must not err");
assert_eq!(
parsed, None,
"the compact [DONE] marker must end the stream exactly like the spaced form"
);
}
#[tokio::test]
async fn bare_data_line_yields_empty_payload_then_next_chunk_parses() {
let data = "data:\n\ndata:{\"ok\":1}\n\n";
let mut reader = SseReader {
bytes: Box::pin(futures::stream::iter(vec![Ok::<bytes::Bytes, ApiError>(
data.to_string().into(),
)])),
buf: Vec::new(),
done_marker_seen: false,
};
let first = reader
.next_openai_data()
.await
.expect("reader must not err");
assert_eq!(
first,
Some(String::new()),
"a bare data field carries an empty payload, not a skipped line"
);
let second = reader
.next_openai_data()
.await
.expect("reader must not err");
assert_eq!(
second.as_deref(),
Some("{\"ok\":1}"),
"the chunk after a bare data line must still parse"
);
let third = reader
.next_openai_data()
.await
.expect("reader must not err");
assert_eq!(third, None, "the stream must end cleanly after the payload");
}
}