use crate::{
config::{ConfigError, EffectiveConfig, TextVerbosity},
providers::{
ANTHROPIC_PROVIDER, CLAUDE_SUBSCRIPTION_PROVIDER, DEFAULT_ANTHROPIC_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;
use std::{
collections::{BTreeMap, 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 selection = Self::from_config_without_auth(config);
if !config.auth_state().is_ready() {
return Err(config.missing_auth_error());
}
Ok(selection)
}
pub(crate) fn from_config_without_auth(config: &EffectiveConfig) -> Self {
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());
Self { provider, model }
}
}
pub(crate) fn default_model_for_provider(provider: &str) -> &'static str {
match provider {
ANTHROPIC_PROVIDER => DEFAULT_ANTHROPIC_MODEL,
CLAUDE_SUBSCRIPTION_PROVIDER => "claude-opus-5-5",
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(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderToolResult {
pub call_id: String,
pub tool_name: String,
pub success: bool,
pub output: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) skill_reads: Vec<crate::tools::skill_provenance::SkillReadProvenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ProviderConversationItem {
Message(ChatMessage),
ResponseItem(Value),
ToolResult(ProviderToolResult),
LegacyReplayNote {
event_type: String,
content: String,
},
ReasoningSelection {
provider: String,
model: String,
effort: ThinkingLevel,
},
}
impl ProviderConversationItem {
pub(crate) fn legacy_note_text(event_type: &str, content: &str) -> String {
format!("Legacy session replay fallback (event_type={event_type}): {content}")
}
pub(crate) fn reasoning_selection_from_payload(payload: &Value) -> Option<Self> {
let selection = payload.get("reasoning_selection")?;
Some(Self::ReasoningSelection {
provider: selection.get("provider")?.as_str()?.to_owned(),
model: selection.get("model")?.as_str()?.to_owned(),
effort: serde_json::from_value(selection.get("effort")?.clone()).ok()?,
})
}
}
#[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) -> impl Iterator<Item = &ProviderConversationItem> + Clone {
let (base, turn) = match self {
Self::Owned(items) => (items.as_slice(), &[][..]),
Self::Shared { base, turn } => (base.as_ref(), turn.as_slice()),
};
base.iter().chain(turn.iter())
}
}
pub(crate) fn conversation_id_for_session(session_id: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(session_id.as_bytes());
format!("magi-code-session-{}", crate::hex::lower_hex(digest))
.chars()
.take("magi-code-session-".len() + 32)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderRequest {
pub model: String,
conversation_items: ProviderConversation,
skill_projection: BTreeMap<usize, ProviderConversationItem>,
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>,
conversation_id: Option<String>,
pub(crate) codex_turn_context: Option<super::CodexTurnContext>,
subagents_tool_enabled: bool,
dynamic_tool_definitions: Arc<[Value]>,
disabled_tool_names: Arc<[String]>,
resolved_tool_catalog: Option<Arc<[Value]>>,
}
impl ProviderRequest {
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(crate) fn conversation_items_iter(
&self,
) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
Box::new(
self.conversation_items
.iter()
.enumerate()
.map(|(index, raw)| self.skill_projection.get(&index).unwrap_or(raw)),
)
}
pub(crate) fn has_skill_read_provenance(&self) -> bool {
self.conversation_items.iter().any(|item| {
matches!(item, ProviderConversationItem::ToolResult(result) if !result.skill_reads.is_empty())
})
}
pub fn tools_enabled(&self) -> bool {
self.tool_mode == ProviderToolMode::Enabled
}
pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
self.thinking_level = thinking_level;
self
}
pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
let selected_effort = |item: &ProviderConversationItem| match item {
ProviderConversationItem::ReasoningSelection {
provider,
model,
effort,
} if provider == provider_id && model == &self.model => Some(*effort),
_ => None,
};
let reset_index = self
.conversation_items
.iter()
.enumerate()
.filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
.map(|(index, _)| index)
.last()
.unwrap_or(0);
let mut baseline = None;
let mut effective = None;
let mut items = Vec::new();
for (index, item) in self.conversation_items.iter().enumerate() {
if let Some(effort) = selected_effort(item) {
if index < reset_index {
continue;
}
if baseline.is_none() {
baseline = Some(effort);
} else if effective != Some(effort)
&& let Some(effort) = effort.explicit_effort()
{
items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
"type": "configuration_update", "reasoning": {"effort": effort}
})));
}
effective = Some(effort);
} else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
items.push(item.clone());
}
}
if let Some(baseline) = baseline {
self.thinking_level = baseline;
}
self.conversation_items = ProviderConversation::Owned(items);
self.refresh_skill_projection();
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
}
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_conversation_id(mut self, id: impl Into<String>) -> Self {
self.conversation_id = Some(id.into());
self
}
pub(crate) fn conversation_id(&self) -> Option<&str> {
self.conversation_id.as_deref()
}
pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
self.subagents_tool_enabled = enabled;
self
}
pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
(self.tools_enabled()
&& self.resolved_tool_catalog.is_none()
&& self.dynamic_tool_definitions.is_empty()
&& self.disabled_tool_names.is_empty())
.then_some(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 with_resolved_tool_catalog(mut self, definitions: Vec<Value>) -> Self {
self.resolved_tool_catalog = Some(Arc::from(definitions));
self
}
pub(crate) fn tool_definitions_json(&self) -> Value {
if let Some(definitions) = &self.resolved_tool_catalog {
return Value::Array(definitions.to_vec());
}
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 {
let skill_projection = super::skill_projection::project(conversation_items.iter());
Self {
model: model.into(),
conversation_items,
skill_projection,
stream: true,
tool_mode,
thinking_level: ThinkingLevel::Default,
text_verbosity: None,
send_default_reasoning_summary: false,
semantic_progress_timeout: None,
prompt_cache_key: None,
conversation_id: None,
codex_turn_context: 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()),
resolved_tool_catalog: None,
}
}
fn refresh_skill_projection(&mut self) {
self.skill_projection = super::skill_projection::project(self.conversation_items.iter());
}
}
#[cfg(test)]
impl ProviderRequest {
pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
Self::from_messages(model, messages, ProviderToolMode::Enabled)
}
}