use crate::BlockAssembler;
use crate::error::LlmError;
use crate::types::{LlmClient, LlmDoneOutcome, LlmEvent, LlmRequest, LlmStream};
use async_trait::async_trait;
use futures::StreamExt;
use meerkat_core::schema::{CompiledSchema, SchemaError};
use meerkat_core::{
AssistantBlock, ContentBlock, ImageData, Message, OutputSchema, ProviderMeta, StopReason, Usage,
};
use serde::Deserialize;
use serde_json::Value;
use serde_json::value::RawValue;
use std::collections::HashSet;
pub struct OpenAiClient {
api_key: Option<String>,
base_url: String,
http: reqwest::Client,
}
impl OpenAiClient {
pub(crate) const INTERNAL_SUPPORTS_TEMPERATURE: &str = "__meerkat_supports_temperature";
pub(crate) const INTERNAL_SUPPORTS_REASONING: &str = "__meerkat_supports_reasoning";
fn model_supports_temperature(model: &str) -> bool {
meerkat_models::profile::openai::supports_temperature(model)
}
fn model_supports_reasoning_payload(model: &str) -> bool {
meerkat_models::profile::openai::supports_reasoning(model)
}
fn request_supports_temperature(request: &LlmRequest) -> bool {
request
.provider_params
.as_ref()
.and_then(|params| params.get(Self::INTERNAL_SUPPORTS_TEMPERATURE))
.and_then(Value::as_bool)
.unwrap_or_else(|| Self::model_supports_temperature(&request.model))
}
fn request_supports_reasoning_payload(request: &LlmRequest) -> bool {
request
.provider_params
.as_ref()
.and_then(|params| params.get(Self::INTERNAL_SUPPORTS_REASONING))
.and_then(Value::as_bool)
.unwrap_or_else(|| Self::model_supports_reasoning_payload(&request.model))
}
pub fn new(api_key: String) -> Self {
Self::new_with_optional_api_key_and_base_url(
Some(api_key),
"https://api.openai.com".to_string(),
)
}
pub fn new_with_base_url(api_key: String, base_url: String) -> Self {
Self::new_with_optional_api_key_and_base_url(Some(api_key), base_url)
}
pub fn new_with_optional_api_key_and_base_url(
api_key: Option<String>,
base_url: String,
) -> Self {
let http =
crate::http::build_http_client_for_base_url(reqwest::Client::builder(), &base_url)
.unwrap_or_else(|_| reqwest::Client::new());
Self {
api_key,
base_url,
http,
}
}
pub fn with_base_url(mut self, url: String) -> Self {
if let Ok(http) =
crate::http::build_http_client_for_base_url(reqwest::Client::builder(), &url)
{
self.http = http;
}
self.base_url = url;
self
}
pub fn from_env() -> Result<Self, LlmError> {
let api_key = std::env::var("RKAT_OPENAI_API_KEY")
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.map_err(|_| LlmError::InvalidApiKey)?;
Ok(Self::new(api_key))
}
fn build_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
let input = Self::convert_to_responses_input(&request.messages)?;
let reasoning_enabled = Self::request_supports_reasoning_payload(request);
let mut body = serde_json::json!({
"model": request.model,
"input": input,
"max_output_tokens": request.max_tokens,
"stream": true,
});
if reasoning_enabled {
body["include"] = serde_json::json!(["reasoning.encrypted_content"]);
body["reasoning"] = serde_json::json!({
"effort": "medium",
"summary": "auto"
});
}
if Self::request_supports_temperature(request)
&& let Some(temp) = request.temperature
&& let Some(num) = serde_json::Number::from_f64(temp as f64)
{
body["temperature"] = Value::Number(num);
}
if !request.tools.is_empty() {
let tools: Vec<Value> = request
.tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.input_schema
})
})
.collect();
body["tools"] = Value::Array(tools);
}
if let Some(ref params) = request.provider_params
&& let Some(ws) = params.get("web_search")
&& ws.is_object()
{
match body.get_mut("tools").and_then(|v| v.as_array_mut()) {
Some(arr) => arr.push(ws.clone()),
None => body["tools"] = Value::Array(vec![ws.clone()]),
}
}
if let Some(params) = &request.provider_params {
if reasoning_enabled && let Some(reasoning_effort) = params.get("reasoning_effort") {
body["reasoning"]["effort"] = reasoning_effort.clone();
}
if let Some(seed) = params.get("seed") {
body["seed"] = seed.clone();
}
if let Some(frequency_penalty) = params.get("frequency_penalty") {
body["frequency_penalty"] = frequency_penalty.clone();
}
if let Some(presence_penalty) = params.get("presence_penalty") {
body["presence_penalty"] = presence_penalty.clone();
}
if let Some(structured) = params.get("structured_output") {
let output_schema: OutputSchema = serde_json::from_value(structured.clone())
.map_err(|e| LlmError::InvalidRequest {
message: format!("Invalid structured_output schema: {e}"),
})?;
let compiled =
self.compile_schema(&output_schema)
.map_err(|e| LlmError::InvalidRequest {
message: e.to_string(),
})?;
let name = output_schema.name.as_deref().unwrap_or("output");
let strict = output_schema.strict;
body["text"] = serde_json::json!({
"format": {
"type": "json_schema",
"name": name,
"schema": compiled.schema,
"strict": strict
}
});
}
}
Ok(body)
}
fn convert_to_responses_input(messages: &[Message]) -> Result<Vec<Value>, LlmError> {
let mut items = Vec::new();
for msg in messages {
match msg {
Message::System(s) => {
items.push(serde_json::json!({
"type": "message",
"role": "system",
"content": s.content
}));
}
Message::SystemNotice(notice) => {
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": notice.rendered_text()
}));
}
Message::User(u) => {
if meerkat_core::has_non_text_content(&u.content) {
let content_array: Vec<Value> = u
.content
.iter()
.map(|block| match block {
ContentBlock::Text { text } => serde_json::json!({
"type": "input_text",
"text": text
}),
ContentBlock::Image { media_type, data } => match data {
ImageData::Inline { data } => serde_json::json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
}),
ImageData::Blob { .. } => serde_json::json!({
"type": "input_text",
"text": block.text_projection()
}),
},
_ => serde_json::json!({
"type": "input_text",
"text": block.text_projection()
}),
})
.collect();
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": content_array
}));
} else {
items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": u.text_content()
}));
}
}
Message::Assistant(a) => {
if !a.content.is_empty() {
items.push(serde_json::json!({
"type": "message",
"role": "assistant",
"content": a.content
}));
}
for tc in &a.tool_calls {
items.push(serde_json::json!({
"type": "function_call",
"call_id": tc.id,
"name": tc.name,
"arguments": tc.args.to_string()
}));
}
}
Message::BlockAssistant(a) => {
for block in &a.blocks {
match block {
AssistantBlock::Text { text, .. } => {
if !text.is_empty() {
items.push(serde_json::json!({
"type": "message",
"role": "assistant",
"content": text
}));
}
}
AssistantBlock::ToolUse { id, name, args, .. } => {
items.push(serde_json::json!({
"type": "function_call",
"call_id": id,
"name": name,
"arguments": args.get() }));
}
_ => {}
}
}
}
Message::ToolResults { results } => {
for r in results {
if r.has_video() {
return Err(LlmError::InvalidRequest {
message: "video blocks are not supported in OpenAI tool results"
.to_string(),
});
}
items.push(serde_json::json!({
"type": "function_call_output",
"call_id": r.tool_use_id,
"output": r.text_content()
}));
}
}
}
}
Ok(items)
}
fn parse_responses_sse_line(line: &str) -> Option<ResponsesStreamEvent> {
if let Some(data) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
{
if data == "[DONE]" {
return None;
}
serde_json::from_str(data).ok()
} else {
None
}
}
}
fn ensure_additional_properties_false(value: &mut Value) {
match value {
Value::Object(obj) => {
let is_object_type = match obj.get("type") {
Some(Value::String(t)) => t == "object",
Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("object")),
_ => obj.contains_key("properties") || obj.contains_key("required"),
};
if is_object_type && !obj.contains_key("additionalProperties") {
obj.insert("additionalProperties".to_string(), Value::Bool(false));
}
for child in obj.values_mut() {
ensure_additional_properties_false(child);
}
}
Value::Array(items) => {
for item in items.iter_mut() {
ensure_additional_properties_false(item);
}
}
_ => {}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl LlmClient for OpenAiClient {
fn stream<'a>(&'a self, request: &'a LlmRequest) -> LlmStream<'a> {
let inner: LlmStream<'a> = Box::pin(async_stream::try_stream! {
let body = self.build_request_body(request)?;
let request_builder = self
.http
.post(format!("{}/v1/responses", self.base_url))
.header("Content-Type", "application/json");
let request_builder = if let Some(api_key) = &self.api_key {
request_builder.header("Authorization", format!("Bearer {api_key}"))
} else {
request_builder
};
let response = request_builder
.json(&body)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
LlmError::NetworkTimeout { duration_ms: 30000 }
} else {
#[cfg(not(target_arch = "wasm32"))]
if e.is_connect() {
return LlmError::ConnectionReset;
}
LlmError::Unknown { message: e.to_string() }
}
})?;
let status_code = response.status().as_u16();
let stream_result = if (200..=299).contains(&status_code) {
Ok(response.bytes_stream())
} else {
let headers = response.headers().clone();
let text = response.text().await.unwrap_or_default();
Err(LlmError::from_http_response(status_code, text, &headers))
};
let mut stream = stream_result?;
let mut buffer = String::with_capacity(512);
let mut assembler = BlockAssembler::new();
let mut usage = Usage::default();
let mut saw_stream_text_delta = false;
let mut streamed_tool_ids: HashSet<String> = HashSet::with_capacity(4);
let mut streamed_reasoning_ids: HashSet<String> = HashSet::with_capacity(2);
let mut done_emitted = false;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| LlmError::ConnectionReset)?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim();
let should_process = !line.is_empty() && !line.starts_with(':');
let parsed_event = if should_process {
Self::parse_responses_sse_line(line)
} else {
None
};
buffer.drain(..=newline_pos);
if let Some(event) = parsed_event {
if event.event_type == "response.completed" {
if done_emitted {
continue;
}
if let Some(response_obj) = &event.response {
if let Some(output) = response_obj.get("output").and_then(|o| o.as_array()) {
for item in output {
if let Some(item_type) = item.get("type").and_then(|t| t.as_str()) {
match item_type {
"message" => {
if let Some(content_parts) = item.get("content").and_then(|c| c.as_array()) {
for part in content_parts {
if let Some(part_type) = part.get("type").and_then(|t| t.as_str()) {
match part_type {
"output_text" => {
if let Some(text) = part.get("text").and_then(|t| t.as_str())
&& !saw_stream_text_delta
{
assembler.on_text_delta(text, None);
yield LlmEvent::TextDelta { delta: text.to_string(), meta: None };
}
}
"refusal" => {
if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str())
&& !saw_stream_text_delta
{
assembler.on_text_delta(refusal, None);
yield LlmEvent::TextDelta { delta: refusal.to_string(), meta: None };
}
}
_ => {}
}
}
}
}
}
"reasoning" => {
let Some(reasoning_id) = item.get("id").and_then(|i| i.as_str()) else {
tracing::warn!("reasoning item missing id, skipping");
continue;
};
if streamed_reasoning_ids.contains(reasoning_id) {
continue;
}
let mut summary_text = String::new();
if let Some(summaries) = item.get("summary").and_then(|s| s.as_array()) {
for summary in summaries {
if let Some(text) = summary.get("text").and_then(|t| t.as_str()) {
if !summary_text.is_empty() {
summary_text.push('\n');
}
summary_text.push_str(text);
}
}
}
let encrypted = item.get("encrypted_content")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let meta = Some(Box::new(ProviderMeta::OpenAi {
id: reasoning_id.to_string(),
encrypted_content: encrypted,
}));
assembler.on_reasoning_start();
if !summary_text.is_empty() {
let _ = assembler.on_reasoning_delta(&summary_text);
}
assembler.on_reasoning_complete(meta.clone());
yield LlmEvent::ReasoningComplete {
text: summary_text,
meta,
};
}
"function_call" => {
let Some(call_id) = item.get("call_id").and_then(|c| c.as_str()) else {
tracing::warn!("function_call missing call_id");
continue;
};
if streamed_tool_ids.contains(call_id) {
continue;
}
let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
tracing::warn!(call_id, "function_call missing name");
continue;
};
let args: Box<RawValue> = match item.get("arguments").and_then(|a| a.as_str()) {
Some(args_str) => match RawValue::from_string(args_str.to_string()) {
Ok(raw) => raw,
Err(e) => {
tracing::error!(call_id, "invalid args JSON, skipping: {e}");
continue;
}
},
None => {
fallback_raw_value()
}
};
let _ = assembler.on_tool_call_start(call_id.to_string());
let _ = assembler.on_tool_call_complete(
call_id.to_string(),
name.to_string(),
args.clone(),
None,
);
let args_value: Value = serde_json::from_str(args.get()).unwrap_or_default();
yield LlmEvent::ToolCallComplete {
id: call_id.to_string(),
name: name.to_string(),
args: args_value,
meta: None,
};
}
_ => {}
}
}
}
}
if let Some(usage_obj) = response_obj.get("usage") {
usage.input_tokens = usage_obj.get("input_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
usage.output_tokens = usage_obj.get("output_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
yield LlmEvent::UsageUpdate { usage: usage.clone() };
}
let stop_reason = match response_obj.get("status").and_then(|s| s.as_str()) {
Some("completed") => {
let has_tool_calls = response_obj.get("output")
.and_then(|o| o.as_array())
.is_some_and(|arr| arr.iter().any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call")));
if has_tool_calls {
StopReason::ToolUse
} else {
StopReason::EndTurn
}
}
Some("incomplete") => {
match response_obj.get("incomplete_details").and_then(|d| d.get("reason")).and_then(|r| r.as_str()) {
Some("max_output_tokens") => StopReason::MaxTokens,
Some("content_filter") => StopReason::ContentFilter,
_ => StopReason::EndTurn,
}
}
Some("cancelled") => StopReason::Cancelled,
_ => StopReason::EndTurn,
};
done_emitted = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason },
};
}
}
else if event.event_type == "response.output_text.delta" {
if let Some(delta) = &event.delta {
saw_stream_text_delta = true;
assembler.on_text_delta(delta, None);
yield LlmEvent::TextDelta { delta: delta.clone(), meta: None };
}
}
else if event.event_type == "response.reasoning_summary_text.delta" {
if let Some(delta) = &event.delta {
yield LlmEvent::ReasoningDelta { delta: delta.clone() };
}
}
else if event.event_type == "response.function_call_arguments.delta" {
if let (Some(call_id), Some(delta)) = (&event.call_id, &event.delta) {
let name = event.name.clone();
yield LlmEvent::ToolCallDelta {
id: call_id.clone(),
name,
args_delta: delta.clone(),
};
}
}
else if event.event_type == "response.function_call_arguments.done" {
if let (Some(call_id), Some(arguments)) = (&event.call_id, &event.arguments) {
let name = event.name.clone().unwrap_or_default();
let args: Box<RawValue> = RawValue::from_string(arguments.clone())
.unwrap_or_else(|_| fallback_raw_value());
let _ = assembler.on_tool_call_start(call_id.clone());
let _ = assembler.on_tool_call_complete(
call_id.clone(),
name.clone(),
args.clone(),
None,
);
let args_value: Value = serde_json::from_str(args.get()).unwrap_or_default();
streamed_tool_ids.insert(call_id.clone());
yield LlmEvent::ToolCallComplete {
id: call_id.clone(),
name,
args: args_value,
meta: None,
};
}
}
else if event.event_type == "response.reasoning_summary.done" || event.event_type == "response.reasoning.done" {
if let Some(item) = &event.item {
let Some(reasoning_id) = item.get("id")
.and_then(|i| i.as_str()) else {
tracing::warn!("reasoning item missing id, skipping");
continue;
};
let mut summary_text = String::new();
if let Some(summaries) = item.get("summary").and_then(|s| s.as_array()) {
for summary in summaries {
if let Some(text) = summary.get("text").and_then(|t| t.as_str()) {
if !summary_text.is_empty() {
summary_text.push('\n');
}
summary_text.push_str(text);
}
}
}
let encrypted = item.get("encrypted_content")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let meta = Some(Box::new(ProviderMeta::OpenAi {
id: reasoning_id.to_string(),
encrypted_content: encrypted,
}));
assembler.on_reasoning_start();
if !summary_text.is_empty() {
let _ = assembler.on_reasoning_delta(&summary_text);
}
assembler.on_reasoning_complete(meta.clone());
streamed_reasoning_ids.insert(reasoning_id.to_string());
yield LlmEvent::ReasoningComplete {
text: summary_text,
meta,
};
}
}
else if event.event_type == "response.done" {
if let Some(response_obj) = &event.response {
if let Some(usage_obj) = response_obj.get("usage") {
usage.input_tokens = usage_obj.get("input_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
usage.output_tokens = usage_obj.get("output_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
yield LlmEvent::UsageUpdate { usage: usage.clone() };
}
if !done_emitted {
let stop_reason = match response_obj.get("status").and_then(|s| s.as_str()) {
Some("completed") => {
let has_tool_calls = response_obj.get("output")
.and_then(|o| o.as_array())
.is_some_and(|arr| arr.iter().any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call")));
if has_tool_calls {
StopReason::ToolUse
} else {
StopReason::EndTurn
}
}
Some("incomplete") => {
match response_obj.get("incomplete_details").and_then(|d| d.get("reason")).and_then(|r| r.as_str()) {
Some("max_output_tokens") => StopReason::MaxTokens,
Some("content_filter") => StopReason::ContentFilter,
_ => StopReason::EndTurn,
}
}
Some("cancelled") => StopReason::Cancelled,
_ => StopReason::EndTurn,
};
done_emitted = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason },
};
}
}
}
else if event.event_type == "error" {
let error_msg = event.error
.as_ref()
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("unknown streaming error");
let error_code = event.error
.as_ref()
.and_then(|e| e.get("code"))
.and_then(|c| c.as_str())
.unwrap_or("unknown");
tracing::error!(
code = error_code,
message = error_msg,
"OpenAI streaming error"
);
let error = match error_code {
"rate_limit_exceeded" => LlmError::RateLimited { retry_after_ms: None },
"server_error" => LlmError::ServerError {
status: 500,
message: error_msg.to_string(),
},
"invalid_request_error" => LlmError::InvalidRequest {
message: error_msg.to_string(),
},
_ => LlmError::Unknown {
message: format!("{error_code}: {error_msg}"),
},
};
done_emitted = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Error { error },
};
}
}
}
}
});
crate::streaming::ensure_terminal_done(inner)
}
fn provider(&self) -> &'static str {
"openai"
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
let mut schema = output_schema.schema.as_value().clone();
if output_schema.strict {
ensure_additional_properties_false(&mut schema);
}
Ok(CompiledSchema {
schema,
warnings: Vec::new(),
})
}
}
#[derive(Debug, Deserialize)]
struct ResponsesStreamEvent {
#[serde(rename = "type")]
event_type: String,
delta: Option<String>,
call_id: Option<String>,
name: Option<String>,
arguments: Option<String>,
item: Option<Value>,
response: Option<Value>,
error: Option<Value>,
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
fn fallback_raw_value() -> Box<RawValue> {
RawValue::from_string("{}".to_string()).expect("static JSON is valid")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use axum::{Router, extract::State, response::IntoResponse, routing::post};
use meerkat_core::UserMessage;
use tokio::net::TcpListener;
async fn responses_sse(State(payload): State<String>) -> impl IntoResponse {
([("content-type", "text/event-stream")], payload)
}
async fn spawn_openai_stub_server(payload: String) -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new()
.route("/v1/responses", post(responses_sse))
.with_state(payload);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("local addr");
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve test server");
});
(format!("http://{addr}"), handle)
}
#[test]
fn test_request_uses_responses_api_endpoint_format() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("Hello".to_string()))],
);
let body = client.build_request_body(&request).expect("build request");
assert!(body.get("input").is_some(), "should have 'input' field");
assert!(
body.get("messages").is_none(),
"should NOT have 'messages' field"
);
let include = body.get("include").expect("should have include");
let include_arr = include.as_array().expect("include should be array");
assert!(
include_arr
.iter()
.any(|v| v.as_str() == Some("reasoning.encrypted_content")),
"should include reasoning.encrypted_content"
);
}
#[test]
fn test_request_input_format_system_message() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::System(meerkat_core::SystemMessage {
content: "You are helpful".to_string(),
}),
Message::User(UserMessage::text("Hello".to_string())),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "system");
assert_eq!(input[0]["content"], "You are helpful");
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["role"], "user");
assert_eq!(input[1]["content"], "Hello");
}
#[test]
fn test_request_input_format_degrades_video_user_content_to_text() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::with_blocks(vec![
ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 12_000,
data: meerkat_core::VideoData::Inline {
data: "AAAA".to_string(),
},
},
]))],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[0]["role"], "user");
let content = input[0]["content"]
.as_array()
.expect("content should be array");
assert_eq!(content[0]["type"], "input_text");
assert_eq!(content[0]["text"], "[video: video/mp4]");
}
#[test]
fn test_request_input_rejects_video_tool_results() {
let err = OpenAiClient::convert_to_responses_input(&[Message::ToolResults {
results: vec![meerkat_core::ToolResult::with_blocks(
"tool_1".to_string(),
vec![ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 12_000,
data: meerkat_core::VideoData::Inline {
data: "AAAA".to_string(),
},
}],
false,
)],
}])
.expect_err("video tool results should be rejected");
match err {
LlmError::InvalidRequest { message } => {
assert!(message.contains("video blocks are not supported"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn test_request_input_format_tool_call() {
let client = OpenAiClient::new("test-key".to_string());
let tool_args = serde_json::json!({"location": "Tokyo"});
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Weather?".to_string())),
Message::Assistant(meerkat_core::AssistantMessage {
content: String::new(),
tool_calls: vec![meerkat_core::ToolCall::new(
"call_abc123".to_string(),
"get_weather".to_string(),
tool_args,
)],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[1]["type"], "function_call");
assert_eq!(input[1]["call_id"], "call_abc123");
assert_eq!(input[1]["name"], "get_weather");
let args_str = input[1]["arguments"]
.as_str()
.expect("arguments should be string");
let parsed_args: Value = serde_json::from_str(args_str).expect("should be valid JSON");
assert_eq!(parsed_args["location"], "Tokyo");
}
#[test]
fn test_request_input_format_tool_result() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Weather?".to_string())),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::new(
"call_abc123".to_string(),
"Sunny, 25C".to_string(),
false,
)],
},
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[1]["type"], "function_call_output");
assert_eq!(input[1]["call_id"], "call_abc123");
assert_eq!(input[1]["output"], "Sunny, 25C");
}
#[test]
fn test_tool_definition_format() {
use meerkat_core::ToolDef;
use std::sync::Arc;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "get_weather".to_string(),
description: "Get weather info".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string"}
}
}),
provenance: None,
})]);
let body = client.build_request_body(&request).expect("build request");
let tools = body["tools"].as_array().expect("tools should be array");
assert_eq!(tools[0]["type"], "function");
assert_eq!(tools[0]["name"], "get_weather");
assert_eq!(tools[0]["description"], "Get weather info");
assert!(tools[0]["parameters"].is_object());
assert!(tools[0].get("function").is_none());
}
#[test]
fn test_request_includes_reasoning_config() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request).expect("build request");
let reasoning = body.get("reasoning").expect("should have reasoning");
assert_eq!(reasoning["effort"], "medium");
assert_eq!(reasoning["summary"], "auto");
}
#[test]
fn test_request_reasoning_effort_override() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("reasoning_effort", "high");
let body = client.build_request_body(&request).expect("build request");
assert_eq!(body["reasoning"]["effort"], "high");
}
#[test]
fn test_request_omits_reasoning_payload_for_non_gpt5_model() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request).expect("build request");
assert!(body.get("reasoning").is_none());
assert!(body.get("include").is_none());
}
#[test]
fn test_reasoning_effort_ignored_for_non_gpt5_model() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("reasoning_effort", "high");
let body = client.build_request_body(&request).expect("build request");
assert!(body.get("reasoning").is_none());
}
#[test]
fn test_request_respects_internal_capability_overrides_for_self_hosted_aliases() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemma4:e2b",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_temperature(0.3)
.with_provider_param(OpenAiClient::INTERNAL_SUPPORTS_TEMPERATURE, true)
.with_provider_param(OpenAiClient::INTERNAL_SUPPORTS_REASONING, true)
.with_provider_param("reasoning_effort", "high");
let body = client.build_request_body(&request).expect("build request");
let temperature = body["temperature"]
.as_f64()
.expect("temperature should be numeric");
assert!((temperature - 0.3).abs() < 1e-6);
assert_eq!(body["reasoning"]["effort"], "high");
assert_eq!(body["reasoning"]["summary"], "auto");
}
#[test]
fn test_request_input_format_block_assistant_text() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Text {
text: "Hi there!".to_string(),
meta: None,
}],
stop_reason: StopReason::EndTurn,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["role"], "assistant");
assert_eq!(input[1]["content"], "Hi there!");
}
#[test]
fn test_request_input_format_block_assistant_reasoning_with_output_skips_reasoning_replay() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "Let me think about this".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_abc123".to_string(),
encrypted_content: Some("encrypted_data".to_string()),
})),
},
AssistantBlock::Text {
text: "Here is my answer".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["role"], "assistant");
}
#[test]
fn test_request_input_format_block_assistant_tool_use() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let args = RawValue::from_string(r#"{"location":"Tokyo"}"#.to_string()).unwrap();
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Weather?".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::ToolUse {
id: "call_xyz".to_string(),
name: "get_weather".to_string(),
args,
meta: None,
}],
stop_reason: StopReason::ToolUse,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input[1]["type"], "function_call");
assert_eq!(input[1]["call_id"], "call_xyz");
assert_eq!(input[1]["name"], "get_weather");
let args_str = input[1]["arguments"]
.as_str()
.expect("arguments should be string");
assert_eq!(args_str, r#"{"location":"Tokyo"}"#);
}
#[test]
fn test_request_includes_seed_from_provider_params() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("seed", 12345);
let body = client.build_request_body(&request).expect("build request");
assert_eq!(body["seed"], 12345);
}
#[test]
fn test_request_includes_frequency_penalty_from_provider_params() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("frequency_penalty", 0.5);
let body = client.build_request_body(&request).expect("build request");
assert_eq!(body["frequency_penalty"], 0.5);
}
#[test]
fn test_request_includes_presence_penalty_from_provider_params() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("presence_penalty", 0.8);
let body = client.build_request_body(&request).expect("build request");
assert_eq!(body["presence_penalty"], 0.8);
}
#[test]
fn test_unknown_provider_params_are_ignored() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("unknown_param", "some_value")
.with_provider_param("another_unknown", 123)
.with_provider_param("seed", 42);
let body = client.build_request_body(&request).expect("build request");
assert!(body.get("unknown_param").is_none());
assert!(body.get("another_unknown").is_none());
assert_eq!(body["seed"], 42);
}
#[test]
fn test_request_omits_temperature_for_gpt5_family() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2-codex",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_temperature(0.2);
let body = client.build_request_body(&request).expect("build request");
assert!(
body.get("temperature").is_none(),
"gpt-5/codex requests should not include temperature"
);
}
#[test]
fn test_request_includes_temperature_for_supported_model() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_temperature(0.3);
let body = client.build_request_body(&request).expect("build request");
let temp = body["temperature"]
.as_f64()
.expect("temperature should be numeric");
assert!((temp - 0.3).abs() < 1e-6);
}
#[test]
fn test_multiple_provider_params_combined() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("reasoning_effort", "high")
.with_provider_param("seed", 999)
.with_provider_param("frequency_penalty", 0.3)
.with_provider_param("presence_penalty", 0.4);
let body = client.build_request_body(&request).expect("build request");
assert_eq!(body["reasoning"]["effort"], "high");
assert_eq!(body["seed"], 999);
assert_eq!(body["frequency_penalty"], 0.3);
assert_eq!(body["presence_penalty"], 0.4);
}
#[test]
fn test_tool_args_serialization_no_double_encoding() -> Result<(), Box<dyn std::error::Error>> {
let client = OpenAiClient::new("test-key".to_string());
let tool_args = serde_json::json!({"city": "Tokyo", "units": "celsius"});
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("What's the weather?".to_string())),
Message::Assistant(meerkat_core::AssistantMessage {
content: String::new(),
tool_calls: vec![meerkat_core::ToolCall::new(
"call_123".to_string(),
"get_weather".to_string(),
tool_args,
)],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().ok_or("not array")?;
let tool_call = input
.iter()
.find(|item| item["type"] == "function_call")
.ok_or("no tool call")?;
let arguments = tool_call["arguments"].as_str().ok_or("not string")?;
let parsed: serde_json::Value = serde_json::from_str(arguments)?;
assert_eq!(parsed["city"], "Tokyo");
assert_eq!(parsed["units"], "celsius");
assert!(
!arguments.starts_with(r"{\"),
"arguments should not be double-encoded: {arguments}"
);
Ok(())
}
#[test]
fn test_build_request_body_with_structured_output() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
});
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"name": "person",
"strict": true
}),
);
let body = client.build_request_body(&request).expect("build request");
let text = body.get("text").expect("should have text");
let format = text.get("format").expect("should have format");
assert_eq!(format["type"], "json_schema");
assert_eq!(format["name"], "person");
assert_eq!(format["strict"], true);
assert!(format["schema"].is_object());
}
#[test]
fn test_build_request_body_with_structured_output_defaults() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({"type": "object"});
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema
}),
);
let body = client.build_request_body(&request).expect("build request");
let format = &body["text"]["format"];
assert_eq!(format["name"], "output"); assert_eq!(format["strict"], false); }
#[test]
fn test_build_request_body_without_structured_output() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request).expect("build request");
assert!(
body.get("text").is_none(),
"text should not be present without structured_output"
);
}
#[test]
fn test_strict_structured_output_injects_additional_properties_recursively() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"profile": {
"type": "object",
"properties": {
"city": {"type": "string"}
}
},
"addresses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"street": {"type": "string"}
}
}
},
"choice": {
"anyOf": [
{
"type": "object",
"properties": {
"kind": {"type": "string"}
}
},
{"type": "string"}
]
}
},
"required": ["name", "profile", "addresses"]
});
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"name": "person",
"strict": true
}),
);
let body = client.build_request_body(&request).expect("build request");
let compiled = &body["text"]["format"]["schema"];
assert_eq!(compiled["additionalProperties"], false);
assert_eq!(
compiled["properties"]["profile"]["additionalProperties"],
false
);
assert_eq!(
compiled["properties"]["addresses"]["items"]["additionalProperties"],
false
);
assert_eq!(
compiled["properties"]["choice"]["anyOf"][0]["additionalProperties"],
false
);
}
#[test]
fn test_strict_structured_output_preserves_explicit_additional_properties() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"additionalProperties": true,
"properties": {
"nested": {
"type": "object",
"additionalProperties": {"type": "string"},
"properties": {
"x": {"type": "string"}
}
},
"auto": {
"type": "object",
"properties": {
"y": {"type": "integer"}
}
}
}
});
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"strict": true
}),
);
let body = client.build_request_body(&request).expect("build request");
let compiled = &body["text"]["format"]["schema"];
assert_eq!(compiled["additionalProperties"], true);
assert_eq!(
compiled["properties"]["nested"]["additionalProperties"],
serde_json::json!({"type": "string"})
);
assert_eq!(
compiled["properties"]["auto"]["additionalProperties"],
false
);
}
#[test]
fn test_non_strict_structured_output_does_not_inject_additional_properties() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"properties": {
"nested": {
"type": "object",
"properties": {
"x": {"type": "string"}
}
}
}
});
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"strict": false
}),
);
let body = client.build_request_body(&request).expect("build request");
let compiled = &body["text"]["format"]["schema"];
assert!(
compiled.get("additionalProperties").is_none(),
"root should not be modified in non-strict mode"
);
assert!(
compiled["properties"]["nested"]
.get("additionalProperties")
.is_none(),
"nested object should not be modified in non-strict mode"
);
}
#[test]
fn test_compile_schema_strict_handles_object_union_and_defs() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": ["object", "null"],
"$defs": {
"Meta": {
"type": "object",
"properties": {
"id": {"type": "string"}
}
}
},
"properties": {
"meta": {"$ref": "#/$defs/Meta"},
"items": {
"type": "array",
"items": {
"type": ["object", "null"],
"properties": {
"value": {"type": "number"}
}
}
}
}
});
let output_schema = OutputSchema::new(schema).expect("valid schema").strict();
let compiled = client
.compile_schema(&output_schema)
.expect("compile should succeed");
assert!(compiled.warnings.is_empty());
assert_eq!(compiled.schema["additionalProperties"], false);
assert_eq!(
compiled.schema["$defs"]["Meta"]["additionalProperties"],
false
);
assert_eq!(
compiled.schema["properties"]["items"]["items"]["additionalProperties"],
false
);
}
#[test]
fn test_compile_schema_strict_keeps_explicit_additional_properties_forms() {
let client = OpenAiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"additionalProperties": {"type": "integer"},
"properties": {
"a": {"type": "string"},
"b": {
"type": "object",
"additionalProperties": true,
"properties": {
"x": {"type": "string"}
}
}
}
});
let output_schema = OutputSchema::new(schema).expect("valid schema").strict();
let compiled = client
.compile_schema(&output_schema)
.expect("compile should succeed");
assert!(compiled.warnings.is_empty());
assert_eq!(
compiled.schema["additionalProperties"],
serde_json::json!({"type": "integer"})
);
assert_eq!(
compiled.schema["properties"]["b"]["additionalProperties"],
true
);
}
#[test]
fn test_parse_responses_sse_line_text_delta() {
let line = r#"data: {"type":"response.output_text.delta","delta":"Hello"}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "response.output_text.delta");
assert_eq!(event.delta, Some("Hello".to_string()));
}
#[test]
fn test_parse_responses_sse_line_reasoning_delta() {
let line =
r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking..."}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "response.reasoning_summary_text.delta");
assert_eq!(event.delta, Some("thinking...".to_string()));
}
#[test]
fn test_parse_responses_sse_line_function_call_done() {
let line = r#"data: {"type":"response.function_call_arguments.done","call_id":"call_123","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "response.function_call_arguments.done");
assert_eq!(event.call_id, Some("call_123".to_string()));
assert_eq!(event.name, Some("get_weather".to_string()));
assert_eq!(event.arguments, Some(r#"{"location":"Tokyo"}"#.to_string()));
}
#[test]
fn test_parse_responses_sse_line_response_done() {
let line = r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hi"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "response.done");
assert!(event.response.is_some());
let response = event.response.unwrap();
assert_eq!(response["status"], "completed");
}
#[test]
fn test_parse_responses_sse_line_done_marker() {
let line = "data: [DONE]";
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_none());
}
#[test]
fn test_parse_responses_sse_line_without_trailing_space() {
let line = r#"data:{"type":"response.output_text.delta","delta":"hello"}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
}
#[test]
fn test_parse_responses_sse_line_non_data_line() {
let line = "event: message";
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_none());
}
#[test]
fn test_parse_responses_sse_line_reasoning_item_with_encrypted() {
let line = r#"data: {"type":"response.reasoning.done","item":{"id":"rs_abc123","summary":[{"type":"summary_text","text":"I need to think"}],"encrypted_content":"enc_xyz"}}"#;
let event = OpenAiClient::parse_responses_sse_line(line);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "response.reasoning.done");
let item = event.item.expect("should have item");
assert_eq!(item["id"], "rs_abc123");
assert_eq!(item["encrypted_content"], "enc_xyz");
let summary = item["summary"].as_array().expect("summary array");
assert_eq!(summary[0]["text"], "I need to think");
}
#[tokio::test]
async fn test_stream_does_not_duplicate_text_when_completed_replays_output() {
let payload = [
r#"data: {"type":"response.output_text.delta","delta":"Hello"}"#,
r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
"data: [DONE]",
"",
]
.join("\n");
let (base_url, server) = spawn_openai_stub_server(payload).await;
let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
let request = LlmRequest::new(
"gpt-5-mini",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut deltas = Vec::new();
while let Some(event) = stream.next().await {
match event.expect("stream event") {
LlmEvent::TextDelta { delta, .. } => deltas.push(delta),
LlmEvent::Done { .. } => break,
_ => {}
}
}
server.abort();
assert_eq!(deltas, vec!["Hello"]);
}
#[test]
fn test_response_completed_parses_message_content() {
let response_json = serde_json::json!({
"status": "completed",
"output": [
{
"type": "message",
"content": [
{"type": "output_text", "text": "Hello"},
{"type": "output_text", "text": " World"}
]
}
],
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let output = response_json["output"].as_array().expect("output array");
assert_eq!(output[0]["type"], "message");
let content = output[0]["content"].as_array().expect("content array");
assert_eq!(content[0]["type"], "output_text");
assert_eq!(content[0]["text"], "Hello");
}
#[test]
fn test_response_completed_parses_reasoning_item() {
let response_json = serde_json::json!({
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_abc123",
"summary": [
{"type": "summary_text", "text": "Let me think about this"}
],
"encrypted_content": "encrypted_stuff_here"
}
],
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let output = response_json["output"].as_array().expect("output array");
let reasoning = &output[0];
assert_eq!(reasoning["type"], "reasoning");
assert_eq!(reasoning["id"], "rs_abc123");
assert_eq!(reasoning["encrypted_content"], "encrypted_stuff_here");
let summary = reasoning["summary"].as_array().expect("summary array");
assert_eq!(summary[0]["text"], "Let me think about this");
}
#[test]
fn test_response_completed_parses_function_call() {
let response_json = serde_json::json!({
"status": "completed",
"output": [
{
"type": "function_call",
"call_id": "call_xyz789",
"name": "get_weather",
"arguments": "{\"location\":\"Tokyo\"}"
}
],
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let output = response_json["output"].as_array().expect("output array");
let func_call = &output[0];
assert_eq!(func_call["type"], "function_call");
assert_eq!(func_call["call_id"], "call_xyz789");
assert_eq!(func_call["name"], "get_weather");
let args_str = func_call["arguments"].as_str().expect("string");
let args: Value = serde_json::from_str(args_str).expect("valid json");
assert_eq!(args["location"], "Tokyo");
}
#[test]
fn test_orphaned_reasoning_at_end_is_stripped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Reasoning {
text: "Let me think".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_orphan".to_string(),
encrypted_content: None,
})),
}],
stop_reason: StopReason::EndTurn,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "user");
}
#[test]
fn test_orphaned_reasoning_before_user_message_is_stripped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("First question".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Reasoning {
text: "Thinking...".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_mid".to_string(),
encrypted_content: None,
})),
}],
stop_reason: StopReason::EndTurn,
}),
Message::User(UserMessage::text("Second question".to_string())),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], "user");
assert_eq!(input[0]["content"], "First question");
assert_eq!(input[1]["role"], "user");
assert_eq!(input[1]["content"], "Second question");
}
#[test]
fn test_orphaned_reasoning_before_tool_result_is_stripped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Reasoning {
text: "Thinking...".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_before_tool".to_string(),
encrypted_content: None,
})),
}],
stop_reason: StopReason::EndTurn,
}),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::new(
"call_123".to_string(),
"result".to_string(),
false,
)],
},
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[1]["type"], "function_call_output");
}
#[test]
fn test_reasoning_followed_by_function_call_skips_reasoning_replay() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let args = RawValue::from_string(r#"{"q":"test"}"#.to_string()).unwrap();
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "I should search".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_valid".to_string(),
encrypted_content: Some("enc_valid".to_string()),
})),
},
AssistantBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
args,
meta: None,
},
],
stop_reason: StopReason::ToolUse,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[1]["type"], "function_call");
}
#[test]
fn test_non_openai_reasoning_blocks_are_skipped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "Anthropic thinking".to_string(),
meta: Some(Box::new(ProviderMeta::Anthropic {
signature: "sig_abc".to_string(),
})),
},
AssistantBlock::Text {
text: "Answer".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "user");
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["role"], "assistant");
}
#[test]
fn test_consecutive_orphaned_reasoning_items_all_stripped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Reasoning {
text: "First thought".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_first".to_string(),
encrypted_content: Some("enc_1".to_string()),
})),
}],
stop_reason: StopReason::EndTurn,
}),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::Reasoning {
text: "Second thought".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_second".to_string(),
encrypted_content: None,
})),
}],
stop_reason: StopReason::EndTurn,
}),
Message::User(UserMessage::text("Still here".to_string())),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["content"], "Hello");
assert_eq!(input[1]["content"], "Still here");
}
#[test]
fn test_stop_reason_from_response_status() {
let response_tool = serde_json::json!({
"status": "completed",
"output": [{"type": "function_call", "call_id": "1", "name": "x", "arguments": "{}"}]
});
let has_tools = response_tool["output"].as_array().is_some_and(|arr| {
arr.iter()
.any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call"))
});
assert!(has_tools);
let response_text = serde_json::json!({
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": "Hi"}]}]
});
let has_tools = response_text["output"].as_array().is_some_and(|arr| {
arr.iter()
.any(|item| item.get("type").and_then(|t| t.as_str()) == Some("function_call"))
});
assert!(!has_tools);
}
#[test]
fn test_reasoning_without_encrypted_content_is_stripped() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let args = RawValue::from_string(r#"{"q":"test"}"#.to_string()).unwrap();
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "I should search".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_no_enc".to_string(),
encrypted_content: None,
})),
},
AssistantBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
args,
meta: None,
},
],
stop_reason: StopReason::ToolUse,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[1]["type"], "function_call");
}
#[test]
fn test_reasoning_with_encrypted_content_is_not_replayed() {
use meerkat_core::BlockAssistantMessage;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "Let me think".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_enc".to_string(),
encrypted_content: Some("enc_data_here".to_string()),
})),
},
AssistantBlock::Text {
text: "Here is my answer".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
}),
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input should be array");
assert_eq!(input.len(), 2);
assert_eq!(input[1]["type"], "message");
}
#[tokio::test]
async fn test_stream_does_not_duplicate_tool_calls_when_completed_replays() {
let payload = [
r#"data: {"type":"response.function_call_arguments.delta","call_id":"call_1","name":"get_weather","delta":"{\"loc"}"#,
r#"data: {"type":"response.function_call_arguments.delta","call_id":"call_1","delta":"ation\":\"Tokyo\"}"}"#,
r#"data: {"type":"response.function_call_arguments.done","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}"#,
r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
r#"data: {"type":"response.done","response":{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
"data: [DONE]",
"",
]
.join("\n");
let (base_url, server) = spawn_openai_stub_server(payload).await;
let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
let request = LlmRequest::new(
"gpt-5-mini",
vec![Message::User(UserMessage::text("weather".to_string()))],
);
let mut stream = client.stream(&request);
let mut tool_completes = Vec::new();
while let Some(event) = stream.next().await {
match event.expect("stream event") {
LlmEvent::ToolCallComplete { id, .. } => tool_completes.push(id),
LlmEvent::Done { .. } => break,
_ => {}
}
}
server.abort();
assert_eq!(tool_completes.len(), 1);
assert_eq!(tool_completes[0], "call_1");
}
#[tokio::test]
async fn test_stream_does_not_duplicate_reasoning_when_completed_replays() {
let payload = [
r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking..."}"#,
r#"data: {"type":"response.reasoning.done","item":{"id":"rs_1","summary":[{"type":"summary_text","text":"thinking..."}],"encrypted_content":"enc_xyz"}}"#,
r#"data: {"type":"response.completed","response":{"status":"completed","output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"thinking..."}],"encrypted_content":"enc_xyz"},{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
r#"data: {"type":"response.done","response":{"status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":5}}}"#,
"data: [DONE]",
"",
]
.join("\n");
let (base_url, server) = spawn_openai_stub_server(payload).await;
let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
let request = LlmRequest::new(
"gpt-5-mini",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut reasoning_completes = 0;
while let Some(event) = stream.next().await {
match event.expect("stream event") {
LlmEvent::ReasoningComplete { .. } => reasoning_completes += 1,
LlmEvent::Done { .. } => break,
_ => {}
}
}
server.abort();
assert_eq!(reasoning_completes, 1);
}
#[tokio::test]
async fn test_stream_error_event_yields_done_with_error() {
let payload = [
r#"data: {"type":"error","error":{"code":"server_error","message":"Internal server error"}}"#,
"data: [DONE]",
"",
]
.join("\n");
let (base_url, server) = spawn_openai_stub_server(payload).await;
let client = OpenAiClient::new_with_base_url("test-key".to_string(), base_url);
let request = LlmRequest::new(
"gpt-5-mini",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut saw_error_done = false;
while let Some(event) = stream.next().await {
if let LlmEvent::Done {
outcome: LlmDoneOutcome::Error { error },
} = event.expect("stream event")
{
assert!(
matches!(error, LlmError::ServerError { status: 500, .. }),
"expected ServerError, got: {error:?}"
);
let msg = error.to_string();
assert!(
msg.contains("Internal server error"),
"error should contain message: {msg}"
);
saw_error_done = true;
break;
}
}
server.abort();
assert!(saw_error_done, "Expected Done with error outcome");
}
#[test]
fn openai_user_message_with_image() {
use meerkat_core::ContentBlock;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "describe this".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBOR...".into(),
},
]))],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input array");
let user_item = &input[0];
assert_eq!(user_item["type"], "message");
assert_eq!(user_item["role"], "user");
let content = user_item["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "input_text");
assert_eq!(content[0]["text"], "describe this");
assert_eq!(content[1]["type"], "input_image");
assert_eq!(
content[1]["image_url"], "data:image/png;base64,iVBOR...",
"should be a data URI"
);
let body_str = serde_json::to_string(&body).unwrap();
assert!(
!body_str.contains("source_path"),
"source_path must never appear in provider payload"
);
assert!(
!body_str.contains("/tmp/img.png"),
"source_path value must never appear in provider payload"
);
}
#[test]
fn openai_text_only_user_message_stays_string() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text("just text"))],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input array");
assert!(
input[0]["content"].is_string(),
"text-only user message content should be a string"
);
assert_eq!(input[0]["content"], "just text");
}
#[test]
fn openai_tool_result_with_image_degrades_to_text() {
use meerkat_core::ContentBlock;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-5.2",
vec![
Message::User(UserMessage::text("Take a screenshot")),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::with_blocks(
"call_1".to_string(),
vec![
ContentBlock::Text {
text: "screenshot taken".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBOR...".into(),
},
],
false,
)],
},
],
);
let body = client.build_request_body(&request).expect("build request");
let input = body["input"].as_array().expect("input array");
let tool_output = &input[1];
assert_eq!(tool_output["type"], "function_call_output");
assert_eq!(tool_output["call_id"], "call_1");
let output = tool_output["output"]
.as_str()
.expect("output should be string");
assert!(
output.contains("screenshot taken"),
"text content should be preserved"
);
assert!(
output.contains("[image: image/png]"),
"image should degrade to text projection: got {output}"
);
}
#[test]
fn test_web_search_tool_appended_openai() {
use meerkat_core::ToolDef;
use std::sync::Arc;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef::new(
"my_tool",
"A test tool",
serde_json::json!({"type": "object"}),
))])
.with_provider_params(serde_json::json!({
"web_search": {"type": "web_search"}
}));
let body = client.build_request_body(&request).expect("build request");
let tools = body["tools"].as_array().expect("tools should be array");
assert_eq!(tools.len(), 2, "should have regular tool + web_search");
assert_eq!(tools[0]["type"], "function");
assert_eq!(tools[1]["type"], "web_search");
}
#[test]
fn test_web_search_only_openai() {
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_params(serde_json::json!({
"web_search": {"type": "web_search"}
}));
let body = client.build_request_body(&request).expect("build request");
let tools = body["tools"].as_array().expect("tools should be array");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["type"], "web_search");
}
#[test]
fn test_no_web_search_when_absent_openai() {
use meerkat_core::ToolDef;
use std::sync::Arc;
let client = OpenAiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gpt-4.1-mini",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef::new(
"my_tool",
"A test tool",
serde_json::json!({"type": "object"}),
))]);
let body = client.build_request_body(&request).expect("build request");
let tools = body["tools"].as_array().expect("tools should be array");
assert_eq!(tools.len(), 1, "should only have the regular tool");
assert_eq!(tools[0]["type"], "function");
}
}