use std::collections::HashMap;
use std::fmt;
use std::pin::Pin;
use async_trait::async_trait;
use futures::stream::Stream;
#[cfg(not(target_arch = "wasm32"))]
use futures::stream::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{ToolCall, error::LLMError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(alias = "input_tokens")]
pub prompt_tokens: u32,
#[serde(alias = "output_tokens")]
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "output_tokens_details"
)]
pub completion_tokens_details: Option<CompletionTokensDetails>,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "input_tokens_details"
)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamResponse {
pub choices: Vec<StreamChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChoice {
pub delta: StreamDelta,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamChunk {
Text(String),
ReasoningContent(String),
ToolUseStart {
index: usize,
id: String,
name: String,
},
ToolUseInputDelta {
index: usize,
partial_json: String,
},
ToolUseComplete {
index: usize,
tool_call: ToolCall,
},
Done {
stop_reason: String,
},
Usage(Usage),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompletionTokensDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptTokensDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole {
System,
User,
Assistant,
Tool,
}
impl fmt::Display for ChatRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => "tool",
};
f.write_str(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ImageMime {
JPEG,
PNG,
GIF,
WEBP,
}
impl ImageMime {
pub fn mime_type(&self) -> &'static str {
match self {
ImageMime::JPEG => "image/jpeg",
ImageMime::PNG => "image/png",
ImageMime::GIF => "image/gif",
ImageMime::WEBP => "image/webp",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MessageType {
#[default]
Text,
Image((ImageMime, Vec<u8>)),
Pdf(Vec<u8>),
ImageURL(String),
ToolUse(Vec<ToolCall>),
ToolResult(Vec<ToolCall>),
}
pub enum ReasoningEffort {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: ChatRole,
pub message_type: MessageType,
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ParameterProperty {
#[serde(rename = "type")]
pub property_type: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<ParameterProperty>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "enum")]
pub enum_list: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ParametersSchema {
#[serde(rename = "type")]
pub schema_type: String,
pub properties: HashMap<String, ParameterProperty>,
pub required: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FunctionTool {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct StructuredOutputFormat {
pub name: String,
pub description: Option<String>,
pub schema: Option<Value>,
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Tool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionTool,
}
#[derive(Debug, Clone, Default)]
pub enum ToolChoice {
Any,
#[default]
Auto,
Tool(String),
None,
}
impl Serialize for ToolChoice {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ToolChoice::Any => serializer.serialize_str("required"),
ToolChoice::Auto => serializer.serialize_str("auto"),
ToolChoice::None => serializer.serialize_str("none"),
ToolChoice::Tool(name) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("type", "function")?;
let mut function_obj = std::collections::HashMap::new();
function_obj.insert("name", name.as_str());
map.serialize_entry("function", &function_obj)?;
map.end()
}
}
}
}
pub trait ChatResponse: std::fmt::Debug + std::fmt::Display + Send + Sync {
fn text(&self) -> Option<String>;
fn tool_calls(&self) -> Option<Vec<ToolCall>>;
fn thinking(&self) -> Option<String> {
None
}
fn usage(&self) -> Option<Usage> {
None
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct SamplingOverrides {
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub max_tokens: Option<u32>,
}
impl SamplingOverrides {
pub fn empty() -> Self {
Self::default()
}
pub fn with_temperature(temperature: f32) -> Self {
Self {
temperature: Some(temperature),
..Self::default()
}
}
pub fn with_top_p(top_p: f32) -> Self {
Self {
top_p: Some(top_p),
..Self::default()
}
}
pub fn with_max_tokens(max_tokens: u32) -> Self {
Self {
max_tokens: Some(max_tokens),
..Self::default()
}
}
}
#[async_trait]
pub trait ChatProvider: Sync + Send {
async fn chat(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools(messages, None, json_schema).await
}
async fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError>;
async fn chat_and_sampling(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
sampling: Option<&SamplingOverrides>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools_and_sampling(messages, None, json_schema, sampling)
.await
}
async fn chat_with_tools_and_sampling(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
sampling: Option<&SamplingOverrides>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
let _ = sampling;
self.chat_with_tools(messages, tools, json_schema).await
}
async fn chat_with_web_search(
&self,
_input: String,
) -> Result<Box<dyn ChatResponse>, LLMError> {
Err(LLMError::Generic(
"Web search not supported for this provider".to_string(),
))
}
async fn chat_stream(
&self,
_messages: &[ChatMessage],
_json_schema: Option<StructuredOutputFormat>,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
Err(LLMError::Generic(
"Streaming not supported for this provider".to_string(),
))
}
async fn chat_stream_struct(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<
std::pin::Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>,
LLMError,
> {
Err(LLMError::Generic(
"Structured streaming not supported for this provider".to_string(),
))
}
async fn chat_stream_with_tools(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>, LLMError> {
Err(LLMError::Generic(
"Streaming with tools not supported for this provider".to_string(),
))
}
async fn chat_stream_and_sampling(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
sampling: Option<&SamplingOverrides>,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
let _ = sampling;
self.chat_stream(messages, json_schema).await
}
async fn chat_stream_struct_and_sampling(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
sampling: Option<&SamplingOverrides>,
) -> Result<
std::pin::Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>,
LLMError,
> {
let _ = sampling;
self.chat_stream_struct(messages, tools, json_schema).await
}
fn model(&self) -> &str {
""
}
}
impl fmt::Display for ReasoningEffort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReasoningEffort::Low => write!(f, "low"),
ReasoningEffort::Medium => write!(f, "medium"),
ReasoningEffort::High => write!(f, "high"),
}
}
}
impl ChatMessage {
pub fn user() -> ChatMessageBuilder {
ChatMessageBuilder::new(ChatRole::User)
}
pub fn assistant() -> ChatMessageBuilder {
ChatMessageBuilder::new(ChatRole::Assistant)
}
}
#[derive(Debug)]
pub struct ChatMessageBuilder {
role: ChatRole,
message_type: MessageType,
content: String,
}
impl ChatMessageBuilder {
pub fn new(role: ChatRole) -> Self {
Self {
role,
message_type: MessageType::default(),
content: String::default(),
}
}
pub fn content<S: Into<String>>(mut self, content: S) -> Self {
self.content = content.into();
self
}
pub fn image(mut self, image_mime: ImageMime, raw_bytes: Vec<u8>) -> Self {
self.message_type = MessageType::Image((image_mime, raw_bytes));
self
}
pub fn pdf(mut self, raw_bytes: Vec<u8>) -> Self {
self.message_type = MessageType::Pdf(raw_bytes);
self
}
pub fn image_url(mut self, url: impl Into<String>) -> Self {
self.message_type = MessageType::ImageURL(url.into());
self
}
pub fn tool_use(mut self, tools: Vec<ToolCall>) -> Self {
self.message_type = MessageType::ToolUse(tools);
self
}
pub fn tool_result(mut self, tools: Vec<ToolCall>) -> Self {
self.message_type = MessageType::ToolResult(tools);
self
}
pub fn build(self) -> ChatMessage {
ChatMessage {
role: self.role,
message_type: self.message_type,
content: self.content,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
pub(crate) fn create_sse_stream<F>(
response: reqwest::Response,
parser: F,
) -> std::pin::Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>
where
F: Fn(&str) -> Result<Option<String>, LLMError> + Send + 'static,
{
let stream = response
.bytes_stream()
.scan(
(String::default(), Vec::default()),
move |(buffer, utf8_buffer): &mut (String, Vec<u8>),
chunk: Result<bytes::Bytes, reqwest::Error>| {
let result = match chunk {
Ok(bytes) => {
utf8_buffer.extend_from_slice(&bytes);
match String::from_utf8(utf8_buffer.clone()) {
Ok(text) => {
buffer.push_str(&text);
utf8_buffer.clear();
}
Err(e) => {
let valid_up_to = e.utf8_error().valid_up_to();
if valid_up_to > 0 {
let valid =
String::from_utf8_lossy(&utf8_buffer[..valid_up_to]);
buffer.push_str(&valid);
utf8_buffer.drain(..valid_up_to);
}
}
}
let mut results = Vec::default();
while let Some(pos) = buffer.find("\n\n") {
let event = buffer[..pos + 2].to_string();
buffer.drain(..pos + 2);
match parser(&event) {
Ok(Some(content)) => results.push(Ok(content)),
Ok(None) => {}
Err(e) => results.push(Err(e)),
}
}
Some(results)
}
Err(e) => Some(vec![Err(LLMError::HttpError(e.to_string()))]),
};
async move { result }
},
)
.flat_map(futures::stream::iter);
Box::pin(stream)
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::stream::StreamExt;
#[test]
fn test_chat_message_builder_user() {
let msg = ChatMessage::user().content("hello").build();
assert_eq!(msg.role, ChatRole::User);
assert_eq!(msg.content, "hello");
assert!(matches!(msg.message_type, MessageType::Text));
}
#[test]
fn test_chat_message_builder_assistant() {
let msg = ChatMessage::assistant().content("reply").build();
assert_eq!(msg.role, ChatRole::Assistant);
assert_eq!(msg.content, "reply");
}
#[test]
fn test_chat_message_builder_image() {
let msg = ChatMessage::user()
.content("describe")
.image(ImageMime::PNG, vec![1, 2, 3])
.build();
assert!(matches!(msg.message_type, MessageType::Image(_)));
}
#[test]
fn test_chat_message_builder_pdf() {
let msg = ChatMessage::user()
.content("read")
.pdf(vec![4, 5, 6])
.build();
assert!(matches!(msg.message_type, MessageType::Pdf(_)));
}
#[test]
fn test_chat_message_builder_tool_use() {
let tc = crate::ToolCall {
id: "t1".to_string(),
call_type: "function".to_string(),
function: crate::FunctionCall {
name: "tool".to_string(),
arguments: "{}".to_string(),
},
};
let msg = ChatMessage::assistant()
.content("calling tool")
.tool_use(vec![tc])
.build();
assert!(matches!(msg.message_type, MessageType::ToolUse(_)));
}
#[test]
fn test_chat_message_builder_tool_result() {
let tc = crate::ToolCall {
id: "t1".to_string(),
call_type: "function".to_string(),
function: crate::FunctionCall {
name: "tool".to_string(),
arguments: "result".to_string(),
},
};
let msg = ChatMessageBuilder::new(ChatRole::Tool)
.tool_result(vec![tc])
.build();
assert!(matches!(msg.message_type, MessageType::ToolResult(_)));
assert_eq!(msg.role, ChatRole::Tool);
}
#[test]
fn test_chat_role_display() {
assert_eq!(format!("{}", ChatRole::System), "system");
assert_eq!(format!("{}", ChatRole::User), "user");
assert_eq!(format!("{}", ChatRole::Assistant), "assistant");
assert_eq!(format!("{}", ChatRole::Tool), "tool");
}
#[test]
fn test_image_mime_mime_type() {
assert_eq!(ImageMime::JPEG.mime_type(), "image/jpeg");
assert_eq!(ImageMime::PNG.mime_type(), "image/png");
assert_eq!(ImageMime::GIF.mime_type(), "image/gif");
assert_eq!(ImageMime::WEBP.mime_type(), "image/webp");
}
#[test]
fn test_reasoning_effort_display() {
assert_eq!(format!("{}", ReasoningEffort::Low), "low");
assert_eq!(format!("{}", ReasoningEffort::Medium), "medium");
assert_eq!(format!("{}", ReasoningEffort::High), "high");
}
#[test]
fn test_tool_choice_serialization() {
let any_json = serde_json::to_value(&ToolChoice::Any).unwrap();
assert_eq!(any_json, "required");
let auto_json = serde_json::to_value(&ToolChoice::Auto).unwrap();
assert_eq!(auto_json, "auto");
let none_json = serde_json::to_value(&ToolChoice::None).unwrap();
assert_eq!(none_json, "none");
let tool_json = serde_json::to_value(ToolChoice::Tool("my_func".to_string())).unwrap();
assert_eq!(tool_json["type"], "function");
assert_eq!(tool_json["function"]["name"], "my_func");
}
#[test]
fn test_structured_output_format_roundtrip() {
let format = StructuredOutputFormat {
name: "Test".to_string(),
description: Some("A test".to_string()),
schema: Some(serde_json::json!({"type": "object"})),
strict: Some(true),
};
let json = serde_json::to_string(&format).unwrap();
let parsed: StructuredOutputFormat = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, format);
}
#[test]
fn test_structured_output_format_minimal() {
let json_str = r#"{"name":"Minimal"}"#;
let parsed: StructuredOutputFormat = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed.name, "Minimal");
assert_eq!(parsed.description, None);
assert_eq!(parsed.schema, None);
assert_eq!(parsed.strict, None);
}
#[test]
fn test_chat_message_builder_image_url() {
let msg = ChatMessage::user()
.image_url("https://example.com/img.png")
.content("describe this")
.build();
assert!(matches!(msg.message_type, MessageType::ImageURL(_)));
}
#[tokio::test]
async fn test_create_sse_stream_handles_split_utf8() {
let test_data = "data: Positive reactions\n\n".as_bytes();
let chunks: Vec<Result<Bytes, reqwest::Error>> = vec![
Ok(Bytes::from(&test_data[..10])),
Ok(Bytes::from(&test_data[10..])),
];
let mock_response = create_mock_response(chunks);
let parser = |event: &str| -> Result<Option<String>, LLMError> {
if let Some(content) = event.strip_prefix("data: ") {
let content = content.trim();
if content.is_empty() {
return Ok(None);
}
Ok(Some(content.to_string()))
} else {
Ok(None)
}
};
let mut stream = create_sse_stream(mock_response, parser);
let mut results = Vec::new();
while let Some(result) = stream.next().await {
results.push(result);
}
assert_eq!(results.len(), 1);
assert_eq!(results[0].as_ref().unwrap(), "Positive reactions");
}
#[tokio::test]
async fn test_create_sse_stream_handles_split_sse_events() {
let event1 = "data: First event\n\n";
let event2 = "data: Second event\n\n";
let combined = format!("{}{}", event1, event2);
let test_data = combined.as_bytes().to_vec();
let split_point = event1.len() + 5;
let chunks: Vec<Result<Bytes, reqwest::Error>> = vec![
Ok(Bytes::from(test_data[..split_point].to_vec())),
Ok(Bytes::from(test_data[split_point..].to_vec())),
];
let mock_response = create_mock_response(chunks);
let parser = |event: &str| -> Result<Option<String>, LLMError> {
if let Some(content) = event.strip_prefix("data: ") {
let content = content.trim();
if content.is_empty() {
return Ok(None);
}
Ok(Some(content.to_string()))
} else {
Ok(None)
}
};
let mut stream = create_sse_stream(mock_response, parser);
let mut results = Vec::new();
while let Some(result) = stream.next().await {
results.push(result);
}
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_ref().unwrap(), "First event");
assert_eq!(results[1].as_ref().unwrap(), "Second event");
}
#[tokio::test]
async fn test_create_sse_stream_handles_multibyte_utf8_split() {
let multibyte_char = "✨";
let event = format!("data: Star {}\n\n", multibyte_char);
let test_data = event.as_bytes().to_vec();
let emoji_start = event.find(multibyte_char).unwrap();
let split_in_emoji = emoji_start + 1;
let chunks: Vec<Result<Bytes, reqwest::Error>> = vec![
Ok(Bytes::from(test_data[..split_in_emoji].to_vec())),
Ok(Bytes::from(test_data[split_in_emoji..].to_vec())),
];
let mock_response = create_mock_response(chunks);
let parser = |event: &str| -> Result<Option<String>, LLMError> {
if let Some(content) = event.strip_prefix("data: ") {
let content = content.trim();
if content.is_empty() {
return Ok(None);
}
Ok(Some(content.to_string()))
} else {
Ok(None)
}
};
let mut stream = create_sse_stream(mock_response, parser);
let mut results = Vec::new();
while let Some(result) = stream.next().await {
results.push(result);
}
assert_eq!(results.len(), 1);
assert_eq!(
results[0].as_ref().unwrap(),
&format!("Star {}", multibyte_char)
);
}
fn create_mock_response(chunks: Vec<Result<Bytes, reqwest::Error>>) -> reqwest::Response {
use http_body_util::StreamBody;
use reqwest::Body;
let frame_stream = futures::stream::iter(
chunks
.into_iter()
.map(|chunk| chunk.map(hyper::body::Frame::data)),
);
let body = StreamBody::new(frame_stream);
let body = Body::wrap(body);
let http_response = http::Response::builder().status(200).body(body).unwrap();
http_response.into()
}
}
#[cfg(test)]
mod model_accessor_tests {
use super::*;
#[test]
fn default_impl_returns_empty_string() {
struct MinimalMock;
#[async_trait]
impl ChatProvider for MinimalMock {
async fn chat_with_tools(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, crate::error::LLMError> {
unimplemented!()
}
}
let mock = MinimalMock;
assert_eq!(mock.model(), "");
}
#[cfg(all(feature = "ollama", not(target_arch = "wasm32")))]
#[test]
fn ollama_backend_exposes_model_string() {
let ollama = crate::backends::ollama::Ollama::new(
"http://localhost:11434", None, Some("qwen2.5:14b".to_string()), None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, );
assert_eq!(ollama.model(), "qwen2.5:14b");
}
#[cfg(all(feature = "anthropic", not(target_arch = "wasm32")))]
#[test]
fn anthropic_backend_exposes_model_string() {
let anthropic = crate::backends::anthropic::Anthropic::new(
"test-key", Some("claude-haiku-4-5-20251001".to_string()), None, None, None, None, None, None, None, None, );
assert_eq!(anthropic.model(), "claude-haiku-4-5-20251001");
}
#[cfg(all(feature = "ollama", not(target_arch = "wasm32")))]
#[test]
fn arc_dyn_chat_provider_dispatches_model_via_deref() {
use std::sync::Arc;
let ollama = crate::backends::ollama::Ollama::new(
"http://localhost:11434",
None,
Some("qwen2.5:14b".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
);
let arc: Arc<dyn ChatProvider> = Arc::new(ollama);
assert_eq!(arc.model(), "qwen2.5:14b");
}
#[cfg(all(feature = "ollama", not(target_arch = "wasm32")))]
#[tokio::test]
#[ignore]
async fn model_accessor_wires_to_chat_request() {
use httpmock::{Method::POST, MockServer};
use serde_json::json;
let configured_model = "qwen2.5:14b";
let server = MockServer::start();
let provider = crate::backends::ollama::Ollama::new(
server.base_url(),
None,
Some(configured_model.to_string()),
Some(128),
Some(0.0),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
);
assert_eq!(provider.model(), configured_model);
let model_in_body = format!("\"model\":\"{configured_model}\"");
let chat_mock = server.mock(|when, then| {
when.method(POST)
.path("/api/chat")
.body_includes(model_in_body.as_str());
then.status(200).json_body(json!({
"message": {
"content": "mock reply",
"tool_calls": null
}
}));
});
let messages = vec![ChatMessage::user().content("ping").build()];
let response = provider
.chat_with_tools(&messages, None, None)
.await
.expect("Mock-backed chat_with_tools must succeed");
assert!(response.text().is_some(), "Response must contain text");
chat_mock.assert();
}
}