use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::macros::enum_names;
use crate::{
CacheBreakpoint, Message, PromptCache, ProviderOptions, RequestOptions, Result, SystemPrompt,
ToolCall, ToolChoice, ToolDefinition, ToolMessage, ToolResultContent, UnsupportedPolicy,
UserContent,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ChatRequest {
pub model: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub system: Vec<SystemPrompt>,
#[serde(default)]
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "null_as_empty"
)]
pub stop: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f64>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "null_as_empty"
)]
pub tools: Vec<ToolDefinition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<PromptCache>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unsupported_policy: Option<UnsupportedPolicy>,
#[serde(default, skip_serializing_if = "RequestOptions::is_empty")]
pub options: RequestOptions,
}
impl ChatRequest {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
Self {
model: model.into(),
system: Vec::new(),
messages: Vec::new(),
temperature: None,
max_output_tokens: None,
top_p: None,
top_k: None,
stop: Vec::new(),
frequency_penalty: None,
presence_penalty: None,
tools: Vec::new(),
tool_choice: None,
response_format: None,
seed: None,
reasoning: None,
parallel_tool_calls: None,
cache: None,
unsupported_policy: None,
options: RequestOptions::new(),
}
}
#[must_use]
pub fn message(mut self, message: impl Into<Message>) -> Self {
self.messages.push(message.into());
self
}
#[must_use]
pub fn extend_messages<I: IntoIterator<Item = Message>>(mut self, messages: I) -> Self {
self.messages.extend(messages);
self
}
#[must_use]
pub fn system(mut self, prompt: impl Into<SystemPrompt>) -> Self {
self.system.push(prompt.into());
self
}
#[must_use]
pub fn user(self, content: impl Into<UserContent>) -> Self {
self.message(Message::user(content))
}
#[must_use]
pub fn assistant(self, content: impl Into<String>) -> Self {
self.message(Message::assistant(content))
}
pub fn push_message(&mut self, message: impl Into<Message>) {
self.messages.push(message.into());
}
pub fn cache_breakpoints(&self) -> impl Iterator<Item = CacheBreakpoint> + '_ {
self.system
.iter()
.filter_map(|prompt| prompt.cache)
.chain(self.tools.iter().filter_map(|tool| tool.cache))
.chain(self.messages.iter().filter_map(Message::cache))
}
pub fn push_tool_result(&mut self, call: &ToolCall, content: impl Into<ToolResultContent>) {
self.messages.push(Message::tool_result(
call.id.clone(),
call.name.clone(),
content,
));
}
pub fn push_tool_error(&mut self, call: &ToolCall, error: impl Into<ToolResultContent>) {
self.messages.push(Message::tool_error(
call.id.clone(),
call.name.clone(),
error,
));
}
pub fn tool_results(&self) -> impl Iterator<Item = &ToolMessage> {
self.messages.iter().filter_map(Message::as_tool)
}
#[must_use]
pub fn temperature(mut self, value: f64) -> Self {
self.temperature = Some(value);
self
}
#[must_use]
pub fn max_output_tokens(mut self, value: u32) -> Self {
self.max_output_tokens = Some(value);
self
}
#[must_use]
pub fn top_p(mut self, value: f64) -> Self {
self.top_p = Some(value);
self
}
pub fn for_each_text_mut(&mut self, mut f: impl FnMut(&mut String)) {
for prompt in &mut self.system {
f(&mut prompt.content);
}
for message in &mut self.messages {
message.for_each_text_mut(&mut f);
}
}
#[must_use]
pub fn top_k(mut self, value: u32) -> Self {
self.top_k = Some(value);
self
}
#[must_use]
pub fn stop<I, S>(mut self, sequences: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.stop = sequences.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn frequency_penalty(mut self, value: f64) -> Self {
self.frequency_penalty = Some(value);
self
}
#[must_use]
pub fn presence_penalty(mut self, value: f64) -> Self {
self.presence_penalty = Some(value);
self
}
#[must_use]
pub fn seed(mut self, value: u64) -> Self {
self.seed = Some(value);
self
}
#[must_use]
pub fn tools<I: IntoIterator<Item = ToolDefinition>>(mut self, tools: I) -> Self {
self.tools = tools.into_iter().collect();
self
}
#[must_use]
pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
self.tool_choice = Some(choice);
self
}
#[must_use]
pub fn response_format(mut self, format: ResponseFormat) -> Self {
self.response_format = Some(format);
self
}
#[must_use]
pub fn reasoning(mut self, config: impl Into<ReasoningConfig>) -> Self {
self.reasoning = Some(config.into());
self
}
#[must_use]
pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
self.parallel_tool_calls = Some(enabled);
self
}
#[must_use]
pub fn cache(mut self, cache: PromptCache) -> Self {
self.cache = Some(cache);
self
}
#[must_use]
pub fn unsupported_policy(mut self, policy: UnsupportedPolicy) -> Self {
self.unsupported_policy = Some(policy);
self
}
#[must_use]
pub fn with_option<T: ProviderOptions + Serialize>(mut self, value: T) -> Self {
self.options.insert(value);
self
}
#[must_use]
pub fn with_opaque_option<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
self.options.insert_opaque(value);
self
}
#[must_use]
pub fn with_options_json(
mut self,
namespace: impl Into<String>,
value: serde_json::Value,
) -> Self {
self.options.insert_json(namespace, value);
self
}
#[must_use]
pub fn option<T: 'static>(&self) -> Option<&T> {
self.options.get::<T>()
}
pub fn resolve_option<T>(&self) -> Result<T>
where
T: ProviderOptions + DeserializeOwned + Default,
{
Ok(self.options.resolve_or_default::<T>()?.into_owned())
}
pub fn option_mut<T: Clone + Send + Sync + 'static>(&mut self) -> Option<&mut T> {
self.options.get_mut::<T>()
}
pub fn resolve_option_mut<T>(&mut self) -> Result<&mut T>
where
T: ProviderOptions + Serialize + DeserializeOwned + Default,
{
self.options.resolve_mut::<T>()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
#[serde(rename = "xhigh")]
XHigh,
}
enum_names!(ReasoningEffort, "reasoning effort", {
None => "none",
Minimal => "minimal",
Low => "low",
Medium => "medium",
High => "high",
XHigh => "xhigh",
});
impl ReasoningEffort {
pub const ALL: &'static [Self] = &[
Self::None,
Self::Minimal,
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
];
#[must_use]
pub fn nearest(self, supported: &[ReasoningEffort]) -> Option<ReasoningEffort> {
let rank = |e: ReasoningEffort| Self::ALL.iter().position(|x| *x == e).unwrap_or(0) as i32;
supported
.iter()
.copied()
.min_by_key(|candidate| ((rank(*candidate) - rank(self)).abs(), -rank(*candidate)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasoningOutput {
Omitted,
Summary,
Full,
}
enum_names!(ReasoningOutput, "reasoning output", {
Omitted => "omitted",
Summary => "summary",
Full => "full",
});
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReasoningConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<ReasoningOutput>,
}
impl ReasoningConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn effort(mut self, effort: ReasoningEffort) -> Self {
self.effort = Some(effort);
self
}
#[must_use]
pub fn output(mut self, output: ReasoningOutput) -> Self {
self.output = Some(output);
self
}
}
impl From<ReasoningEffort> for ReasoningConfig {
fn from(effort: ReasoningEffort) -> Self {
Self::new().effort(effort)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseFormat {
Text,
Json,
#[non_exhaustive]
JsonSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
schema: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
}
impl ResponseFormat {
#[cfg_attr(
not(feature = "extract"),
doc = "[crate::extract::Extractor]: crate#cargo-features"
)]
#[must_use]
pub fn json_schema(schema: serde_json::Value) -> Self {
Self::JsonSchema {
name: None,
schema,
strict: Some(true),
}
}
#[cfg(feature = "schema")]
#[must_use]
pub fn from_schema<T: schemars::JsonSchema>() -> Self {
Self::json_schema(crate::schema::schema_for::<T>())
}
#[must_use]
pub fn non_strict(self) -> Self {
match self {
Self::JsonSchema { name, schema, .. } => Self::JsonSchema {
name,
schema,
strict: Some(false),
},
other => other,
}
}
#[must_use]
pub fn name(self, name: impl Into<String>) -> Self {
match self {
Self::JsonSchema { schema, strict, .. } => Self::JsonSchema {
name: Some(name.into()),
schema,
strict,
},
other => other,
}
}
}
fn null_as_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CacheRetention, ContentBlock};
use serde_json::json;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct DemoOptions {
tier: Option<String>,
}
impl ProviderOptions for DemoOptions {
const NAMESPACE: &'static str = "demo";
}
#[test]
fn options_change_in_place() {
let mut request = ChatRequest::new("m");
assert!(request.option_mut::<DemoOptions>().is_none());
request.resolve_option_mut::<DemoOptions>().unwrap().tier = Some("flex".into());
request.option_mut::<DemoOptions>().unwrap().tier = Some("priority".into());
assert_eq!(
request.option::<DemoOptions>().unwrap().tier.as_deref(),
Some("priority")
);
}
#[test]
fn request_round_trips_with_options() {
let request = ChatRequest::new("m")
.system("Be concise")
.user("hi")
.max_output_tokens(100)
.reasoning(ReasoningEffort::High)
.cache(
PromptCache::new()
.key("session")
.retention(CacheRetention::Long),
)
.unsupported_policy(UnsupportedPolicy::Error)
.with_option(DemoOptions {
tier: Some("flex".into()),
});
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["options"]["demo"]["tier"], "flex");
assert_eq!(value["reasoning"]["effort"], "high");
assert_eq!(value["unsupported_policy"], "error");
let back: ChatRequest = serde_json::from_value(value).unwrap();
assert_eq!(back, request);
assert_eq!(
back.resolve_option::<DemoOptions>()
.unwrap()
.tier
.as_deref(),
Some("flex")
);
}
#[test]
fn minimal_request_shape() {
let value = serde_json::to_value(ChatRequest::new("m")).unwrap();
assert_eq!(value, json!({"model": "m", "messages": []}));
}
#[test]
fn push_tool_result_uses_call_identity() {
let call = ToolCall::new("c1", "lookup", "{}");
let mut request =
ChatRequest::new("m").message(Message::assistant_blocks(vec![ContentBlock::ToolCall(
call.clone(),
)]));
request.push_tool_result(&call, "ok");
request.push_tool_error(&call, "failed");
let tool = request.messages[1].as_tool().unwrap();
assert_eq!(
(tool.tool_call_id.as_str(), tool.name.as_str()),
("c1", "lookup")
);
assert!(request.messages[2].as_tool().unwrap().is_error);
}
#[test]
fn name_and_non_strict_adjust_only_a_schema_format() {
let format = ResponseFormat::json_schema(json!({"type": "object"}))
.name("rating")
.non_strict();
assert_eq!(
format,
ResponseFormat::JsonSchema {
name: Some("rating".into()),
schema: json!({"type": "object"}),
strict: Some(false),
}
);
for other in [ResponseFormat::Text, ResponseFormat::Json] {
assert_eq!(other.clone().name("x"), other);
assert_eq!(other.clone().non_strict(), other);
}
}
#[test]
fn empty_tools_and_stop_are_not_serialized() {
let request = ChatRequest::new("m")
.stop(["x"])
.tools([ToolDefinition::new(
"t",
serde_json::json!({"type": "object"}),
)])
.stop(Vec::<String>::new())
.tools(Vec::new());
assert!(request.stop.is_empty() && request.tools.is_empty());
assert_eq!(request, ChatRequest::new("m"));
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({"model": "m", "messages": []})
);
}
#[test]
fn null_and_empty_tools_and_stop_load_as_empty() {
for value in [
serde_json::json!({"model": "m", "tools": null, "stop": null}),
serde_json::json!({"model": "m", "tools": [], "stop": []}),
serde_json::json!({"model": "m"}),
] {
let request: ChatRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(request, ChatRequest::new("m"), "{value}");
}
}
#[test]
fn extend_messages_appends() {
let history = [Message::user("earlier"), Message::assistant("reply")];
let request = ChatRequest::new("m")
.user("first")
.extend_messages(history)
.user("last");
assert_eq!(
request.messages,
[
Message::user("first"),
Message::user("earlier"),
Message::assistant("reply"),
Message::user("last"),
]
);
}
#[test]
fn system_appends_and_collection_setters_replace() {
let request = ChatRequest::new("m")
.system("one")
.system("two")
.stop(["a"])
.stop(["b"])
.tools([ToolDefinition::new("x", json!({"type": "object"}))])
.tools([ToolDefinition::new("y", json!({"type": "object"}))]);
assert_eq!(request.system.len(), 2);
assert_eq!(request.stop, ["b"]);
let names: Vec<_> = request.tools.iter().map(|t| &*t.name).collect();
assert_eq!(names, ["y"]);
}
#[test]
fn effort_nearest() {
use ReasoningEffort::*;
assert_eq!(XHigh.nearest(&[Low, Medium, High]), Some(High));
assert_eq!(Minimal.nearest(&[Low, Medium, High]), Some(Low));
assert_eq!(None.nearest(&[Low, High]), Some(Low));
assert_eq!(Medium.nearest(&[Low, High]), Some(High));
assert_eq!(Medium.nearest(&[]), Option::None);
}
#[test]
fn effort_all_is_a_sorted_slice() {
let all: &'static [ReasoningEffort] = ReasoningEffort::ALL;
assert_eq!(all.len(), 6);
assert!(all.windows(2).all(|w| w[0] < w[1]));
let names: Vec<_> = ReasoningEffort::ALL.iter().map(|e| e.as_str()).collect();
assert_eq!(names, ["none", "minimal", "low", "medium", "high", "xhigh"]);
}
#[test]
fn sampling_values_encode_with_json_as_written() {
let request = ChatRequest::new("m")
.temperature(0.2)
.top_p(0.9)
.frequency_penalty(0.1)
.presence_penalty(-0.3);
let mut body = json!({"model": request.model});
body["temperature"] = json!(request.temperature);
body["top_p"] = json!(request.top_p);
body["frequency_penalty"] = json!(request.frequency_penalty);
body["presence_penalty"] = json!(request.presence_penalty);
let sent = body.to_string();
for written in [
r#""temperature":0.2"#,
r#""top_p":0.9"#,
r#""frequency_penalty":0.1"#,
r#""presence_penalty":-0.3"#,
] {
assert!(sent.contains(written), "{written} in {sent}");
}
}
}