use atman_runtime::event::NodeEvent;
use atman_runtime::provider::{LlmRequest, Provider};
use atman_runtime::providers::openai::OpenAiProvider;
use atman_runtime::value::Value;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SSE_STREAM: &str = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\" openai\"}}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":9,\"total_tokens\":14}}\n\n\
data: [DONE]\n\n";
#[tokio::test]
async fn openai_streaming_parses_delta_content() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(header("authorization", "Bearer test-key"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(SSE_STREAM, "text/event-stream"),
)
.mount(&server)
.await;
let provider = OpenAiProvider::new("openai", "test-key").with_base_url(server.uri());
let mut obs = provider.call_streaming(LlmRequest {
model: "gpt-test".to_string(),
messages: vec![atman_runtime::provider::user_text_message("hi")],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: Vec::new(),
thinking_enabled: false,
stall_timeout_secs: 0,
});
let final_value = obs.output.await.unwrap();
assert!(final_value.text_concat() == "hello openai");
let mut chunks = Vec::new();
let mut total_tokens = 0u64;
while let Ok(ev) = obs.events.try_recv() {
match ev {
NodeEvent::LlmChunk { text, .. } => chunks.push(text),
NodeEvent::LlmDone { total_tokens: t } => total_tokens = t,
_ => {}
}
}
assert_eq!(chunks, vec!["hello", " openai"]);
assert_eq!(total_tokens, 9);
}
#[tokio::test]
async fn openai_streaming_keeps_tool_name_when_later_delta_is_empty() {
const FASTAI_TOOL_STREAM: &str = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_fastai\",\"type\":\"function\",\"function\":{\"name\":\"fs_list\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"\",\"arguments\":\"{\\\"path\\\":\\\"/tmp/project\\\"}\"}}]},\"finish_reason\":null}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"tool_calls\"}]}\n\n\
data: [DONE]\n\n";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(FASTAI_TOOL_STREAM, "text/event-stream"),
)
.mount(&server)
.await;
let provider = OpenAiProvider::new("fastai", "test-key").with_base_url(server.uri());
let obs = provider.call_streaming(LlmRequest {
model: "gpt-5.6-sol".to_string(),
messages: vec![atman_runtime::provider::user_text_message("list files")],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: vec![atman_runtime::tool::ToolSpec {
name: "fs.list".to_string(),
description: None,
input_schema: serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}),
}],
thinking_enabled: false,
stall_timeout_secs: 0,
});
let message = obs.output.await.unwrap().message;
assert!(matches!(
message.parts.as_slice(),
[atman_runtime::message::MessagePart::ToolUse { id, name, input }]
if id == "call_fastai"
&& name == "fs.list"
&& input == &serde_json::json!({"path": "/tmp/project"})
));
}
#[tokio::test]
async fn openai_non_streaming_returns_message_content() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "cmpl-xyz",
"object": "chat.completion",
"model": "gpt-test",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "hello world"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}
})))
.mount(&server)
.await;
let provider = OpenAiProvider::new("openai", "test-key").with_base_url(server.uri());
let value = provider
.call(LlmRequest {
model: "gpt-test".to_string(),
messages: vec![atman_runtime::provider::user_text_message("hi")],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: Vec::new(),
thinking_enabled: false,
stall_timeout_secs: 0,
})
.await
.unwrap();
assert!(value.text_concat() == "hello world");
}
#[tokio::test]
async fn openai_multimodal_request_uses_image_url_parts() {
let dir = tempfile::tempdir().unwrap();
let img_path = dir.path().join("pic.jpg");
let jpg_bytes: [u8; 4] = [0xFF, 0xD8, 0xFF, 0xE0];
std::fs::write(&img_path, jpg_bytes).unwrap();
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(jpg_bytes);
let expected_url = format!("data:image/jpeg;base64,{data}");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(wiremock::matchers::body_partial_json(serde_json::json!({
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": expected_url}},
{"type": "text", "text": "describe"}
]
}]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "cmpl-x",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
})))
.expect(1)
.mount(&server)
.await;
let provider = OpenAiProvider::new("openai", "k").with_base_url(server.uri());
let user_msg = atman_runtime::message::Message {
role: atman_runtime::message::MessageRole::User,
parts: vec![
atman_runtime::message::MessagePart::Image {
source: atman_runtime::message::ImageSource {
media_type: "image/jpeg".into(),
data: atman_runtime::message::ImageData::Path {
path: img_path.clone(),
},
},
},
atman_runtime::message::MessagePart::Text {
text: "describe".into(),
},
],
turn_id: atman_runtime::event::TurnId::now(),
origin: atman_runtime::message::MessageOrigin::User,
};
let v = provider
.call(LlmRequest {
model: "gpt-4o".to_string(),
messages: vec![user_msg],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: Vec::new(),
thinking_enabled: false,
stall_timeout_secs: 0,
})
.await
.unwrap();
assert!(v.text_concat() == "ok");
}
#[tokio::test]
async fn openai_http_error_becomes_tool_failed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
.mount(&server)
.await;
let provider = OpenAiProvider::new("openai", "bad").with_base_url(server.uri());
let err = provider
.call(LlmRequest {
model: "gpt-test".to_string(),
messages: vec![atman_runtime::provider::user_text_message("hi")],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: Vec::new(),
thinking_enabled: false,
stall_timeout_secs: 0,
})
.await
.unwrap_err();
assert!(matches!(
err,
atman_runtime::RuntimeError::ToolFailed(msg) if msg.contains("401")
));
}
#[tokio::test]
#[ignore]
async fn openai_real() {
let base = std::env::var("ATMAN_TEST_OLLAMA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:11434/v1".into());
let model = std::env::var("ATMAN_TEST_OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".into());
let key = std::env::var("ATMAN_TEST_OLLAMA_KEY").unwrap_or_else(|_| "sk-anything".into());
let provider = OpenAiProvider::new("openai-compat", key).with_base_url(base);
let obs = provider.call_streaming(LlmRequest {
model,
messages: vec![atman_runtime::provider::user_text_message(
"Reply with exactly one short sentence.",
)],
system: None,
input: Value::Unit,
schema: None,
cache_prompt: false,
tools: Vec::new(),
thinking_enabled: false,
stall_timeout_secs: 0,
});
let value = obs.output.await.unwrap();
let text = value.text_concat();
println!("[openai_real] {text}");
assert!(!text.trim().is_empty());
}