Skip to main content

magi_code/providers/
request.rs

1use crate::{
2    config::{ConfigError, EffectiveConfig, TextVerbosity},
3    providers::{
4        ANTHROPIC_PROVIDER, CLAUDE_SUBSCRIPTION_PROVIDER, DEFAULT_ANTHROPIC_MODEL,
5        DEFAULT_CODEX_MODEL, OPENAI_CODEX_PROVIDER,
6        openai_stream::PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT,
7    },
8    thinking::ThinkingLevel,
9    tools::tool_definitions_json_with_dynamic,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::{
14    collections::{BTreeMap, HashSet},
15    sync::Arc,
16    time::Duration,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProviderSelection {
21    pub provider: String,
22    pub model: String,
23}
24
25impl ProviderSelection {
26    pub fn from_config(config: &EffectiveConfig) -> Result<Self, ConfigError> {
27        let selection = Self::from_config_without_auth(config);
28        if !config.auth_state().is_ready() {
29            return Err(config.missing_auth_error());
30        }
31        Ok(selection)
32    }
33
34    pub(crate) fn from_config_without_auth(config: &EffectiveConfig) -> Self {
35        let provider = config
36            .provider
37            .clone()
38            .unwrap_or_else(|| OPENAI_CODEX_PROVIDER.to_string());
39        let model = config
40            .model
41            .clone()
42            .unwrap_or_else(|| default_model_for_provider(&provider).to_string());
43        Self { provider, model }
44    }
45}
46
47pub(crate) fn default_model_for_provider(provider: &str) -> &'static str {
48    match provider {
49        ANTHROPIC_PROVIDER => DEFAULT_ANTHROPIC_MODEL,
50        CLAUDE_SUBSCRIPTION_PROVIDER => "claude-opus-5-5",
51        OPENAI_CODEX_PROVIDER => DEFAULT_CODEX_MODEL,
52        _ => DEFAULT_CODEX_MODEL,
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub enum MessageRole {
58    System,
59    User,
60    Assistant,
61    Tool,
62}
63
64impl MessageRole {
65    pub(crate) fn as_api_str(&self) -> &'static str {
66        match self {
67            Self::System => "system",
68            Self::User => "user",
69            Self::Assistant => "assistant",
70            Self::Tool => "tool",
71        }
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct ChatMessage {
77    pub role: MessageRole,
78    pub content: String,
79}
80
81impl ChatMessage {
82    pub fn system(content: impl Into<String>) -> Self {
83        Self {
84            role: MessageRole::System,
85            content: content.into(),
86        }
87    }
88
89    pub fn user(content: impl Into<String>) -> Self {
90        Self {
91            role: MessageRole::User,
92            content: content.into(),
93        }
94    }
95
96    pub fn assistant(content: impl Into<String>) -> Self {
97        Self {
98            role: MessageRole::Assistant,
99            content: content.into(),
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct ProviderToolResult {
106    pub call_id: String,
107    pub tool_name: String,
108    pub success: bool,
109    pub output: String,
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub(crate) skill_reads: Vec<crate::tools::skill_provenance::SkillReadProvenance>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub enum ProviderConversationItem {
116    Message(ChatMessage),
117    ResponseItem(Value),
118    ToolResult(ProviderToolResult),
119    LegacyReplayNote {
120        event_type: String,
121        content: String,
122    },
123    /// Durable local metadata, not a provider wire item.
124    ReasoningSelection {
125        provider: String,
126        model: String,
127        effort: ThinkingLevel,
128    },
129}
130
131impl ProviderConversationItem {
132    pub(crate) fn legacy_note_text(event_type: &str, content: &str) -> String {
133        format!("Legacy session replay fallback (event_type={event_type}): {content}")
134    }
135
136    pub(crate) fn reasoning_selection_from_payload(payload: &Value) -> Option<Self> {
137        let selection = payload.get("reasoning_selection")?;
138        Some(Self::ReasoningSelection {
139            provider: selection.get("provider")?.as_str()?.to_owned(),
140            model: selection.get("model")?.as_str()?.to_owned(),
141            effort: serde_json::from_value(selection.get("effort")?.clone()).ok()?,
142        })
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ProviderToolMode {
148    Enabled,
149    Disabled,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153enum ProviderConversation {
154    Owned(Vec<ProviderConversationItem>),
155    Shared {
156        base: Arc<[ProviderConversationItem]>,
157        turn: Vec<ProviderConversationItem>,
158    },
159}
160
161impl ProviderConversation {
162    fn iter(&self) -> impl Iterator<Item = &ProviderConversationItem> + Clone {
163        let (base, turn) = match self {
164            Self::Owned(items) => (items.as_slice(), &[][..]),
165            Self::Shared { base, turn } => (base.as_ref(), turn.as_slice()),
166        };
167        base.iter().chain(turn.iter())
168    }
169}
170
171pub(crate) fn conversation_id_for_session(session_id: &str) -> String {
172    use sha2::{Digest, Sha256};
173
174    let digest = Sha256::digest(session_id.as_bytes());
175    format!("magi-code-session-{}", crate::hex::lower_hex(digest))
176        .chars()
177        .take("magi-code-session-".len() + 32)
178        .collect()
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ProviderRequest {
183    pub model: String,
184    conversation_items: ProviderConversation,
185    // Deterministic, request-local replacements; conversation_items always retains raw bodies.
186    skill_projection: BTreeMap<usize, ProviderConversationItem>,
187    pub stream: bool,
188    pub tool_mode: ProviderToolMode,
189    pub thinking_level: ThinkingLevel,
190    text_verbosity: Option<TextVerbosity>,
191    send_default_reasoning_summary: bool,
192    semantic_progress_timeout: Option<Duration>,
193    prompt_cache_key: Option<String>,
194    conversation_id: Option<String>,
195    pub(crate) codex_turn_context: Option<super::CodexTurnContext>,
196    subagents_tool_enabled: bool,
197    dynamic_tool_definitions: Arc<[Value]>,
198    disabled_tool_names: Arc<[String]>,
199    resolved_tool_catalog: Option<Arc<[Value]>>,
200}
201
202impl ProviderRequest {
203    pub fn new_without_tools(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
204        Self::from_messages(model, messages, ProviderToolMode::Disabled)
205    }
206
207    pub fn from_conversation(
208        model: impl Into<String>,
209        conversation_items: Vec<ProviderConversationItem>,
210    ) -> Self {
211        Self::from_conversation_with_tool_mode(model, conversation_items, ProviderToolMode::Enabled)
212    }
213
214    pub fn from_conversation_without_tools(
215        model: impl Into<String>,
216        conversation_items: Vec<ProviderConversationItem>,
217    ) -> Self {
218        Self::from_conversation_with_tool_mode(
219            model,
220            conversation_items,
221            ProviderToolMode::Disabled,
222        )
223    }
224
225    pub(crate) fn from_shared_conversation(
226        model: impl Into<String>,
227        base_items: Arc<[ProviderConversationItem]>,
228        turn_items: &[ProviderConversationItem],
229    ) -> Self {
230        Self::from_conversation_storage(
231            model,
232            ProviderConversation::Shared {
233                base: base_items,
234                turn: turn_items.to_vec(),
235            },
236            ProviderToolMode::Enabled,
237        )
238    }
239
240    /// Provider-visible view only. Rebuilds and snapshots must use raw storage so
241    /// removing a witness can restore previously projected skill bodies.
242    pub(crate) fn conversation_items_iter(
243        &self,
244    ) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
245        Box::new(
246            self.conversation_items
247                .iter()
248                .enumerate()
249                .map(|(index, raw)| self.skill_projection.get(&index).unwrap_or(raw)),
250        )
251    }
252
253    /// Raw provenance disables additive raw token estimates even when projection fails open.
254    pub(crate) fn has_skill_read_provenance(&self) -> bool {
255        self.conversation_items.iter().any(|item| {
256            matches!(item, ProviderConversationItem::ToolResult(result) if !result.skill_reads.is_empty())
257        })
258    }
259
260    pub fn tools_enabled(&self) -> bool {
261        self.tool_mode == ProviderToolMode::Enabled
262    }
263
264    pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
265        self.thinking_level = thinking_level;
266        self
267    }
268
269    /// Reconstruct updates from ordered durable selections, not process state.
270    pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
271        let selected_effort = |item: &ProviderConversationItem| match item {
272            ProviderConversationItem::ReasoningSelection {
273                provider,
274                model,
275                effort,
276            } if provider == provider_id && model == &self.model => Some(*effort),
277            _ => None,
278        };
279        // Default has no documented update representation. Start a new baseline
280        // instead of guessing a provider default when returning to it.
281        let reset_index = self
282            .conversation_items
283            .iter()
284            .enumerate()
285            .filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
286            .map(|(index, _)| index)
287            .last()
288            .unwrap_or(0);
289        let mut baseline = None;
290        let mut effective = None;
291        let mut items = Vec::new();
292        for (index, item) in self.conversation_items.iter().enumerate() {
293            if let Some(effort) = selected_effort(item) {
294                if index < reset_index {
295                    continue;
296                }
297                if baseline.is_none() {
298                    baseline = Some(effort);
299                } else if effective != Some(effort)
300                    && let Some(effort) = effort.explicit_effort()
301                {
302                    items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
303                        "type": "configuration_update", "reasoning": {"effort": effort}
304                    })));
305                }
306                effective = Some(effort);
307            } else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
308                items.push(item.clone());
309            }
310        }
311        if let Some(baseline) = baseline {
312            self.thinking_level = baseline;
313        }
314        self.conversation_items = ProviderConversation::Owned(items);
315        self.refresh_skill_projection();
316        self
317    }
318
319    pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
320        self.text_verbosity = text_verbosity;
321        self
322    }
323
324    pub fn text_verbosity(&self) -> Option<TextVerbosity> {
325        self.text_verbosity
326    }
327
328    pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
329        self.send_default_reasoning_summary = supported;
330        self
331    }
332
333    pub(crate) fn send_default_reasoning_summary(&self) -> bool {
334        self.send_default_reasoning_summary
335    }
336
337    pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
338        self.semantic_progress_timeout = Some(timeout);
339        self
340    }
341
342    pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
343        self.semantic_progress_timeout
344    }
345
346    pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
347        self.semantic_progress_timeout
348            .unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
349    }
350
351    pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
352        self.prompt_cache_key = Some(key.into());
353        self
354    }
355
356    pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
357        self.prompt_cache_key.as_deref()
358    }
359
360    pub(crate) fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
361        self.conversation_id = Some(id.into());
362        self
363    }
364
365    pub(crate) fn conversation_id(&self) -> Option<&str> {
366        self.conversation_id.as_deref()
367    }
368
369    pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
370        self.subagents_tool_enabled = enabled;
371        self
372    }
373
374    pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
375        (self.tools_enabled()
376            && self.resolved_tool_catalog.is_none()
377            && self.dynamic_tool_definitions.is_empty()
378            && self.disabled_tool_names.is_empty())
379        .then_some(self.subagents_tool_enabled)
380    }
381
382    pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
383        self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
384        self
385    }
386
387    pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
388        self.disabled_tool_names = Arc::from(names.into_boxed_slice());
389        self
390    }
391
392    pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
393        if !self.tools_enabled() {
394            return None;
395        }
396        let definitions = self.tool_definitions_json();
397        definitions
398            .as_array()
399            .is_some_and(|definitions| !definitions.is_empty())
400            .then_some(definitions)
401    }
402
403    pub(crate) fn with_resolved_tool_catalog(mut self, definitions: Vec<Value>) -> Self {
404        self.resolved_tool_catalog = Some(Arc::from(definitions));
405        self
406    }
407
408    pub(crate) fn tool_definitions_json(&self) -> Value {
409        if let Some(definitions) = &self.resolved_tool_catalog {
410            return Value::Array(definitions.to_vec());
411        }
412        let disabled = self
413            .disabled_tool_names
414            .iter()
415            .cloned()
416            .collect::<HashSet<_>>();
417        tool_definitions_json_with_dynamic(
418            self.subagents_tool_enabled,
419            &self.dynamic_tool_definitions,
420            &disabled,
421        )
422    }
423
424    fn from_messages(
425        model: impl Into<String>,
426        messages: Vec<ChatMessage>,
427        tool_mode: ProviderToolMode,
428    ) -> Self {
429        let conversation_items = messages
430            .into_iter()
431            .map(ProviderConversationItem::Message)
432            .collect();
433        Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
434    }
435
436    fn from_conversation_with_tool_mode(
437        model: impl Into<String>,
438        conversation_items: Vec<ProviderConversationItem>,
439        tool_mode: ProviderToolMode,
440    ) -> Self {
441        Self::from_conversation_storage(
442            model,
443            ProviderConversation::Owned(conversation_items),
444            tool_mode,
445        )
446    }
447
448    fn from_conversation_storage(
449        model: impl Into<String>,
450        conversation_items: ProviderConversation,
451        tool_mode: ProviderToolMode,
452    ) -> Self {
453        let skill_projection = super::skill_projection::project(conversation_items.iter());
454        Self {
455            model: model.into(),
456            conversation_items,
457            skill_projection,
458            stream: true,
459            tool_mode,
460            thinking_level: ThinkingLevel::Default,
461            text_verbosity: None,
462            send_default_reasoning_summary: false,
463            semantic_progress_timeout: None,
464            prompt_cache_key: None,
465            conversation_id: None,
466            codex_turn_context: None,
467            subagents_tool_enabled: true,
468            dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
469            disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
470            resolved_tool_catalog: None,
471        }
472    }
473
474    fn refresh_skill_projection(&mut self) {
475        self.skill_projection = super::skill_projection::project(self.conversation_items.iter());
476    }
477}
478
479#[cfg(test)]
480impl ProviderRequest {
481    // Message-only request constructor for profiling workloads.
482    pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
483        Self::from_messages(model, messages, ProviderToolMode::Enabled)
484    }
485}