#[cfg(not(target_arch = "wasm32"))]
use crate::FunctionCall;
#[cfg(not(target_arch = "wasm32"))]
use crate::chat::{ChatMessage, ChatRole, MessageType};
#[cfg(not(target_arch = "wasm32"))]
use crate::chat::{
ChatProvider, StreamChoice, StreamChunk as ChatStreamChunk, StreamDelta, StreamResponse,
};
use crate::config::resolve_request_timeout;
#[cfg(not(target_arch = "wasm32"))]
use crate::error::LLMError;
#[cfg(not(target_arch = "wasm32"))]
use crate::http::ensure_success;
use crate::{
ToolCall,
chat::ChatResponse,
chat::{StructuredOutputFormat, Tool, ToolChoice, Usage},
default_call_type,
};
#[cfg(not(target_arch = "wasm32"))]
use async_trait::async_trait;
#[cfg(not(target_arch = "wasm32"))]
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use either::*;
#[cfg(not(target_arch = "wasm32"))]
use futures::{StreamExt, stream::Stream};
#[cfg(not(target_arch = "wasm32"))]
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
use std::marker::PhantomData;
#[cfg(not(target_arch = "wasm32"))]
use std::pin::Pin;
use url::Url;
pub struct OpenAICompatibleProvider<T: OpenAIProviderConfig> {
pub api_key: String,
pub base_url: Url,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub timeout_seconds: u64,
pub top_p: Option<f32>,
pub top_k: Option<u32>,
pub tool_choice: Option<ToolChoice>,
pub reasoning_effort: Option<String>,
#[allow(dead_code)]
pub voice: Option<String>,
pub extra_body: serde_json::Map<String, serde_json::Value>,
pub parallel_tool_calls: bool,
pub embedding_encoding_format: Option<String>,
pub embedding_dimensions: Option<u32>,
pub normalize_response: bool,
#[cfg(not(target_arch = "wasm32"))]
pub client: Client,
_phantom: PhantomData<T>,
}
pub trait OpenAIProviderConfig: Send + Sync {
const PROVIDER_NAME: &'static str;
const DEFAULT_BASE_URL: &'static str;
const DEFAULT_MODEL: &'static str;
const CHAT_ENDPOINT: &'static str = "chat/completions";
const SUPPORTS_REASONING_EFFORT: bool = false;
const SUPPORTS_STRUCTURED_OUTPUT: bool = false;
const SUPPORTS_PARALLEL_TOOL_CALLS: bool = false;
const SUPPORTS_STREAM_OPTIONS: bool = false;
fn custom_headers() -> Option<Vec<(String, String)>> {
None
}
}
#[derive(Serialize, Debug)]
pub struct OpenAIChatMessage<'a> {
pub role: &'a str,
#[serde(
skip_serializing_if = "Option::is_none",
with = "either::serde_untagged_optional"
)]
pub content: Option<Either<Vec<OpenAIMessageContent<'a>>, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Serialize, Debug)]
pub struct OpenAIMessageContent<'a> {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub message_type: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<ImageUrlContent>,
#[serde(skip_serializing_if = "Option::is_none", rename = "tool_call_id")]
pub tool_call_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none", rename = "content")]
pub tool_output: Option<&'a str>,
}
#[derive(Serialize, Debug)]
pub struct ImageUrlContent {
pub url: String,
}
#[derive(Serialize, Debug)]
pub struct OpenAIChatRequest<'a> {
pub model: &'a str,
pub messages: Vec<OpenAIChatMessage<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<OpenAIResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<OpenAIStreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(flatten)]
pub extra_body: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize, Debug)]
pub struct OpenAIChatResponse {
pub choices: Vec<OpenAIChatChoice>,
pub usage: Option<Usage>,
}
#[derive(Deserialize, Debug)]
pub struct OpenAIChatChoice {
pub message: OpenAIChatMsg,
}
#[derive(Deserialize, Debug)]
pub struct OpenAIChatMsg {
#[allow(dead_code)]
pub role: String,
pub content: Option<String>,
#[serde(default, alias = "reasoning")]
pub reasoning_content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Deserialize, Debug, Serialize)]
pub enum OpenAIResponseType {
#[serde(rename = "text")]
Text,
#[serde(rename = "json_schema")]
JsonSchema,
#[serde(rename = "json_object")]
JsonObject,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct OpenAIResponseFormat {
#[serde(rename = "type")]
pub response_type: OpenAIResponseType,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema: Option<StructuredOutputFormat>,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct OpenAIStreamOptions {
pub include_usage: bool,
}
#[derive(Deserialize, Debug)]
pub struct StreamChunk {
pub choices: Vec<OpenAIStreamChoice>,
pub usage: Option<Usage>,
}
#[derive(Deserialize, Debug)]
pub struct OpenAIStreamChoice {
pub delta: OpenAIStreamDelta,
}
#[derive(Deserialize, Debug)]
pub struct OpenAIStreamDelta {
pub content: Option<String>,
#[serde(default, alias = "reasoning")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<StreamToolCall>>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)]
pub struct StreamToolCall {
pub id: Option<String>,
#[serde(rename = "type", default = "default_call_type")]
pub call_type: String,
pub function: StreamFunctionCall,
}
#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)]
pub struct StreamFunctionCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub arguments: String,
}
impl From<StructuredOutputFormat> for OpenAIResponseFormat {
fn from(structured_response_format: StructuredOutputFormat) -> Self {
match structured_response_format.schema {
None => OpenAIResponseFormat {
response_type: OpenAIResponseType::JsonSchema,
json_schema: Some(structured_response_format),
},
Some(mut schema) => {
schema = if schema.get("additionalProperties").is_none() {
schema["additionalProperties"] = serde_json::json!(false);
schema
} else {
schema
};
OpenAIResponseFormat {
response_type: OpenAIResponseType::JsonSchema,
json_schema: Some(StructuredOutputFormat {
name: structured_response_format.name,
description: structured_response_format.description,
schema: Some(schema),
strict: structured_response_format.strict,
}),
}
}
}
}
}
impl ChatResponse for OpenAIChatResponse {
fn text(&self) -> Option<String> {
self.choices.first().and_then(|c| c.message.content.clone())
}
fn tool_calls(&self) -> Option<Vec<ToolCall>> {
self.choices
.first()
.and_then(|c| c.message.tool_calls.clone())
}
fn thinking(&self) -> Option<String> {
self.choices
.first()
.and_then(|c| c.message.reasoning_content.clone())
}
fn usage(&self) -> Option<Usage> {
self.usage.clone()
}
}
impl std::fmt::Display for OpenAIChatResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (
&self.choices.first().unwrap().message.content,
&self.choices.first().unwrap().message.tool_calls,
) {
(Some(content), Some(tool_calls)) => {
for tool_call in tool_calls {
write!(f, "{tool_call}")?;
}
write!(f, "{content}")
}
(Some(content), None) => write!(f, "{content}"),
(None, Some(tool_calls)) => {
for tool_call in tool_calls {
write!(f, "{tool_call}")?;
}
Ok(())
}
(None, None) => write!(f, ""),
}
}
}
impl<T: OpenAIProviderConfig> OpenAICompatibleProvider<T> {
#[allow(clippy::too_many_arguments)]
pub fn new(
api_key: impl Into<String>,
base_url: Option<String>,
model: Option<String>,
max_tokens: Option<u32>,
temperature: Option<f32>,
timeout_seconds: Option<u64>,
top_p: Option<f32>,
top_k: Option<u32>,
tool_choice: Option<ToolChoice>,
reasoning_effort: Option<String>,
voice: Option<String>,
extra_body: Option<serde_json::Value>,
parallel_tool_calls: Option<bool>,
normalize_response: Option<bool>,
embedding_encoding_format: Option<String>,
embedding_dimensions: Option<u32>,
) -> Self {
let timeout_seconds = resolve_request_timeout(timeout_seconds);
#[cfg(not(target_arch = "wasm32"))]
let client = {
let _ = timeout_seconds; Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.expect("Failed to build reqwest Client")
};
let extra_body = match extra_body {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(), };
Self {
api_key: api_key.into(),
base_url: Url::parse(&format!(
"{}/",
base_url
.unwrap_or_else(|| T::DEFAULT_BASE_URL.to_owned())
.trim_end_matches("/")
))
.expect("Failed to parse base URL"),
model: model.unwrap_or_else(|| T::DEFAULT_MODEL.to_string()),
max_tokens,
temperature,
timeout_seconds,
top_p,
top_k,
tool_choice,
reasoning_effort,
voice,
extra_body,
parallel_tool_calls: parallel_tool_calls.unwrap_or(false),
normalize_response: normalize_response.unwrap_or(true),
embedding_encoding_format,
embedding_dimensions,
#[cfg(not(target_arch = "wasm32"))]
client,
_phantom: PhantomData,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn prepare_messages(
&self,
messages: &[ChatMessage],
) -> Result<Vec<OpenAIChatMessage<'_>>, LLMError> {
let mut openai_msgs = Vec::new();
for msg in messages {
if let MessageType::ToolResult(ref results) = msg.message_type {
openai_msgs.extend(results.iter().map(|result| OpenAIChatMessage {
role: "tool",
tool_call_id: Some(result.id.clone()),
tool_calls: None,
content: Some(Right(result.function.arguments.clone())),
}));
} else {
openai_msgs.push(chat_message_to_openai_message(msg.clone())?);
}
}
Ok(openai_msgs)
}
}
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
impl<T: OpenAIProviderConfig> ChatProvider for OpenAICompatibleProvider<T> {
async fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(format!(
"Missing {} API key",
T::PROVIDER_NAME
)));
}
let openai_msgs = self.prepare_messages(messages)?;
let response_format: Option<OpenAIResponseFormat> = if T::SUPPORTS_STRUCTURED_OUTPUT {
json_schema.clone().map(|s| s.into())
} else {
None
};
let request_tools = tools.map(|t| t.to_vec());
let request_tool_choice = if request_tools.is_some() {
self.tool_choice.clone()
} else {
None
};
let reasoning_effort = if T::SUPPORTS_REASONING_EFFORT {
self.reasoning_effort.clone()
} else {
None
};
let parallel_tool_calls = if T::SUPPORTS_PARALLEL_TOOL_CALLS {
Some(self.parallel_tool_calls)
} else {
None
};
let body = OpenAIChatRequest {
model: &self.model,
messages: openai_msgs,
max_tokens: self.max_tokens,
temperature: self.temperature,
stream: false,
top_p: self.top_p,
top_k: self.top_k,
tools: request_tools,
tool_choice: request_tool_choice,
reasoning_effort,
response_format,
stream_options: None,
parallel_tool_calls,
extra_body: self.extra_body.clone(),
};
let url = self
.base_url
.join(T::CHAT_ENDPOINT)
.map_err(|e| LLMError::HttpError(e.to_string()))?;
let mut request = self.client.post(url).bearer_auth(&self.api_key).json(&body);
if let Some(headers) = T::custom_headers() {
for (key, value) in headers {
request = request.header(key, value);
}
}
if log::log_enabled!(log::Level::Trace) {
log::trace!(
"{}",
crate::request_diagnostics::summarize_json_request(
T::PROVIDER_NAME,
"chat request",
&body
)
);
}
let response = request.send().await?;
log::debug!("{} HTTP status: {}", T::PROVIDER_NAME, response.status());
let response = ensure_success(response, T::PROVIDER_NAME).await?;
let resp_text = response.text().await?;
let json_resp: Result<OpenAIChatResponse, serde_json::Error> =
serde_json::from_str(&resp_text);
match json_resp {
Ok(response) => Ok(Box::new(response)),
Err(e) => Err(LLMError::ResponseFormatError {
message: format!("Failed to decode {} API response: {e}", T::PROVIDER_NAME),
raw_response: resp_text,
}),
}
}
async fn chat(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools(messages, None, json_schema).await
}
async fn chat_stream(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
let struct_stream = self.chat_stream_struct(messages, None, json_schema).await?;
let content_stream = struct_stream.filter_map(|result| async move {
match result {
Ok(stream_response) => {
if let Some(choice) = stream_response.choices.first()
&& let Some(content) = &choice.delta.content
&& !content.is_empty()
{
return Some(Ok(content.clone()));
}
None
}
Err(e) => Some(Err(e)),
}
});
Ok(Box::pin(content_stream))
}
async fn chat_stream_struct(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<
std::pin::Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>,
LLMError,
> {
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(format!(
"Missing {} API key",
T::PROVIDER_NAME
)));
}
let openai_msgs = self.prepare_messages(messages)?;
let request_tools = tools.map(|t| t.to_vec());
let response_format: Option<OpenAIResponseFormat> = if T::SUPPORTS_STRUCTURED_OUTPUT {
json_schema.clone().map(|s| s.into())
} else {
None
};
let body = OpenAIChatRequest {
model: &self.model,
messages: openai_msgs,
max_tokens: self.max_tokens,
temperature: self.temperature,
stream: true,
top_p: self.top_p,
top_k: self.top_k,
tools: request_tools,
tool_choice: self.tool_choice.clone(),
reasoning_effort: if T::SUPPORTS_REASONING_EFFORT {
self.reasoning_effort.clone()
} else {
None
},
response_format,
stream_options: if T::SUPPORTS_STREAM_OPTIONS {
Some(OpenAIStreamOptions {
include_usage: true,
})
} else {
None
},
parallel_tool_calls: if T::SUPPORTS_PARALLEL_TOOL_CALLS {
Some(self.parallel_tool_calls)
} else {
None
},
extra_body: self.extra_body.clone(),
};
let url = self
.base_url
.join(T::CHAT_ENDPOINT)
.map_err(|e| LLMError::HttpError(e.to_string()))?;
let mut request = self.client.post(url).bearer_auth(&self.api_key).json(&body);
if let Some(headers) = T::custom_headers() {
for (key, value) in headers {
request = request.header(key, value);
}
}
if log::log_enabled!(log::Level::Trace) {
log::trace!(
"{}",
crate::request_diagnostics::summarize_json_request(
T::PROVIDER_NAME,
"stream request",
&body
)
);
}
let response = request.send().await?;
log::debug!("{} HTTP status: {}", T::PROVIDER_NAME, response.status());
let response = ensure_success(response, T::PROVIDER_NAME).await?;
Ok(create_sse_stream(response, self.normalize_response))
}
async fn chat_stream_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<ChatStreamChunk, LLMError>> + Send>>, LLMError>
{
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(format!(
"Missing {} API key",
T::PROVIDER_NAME
)));
}
let openai_msgs = self.prepare_messages(messages)?;
let requested_tools = tools.map(|t| t.to_vec());
let response_format: Option<OpenAIResponseFormat> = if T::SUPPORTS_STRUCTURED_OUTPUT {
json_schema.clone().map(|s| s.into())
} else {
None
};
let body = OpenAIChatRequest {
model: &self.model,
messages: openai_msgs,
max_tokens: self.max_tokens,
temperature: self.temperature,
stream: true,
top_p: self.top_p,
top_k: self.top_k,
tools: requested_tools,
tool_choice: self.tool_choice.clone(),
reasoning_effort: if T::SUPPORTS_REASONING_EFFORT {
self.reasoning_effort.clone()
} else {
None
},
response_format,
stream_options: if T::SUPPORTS_STREAM_OPTIONS {
Some(OpenAIStreamOptions {
include_usage: true,
})
} else {
None
},
parallel_tool_calls: if T::SUPPORTS_PARALLEL_TOOL_CALLS {
Some(self.parallel_tool_calls)
} else {
None
},
extra_body: self.extra_body.clone(),
};
let url = self
.base_url
.join(T::CHAT_ENDPOINT)
.map_err(|e| LLMError::HttpError(e.to_string()))?;
let mut request = self.client.post(url).bearer_auth(&self.api_key).json(&body);
if let Some(headers) = T::custom_headers() {
for (key, value) in headers {
request = request.header(key, value);
}
}
if log::log_enabled!(log::Level::Trace) {
log::trace!(
"{}",
crate::request_diagnostics::summarize_json_request(
T::PROVIDER_NAME,
"streaming tools request",
&body
)
);
}
log::debug!(
"{} request: POST {} (streaming with tools)",
T::PROVIDER_NAME,
T::CHAT_ENDPOINT
);
let response = request.send().await?;
log::debug!("{} HTTP status: {}", T::PROVIDER_NAME, response.status());
let response = ensure_success(response, T::PROVIDER_NAME).await?;
Ok(create_openai_tool_stream(response))
}
fn model(&self) -> &str {
&self.model
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Default)]
struct OpenAIToolUseState {
id: String,
name: String,
arguments_buffer: String,
started: bool,
}
#[cfg(not(target_arch = "wasm32"))]
fn create_openai_tool_stream(
response: reqwest::Response,
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk, LLMError>> + Send>> {
let stream = response
.bytes_stream()
.scan(
(
Vec::<u8>::new(),
HashMap::<usize, OpenAIToolUseState>::default(),
),
move |(buffer, tool_states), chunk| {
let result = match chunk {
Ok(bytes) => {
let mut results = Vec::new();
buffer.extend_from_slice(&bytes);
while let Some((pos, delimiter_len)) = find_sse_event_boundary(buffer) {
let event_bytes: Vec<u8> = buffer[..pos].to_vec();
buffer.drain(..pos + delimiter_len);
let event = String::from_utf8_lossy(&event_bytes).into_owned();
let event = event.trim();
if event.is_empty() {
continue;
}
match parse_openai_sse_chunk_with_tools(event, tool_states) {
Ok(chunks) => results.extend(chunks.into_iter().map(Ok)),
Err(e) => results.push(Err(e)),
}
}
Some(results)
}
Err(e) => Some(vec![Err(LLMError::HttpError(e.to_string()))]),
};
async move { result }
},
)
.flat_map(futures::stream::iter);
Box::pin(stream)
}
#[cfg(not(target_arch = "wasm32"))]
fn find_sse_event_boundary(buffer: &[u8]) -> Option<(usize, usize)> {
let lf = buffer
.windows(2)
.position(|window| window == b"\n\n")
.map(|pos| (pos, 2));
let crlf = buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|pos| (pos, 4));
match (lf, crlf) {
(Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }),
(Some(boundary), None) | (None, Some(boundary)) => Some(boundary),
(None, None) => None,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn parse_openai_sse_chunk_with_tools(
event: &str,
tool_states: &mut HashMap<usize, OpenAIToolUseState>,
) -> Result<Vec<ChatStreamChunk>, LLMError> {
let mut results = Vec::new();
for line in event.lines() {
let line = line.trim();
let data_opt = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:").map(|d| d.trim_start()));
if let Some(data) = data_opt {
let data_trimmed = data.trim();
if data_trimmed == "[DONE]" {
for (index, state) in tool_states.drain() {
if state.started {
results.push(ChatStreamChunk::ToolUseComplete {
index,
tool_call: ToolCall {
id: state.id,
call_type: "function".to_string(),
function: FunctionCall {
name: state.name,
arguments: state.arguments_buffer,
},
},
});
}
}
results.push(ChatStreamChunk::Done {
stop_reason: "end_turn".to_string(),
});
return Ok(results);
}
let chunk: OpenAIToolStreamChunk = serde_json::from_str(data_trimmed)
.map_err(|e| LLMError::JsonError(e.to_string()))?;
let mut usage_opt = chunk.usage.clone();
for choice in &chunk.choices {
if let Some(content) = &choice.delta.content
&& !content.is_empty()
{
results.push(ChatStreamChunk::Text(content.clone()));
}
if let Some(reasoning_content) = &choice.delta.reasoning_content
&& !reasoning_content.is_empty()
{
results.push(ChatStreamChunk::ReasoningContent(reasoning_content.clone()));
}
if let Some(tool_calls) = &choice.delta.tool_calls {
for tc in tool_calls {
let index = tc.index.unwrap_or(0);
let state = tool_states.entry(index).or_default();
if let Some(id) = &tc.id {
state.id = id.clone();
}
if let Some(name) = &tc.function.name {
state.name = name.clone();
if !state.started {
state.started = true;
results.push(ChatStreamChunk::ToolUseStart {
index,
id: state.id.clone(),
name: state.name.clone(),
});
}
}
if !tc.function.arguments.is_empty() {
state.arguments_buffer.push_str(&tc.function.arguments);
results.push(ChatStreamChunk::ToolUseInputDelta {
index,
partial_json: tc.function.arguments.clone(),
});
}
}
}
if let Some(finish_reason) = &choice.finish_reason {
for (index, state) in tool_states.drain() {
if state.started {
results.push(ChatStreamChunk::ToolUseComplete {
index,
tool_call: ToolCall {
id: state.id,
call_type: "function".to_string(),
function: FunctionCall {
name: state.name,
arguments: state.arguments_buffer,
},
},
});
}
}
if let Some(u) = usage_opt.take() {
results.push(ChatStreamChunk::Usage(u));
}
let stop_reason = match finish_reason.as_str() {
"tool_calls" => "tool_use",
"stop" => "end_turn",
other => other,
};
results.push(ChatStreamChunk::Done {
stop_reason: stop_reason.to_string(),
});
}
}
if let Some(u) = usage_opt.take() {
results.push(ChatStreamChunk::Usage(u));
}
}
}
Ok(results)
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
struct OpenAIToolStreamChunk {
choices: Vec<OpenAIToolStreamChoice>,
#[serde(default)]
usage: Option<Usage>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
struct OpenAIToolStreamChoice {
delta: OpenAIToolStreamDelta,
finish_reason: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
struct OpenAIToolStreamDelta {
content: Option<String>,
#[serde(default, alias = "reasoning")]
reasoning_content: Option<String>,
tool_calls: Option<Vec<OpenAIToolStreamToolCall>>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
struct OpenAIToolStreamToolCall {
index: Option<usize>,
id: Option<String>,
function: OpenAIToolStreamFunction,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
struct OpenAIToolStreamFunction {
name: Option<String>,
#[serde(default)]
arguments: String,
}
#[cfg(not(target_arch = "wasm32"))]
pub fn chat_message_to_openai_message(
chat_msg: ChatMessage,
) -> Result<OpenAIChatMessage<'static>, LLMError> {
let message = OpenAIChatMessage {
role: match chat_msg.role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::System => "system",
ChatRole::Tool => "user",
},
tool_call_id: None,
content: match &chat_msg.message_type {
MessageType::Text => Some(Right(chat_msg.content.clone())),
MessageType::Image((mime, bytes)) => {
let url = format!("data:{};base64,{}", mime.mime_type(), BASE64.encode(bytes));
Some(Left(vec![OpenAIMessageContent {
message_type: Some("image_url"),
text: None,
image_url: Some(ImageUrlContent { url }),
tool_output: None,
tool_call_id: None,
}]))
}
MessageType::Pdf(_) => {
return Err(LLMError::invalid_request(
"PDF input is not supported by OpenAI-compatible chat completions backends"
.to_string(),
));
}
MessageType::ImageURL(url) => Some(Left(vec![OpenAIMessageContent {
message_type: Some("image_url"),
text: None,
image_url: Some(ImageUrlContent { url: url.clone() }),
tool_output: None,
tool_call_id: None,
}])),
MessageType::ToolUse(_) => None,
MessageType::ToolResult(_) => None,
},
tool_calls: match &chat_msg.message_type {
MessageType::ToolUse(calls) => {
let owned_calls: Vec<ToolCall> = calls
.iter()
.map(|c| ToolCall {
id: c.id.clone(),
call_type: "function".to_string(),
function: FunctionCall {
name: c.function.name.clone(),
arguments: c.function.arguments.clone(),
},
})
.collect();
Some(owned_calls)
}
_ => None,
},
};
Ok(message)
}
#[cfg(not(target_arch = "wasm32"))]
struct SSEStreamParser {
event_buffer: Vec<u8>,
tool_buffer: ToolCall,
usage: Option<Usage>,
results: Vec<Result<StreamResponse, LLMError>>,
normalize_response: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl SSEStreamParser {
fn new(normalize_response: bool) -> Self {
Self {
event_buffer: Vec::default(),
usage: None,
results: Vec::default(),
tool_buffer: ToolCall {
id: String::default(),
call_type: "function".to_string(),
function: FunctionCall {
name: String::default(),
arguments: String::default(),
},
},
normalize_response,
}
}
fn push_tool_call(&mut self) {
if self.normalize_response && !self.tool_buffer.function.name.is_empty() {
self.results.push(Ok(StreamResponse {
choices: vec![StreamChoice {
delta: StreamDelta {
content: None,
reasoning_content: None,
tool_calls: Some(vec![self.tool_buffer.clone()]),
},
}],
usage: None,
}));
}
self.tool_buffer = ToolCall {
id: String::default(),
call_type: "function".to_string(),
function: FunctionCall {
name: String::default(),
arguments: String::default(),
},
};
}
fn parse_event(&mut self) {
let event = String::from_utf8_lossy(&self.event_buffer);
let mut data_payload = String::default();
for line in event.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
self.push_tool_call();
if let Some(usage) = self.usage.clone() {
self.results.push(Ok(StreamResponse {
choices: vec![StreamChoice {
delta: StreamDelta {
content: None,
reasoning_content: None,
tool_calls: None,
},
}],
usage: Some(usage),
}));
}
return;
}
data_payload.push_str(data);
} else {
data_payload.push_str(line);
}
}
if data_payload.is_empty() {
return;
}
if let Ok(response) = serde_json::from_str::<StreamChunk>(&data_payload) {
if let Some(resp_usage) = response.usage.clone() {
self.usage = Some(resp_usage);
}
for choice in &response.choices {
let content = choice.delta.content.clone();
let reasoning_content = choice.delta.reasoning_content.clone();
let tool_calls: Option<Vec<ToolCall>> =
choice.delta.tool_calls.clone().map(|calls| {
calls
.into_iter()
.map(|c| ToolCall {
id: c.id.unwrap_or_default(),
call_type: c.call_type,
function: FunctionCall {
name: c.function.name.unwrap_or_default(),
arguments: c.function.arguments,
},
})
.collect::<Vec<ToolCall>>()
});
if content.is_some() || reasoning_content.is_some() || tool_calls.is_some() {
if self.normalize_response && tool_calls.is_some() {
if let Some(calls) = &tool_calls {
for call in calls {
if !call.function.name.is_empty() {
self.push_tool_call();
self.tool_buffer
.function
.name
.clone_from(&call.function.name);
}
if !call.function.arguments.is_empty() {
self.tool_buffer
.function
.arguments
.push_str(&call.function.arguments);
}
if !call.id.is_empty() {
self.tool_buffer.id.clone_from(&call.id);
}
if !call.call_type.is_empty() {
self.tool_buffer.call_type.clone_from(&call.call_type);
}
}
}
} else {
self.push_tool_call();
self.results.push(Ok(StreamResponse {
choices: vec![StreamChoice {
delta: StreamDelta {
content,
reasoning_content,
tool_calls,
},
}],
usage: None,
}));
}
}
}
}
}
fn consume_bytes(&mut self, bytes: &[u8]) -> Vec<Result<StreamResponse, LLMError>> {
self.event_buffer.extend_from_slice(bytes);
while let Some((pos, delimiter_len)) = find_sse_event_boundary(&self.event_buffer) {
let event_bytes = self.event_buffer[..pos].to_vec();
self.event_buffer.drain(..pos + delimiter_len);
self.event_buffer = event_bytes;
self.parse_event();
self.event_buffer.clear();
}
self.results.drain(..).collect::<Vec<_>>()
}
#[cfg(test)]
fn consume_text(&mut self, text: &str) -> Vec<Result<StreamResponse, LLMError>> {
self.consume_bytes(text.as_bytes())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn create_sse_stream(
response: reqwest::Response,
normalize_response: bool,
) -> std::pin::Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>> {
let bytes_stream = response.bytes_stream();
let stream = bytes_stream
.scan(SSEStreamParser::new(normalize_response), |parser, chunk| {
let results = match chunk {
Ok(bytes) => parser.consume_bytes(&bytes),
Err(e) => vec![Err(LLMError::HttpError(e.to_string()))],
};
futures::future::ready(Some(results))
})
.flat_map(futures::stream::iter);
Box::pin(stream)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::chat::FunctionTool;
use futures::StreamExt;
use httpmock::{Method::POST, MockServer};
use serde_json::json;
struct TestConfig;
impl OpenAIProviderConfig for TestConfig {
const PROVIDER_NAME: &'static str = "Test";
const DEFAULT_BASE_URL: &'static str = "https://example.com/v1/";
const DEFAULT_MODEL: &'static str = "test-model";
}
struct FullSupportConfig;
impl OpenAIProviderConfig for FullSupportConfig {
const PROVIDER_NAME: &'static str = "FullTest";
const DEFAULT_BASE_URL: &'static str = "https://example.com/v1/";
const DEFAULT_MODEL: &'static str = "full-model";
const SUPPORTS_REASONING_EFFORT: bool = true;
const SUPPORTS_STRUCTURED_OUTPUT: bool = true;
const SUPPORTS_PARALLEL_TOOL_CALLS: bool = true;
const SUPPORTS_STREAM_OPTIONS: bool = true;
fn custom_headers() -> Option<Vec<(String, String)>> {
Some(vec![("x-provider".to_string(), "enabled".to_string())])
}
}
fn sample_function_tool() -> Tool {
Tool {
tool_type: "function".to_string(),
function: FunctionTool {
name: "lookup".to_string(),
description: "Lookup data".to_string(),
parameters: json!({
"type": "object",
"properties": {
"q": { "type": "string" }
},
"required": ["q"]
}),
},
}
}
fn sample_schema() -> StructuredOutputFormat {
StructuredOutputFormat {
name: "Answer".to_string(),
description: Some("Structured answer".to_string()),
schema: Some(json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"]
})),
strict: Some(true),
}
}
fn full_support_provider(base_url: String) -> OpenAICompatibleProvider<FullSupportConfig> {
OpenAICompatibleProvider::<FullSupportConfig>::new(
"key",
Some(base_url),
Some("full-model".to_string()),
Some(128),
Some(0.2),
Some(5),
Some(0.9),
Some(10),
Some(ToolChoice::Auto),
Some("high".to_string()),
None,
Some(json!({"seed": 7})),
Some(true),
Some(false),
None,
None,
)
}
#[test]
fn test_parse_openai_stream_text_delta() {
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::Text(text) => assert_eq!(text, "Hello"),
_ => panic!("Expected Text chunk, got {:?}", results[0]),
}
}
#[test]
fn test_parse_openai_stream_reasoning_delta() {
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"think"},"finish_reason":null}]}"#;
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::ReasoningContent(text) => assert_eq!(text, "think"),
_ => panic!("Expected ReasoningContent chunk, got {:?}", results[0]),
}
}
#[test]
fn test_parse_openai_stream_tool_call_start() {
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::ToolUseStart { index, id, name } => {
assert_eq!(*index, 0);
assert_eq!(id, "call_abc123");
assert_eq!(name, "get_weather");
}
_ => panic!("Expected ToolUseStart chunk, got {:?}", results[0]),
}
assert!(tool_states.contains_key(&0));
assert_eq!(tool_states[&0].id, "call_abc123");
assert_eq!(tool_states[&0].name, "get_weather");
assert!(tool_states[&0].started);
}
#[test]
fn test_parse_openai_stream_tool_call_arguments_delta() {
let mut tool_states = HashMap::default();
tool_states.insert(
0,
OpenAIToolUseState {
id: "call_abc123".to_string(),
name: "get_weather".to_string(),
arguments_buffer: String::default(),
started: true,
},
);
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\":"}}]},"finish_reason":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::ToolUseInputDelta {
index,
partial_json,
} => {
assert_eq!(*index, 0);
assert_eq!(partial_json, "{\"location\":");
}
_ => panic!("Expected ToolUseInputDelta chunk, got {:?}", results[0]),
}
assert_eq!(tool_states[&0].arguments_buffer, "{\"location\":");
}
#[test]
fn test_parse_openai_stream_finish_reason_tool_calls() {
let mut tool_states = HashMap::new();
tool_states.insert(
0,
OpenAIToolUseState {
id: "call_abc123".to_string(),
name: "get_weather".to_string(),
arguments_buffer: r#"{"location": "Paris"}"#.to_string(),
started: true,
},
);
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 2);
match &results[0] {
ChatStreamChunk::ToolUseComplete { index, tool_call } => {
assert_eq!(*index, 0);
assert_eq!(tool_call.id, "call_abc123");
assert_eq!(tool_call.function.name, "get_weather");
assert_eq!(tool_call.function.arguments, r#"{"location": "Paris"}"#);
}
_ => panic!("Expected ToolUseComplete chunk, got {:?}", results[0]),
}
match &results[1] {
ChatStreamChunk::Done { stop_reason } => {
assert_eq!(stop_reason, "tool_use");
}
_ => panic!("Expected Done chunk, got {:?}", results[1]),
}
assert!(tool_states.is_empty());
}
#[test]
fn test_parse_openai_stream_finish_reason_stop() {
let event = r#"data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::Done { stop_reason } => {
assert_eq!(stop_reason, "end_turn");
}
_ => panic!("Expected Done chunk, got {:?}", results[0]),
}
}
#[test]
fn test_parse_openai_stream_done_marker() {
let event = "data: [DONE]";
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 1);
match &results[0] {
ChatStreamChunk::Done { stop_reason } => {
assert_eq!(stop_reason, "end_turn");
}
_ => panic!("Expected Done chunk, got {:?}", results[0]),
}
}
#[test]
fn test_parse_openai_stream_done_marker_with_pending_tool() {
let mut tool_states = HashMap::new();
tool_states.insert(
0,
OpenAIToolUseState {
id: "call_xyz".to_string(),
name: "some_function".to_string(),
arguments_buffer: "{}".to_string(),
started: true,
},
);
let event = "data: [DONE]";
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 2);
assert!(matches!(
&results[0],
ChatStreamChunk::ToolUseComplete { .. }
));
assert!(matches!(&results[1], ChatStreamChunk::Done { .. }));
}
#[test]
fn test_parse_openai_stream_full_tool_sequence() {
let mut tool_states = HashMap::new();
let start_event = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(start_event, &mut tool_states).unwrap();
assert!(
matches!(&results[0], ChatStreamChunk::ToolUseStart { name, .. } if name == "get_weather")
);
let delta1 = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"loc"}}]},"finish_reason":null}]}"#;
let _ = parse_openai_sse_chunk_with_tools(delta1, &mut tool_states).unwrap();
let delta2 = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ation\":\"Tokyo\"}"}}]},"finish_reason":null}]}"#;
let _ = parse_openai_sse_chunk_with_tools(delta2, &mut tool_states).unwrap();
assert_eq!(tool_states[&0].arguments_buffer, "{\"location\":\"Tokyo\"}");
let finish_event = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
let results = parse_openai_sse_chunk_with_tools(finish_event, &mut tool_states).unwrap();
assert_eq!(results.len(), 2);
match &results[0] {
ChatStreamChunk::ToolUseComplete { tool_call, .. } => {
assert_eq!(tool_call.function.arguments, "{\"location\":\"Tokyo\"}");
}
_ => panic!("Expected ToolUseComplete"),
}
assert!(matches!(
&results[1],
ChatStreamChunk::Done { stop_reason } if stop_reason == "tool_use"
));
}
#[test]
fn test_parse_openai_stream_parallel_tool_calls() {
let mut tool_states = HashMap::new();
let event = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}},{"index":1,"id":"call_2","type":"function","function":{"name":"get_time","arguments":""}}]},"finish_reason":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert_eq!(results.len(), 2);
assert!(
matches!(&results[0], ChatStreamChunk::ToolUseStart { index: 0, name, .. } if name == "get_weather")
);
assert!(
matches!(&results[1], ChatStreamChunk::ToolUseStart { index: 1, name, .. } if name == "get_time")
);
assert!(tool_states.contains_key(&0));
assert!(tool_states.contains_key(&1));
}
#[test]
fn test_parse_openai_stream_ignores_empty_content() {
let event = r#"data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
let mut tool_states = HashMap::new();
let results = parse_openai_sse_chunk_with_tools(event, &mut tool_states).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_parse_vllm_stream_tool_calls() {
let mut tool_states = HashMap::new();
let first_chunk = r#"data: {"id":"chatcmpl-be8d6d925ff14741","object":"chat.completion.chunk","created":1765374283,"model":"Qwen/Qwen2.5-Coder-7B-Instruct-AWQ","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null},"logprobs":null,"finish_reason":null}],"prompt_token_ids":null}"#;
let results = parse_openai_sse_chunk_with_tools(first_chunk, &mut tool_states).unwrap();
assert!(results.is_empty(), "First chunk should produce no results");
let tool_start = r#"data: {"id":"chatcmpl-be8d6d925ff14741","object":"chat.completion.chunk","created":1765374283,"model":"Qwen/Qwen2.5-Coder-7B-Instruct-AWQ","choices":[{"index":0,"delta":{"reasoning_content":null,"tool_calls":[{"id":"chatcmpl-tool-a331788bab1045a8","type":"function","index":0,"function":{"name":"db_list_databases","arguments":"{\"catalog\":"}}]},"logprobs":null,"finish_reason":null,"token_ids":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(tool_start, &mut tool_states).unwrap();
assert!(
!results.is_empty(),
"Expected at least 1 result, got {:?}",
results
);
assert!(
matches!(&results[0], ChatStreamChunk::ToolUseStart { name, .. } if name == "db_list_databases"),
"Expected ToolUseStart, got {:?}",
results[0]
);
let args_delta = r#"data: {"id":"chatcmpl-be8d6d925ff14741","object":"chat.completion.chunk","created":1765374283,"model":"Qwen/Qwen2.5-Coder-7B-Instruct-AWQ","choices":[{"index":0,"delta":{"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"\"default\"}"}}]},"logprobs":null,"finish_reason":null,"token_ids":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(args_delta, &mut tool_states).unwrap();
assert!(
matches!(&results[0], ChatStreamChunk::ToolUseInputDelta { partial_json, .. } if partial_json == "\"default\"}"),
"Expected ToolUseInputDelta, got {:?}",
results
);
let finish = r#"data: {"id":"chatcmpl-be8d6d925ff14741","object":"chat.completion.chunk","created":1765374283,"model":"Qwen/Qwen2.5-Coder-7B-Instruct-AWQ","choices":[{"index":0,"delta":{"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":""}}]},"logprobs":null,"finish_reason":"stop","stop_reason":null,"token_ids":null}]}"#;
let results = parse_openai_sse_chunk_with_tools(finish, &mut tool_states).unwrap();
assert!(
results.len() >= 2,
"Expected ToolUseComplete and Done, got {:?}",
results
);
assert!(
matches!(&results[0], ChatStreamChunk::ToolUseComplete { tool_call, .. } if tool_call.function.name == "db_list_databases"),
"Expected ToolUseComplete, got {:?}",
results[0]
);
}
#[test]
fn test_sse_stream_parser_preserves_split_utf8_content() {
let mut parser = SSEStreamParser::new(false);
let event = b"data: {\"choices\":[{\"delta\":{\"content\":\"Hi \xF0\x9F\x98\x80\"}}]}\n\n";
let first = parser.consume_bytes(&event[..43]);
assert!(first.is_empty());
let second = parser.consume_bytes(&event[43..]);
assert_eq!(second.len(), 1);
match &second[0] {
Ok(StreamResponse { choices, .. }) => {
assert_eq!(choices[0].delta.content.as_deref(), Some("Hi 😀"));
}
other => panic!("Expected content delta, got {other:?}"),
}
}
#[test]
fn test_sse_stream_parser_handles_crlf_event_boundaries() {
let mut parser = SSEStreamParser::new(false);
let results = parser
.consume_bytes(b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\r\n\r\n");
assert_eq!(results.len(), 1);
match &results[0] {
Ok(StreamResponse { choices, .. }) => {
assert_eq!(choices[0].delta.content.as_deref(), Some("hello"));
}
other => panic!("Expected content delta, got {other:?}"),
}
}
#[test]
fn test_response_format_adds_additional_properties() {
let format = StructuredOutputFormat {
name: "Test".to_string(),
description: None,
schema: Some(serde_json::json!({
"type": "object",
"properties": {
"foo": { "type": "string" }
},
"required": ["foo"]
})),
strict: Some(true),
};
let response_format: OpenAIResponseFormat = format.into();
let schema = response_format.json_schema.unwrap().schema.unwrap();
assert_eq!(
schema.get("additionalProperties"),
Some(&serde_json::json!(false))
);
}
#[test]
fn test_response_format_preserves_additional_properties() {
let format = StructuredOutputFormat {
name: "Test".to_string(),
description: None,
schema: Some(serde_json::json!({
"type": "object",
"additionalProperties": true,
"properties": {
"foo": { "type": "string" }
}
})),
strict: None,
};
let response_format: OpenAIResponseFormat = format.into();
let schema = response_format.json_schema.unwrap().schema.unwrap();
assert_eq!(
schema.get("additionalProperties"),
Some(&serde_json::json!(true))
);
}
#[test]
fn test_prepare_messages_expands_tool_results() {
let tool_calls = vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
}];
let messages = vec![ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolResult(tool_calls.clone()),
content: "tool result".to_string(),
}];
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"key", None, None, None, None, None, None, None, None, None, None, None, None, None,
None, None,
);
let prepared = provider
.prepare_messages(&messages)
.expect("tool result messages should prepare");
assert_eq!(prepared.len(), 1);
assert_eq!(prepared[0].role, "tool");
assert_eq!(prepared[0].tool_call_id.as_deref(), Some("call_1"));
match &prepared[0].content {
Some(Right(text)) => assert_eq!(text, "{\"q\":\"value\"}"),
other => panic!("Unexpected content: {other:?}"),
}
assert!(prepared[0].tool_calls.is_none());
}
#[test]
fn test_chat_message_to_openai_message_image_url() {
let msg = ChatMessage {
role: ChatRole::User,
message_type: MessageType::ImageURL("https://example.com/image.png".to_string()),
content: "describe".to_string(),
};
let openai_msg = chat_message_to_openai_message(msg).expect("image URL should convert");
assert_eq!(openai_msg.role, "user");
match openai_msg.content.unwrap() {
Left(parts) => {
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].message_type, Some("image_url"));
assert!(parts[0].text.is_none());
assert_eq!(
parts[0].image_url.as_ref().unwrap().url,
"https://example.com/image.png"
);
}
Right(_) => panic!("Expected multipart content"),
}
}
#[test]
fn test_chat_message_to_openai_message_tool_use() {
let msg = ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolUse(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
}]),
content: "call tool".to_string(),
};
let openai_msg = chat_message_to_openai_message(msg).expect("tool use should convert");
assert!(openai_msg.content.is_none());
assert!(openai_msg.tool_calls.is_some());
let calls = openai_msg.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "lookup");
}
#[test]
fn test_chat_message_to_openai_message_image_base64() {
use crate::chat::ImageMime;
let msg = ChatMessage {
role: ChatRole::User,
message_type: MessageType::Image((ImageMime::PNG, vec![1, 2, 3, 4])),
content: "caption".to_string(),
};
let openai_msg = chat_message_to_openai_message(msg).expect("image should convert");
let content = openai_msg.content.unwrap();
match content {
Left(parts) => {
assert_eq!(parts.len(), 1);
let url = parts[0].image_url.as_ref().unwrap().url.clone();
assert!(url.starts_with("data:image/png;base64,"));
}
Right(_) => panic!("Expected multipart content"),
}
}
#[test]
fn test_chat_message_to_openai_message_tool_use_and_result() {
let tool_call = ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
};
let tool_use_msg = ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolUse(vec![tool_call.clone()]),
content: "call".to_string(),
};
let openai_msg =
chat_message_to_openai_message(tool_use_msg).expect("tool use should convert");
assert!(openai_msg.content.is_none());
let calls = openai_msg.tool_calls.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "lookup");
let tool_result_msg = ChatMessage {
role: ChatRole::Tool,
message_type: MessageType::ToolResult(vec![tool_call]),
content: "result".to_string(),
};
let openai_msg =
chat_message_to_openai_message(tool_result_msg).expect("tool result should convert");
assert!(openai_msg.content.is_none());
assert!(openai_msg.tool_calls.is_none());
}
#[test]
fn test_prepare_messages_rejects_pdf() {
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"key", None, None, None, None, None, None, None, None, None, None, None, None, None,
None, None,
);
let messages = vec![ChatMessage {
role: ChatRole::User,
message_type: MessageType::Pdf(vec![1, 2, 3]),
content: "doc".to_string(),
}];
let err = provider
.prepare_messages(&messages)
.expect_err("PDF input should be rejected");
assert!(matches!(
err,
LLMError::InvalidRequest { message, .. }
if message == "PDF input is not supported by OpenAI-compatible chat completions backends"
));
}
#[test]
fn test_openai_response_format_inserts_additional_properties() {
let structured = StructuredOutputFormat {
name: "TestSchema".to_string(),
description: Some("desc".to_string()),
schema: Some(serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
})),
strict: Some(true),
};
let response_format: OpenAIResponseFormat = structured.into();
assert!(matches!(
response_format.response_type,
OpenAIResponseType::JsonSchema
));
let schema = response_format.json_schema.unwrap().schema.unwrap();
assert_eq!(
schema.get("additionalProperties"),
Some(&serde_json::json!(false))
);
}
#[test]
fn test_provider_new_base_url_and_extra_body() {
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"key",
Some("https://example.com/api".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(serde_json::json!("not-an-object")),
None,
None,
None,
None,
);
assert_eq!(provider.base_url.as_str(), "https://example.com/api/");
assert!(provider.extra_body.is_empty());
}
#[tokio::test]
async fn test_missing_api_key_returns_error() {
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"", None, None, None, None, None, None, None, None, None, None, None, None, None, None,
None,
);
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider.chat(&messages, None).await.unwrap_err();
assert!(matches!(err, LLMError::AuthError { .. }));
}
#[tokio::test]
async fn test_missing_api_key_stream_returns_error() {
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"", None, None, None, None, None, None, None, None, None, None, None, None, None, None,
None,
);
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider
.chat_stream(&messages, None)
.await
.err()
.expect("expected auth error");
assert!(matches!(err, LLMError::AuthError { .. }));
}
#[tokio::test]
async fn test_missing_api_key_stream_with_tools_returns_error() {
let provider = OpenAICompatibleProvider::<TestConfig>::new(
"", None, None, None, None, None, None, None, None, None, None, None, None, None, None,
None,
);
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider
.chat_stream_with_tools(&messages, None, None)
.await
.err()
.expect("expected auth error");
assert!(matches!(err, LLMError::AuthError { .. }));
}
#[test]
fn test_openai_chat_response_helpers() {
let response = OpenAIChatResponse {
choices: vec![OpenAIChatChoice {
message: OpenAIChatMsg {
role: "assistant".to_string(),
content: Some("hi".to_string()),
reasoning_content: Some("plan".to_string()),
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
}]),
},
}],
usage: Some(Usage {
prompt_tokens: 1,
completion_tokens: 2,
total_tokens: 3,
prompt_tokens_details: None,
completion_tokens_details: None,
}),
};
assert_eq!(response.text(), Some("hi".to_string()));
assert_eq!(response.thinking(), Some("plan".to_string()));
assert_eq!(response.tool_calls().unwrap().len(), 1);
assert_eq!(response.usage().unwrap().total_tokens, 3);
let display = format!("{response}");
assert!(display.contains("lookup"));
assert!(display.contains("hi"));
}
#[test]
fn test_sse_stream_parser_emits_content() {
let mut parser = SSEStreamParser::new(false);
let results =
parser.consume_text("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n");
assert_eq!(results.len(), 1);
let response = results[0].as_ref().unwrap();
assert_eq!(response.choices.len(), 1);
assert_eq!(response.choices[0].delta.content.as_deref(), Some("Hello"));
}
#[test]
fn test_sse_stream_parser_emits_reasoning_content() {
let mut parser = SSEStreamParser::new(false);
let results = parser.consume_text(
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n",
);
assert_eq!(results.len(), 1);
let response = results[0].as_ref().unwrap();
assert_eq!(response.choices.len(), 1);
assert_eq!(
response.choices[0].delta.reasoning_content.as_deref(),
Some("think")
);
}
#[test]
fn test_sse_stream_parser_emits_usage_on_done() {
let mut parser = SSEStreamParser::new(false);
let usage_event = "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n";
let results = parser.consume_text(usage_event);
assert!(results.is_empty());
let done_results = parser.consume_text("data: [DONE]\n\n");
assert_eq!(done_results.len(), 1);
let response = done_results[0].as_ref().unwrap();
assert_eq!(response.usage.as_ref().unwrap().total_tokens, 3);
}
#[test]
fn test_sse_stream_parser_normalizes_tool_calls() {
let mut parser = SSEStreamParser::new(true);
let tool_event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":1}\"}}]}}]}\n\n";
let results = parser.consume_text(tool_event);
assert!(results.is_empty());
let done_results = parser.consume_text("data: [DONE]\n\n");
assert_eq!(done_results.len(), 1);
let response = done_results[0].as_ref().unwrap();
let calls = response.choices[0].delta.tool_calls.as_ref().unwrap();
assert_eq!(calls[0].function.name, "lookup");
assert_eq!(calls[0].function.arguments, "{\"q\":1}");
assert_eq!(calls[0].id, "call_1");
}
#[tokio::test]
async fn test_chat_with_tools_sends_supported_fields_and_decodes_response() {
let server = MockServer::start();
let response_mock = server.mock(|when, then| {
when.method(POST)
.path("/v1/chat/completions")
.header("authorization", "Bearer key")
.header("x-provider", "enabled")
.body_includes("\"reasoning_effort\":\"high\"")
.body_includes("\"parallel_tool_calls\":true")
.body_includes("\"tool_choice\":\"auto\"")
.body_includes("\"response_format\"")
.body_includes("\"seed\":7");
then.status(200).json_body(json!({
"choices": [{
"message": {
"role": "assistant",
"content": "hello from mock"
}
}],
"usage": {
"prompt_tokens": 2,
"completion_tokens": 3,
"total_tokens": 5
}
}));
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let messages = vec![ChatMessage::user().content("hello").build()];
let tools = vec![sample_function_tool()];
let response = provider
.chat_with_tools(&messages, Some(&tools), Some(sample_schema()))
.await
.expect("chat_with_tools should succeed");
assert_eq!(response.text().as_deref(), Some("hello from mock"));
assert_eq!(response.usage().map(|usage| usage.total_tokens), Some(5));
response_mock.assert();
}
#[tokio::test]
async fn test_chat_with_tools_returns_error_for_status_and_invalid_json() {
let server = MockServer::start();
let status_mock = server.mock(|when, then| {
when.method(POST).path("/v1/chat/completions");
then.status(500).body("provider exploded");
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider
.chat_with_tools(&messages, None, None)
.await
.expect_err("non-success status should fail");
match err {
LLMError::HttpStatusError {
status_code,
response_body,
..
} => {
assert_eq!(status_code, 500);
assert_eq!(response_body.as_ref(), "provider exploded");
}
other => panic!("unexpected error: {other:?}"),
}
status_mock.assert();
let server = MockServer::start();
let invalid_json_mock = server.mock(|when, then| {
when.method(POST).path("/v1/chat/completions");
then.status(200).body("not-json");
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let err = provider
.chat_with_tools(&messages, None, None)
.await
.expect_err("invalid json should fail");
match err {
LLMError::ResponseFormatError {
message,
raw_response,
} => {
assert!(message.contains("Failed to decode"));
assert_eq!(raw_response, "not-json");
}
other => panic!("unexpected error: {other:?}"),
}
invalid_json_mock.assert();
}
#[tokio::test]
async fn test_chat_stream_struct_and_chat_stream_with_tools_parse_mocked_sse() {
let server = MockServer::start();
let struct_mock = server.mock(|when, then| {
when.method(POST)
.path("/v1/chat/completions")
.body_includes("\"stream\":true")
.body_includes("\"include_usage\":true");
then.status(200)
.header("content-type", "text/event-stream")
.body(
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n\
data: [DONE]\n\n",
);
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let messages = vec![ChatMessage::user().content("stream").build()];
let mut stream = provider
.chat_stream_struct(&messages, None, None)
.await
.expect("structured stream should build");
let first = stream
.next()
.await
.expect("first item should exist")
.expect("first item should be ok");
assert_eq!(first.choices[0].delta.content.as_deref(), Some("hello"));
let rest: Vec<_> = stream.collect().await;
assert!(rest.into_iter().all(|item| item.is_ok()));
struct_mock.assert();
let server = MockServer::start();
let tool_mock = server.mock(|when, then| {
when.method(POST)
.path("/v1/chat/completions")
.body_includes("\"stream\":true")
.body_includes("\"tools\":[");
then.status(200)
.header("content-type", "text/event-stream")
.body(
"data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"q\\\":\\\"value\\\"}\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
);
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let tools = vec![sample_function_tool()];
let mut stream = provider
.chat_stream_with_tools(&messages, Some(&tools), None)
.await
.expect("tool stream should build");
let items = [
stream.next().await.expect("tool start"),
stream.next().await.expect("tool delta"),
stream.next().await.expect("tool complete"),
stream.next().await.expect("done"),
];
assert!(matches!(
&items[0],
Ok(ChatStreamChunk::ToolUseStart { id, name, .. }) if id == "call_1" && name == "lookup"
));
assert!(matches!(
&items[1],
Ok(ChatStreamChunk::ToolUseInputDelta { partial_json, .. }) if partial_json == "{\"q\":\"value\"}"
));
assert!(matches!(
&items[2],
Ok(ChatStreamChunk::ToolUseComplete { tool_call, .. })
if tool_call.function.arguments == "{\"q\":\"value\"}"
));
assert!(matches!(
&items[3],
Ok(ChatStreamChunk::Done { stop_reason }) if stop_reason == "tool_use"
));
tool_mock.assert();
}
#[tokio::test]
async fn test_429_maps_to_rate_limit_error() {
let server = MockServer::start();
let rate_mock = server.mock(|when, then| {
when.method(POST).path("/v1/chat/completions");
then.status(429)
.header("Retry-After", "15")
.body(r#"{"error":{"message":"rate limited","code":"rate_limit_exceeded"}}"#);
});
let provider = full_support_provider(format!("{}/v1", server.base_url()));
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider
.chat_with_tools(&messages, None, None)
.await
.expect_err("429 should fail");
match err {
LLMError::RateLimitError {
status_code,
message,
retry_after,
..
} => {
assert_eq!(status_code, 429);
assert_eq!(message, "rate limited");
assert_eq!(retry_after, Some(std::time::Duration::from_secs(15)));
}
other => panic!("unexpected error: {other:?}"),
}
rate_mock.assert();
}
#[tokio::test]
async fn test_streaming_request_times_out_when_body_is_slow() {
let server = MockServer::start();
let slow_stream_mock = server.mock(|when, then| {
when.method(POST).path("/v1/chat/completions");
then.status(200)
.header("content-type", "text/event-stream")
.delay(std::time::Duration::from_secs(3))
.body("data: {\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n");
});
let provider = OpenAICompatibleProvider::<FullSupportConfig>::new(
"key",
Some(format!("{}/v1", server.base_url())),
Some("full-model".to_string()),
Some(128),
Some(0.2),
Some(1),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
);
let messages = vec![ChatMessage::user().content("stream").build()];
let err = match provider.chat_stream_struct(&messages, None, None).await {
Err(err) => err,
Ok(_) => panic!("slow stream should time out during setup/read"),
};
assert!(matches!(err, LLMError::HttpError(_)));
assert!(err.is_transport_retryable());
slow_stream_mock.assert();
}
#[tokio::test]
async fn test_chat_times_out_when_response_exceeds_limit() {
let server = MockServer::start();
let slow_mock = server.mock(|when, then| {
when.method(POST).path("/v1/chat/completions");
then.status(200).delay(std::time::Duration::from_secs(3)).body(
r#"{"choices":[{"message":{"role":"assistant","content":"late"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#,
);
});
let provider = OpenAICompatibleProvider::<FullSupportConfig>::new(
"key",
Some(format!("{}/v1", server.base_url())),
Some("full-model".to_string()),
Some(128),
Some(0.2),
Some(1),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
);
let messages = vec![ChatMessage::user().content("hello").build()];
let err = provider
.chat_with_tools(&messages, None, None)
.await
.expect_err("slow response should time out");
assert!(matches!(err, LLMError::HttpError(_)));
assert!(err.is_transport_retryable());
slow_mock.assert();
}
}