use crate::tool::{ToolDefinition, ToolInvocation};
use anyhow::Result;
use async_trait::async_trait;
use futures_util::StreamExt;
use futures_util::stream::BoxStream;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text {
text: String,
},
Image {
media_type: String,
data: String,
},
Audio {
media_type: String,
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Video {
media_type: String,
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Document {
media_type: String,
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
impl ContentPart {
pub fn is_text(&self) -> bool {
matches!(self, Self::Text { .. })
}
pub fn is_image(&self) -> bool {
matches!(self, Self::Image { .. })
}
pub fn is_audio(&self) -> bool {
matches!(self, Self::Audio { .. })
}
pub fn is_video(&self) -> bool {
matches!(self, Self::Video { .. })
}
pub fn is_document(&self) -> bool {
matches!(self, Self::Document { .. })
}
pub fn attachment_kind(&self) -> Option<AttachmentKind> {
match self {
Self::Text { .. } => None,
Self::Image { .. } => Some(AttachmentKind::Image),
Self::Audio { .. } => Some(AttachmentKind::Audio),
Self::Video { .. } => Some(AttachmentKind::Video),
Self::Document { .. } => Some(AttachmentKind::Document),
}
}
pub fn media_type(&self) -> Option<&str> {
match self {
Self::Text { .. } => None,
Self::Image { media_type, .. }
| Self::Audio { media_type, .. }
| Self::Video { media_type, .. }
| Self::Document { media_type, .. } => Some(media_type),
}
}
pub fn data(&self) -> Option<&str> {
match self {
Self::Text { .. } => None,
Self::Image { data, .. }
| Self::Audio { data, .. }
| Self::Video { data, .. }
| Self::Document { data, .. } => Some(data),
}
}
pub fn name(&self) -> Option<&str> {
match self {
Self::Audio { name, .. } | Self::Video { name, .. } | Self::Document { name, .. } => {
name.as_deref()
}
Self::Text { .. } | Self::Image { .. } => None,
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
pub fn text_from_parts(parts: &[ContentPart]) -> String {
parts
.iter()
.filter_map(|p| p.as_text())
.collect::<Vec<_>>()
.join("")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttachmentKind {
Image,
Audio,
Video,
Document,
}
impl AttachmentKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Image => "image",
Self::Audio => "audio",
Self::Video => "video",
Self::Document => "document",
}
}
}
#[async_trait]
pub trait ModelProvider: Send + Sync {
fn stream(&self, request: ModelRequest) -> ModelStream;
async fn complete(&self, request: ModelRequest) -> Result<ModelResponse> {
let mut stream = self.stream(request);
let mut text = String::new();
while let Some(event) = stream.next().await {
match event? {
ModelStreamEvent::TextDelta { text: delta } => text.push_str(&delta),
ModelStreamEvent::Done => break,
ModelStreamEvent::Status { .. }
| ModelStreamEvent::Usage { .. }
| ModelStreamEvent::ThinkingDelta { .. }
| ModelStreamEvent::ToolCall(_) => {}
}
}
Ok(ModelResponse { text })
}
async fn list_models(&self) -> Result<Vec<String>> {
anyhow::bail!("listing models is not supported by this provider")
}
}
pub type ModelStream = BoxStream<'static, Result<ModelStreamEvent>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRequest {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub messages: Vec<ModelMessage>,
pub thinking: ThinkingConfig,
#[serde(default)]
pub tools: Vec<ToolDefinition>,
#[serde(default, skip_serializing, skip_deserializing)]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelMessage {
pub role: ModelRole,
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content_parts: Vec<ContentPart>,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub tool_calls: Vec<ToolInvocation>,
#[serde(default, skip_serializing, skip_deserializing)]
pub created_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_content: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelRole {
System,
Developer,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResponse {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ModelStreamEvent {
TextDelta {
text: String,
},
ThinkingDelta {
text: String,
},
Status {
label: String,
},
Usage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
},
ToolCall(ToolInvocation),
Done,
}
impl ModelMessage {
pub fn system(content: impl Into<String>) -> Self {
Self::new(ModelRole::System, content)
}
pub fn developer(content: impl Into<String>) -> Self {
Self::new(ModelRole::Developer, content)
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(ModelRole::User, content)
}
pub fn user_multimodal(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self {
role: ModelRole::User,
content: content.into(),
content_parts: parts,
tool_call_id: None,
tool_name: None,
tool_calls: Vec::new(),
created_at: Some(current_unix_millis()),
thinking_content: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
thinking_content: None,
..Self::new(ModelRole::Assistant, content)
}
}
pub fn assistant_with_thinking(content: impl Into<String>, thinking: Option<String>) -> Self {
Self {
thinking_content: thinking,
..Self::new(ModelRole::Assistant, content)
}
}
pub fn tool_result(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::tool_result_with_parts(tool_call_id, tool_name, content, Vec::new())
}
pub fn tool_result_with_parts(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
content_parts: Vec<ContentPart>,
) -> Self {
Self {
role: ModelRole::Tool,
content: content.into(),
content_parts,
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name.into()),
tool_calls: Vec::new(),
created_at: Some(current_unix_millis()),
thinking_content: None,
}
}
pub fn assistant_tool_call(invocation: ToolInvocation) -> Self {
Self::assistant_tool_call_with_context(invocation, String::new(), None)
}
pub fn assistant_tool_call_with_context(
invocation: ToolInvocation,
content: impl Into<String>,
thinking: Option<String>,
) -> Self {
Self::assistant_tool_calls_with_context(vec![invocation], content, thinking)
}
pub fn assistant_tool_calls_with_context(
invocations: Vec<ToolInvocation>,
content: impl Into<String>,
thinking: Option<String>,
) -> Self {
Self {
role: ModelRole::Assistant,
content: content.into(),
content_parts: Vec::new(),
tool_call_id: None,
tool_name: None,
tool_calls: invocations,
created_at: Some(current_unix_millis()),
thinking_content: thinking,
}
}
fn new(role: ModelRole, content: impl Into<String>) -> Self {
Self {
role,
content: content.into(),
content_parts: Vec::new(),
tool_call_id: None,
tool_name: None,
tool_calls: Vec::new(),
created_at: Some(current_unix_millis()),
thinking_content: None,
}
}
}
fn current_unix_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThinkingConfig {
#[serde(alias = "adaptive", alias = "auto")]
Max,
High,
Medium,
Low,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThinkingRequest {
pub enabled: bool,
pub effort: Option<String>,
pub budget_tokens: Option<u32>,
}
impl ThinkingConfig {
pub fn to_thinking_request(self) -> ThinkingRequest {
match self {
Self::Max => ThinkingRequest {
enabled: true,
effort: Some("xhigh".to_string()),
budget_tokens: Some(32000),
},
Self::High => ThinkingRequest {
enabled: true,
effort: Some("high".to_string()),
budget_tokens: Some(10000),
},
Self::Medium => ThinkingRequest {
enabled: true,
effort: Some("medium".to_string()),
budget_tokens: Some(4096),
},
Self::Low => ThinkingRequest {
enabled: true,
effort: Some("low".to_string()),
budget_tokens: Some(1024),
},
Self::Off => ThinkingRequest {
enabled: false,
effort: None,
budget_tokens: None,
},
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Max => "max",
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
Self::Off => "off",
}
}
pub fn from_config_str(value: &str) -> Self {
parse_reasoning_level(value).unwrap_or(Self::Max)
}
pub fn clamp_to_supported(self, supported: &[ThinkingConfig]) -> Self {
if supported.is_empty() || supported.contains(&self) {
return self;
}
for candidate in [Self::Max, Self::High, Self::Medium, Self::Low, Self::Off] {
if supported.contains(&candidate) {
return candidate;
}
}
supported[0]
}
}
pub fn parse_reasoning_level(raw: &str) -> Option<ThinkingConfig> {
match raw.trim().to_ascii_lowercase().as_str() {
"adaptive" | "auto" | "max" | "xhigh" | "x-high" | "ultra" | "highest" => {
Some(ThinkingConfig::Max)
}
"high" => Some(ThinkingConfig::High),
"medium" | "med" | "mid" | "default" => Some(ThinkingConfig::Medium),
"on" | "enabled" | "true" | "1" => Some(ThinkingConfig::Max),
"low" | "minimal" | "min" => Some(ThinkingConfig::Low),
"off" | "none" | "disabled" | "false" | "0" => Some(ThinkingConfig::Off),
_ => None,
}
}
pub fn effort_display_label(level: ThinkingConfig, binary: bool) -> &'static str {
if binary {
match level {
ThinkingConfig::Off => "thinking off",
_ => "thinking on",
}
} else {
level.as_config_str()
}
}
pub const DEFAULT_REASONING_LEVELS: &[ThinkingConfig] = &[
ThinkingConfig::Max,
ThinkingConfig::High,
ThinkingConfig::Medium,
ThinkingConfig::Low,
ThinkingConfig::Off,
];
pub const BINARY_REASONING_LEVELS: &[ThinkingConfig] = &[ThinkingConfig::Max, ThinkingConfig::Off];
pub fn thinking_levels_for_model(
supports_thinking: Option<bool>,
reasoning_levels: &[String],
) -> Vec<ThinkingConfig> {
if supports_thinking == Some(false) {
return vec![ThinkingConfig::Off];
}
if reasoning_levels.is_empty() {
return BINARY_REASONING_LEVELS.to_vec();
}
let mut out = Vec::new();
for raw in reasoning_levels {
if let Some(level) = parse_reasoning_level(raw) {
if !out.contains(&level) {
out.push(level);
}
}
}
if out.is_empty() {
return BINARY_REASONING_LEVELS.to_vec();
}
let order = DEFAULT_REASONING_LEVELS;
out.sort_by_key(|l| order.iter().position(|o| o == l).unwrap_or(99));
out
}
pub fn is_binary_effort_model(
supports_thinking: Option<bool>,
reasoning_levels: &[String],
) -> bool {
if supports_thinking == Some(false) {
return false;
}
if reasoning_levels.is_empty() {
return true;
}
!reasoning_levels
.iter()
.any(|raw| parse_reasoning_level(raw).is_some())
}
pub fn resolve_model_thinking_level(
current: ThinkingConfig,
supports_thinking: Option<bool>,
reasoning_levels: &[String],
default_reasoning_effort: Option<&str>,
) -> ThinkingConfig {
let supported = thinking_levels_for_model(supports_thinking, reasoning_levels);
if supports_thinking == Some(false) {
return ThinkingConfig::Off;
}
if supported.contains(¤t) {
return current;
}
if let Some(def) = default_reasoning_effort.and_then(parse_reasoning_level) {
return def.clamp_to_supported(&supported);
}
ThinkingConfig::Max.clamp_to_supported(&supported)
}
pub fn resolve_effort_label(
thinking: ThinkingConfig,
reasoning_levels: &[String],
provider_id: &str,
) -> Option<String> {
if matches!(thinking, ThinkingConfig::Off) {
return None;
}
let concrete = thinking;
for raw in reasoning_levels {
if parse_reasoning_level(raw) == Some(concrete) {
return Some(raw.trim().to_ascii_lowercase());
}
}
let provider = crate::ProviderId::from_config_id(provider_id);
if provider.as_str() == crate::ProviderId::OPENROUTER {
return Some(
match concrete {
ThinkingConfig::Max => "xhigh",
ThinkingConfig::High => "high",
ThinkingConfig::Medium => "medium",
ThinkingConfig::Low => "low",
ThinkingConfig::Off => "medium",
}
.to_string(),
);
}
Some(
match concrete {
ThinkingConfig::Max => {
if reasoning_levels
.iter()
.any(|l| matches!(l.trim().to_ascii_lowercase().as_str(), "xhigh" | "max"))
{
"xhigh"
} else {
"high"
}
}
ThinkingConfig::High => "high",
ThinkingConfig::Medium => "medium",
ThinkingConfig::Low => "low",
ThinkingConfig::Off => return None,
}
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regression_thinking_request_high_produces_effort_and_budget() {
let request = ThinkingConfig::High.to_thinking_request();
assert!(request.enabled);
assert_eq!(request.effort.as_deref(), Some("high"));
assert_eq!(request.budget_tokens, Some(10000));
}
#[test]
fn regression_thinking_request_max_produces_effort_and_budget() {
let request = ThinkingConfig::Max.to_thinking_request();
assert!(request.enabled);
assert_eq!(request.effort.as_deref(), Some("xhigh"));
assert_eq!(request.budget_tokens, Some(32000));
}
#[test]
fn regression_thinking_request_off_produces_disabled() {
let request = ThinkingConfig::Off.to_thinking_request();
assert!(!request.enabled);
assert!(request.effort.is_none());
assert!(request.budget_tokens.is_none());
}
#[test]
fn regression_thinking_request_medium_produces_medium_effort() {
let request = ThinkingConfig::Medium.to_thinking_request();
assert!(request.enabled);
assert_eq!(request.effort.as_deref(), Some("medium"));
assert_eq!(request.budget_tokens, Some(4096));
}
#[test]
fn regression_thinking_request_low_produces_low_effort() {
let request = ThinkingConfig::Low.to_thinking_request();
assert!(request.enabled);
assert_eq!(request.effort.as_deref(), Some("low"));
assert_eq!(request.budget_tokens, Some(1024));
}
#[test]
fn thinking_levels_for_model_respects_registry() {
let levels = thinking_levels_for_model(
Some(true),
&["none".into(), "low".into(), "high".into(), "xhigh".into()],
);
assert_eq!(
levels,
vec![
ThinkingConfig::Max,
ThinkingConfig::High,
ThinkingConfig::Low,
ThinkingConfig::Off,
]
);
assert!(!is_binary_effort_model(
Some(true),
&["none".into(), "low".into(), "high".into(), "xhigh".into()],
));
}
#[test]
fn thinking_levels_off_only_when_no_thinking() {
let levels = thinking_levels_for_model(Some(false), &["high".into()]);
assert_eq!(levels, vec![ThinkingConfig::Off]);
assert!(!is_binary_effort_model(Some(false), &["high".into()]));
}
#[test]
fn thinking_levels_binary_when_registry_empty() {
let levels = thinking_levels_for_model(Some(true), &[]);
assert_eq!(levels, vec![ThinkingConfig::Max, ThinkingConfig::Off]);
assert!(is_binary_effort_model(Some(true), &[]));
assert!(is_binary_effort_model(None, &[]));
}
#[test]
fn thinking_levels_model_specific_no_extra_options() {
let levels = thinking_levels_for_model(Some(true), &["low".into(), "high".into()]);
assert_eq!(levels, vec![ThinkingConfig::High, ThinkingConfig::Low]);
}
#[test]
fn resolve_effort_prefers_registry_label() {
let label = resolve_effort_label(
ThinkingConfig::Max,
&["low".into(), "high".into(), "xhigh".into()],
"openai",
);
assert_eq!(label.as_deref(), Some("xhigh"));
}
#[test]
fn clamp_unsupported_level_to_supported() {
let supported = vec![ThinkingConfig::Low, ThinkingConfig::Off];
assert_eq!(
ThinkingConfig::High.clamp_to_supported(&supported),
ThinkingConfig::Low
);
}
#[test]
fn regression_system_message_has_correct_role() {
let msg = ModelMessage::system("test".to_string());
assert_eq!(msg.role, ModelRole::System);
assert_eq!(msg.content, "test");
}
#[test]
fn regression_user_message_has_correct_role() {
let msg = ModelMessage::user("hello".to_string());
assert_eq!(msg.role, ModelRole::User);
assert_eq!(msg.content, "hello");
}
#[test]
fn regression_assistant_message_has_correct_role() {
let msg = ModelMessage::assistant("response".to_string());
assert_eq!(msg.role, ModelRole::Assistant);
assert_eq!(msg.content, "response");
}
#[test]
fn regression_tool_result_sets_call_id_and_name() {
let msg = ModelMessage::tool_result("call-1", "read_file", "content");
assert_eq!(msg.role, ModelRole::Tool);
assert_eq!(msg.tool_call_id.as_deref(), Some("call-1"));
assert_eq!(msg.tool_name.as_deref(), Some("read_file"));
assert_eq!(msg.content, "content");
}
#[test]
fn regression_assistant_tool_call_with_context_sets_fields() {
let inv = ToolInvocation {
id: "call-1".to_string(),
tool_name: "read_file".to_string(),
input: serde_json::json!({"path": "test.rs"}),
};
let msg = ModelMessage::assistant_tool_call_with_context(
inv,
"thinking text",
Some("reasoning".to_string()),
);
assert_eq!(msg.role, ModelRole::Assistant);
assert_eq!(msg.content, "thinking text");
assert_eq!(msg.thinking_content.as_deref(), Some("reasoning"));
assert_eq!(msg.tool_calls.len(), 1);
assert_eq!(msg.tool_calls[0].id, "call-1");
}
#[test]
fn regression_model_message_serialization_roundtrip() {
let msg = ModelMessage {
role: ModelRole::Assistant,
content: "hello".to_string(),
content_parts: Vec::new(),
tool_call_id: None,
tool_name: None,
tool_calls: vec![],
thinking_content: Some("thinking".to_string()),
created_at: Some(12345),
};
let json = serde_json::to_string(&msg).unwrap();
let deserialized: ModelMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.role, msg.role);
assert_eq!(deserialized.content, msg.content);
assert_eq!(deserialized.thinking_content, msg.thinking_content);
assert!(deserialized.created_at.is_none());
}
#[test]
fn legacy_adaptive_string_maps_to_max() {
assert_eq!(
ThinkingConfig::from_config_str("adaptive"),
ThinkingConfig::Max
);
assert_eq!(parse_reasoning_level("auto"), Some(ThinkingConfig::Max));
assert_eq!(parse_reasoning_level("on"), Some(ThinkingConfig::Max));
let deserialized: ThinkingConfig = serde_json::from_str("\"adaptive\"").unwrap();
assert_eq!(deserialized, ThinkingConfig::Max);
}
#[test]
fn resolve_forces_off_when_model_lacks_reasoning() {
let resolved =
resolve_model_thinking_level(ThinkingConfig::Max, Some(false), &["high".into()], None);
assert_eq!(resolved, ThinkingConfig::Off);
}
#[test]
fn resolve_defaults_to_max_when_preference_unsupported() {
let resolved = resolve_model_thinking_level(
ThinkingConfig::Low,
Some(true),
&["high".into(), "xhigh".into()],
None,
);
assert_eq!(resolved, ThinkingConfig::Max);
}
#[test]
fn binary_on_is_max() {
assert_eq!(
BINARY_REASONING_LEVELS,
&[ThinkingConfig::Max, ThinkingConfig::Off]
);
assert_eq!(
effort_display_label(ThinkingConfig::Max, true),
"thinking on"
);
assert_eq!(
effort_display_label(ThinkingConfig::Off, true),
"thinking off"
);
}
}