use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::OpenAiClient;
use crate::error::OpenAiError;
use crate::sse::{sse_events, EventStream};
pub struct Chat<'a> {
pub(crate) client: &'a OpenAiClient,
}
impl Chat<'_> {
pub async fn create(
&self,
request: &ChatCompletionRequest,
) -> Result<ChatCompletion, OpenAiError> {
self.client.post_json("/chat/completions", request).await
}
pub async fn stream(
&self,
request: &ChatCompletionRequest,
) -> Result<EventStream<ChatCompletionChunk>, OpenAiError> {
let mut request = request.clone();
request.stream = Some(true);
if request.stream_options.is_none() {
request.stream_options = Some(StreamOptions {
include_usage: true,
});
}
let response = self
.client
.post_json_sse("/chat/completions", &request)
.await?;
Ok(sse_events(response))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ChatCompletionRequest {
model: String,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
logit_bias: Option<HashMap<String, i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
logprobs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
top_logprobs: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ChatResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ChatTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
verbosity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
service_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<StreamOptions>,
}
impl ChatCompletionRequest {
pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
Self {
model: model.into(),
messages,
max_completion_tokens: None,
temperature: None,
top_p: None,
n: None,
stop: None,
presence_penalty: None,
frequency_penalty: None,
logit_bias: None,
logprobs: None,
top_logprobs: None,
seed: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
reasoning_effort: None,
verbosity: None,
store: None,
metadata: None,
service_tier: None,
user: None,
stream: None,
stream_options: None,
}
}
pub fn max_completion_tokens(mut self, max_completion_tokens: u32) -> Self {
self.max_completion_tokens = Some(max_completion_tokens);
self
}
pub fn temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
pub fn top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
pub fn n(mut self, n: u32) -> Self {
self.n = Some(n);
self
}
pub fn stop(mut self, stop: Vec<String>) -> Self {
self.stop = Some(stop);
self
}
pub fn presence_penalty(mut self, presence_penalty: f32) -> Self {
self.presence_penalty = Some(presence_penalty);
self
}
pub fn frequency_penalty(mut self, frequency_penalty: f32) -> Self {
self.frequency_penalty = Some(frequency_penalty);
self
}
pub fn logit_bias(mut self, logit_bias: HashMap<String, i32>) -> Self {
self.logit_bias = Some(logit_bias);
self
}
pub fn logprobs(mut self, logprobs: bool) -> Self {
self.logprobs = Some(logprobs);
self
}
pub fn top_logprobs(mut self, top_logprobs: u8) -> Self {
self.top_logprobs = Some(top_logprobs);
self
}
pub fn seed(mut self, seed: i64) -> Self {
self.seed = Some(seed);
self
}
pub fn response_format(mut self, response_format: ChatResponseFormat) -> Self {
self.response_format = Some(response_format);
self
}
pub fn json_schema(mut self, name: impl Into<String>, schema: Value) -> Self {
self.response_format = Some(ChatResponseFormat::JsonSchema {
json_schema: JsonSchemaSpec {
name: name.into(),
schema,
description: None,
strict: Some(true),
},
});
self
}
pub fn tools(mut self, tools: Vec<ChatTool>) -> Self {
self.tools = Some(tools);
self
}
pub fn tool_choice(mut self, tool_choice: Value) -> Self {
self.tool_choice = Some(tool_choice);
self
}
pub fn parallel_tool_calls(mut self, parallel_tool_calls: bool) -> Self {
self.parallel_tool_calls = Some(parallel_tool_calls);
self
}
pub fn reasoning_effort(mut self, reasoning_effort: impl Into<String>) -> Self {
self.reasoning_effort = Some(reasoning_effort.into());
self
}
pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
self.verbosity = Some(verbosity.into());
self
}
pub fn store(mut self, store: bool) -> Self {
self.store = Some(store);
self
}
pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
self.metadata = Some(metadata);
self
}
pub fn service_tier(mut self, service_tier: impl Into<String>) -> Self {
self.service_tier = Some(service_tier.into());
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct StreamOptions {
pub(crate) include_usage: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<ChatContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<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>,
}
impl ChatMessage {
pub fn new(role: impl Into<String>, content: impl Into<ChatContent>) -> Self {
Self {
role: role.into(),
content: Some(content.into()),
name: None,
tool_calls: None,
tool_call_id: None,
}
}
pub fn system(content: impl Into<ChatContent>) -> Self {
Self::new("system", content)
}
pub fn developer(content: impl Into<ChatContent>) -> Self {
Self::new("developer", content)
}
pub fn user(content: impl Into<ChatContent>) -> Self {
Self::new("user", content)
}
pub fn assistant(content: impl Into<ChatContent>) -> Self {
Self::new("assistant", content)
}
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<ChatContent>) -> Self {
Self {
role: "tool".to_string(),
content: Some(content.into()),
name: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatContent {
Text(String),
Parts(Vec<ChatContentPart>),
}
impl From<&str> for ChatContent {
fn from(text: &str) -> Self {
ChatContent::Text(text.to_string())
}
}
impl From<String> for ChatContent {
fn from(text: String) -> Self {
ChatContent::Text(text)
}
}
impl From<Vec<ChatContentPart>> for ChatContent {
fn from(parts: Vec<ChatContentPart>) -> Self {
ChatContent::Parts(parts)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ChatContentPart {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "image_url")]
ImageUrl {
image_url: ImageUrl,
},
#[serde(untagged)]
Other(Value),
}
impl ChatContentPart {
pub fn text(text: impl Into<String>) -> Self {
ChatContentPart::Text { text: text.into() }
}
pub fn image_url(url: impl Into<String>) -> Self {
ChatContentPart::ImageUrl {
image_url: ImageUrl {
url: url.into(),
detail: None,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ChatResponseFormat {
#[serde(rename = "text")]
Text,
#[serde(rename = "json_object")]
JsonObject,
#[serde(rename = "json_schema")]
JsonSchema {
json_schema: JsonSchemaSpec,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonSchemaSpec {
pub name: String,
pub schema: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatTool {
#[serde(rename = "type")]
tool_type: String,
pub function: FunctionDef,
}
impl ChatTool {
pub fn function(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
) -> Self {
Self {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.into(),
description: Some(description.into()),
parameters,
strict: Some(true),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatCompletion {
pub id: Option<String>,
pub object: Option<String>,
pub created: Option<u64>,
pub model: Option<String>,
#[serde(default)]
pub choices: Vec<ChatChoice>,
pub usage: Option<ChatUsage>,
pub system_fingerprint: Option<String>,
pub service_tier: Option<String>,
}
impl ChatCompletion {
pub fn content(&self) -> Option<&str> {
self.choices.first()?.message.content.as_deref()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatChoice {
pub index: Option<u32>,
pub message: AssistantMessage,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AssistantMessage {
pub role: Option<String>,
pub content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub refusal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub call_type: Option<String>,
pub function: FunctionCallData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallData {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatUsage {
#[serde(default)]
pub prompt_tokens: u64,
#[serde(default)]
pub completion_tokens: u64,
#[serde(default)]
pub total_tokens: u64,
pub prompt_tokens_details: Option<Value>,
pub completion_tokens_details: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatCompletionChunk {
pub id: Option<String>,
pub object: Option<String>,
pub created: Option<u64>,
pub model: Option<String>,
#[serde(default)]
pub choices: Vec<ChunkChoice>,
pub usage: Option<ChatUsage>,
}
impl ChatCompletionChunk {
pub fn delta_content(&self) -> Option<&str> {
self.choices.first()?.delta.content.as_deref()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChunkChoice {
pub index: Option<u32>,
#[serde(default)]
pub delta: ChatDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ChatDelta {
pub role: Option<String>,
pub content: Option<String>,
pub tool_calls: Option<Vec<ToolCallDelta>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ToolCallDelta {
pub index: Option<u32>,
pub id: Option<String>,
pub function: Option<FunctionCallData>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn serializes_minimal_request() {
let request = ChatCompletionRequest::new(
"gpt-5.6-terra",
vec![ChatMessage::system("Be brief."), ChatMessage::user("Hi")],
);
let value = serde_json::to_value(&request).unwrap();
assert_eq!(
value,
json!({
"model": "gpt-5.6-terra",
"messages": [
{"role": "system", "content": "Be brief."},
{"role": "user", "content": "Hi"}
]
})
);
}
#[test]
fn serializes_multimodal_and_tools() {
let request = ChatCompletionRequest::new(
"gpt-5.6-sol",
vec![ChatMessage::user(vec![
ChatContentPart::text("What is this?"),
ChatContentPart::image_url("https://example.com/a.png"),
])],
)
.max_completion_tokens(100)
.tools(vec![ChatTool::function(
"lookup",
"Look something up",
json!({"type": "object"}),
)])
.json_schema("result", json!({"type": "object"}));
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["messages"][0]["content"][1]["type"], "image_url");
assert_eq!(value["max_completion_tokens"], 100);
assert_eq!(value["tools"][0]["type"], "function");
assert_eq!(value["tools"][0]["function"]["name"], "lookup");
assert_eq!(value["response_format"]["type"], "json_schema");
assert_eq!(value["response_format"]["json_schema"]["strict"], true);
}
#[test]
fn deserializes_completion_with_tool_calls() {
let body = json!({
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1_750_000_000u64,
"model": "gpt-5.6-terra",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}
});
let completion: ChatCompletion = serde_json::from_value(body).unwrap();
assert_eq!(completion.content(), None);
let calls = completion.choices[0].message.tool_calls.as_ref().unwrap();
assert_eq!(calls[0].function.name.as_deref(), Some("lookup"));
assert_eq!(completion.usage.unwrap().total_tokens, 30);
}
#[test]
fn deserializes_stream_chunk() {
let chunk: ChatCompletionChunk = serde_json::from_value(json!({
"id": "chatcmpl-1",
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {"content": "Hel"}, "finish_reason": null}]
}))
.unwrap();
assert_eq!(chunk.delta_content(), Some("Hel"));
let last: ChatCompletionChunk = serde_json::from_value(json!({
"id": "chatcmpl-1",
"choices": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
}))
.unwrap();
assert_eq!(last.delta_content(), None);
assert_eq!(last.usage.unwrap().total_tokens, 3);
}
}