use crate::{
config::{ConfigError, EffectiveConfig, TextVerbosity},
providers::{
ANTHROPIC_PROVIDER, CLAUDE_CODE_PROVIDER, DEFAULT_ANTHROPIC_MODEL,
DEFAULT_CLAUDE_CODE_MODEL, DEFAULT_CODEX_MODEL, OPENAI_CODEX_PROVIDER,
openai_stream::PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT,
},
thinking::ThinkingLevel,
tools::tool_definitions_json_with_dynamic,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg(test)]
use serde_json::json;
use std::{borrow::Cow, collections::HashSet, sync::Arc, time::Duration};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSelection {
pub provider: String,
pub model: String,
}
impl ProviderSelection {
pub fn from_config(config: &EffectiveConfig) -> Result<Self, ConfigError> {
let provider = config
.provider
.clone()
.unwrap_or_else(|| OPENAI_CODEX_PROVIDER.to_string());
let model = config
.model
.clone()
.unwrap_or_else(|| default_model_for_provider(&provider).to_string());
if !config.auth_state().is_ready() {
return Err(config.missing_auth_error());
}
Ok(Self { provider, model })
}
}
pub(crate) fn default_model_for_provider(provider: &str) -> &'static str {
match provider {
ANTHROPIC_PROVIDER => DEFAULT_ANTHROPIC_MODEL,
CLAUDE_CODE_PROVIDER => DEFAULT_CLAUDE_CODE_MODEL,
OPENAI_CODEX_PROVIDER => DEFAULT_CODEX_MODEL,
_ => DEFAULT_CODEX_MODEL,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MessageRole {
System,
User,
Assistant,
Tool,
}
impl MessageRole {
pub(crate) fn as_api_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool => "tool",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: MessageRole::System,
content: content.into(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: MessageRole::User,
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: MessageRole::Assistant,
content: content.into(),
}
}
pub fn tool(content: impl Into<String>) -> Self {
Self {
role: MessageRole::Tool,
content: content.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderToolResult {
pub call_id: String,
pub tool_name: String,
pub success: bool,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ProviderConversationItem {
Message(ChatMessage),
ResponseItem(Value),
ToolResult(ProviderToolResult),
LegacyReplayNote { event_type: String, content: String },
}
impl ProviderConversationItem {
pub(crate) fn legacy_note_text(event_type: &str, content: &str) -> String {
format!("Legacy session replay fallback (event_type={event_type}): {content}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderToolMode {
Enabled,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ProviderConversation {
Owned(Vec<ProviderConversationItem>),
Shared {
base: Arc<[ProviderConversationItem]>,
turn: Vec<ProviderConversationItem>,
},
}
impl ProviderConversation {
fn iter(&self) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
match self {
Self::Owned(items) => Box::new(items.iter()),
Self::Shared { base, turn } => Box::new(base.iter().chain(turn.iter())),
}
}
fn to_cow(&self) -> Cow<'_, [ProviderConversationItem]> {
match self {
Self::Owned(items) => Cow::Borrowed(items.as_slice()),
Self::Shared { base, turn } => {
Cow::Owned(base.iter().chain(turn.iter()).cloned().collect())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderRequest {
pub model: String,
conversation_items: ProviderConversation,
pub stream: bool,
pub tool_mode: ProviderToolMode,
pub thinking_level: ThinkingLevel,
text_verbosity: Option<TextVerbosity>,
send_default_reasoning_summary: bool,
semantic_progress_timeout: Option<Duration>,
prompt_cache_key: Option<String>,
subagents_tool_enabled: bool,
dynamic_tool_definitions: Arc<[Value]>,
disabled_tool_names: Arc<[String]>,
}
impl ProviderRequest {
#[cfg(test)]
pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
Self::from_messages(model, messages, ProviderToolMode::Enabled)
}
pub fn new_without_tools(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
Self::from_messages(model, messages, ProviderToolMode::Disabled)
}
pub fn from_conversation(
model: impl Into<String>,
conversation_items: Vec<ProviderConversationItem>,
) -> Self {
Self::from_conversation_with_tool_mode(model, conversation_items, ProviderToolMode::Enabled)
}
pub fn from_conversation_without_tools(
model: impl Into<String>,
conversation_items: Vec<ProviderConversationItem>,
) -> Self {
Self::from_conversation_with_tool_mode(
model,
conversation_items,
ProviderToolMode::Disabled,
)
}
pub(crate) fn from_shared_conversation(
model: impl Into<String>,
base_items: Arc<[ProviderConversationItem]>,
turn_items: &[ProviderConversationItem],
) -> Self {
Self::from_conversation_storage(
model,
ProviderConversation::Shared {
base: base_items,
turn: turn_items.to_vec(),
},
ProviderToolMode::Enabled,
)
}
pub fn conversation_items(&self) -> Cow<'_, [ProviderConversationItem]> {
self.conversation_items.to_cow()
}
pub(crate) fn conversation_items_iter(
&self,
) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
self.conversation_items.iter()
}
#[cfg(test)]
pub(crate) fn to_owned_request(&self) -> Self {
let mut request = Self::from_conversation_with_tool_mode(
self.model.clone(),
self.conversation_items_iter().cloned().collect(),
self.tool_mode,
)
.with_thinking_level(self.thinking_level)
.with_text_verbosity(self.text_verbosity)
.with_default_reasoning_summary(self.send_default_reasoning_summary)
.with_subagents_tool_enabled(self.subagents_tool_enabled)
.with_disabled_tool_names(self.disabled_tool_names.to_vec())
.with_dynamic_tool_definitions(self.dynamic_tool_definitions.to_vec());
request.stream = self.stream;
request.semantic_progress_timeout = self.semantic_progress_timeout;
request.prompt_cache_key.clone_from(&self.prompt_cache_key);
request
}
pub(crate) fn messages(&self) -> Vec<ChatMessage> {
self.conversation_items_iter()
.filter_map(|item| match item {
ProviderConversationItem::Message(message) => Some(message.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
pub(crate) fn response_items(&self) -> Vec<Value> {
self.conversation_items_iter()
.filter_map(|item| match item {
ProviderConversationItem::ResponseItem(item) => Some(item.clone()),
ProviderConversationItem::ToolResult(result) => {
Some(provider_tool_result_response_item(result))
}
_ => None,
})
.collect()
}
#[cfg(test)]
pub(crate) fn tool_results(&self) -> Vec<ProviderToolResult> {
self.conversation_items_iter()
.filter_map(|item| match item {
ProviderConversationItem::ToolResult(result) => Some(result.clone()),
_ => None,
})
.collect()
}
pub fn tools_enabled(&self) -> bool {
self.tool_mode == ProviderToolMode::Enabled
}
#[cfg(test)]
pub fn with_response_items(mut self, response_items: Vec<Value>) -> Self {
let messages = self.messages();
let tool_results = self.tool_results();
self.conversation_items = ProviderConversation::Owned(
messages
.into_iter()
.map(ProviderConversationItem::Message)
.chain(
response_items
.into_iter()
.map(ProviderConversationItem::ResponseItem),
)
.chain(
tool_results
.into_iter()
.map(ProviderConversationItem::ToolResult),
)
.collect(),
);
self
}
#[cfg(test)]
pub fn with_tool_results(mut self, tool_results: Vec<ProviderToolResult>) -> Self {
let messages = self.messages();
let response_items = self.stored_response_items();
self.conversation_items = ProviderConversation::Owned(
messages
.into_iter()
.map(ProviderConversationItem::Message)
.chain(
response_items
.into_iter()
.map(ProviderConversationItem::ResponseItem),
)
.chain(
tool_results
.into_iter()
.map(ProviderConversationItem::ToolResult),
)
.collect(),
);
self
}
pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
self.thinking_level = thinking_level;
self
}
pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
self.text_verbosity = text_verbosity;
self
}
pub fn text_verbosity(&self) -> Option<TextVerbosity> {
self.text_verbosity
}
pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
self.send_default_reasoning_summary = supported;
self
}
pub(crate) fn send_default_reasoning_summary(&self) -> bool {
self.send_default_reasoning_summary
}
pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
self.semantic_progress_timeout = Some(timeout);
self
}
#[cfg(test)]
pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
self.semantic_progress_timeout
}
pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
self.semantic_progress_timeout
.unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
}
pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
self.prompt_cache_key = Some(key.into());
self
}
pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
self.prompt_cache_key.as_deref()
}
pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
self.subagents_tool_enabled = enabled;
self
}
#[cfg(test)]
pub(crate) fn subagents_tool_enabled(&self) -> bool {
self.subagents_tool_enabled
}
pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
self
}
pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
self.disabled_tool_names = Arc::from(names.into_boxed_slice());
self
}
pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
if !self.tools_enabled() {
return None;
}
let definitions = self.tool_definitions_json();
definitions
.as_array()
.is_some_and(|definitions| !definitions.is_empty())
.then_some(definitions)
}
pub(crate) fn tool_definitions_json(&self) -> Value {
let disabled = self
.disabled_tool_names
.iter()
.cloned()
.collect::<HashSet<_>>();
tool_definitions_json_with_dynamic(
self.subagents_tool_enabled,
&self.dynamic_tool_definitions,
&disabled,
)
}
fn from_messages(
model: impl Into<String>,
messages: Vec<ChatMessage>,
tool_mode: ProviderToolMode,
) -> Self {
let conversation_items = messages
.into_iter()
.map(ProviderConversationItem::Message)
.collect();
Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
}
fn from_conversation_with_tool_mode(
model: impl Into<String>,
conversation_items: Vec<ProviderConversationItem>,
tool_mode: ProviderToolMode,
) -> Self {
Self::from_conversation_storage(
model,
ProviderConversation::Owned(conversation_items),
tool_mode,
)
}
fn from_conversation_storage(
model: impl Into<String>,
conversation_items: ProviderConversation,
tool_mode: ProviderToolMode,
) -> Self {
Self {
model: model.into(),
conversation_items,
stream: true,
tool_mode,
thinking_level: ThinkingLevel::Default,
text_verbosity: None,
send_default_reasoning_summary: false,
semantic_progress_timeout: None,
prompt_cache_key: None,
subagents_tool_enabled: true,
dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
}
}
#[cfg(test)]
fn stored_response_items(&self) -> Vec<Value> {
self.conversation_items_iter()
.filter_map(|item| match item {
ProviderConversationItem::ResponseItem(item) => Some(item.clone()),
_ => None,
})
.collect()
}
}
#[cfg(test)]
fn provider_tool_result_response_item(result: &ProviderToolResult) -> Value {
json!({
"type": "function_call_output",
"call_id": result.call_id,
"output": result.output,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{McPaths, ProviderCredential};
#[test]
fn provider_request_empty_dynamic_tools_preserves_static_definitions_byte_for_byte() {
let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
.with_dynamic_tool_definitions(Vec::new());
assert_eq!(
request.tool_definitions_json().to_string(),
crate::tools::mvp_tool_definitions_json().to_string()
);
}
#[test]
fn provider_request_shared_conversation_iterates_base_before_turn() {
let base = std::sync::Arc::<[ProviderConversationItem]>::from(vec![
ProviderConversationItem::Message(ChatMessage::system("sys")),
ProviderConversationItem::Message(ChatMessage::user("prompt")),
]);
let turn = vec![
ProviderConversationItem::ResponseItem(json!({
"type": "function_call",
"call_id": "call_1",
"name": "read",
"arguments": "{}"
})),
ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "ok".to_string(),
}),
];
let request =
ProviderRequest::from_shared_conversation("model", std::sync::Arc::clone(&base), &turn);
let kinds = request
.conversation_items_iter()
.map(|item| match item {
ProviderConversationItem::Message(message) => message.role.as_api_str(),
ProviderConversationItem::ResponseItem(_) => "response_item",
ProviderConversationItem::ToolResult(_) => "tool_result",
ProviderConversationItem::LegacyReplayNote { .. } => "legacy",
})
.collect::<Vec<_>>();
assert_eq!(
kinds,
vec!["system", "user", "response_item", "tool_result"]
);
assert_eq!(request.messages().len(), 2);
assert_eq!(request.response_items().len(), 2);
assert_eq!(request.tool_results().len(), 1);
let returned = request.conversation_items();
assert_eq!(
returned.as_ref(),
[base.as_ref(), turn.as_slice()].concat().as_slice()
);
}
#[test]
fn provider_request_shared_conversation_items_returns_ordered_cow_without_panic() {
let base = std::sync::Arc::<[ProviderConversationItem]>::from(vec![
ProviderConversationItem::Message(ChatMessage::system("sys")),
ProviderConversationItem::Message(ChatMessage::user("prompt")),
]);
let turn = vec![ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "ok".to_string(),
})];
let request =
ProviderRequest::from_shared_conversation("model", std::sync::Arc::clone(&base), &turn);
let items = request.conversation_items();
assert_eq!(
items.as_ref(),
[base.as_ref(), turn.as_slice()].concat().as_slice()
);
}
#[test]
fn provider_request_preserves_ordered_conversation_items() {
let items = vec![
ProviderConversationItem::Message(ChatMessage::system("sys")),
ProviderConversationItem::Message(ChatMessage::user("old")),
ProviderConversationItem::ResponseItem(json!({
"type": "function_call",
"call_id": "call_1",
"name": "read",
"arguments": "{}"
})),
ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "ok".to_string(),
}),
ProviderConversationItem::Message(ChatMessage::user("new")),
];
let request = ProviderRequest::from_conversation("model", items.clone());
assert_eq!(request.conversation_items().as_ref(), items.as_slice());
assert_eq!(request.messages().len(), 3);
assert_eq!(request.response_items().len(), 2);
assert_eq!(request.tool_results().len(), 1);
}
#[test]
fn provider_request_compat_views_are_derived_from_conversation_items() {
let request = ProviderRequest::from_conversation(
"model",
vec![
ProviderConversationItem::Message(ChatMessage::system("sys")),
ProviderConversationItem::ResponseItem(json!({
"type": "function_call",
"call_id": "call_1",
"name": "read",
"arguments": "{}"
})),
ProviderConversationItem::ToolResult(ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "ok".to_string(),
}),
],
);
assert_eq!(request.messages(), vec![ChatMessage::system("sys")]);
assert_eq!(request.tool_results()[0].call_id, "call_1");
assert_eq!(request.response_items()[0]["type"], "function_call");
assert_eq!(request.response_items()[1]["type"], "function_call_output");
assert_eq!(request.response_items()[1]["output"], "ok");
}
#[test]
fn provider_request_compat_constructors_seed_conversation_items() {
let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
.with_response_items(vec![json!({"type":"reasoning"})])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".to_string(),
tool_name: "read".to_string(),
success: true,
output: "ok".to_string(),
}]);
assert!(matches!(
request.conversation_items()[0],
ProviderConversationItem::Message(_)
));
assert!(matches!(
request.conversation_items()[1],
ProviderConversationItem::ResponseItem(_)
));
assert!(matches!(
request.conversation_items()[2],
ProviderConversationItem::ToolResult(_)
));
}
#[test]
fn provider_request_prompt_cache_key_defaults_to_none() {
let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")]);
assert_eq!(request.prompt_cache_key(), None);
}
#[test]
fn provider_request_prompt_cache_key_builder_preserves_metadata_setters() {
let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
.with_prompt_cache_key("magi-code-session-0123456789abcdef0123456789abcdef")
.with_thinking_level(crate::thinking::ThinkingLevel::High)
.with_default_reasoning_summary(true)
.with_semantic_progress_timeout(Duration::from_secs(7));
assert_eq!(
request.prompt_cache_key(),
Some("magi-code-session-0123456789abcdef0123456789abcdef")
);
assert_eq!(request.thinking_level, crate::thinking::ThinkingLevel::High);
assert!(request.send_default_reasoning_summary());
assert_eq!(
request.semantic_progress_timeout(),
Some(Duration::from_secs(7))
);
}
#[test]
fn provider_request_text_verbosity_defaults_and_owned_clone_preserves_value() {
let default = ProviderRequest::new("model", vec![ChatMessage::user("hello")]);
assert_eq!(default.text_verbosity(), None);
let high = default
.with_text_verbosity(Some(crate::config::TextVerbosity::High))
.to_owned_request();
assert_eq!(
high.text_verbosity(),
Some(crate::config::TextVerbosity::High)
);
}
#[test]
fn provider_selection_accepts_configured_no_auth_custom_provider() {
let temp = tempfile::TempDir::new().unwrap();
let mut custom_providers = std::collections::BTreeMap::new();
custom_providers.insert(
"local-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:8080/v1".to_string(),
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
);
let config = EffectiveConfig {
provider: Some("local-ai".to_string()),
model: Some("model-a".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers,
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: Some(ProviderCredential::NoAuth),
paths: McPaths::from_root(temp.path().join("mc")),
};
assert_eq!(
ProviderSelection::from_config(&config).unwrap(),
ProviderSelection {
provider: "local-ai".to_string(),
model: "model-a".to_string(),
}
);
}
#[test]
fn provider_selection_accepts_anthropic_api_key() {
let temp = tempfile::TempDir::new().unwrap();
let config = EffectiveConfig {
provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: Some("anthropic-key".to_string()),
auth: Some(ProviderCredential::ApiKey {
key: "anthropic-key".to_string(),
}),
paths: McPaths::from_root(temp.path().join("mc")),
};
assert_eq!(
ProviderSelection::from_config(&config).unwrap(),
ProviderSelection {
provider: crate::providers::ANTHROPIC_PROVIDER.to_string(),
model: crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string(),
}
);
}
#[test]
fn claude_code_provider_selection_defaults_to_sonnet() {
let temp = tempfile::TempDir::new().unwrap();
let config = EffectiveConfig {
provider: Some(crate::providers::CLAUDE_CODE_PROVIDER.to_string()),
model: None,
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: Some(ProviderCredential::ApiKey {
key: "sk-ant-api-test".to_string(),
}),
paths: McPaths::from_root(temp.path().join("mc")),
};
assert_eq!(
ProviderSelection::from_config(&config).unwrap(),
ProviderSelection {
provider: crate::providers::CLAUDE_CODE_PROVIDER.to_string(),
model: crate::providers::DEFAULT_CLAUDE_CODE_MODEL.to_string(),
}
);
}
#[test]
fn provider_selection_rejects_codex_api_key() {
let temp = tempfile::TempDir::new().unwrap();
let config = EffectiveConfig {
provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
model: Some(DEFAULT_CODEX_MODEL.to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: Some("not-for-codex".to_string()),
auth: Some(ProviderCredential::ApiKey {
key: "not-for-codex".to_string(),
}),
paths: McPaths::from_root(temp.path().join("mc")),
};
let error = ProviderSelection::from_config(&config)
.unwrap_err()
.to_string();
assert!(error.contains("unsupported for openai-codex"), "{error}");
assert!(
error.contains(&config.paths.auth_file.display().to_string()),
"{error}"
);
}
}