use crate::OpenAIModel;
use cogito::prelude::*;
use hypertyper::prelude::*;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::slice::Iter;
#[cfg(doc)]
use cogito::AiModel;
#[derive(Debug)]
pub struct OpenAIClient<T: HttpPost + Sync> {
auth: Auth,
service: T,
}
impl<T: HttpPost + Sync> AiClient for OpenAIClient<T> {
type AiRequest = OpenAIRequest;
type AiResponse = OpenAIResponse;
async fn send(&self, request: &Self::AiRequest) -> AiResult<Self::AiResponse> {
self.service.post(Self::BASE_URI, &self.auth, request).await
}
}
impl<T: HttpPost + Sync> OpenAIClient<T> {
const BASE_URI: &'static str = "https://api.openai.com/v1/responses";
fn with_service(auth: Auth, service: T) -> Self {
Self { auth, service }
}
}
impl OpenAIClient<Service> {
pub fn new(auth: Auth, factory: HttpClientFactory) -> Self {
let service = Service::new(factory);
Self::with_service(auth, service)
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct OpenAIRequest {
model: OpenAIModel,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
input: String,
store: bool,
}
impl AiRequest for OpenAIRequest {
type Model = OpenAIModel;
fn model(self, model: OpenAIModel) -> Self {
Self { model, ..self }
}
fn instructions(self, instructions: impl Into<String>) -> Self {
let instructions = Some(instructions.into());
Self {
instructions,
..self
}
}
fn input(self, input: impl Into<String>) -> Self {
let input = input.into();
Self { input, ..self }
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OpenAIResponse {
output: Vec<OpenAIOutput>,
}
impl AiResponse for OpenAIResponse {
fn result(&self) -> String {
self.concatenate()
}
}
impl OpenAIResponse {
fn concatenate(&self) -> String {
self.output()
.map(|o| o.concatenate())
.join("\n")
.trim()
.to_string()
}
fn output(&self) -> Iter<'_, OpenAIOutput> {
self.output.iter()
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum OpenAIOutput {
Message { content: Vec<OpenAIContent> },
Reasoning,
}
impl OpenAIOutput {
pub fn content(&self) -> Iter<'_, OpenAIContent> {
match self {
OpenAIOutput::Message { content } => content.iter(),
OpenAIOutput::Reasoning => [].iter(),
}
}
pub fn concatenate(&self) -> String {
self.content()
.filter(|c| c.is_output_text())
.map(|c| c.text())
.join("\n")
}
}
#[derive(Debug, Deserialize, Serialize)]
struct OpenAIContent {
#[serde(rename = "type")]
content_type: String,
text: String,
}
impl OpenAIContent {
pub fn content_type(&self) -> &str {
&self.content_type
}
pub fn is_output_text(&self) -> bool {
self.content_type() == "output_text"
}
pub fn text(&self) -> &str {
&self.text
}
}
#[cfg(test)]
mod test {
use crate::client::OpenAIResponse;
use std::fs;
fn load_data(filename: &str) -> String {
fs::read_to_string(format!("tests/data/{filename}.json")).expect("could not find test data")
}
fn load_response(filename: &str) -> OpenAIResponse {
let data = load_data(filename);
serde_json::from_str(&data).expect("could not parse json")
}
mod client {
use super::load_data;
use crate::client::{OpenAIClient, OpenAIRequest};
use cogito::client::{AiClient, AiRequest};
use hypertyper::prelude::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
#[derive(Default)]
struct TestApiService {}
impl HttpPost for TestApiService {
async fn post<U, D, R>(&self, _uri: U, _auth: &Auth, _data: &D) -> HttpResult<R>
where
U: IntoUrl + Send,
D: Serialize + Sync,
R: DeserializeOwned,
{
let data = self.load_data();
Ok(serde_json::from_str(&data)?)
}
}
impl TestApiService {
fn load_data(&self) -> String {
load_data("responses")
}
}
impl OpenAIClient<TestApiService> {
fn test() -> Self {
let auth = Auth::new("some-api-key");
OpenAIClient::with_service(auth, TestApiService::default())
}
}
#[tokio::test]
async fn it_sends_a_request_and_returns_a_response() {
let client = OpenAIClient::test();
let request = OpenAIRequest::default().input("write a haiku about ai");
let response = client.send(&request).await;
assert!(response.is_ok());
let response = response.unwrap();
assert_eq!(response.output().count(), 1);
assert_eq!(response.output().next().unwrap().content().count(), 1);
}
}
mod request {
use super::super::*;
use indoc::indoc;
#[test]
fn it_serializes() {
let body = OpenAIRequest::default()
.model(OpenAIModel::Gpt4omini)
.instructions("Please treat this as a test.")
.input("Serialize me, GPT!");
let expected = indoc! {"{
\"model\": \"gpt-4o-mini\",
\"instructions\": \"Please treat this as a test.\",
\"input\": \"Serialize me, GPT!\",
\"store\": false
}"};
let actual = serde_json::to_string_pretty(&body).unwrap();
assert_eq!(
actual, expected,
"\n\nleft:\n{actual}\n\nright:\n{expected}\n"
);
}
#[test]
fn it_serializes_without_instructions() {
let body = OpenAIRequest::default().input("Serialize me, GPT!");
let expected = indoc! {"{
\"model\": \"gpt-5\",
\"input\": \"Serialize me, GPT!\",
\"store\": false
}"};
let actual = serde_json::to_string_pretty(&body).unwrap();
assert_eq!(
actual, expected,
"\n\nleft:\n{actual}\n\nright:\n{expected}\n"
);
}
#[test]
fn it_deserializes() {
let data = r#"{
"model": "gpt-4o-mini",
"instructions": "Please treat this as a test.",
"input": "Deserialize me, GPT!",
"store": false
}"#;
let body: OpenAIRequest = serde_json::from_str(data).unwrap();
assert_eq!(body.model, OpenAIModel::Gpt4omini);
assert!(body.instructions.is_some());
assert_eq!(body.instructions.unwrap(), "Please treat this as a test.");
assert_eq!(body.input, "Deserialize me, GPT!");
}
#[test]
fn it_deserializes_without_instructions() {
let data = r#"{
"model": "gpt-4o",
"input": "Deserialize me, GPT!",
"store": false
}"#;
let body: OpenAIRequest = serde_json::from_str(data).unwrap();
assert_eq!(body.model, OpenAIModel::Gpt4o);
assert!(body.instructions.is_none());
assert_eq!(body.input, "Deserialize me, GPT!");
}
}
mod response {
use super::super::*;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn it_creates_an_output_iterator_for_gpt4() {
let response = load_response("responses_multi_output");
assert_eq!(response.output().count(), 2);
}
#[test]
fn it_creates_an_output_iterator_for_gpt5() {
let response = load_response("responses_multi_output_gpt5");
assert_eq!(response.output().count(), 3);
}
#[test]
fn it_concatenates_a_response_with_multiple_content_blocks_for_gpt4() {
let response = load_response("responses_multi_content");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_response_with_multiple_content_blocks_for_gpt5() {
let response = load_response("responses_multi_content_gpt5");
let expected = vec![
"Silent circuits dream,",
"Patterns bloom from borrowed light\u{2014}",
"We teach stars to think.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_response_with_multiple_output_blocks_for_gpt4() {
let response = load_response("responses_multi_output");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
"Another piece of content",
"Yet another piece of content",
"A final piece of content",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_response_with_multiple_output_blocks_for_gpt5() {
let response = load_response("responses_multi_output_gpt5");
let expected = vec![
"Silent circuits dream,",
"Patterns bloom from borrowed light\u{2014}",
"We teach stars to think.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
"Silent circuits dream,",
"Patterns bloom from borrowed light\u{2014}",
"We teach stars to think.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_response_when_not_all_content_is_output_text_for_gpt4() {
let response = load_response("responses_non_output_text");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_response_when_not_all_content_is_output_text_for_gpt5() {
let response = load_response("responses_non_output_text_gpt5");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_single_output_and_content_block_for_gpt4() {
let response = load_response("responses");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_single_output_and_content_block_for_gpt5() {
let response = load_response("responses");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
]
.join("\n");
let actual = response.result();
assert_eq!(actual, expected);
}
}
mod output {
use super::*;
use crate::client::OpenAIOutput;
#[test]
fn it_creates_a_content_iterator_for_gpt4() {
let response = load_response("responses_multi_content");
let actual = response
.output()
.next()
.expect("could not get next from iterator")
.content()
.count();
assert_eq!(actual, 5);
}
#[test]
fn it_creates_a_content_iterator_for_gpt5() {
let response = load_response("responses_multi_content_gpt5");
let actual = response
.output()
.nth(1)
.expect("could not get message output from iterator")
.content()
.count();
assert_eq!(actual, 5);
}
#[test]
fn it_creates_an_empty_content_iterator_for_reasoning_output() {
let output = OpenAIOutput::Reasoning;
assert_eq!(output.content().count(), 0);
}
#[test]
fn it_concatenates_multiple_content_blocks_for_gpt4() {
let response = load_response("responses_multi_content");
let output = response.output().next().expect("could not get next output");
let expected = vec![
"Silent circuits hum, ",
"Thoughts woven in coded threads, ",
"Dreams of silicon.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = output.concatenate();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_multiple_content_blocks_for_gpt5() {
let response = load_response("responses_multi_content_gpt5");
let output = response
.output()
.nth(1)
.expect("could not get message output");
let expected = vec![
"Silent circuits dream,",
"Patterns bloom from borrowed light\u{2014}",
"We teach stars to think.",
"Silicon whispers, ",
"Dreams woven in code and light, ",
"Thoughts beyond the stars.",
"Wires hum softly, ",
"Thoughts of silicon arise\u{2014} ",
"Dreams in coded light. ",
"Silent circuits hum, ",
"Thoughts woven in code's embrace\u{2014} ",
"Dreams of minds reborn.",
"Lines of code and dreams, ",
"Whispers of thought intertwined\u{2014} ",
"Silent minds awake.",
]
.join("\n");
let actual = output.concatenate();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_single_content_blocks_for_gpt4() {
let response = load_response("responses");
let output = response.output().next().expect("could not get next output");
let expected =
"Silent circuits hum, \nThoughts woven in coded threads, \nDreams of silicon.";
let actual = output.concatenate();
assert_eq!(actual, expected);
}
#[test]
fn it_concatenates_a_single_content_blocks_for_gpt5() {
let response = load_response("responses_gpt5");
let output = response
.output()
.nth(1)
.expect("could not get message output");
let expected = "Silent circuits dream\nOf patterns we cannot see\nLearning to be kind";
let actual = output.concatenate();
assert_eq!(actual, expected);
}
}
mod content {
use super::super::*;
fn parse(json_str: &str) -> OpenAIContent {
serde_json::from_str(json_str).expect("could not parse json")
}
#[test]
fn it_returns_the_content_type() {
let json_str = r#"{"type": "output_text", "text": "This is some text"}"#;
let content = parse(json_str);
assert_eq!(content.content_type(), "output_text");
}
#[test]
fn it_returns_true_if_it_represents_output_text() {
let json_str = r#"{"type": "output_text", "text": "This is some text"}"#;
let content = parse(json_str);
assert!(content.is_output_text());
}
#[test]
fn it_returns_false_if_it_does_not_represent_output_text() {
let json_str = r#"{"type": "other_content", "text": "This is some text"}"#;
let content = parse(json_str);
assert!(!content.is_output_text());
}
#[test]
fn it_returns_text() {
let json_str = r#"{"type": "output_text", "text": "This is some text"}"#;
let content = parse(json_str);
assert_eq!(content.text(), "This is some text");
}
}
}