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    /// Extend a sent request without changing its provider-visible prefix or settings.
254    pub(crate) fn with_extended_conversation(&self, candidate: &Self) -> Option<Self> {
255        let mut items = candidate.conversation_items_iter();
256        if !self
257            .conversation_items_iter()
258            .all(|item| items.next() == Some(item))
259        {
260            return None;
261        }
262        let mut request = self.clone();
263        request.conversation_items = candidate.conversation_items.clone();
264        request.skill_projection = candidate.skill_projection.clone();
265        Some(request)
266    }
267
268    /// A final user instruction adds no skill witnesses; retain the exact existing overlay.
269    pub(crate) fn with_appended_user_message(mut self, message: String) -> Self {
270        let item = ProviderConversationItem::Message(ChatMessage::user(message));
271        match &mut self.conversation_items {
272            ProviderConversation::Owned(items) => items.push(item),
273            ProviderConversation::Shared { turn, .. } => turn.push(item),
274        }
275        self
276    }
277
278    /// Raw provenance disables additive raw token estimates even when projection fails open.
279    pub(crate) fn has_skill_read_provenance(&self) -> bool {
280        self.conversation_items.iter().any(|item| {
281            matches!(item, ProviderConversationItem::ToolResult(result) if !result.skill_reads.is_empty())
282        })
283    }
284
285    pub fn tools_enabled(&self) -> bool {
286        self.tool_mode == ProviderToolMode::Enabled
287    }
288
289    pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
290        self.thinking_level = thinking_level;
291        self
292    }
293
294    /// Reconstruct updates from ordered durable selections, not process state.
295    pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
296        let selected_effort = |item: &ProviderConversationItem| match item {
297            ProviderConversationItem::ReasoningSelection {
298                provider,
299                model,
300                effort,
301            } if provider == provider_id && model == &self.model => Some(*effort),
302            _ => None,
303        };
304        // Default has no documented update representation. Start a new baseline
305        // instead of guessing a provider default when returning to it.
306        let reset_index = self
307            .conversation_items
308            .iter()
309            .enumerate()
310            .filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
311            .map(|(index, _)| index)
312            .last()
313            .unwrap_or(0);
314        let mut baseline = None;
315        let mut effective = None;
316        let mut items = Vec::new();
317        for (index, item) in self.conversation_items.iter().enumerate() {
318            if let Some(effort) = selected_effort(item) {
319                if index < reset_index {
320                    continue;
321                }
322                if baseline.is_none() {
323                    baseline = Some(effort);
324                } else if effective != Some(effort)
325                    && let Some(effort) = effort.explicit_effort()
326                {
327                    items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
328                        "type": "configuration_update", "reasoning": {"effort": effort}
329                    })));
330                }
331                effective = Some(effort);
332            } else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
333                items.push(item.clone());
334            }
335        }
336        if let Some(baseline) = baseline {
337            self.thinking_level = baseline;
338        }
339        self.conversation_items = ProviderConversation::Owned(items);
340        self.refresh_skill_projection();
341        self
342    }
343
344    pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
345        self.text_verbosity = text_verbosity;
346        self
347    }
348
349    pub fn text_verbosity(&self) -> Option<TextVerbosity> {
350        self.text_verbosity
351    }
352
353    pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
354        self.send_default_reasoning_summary = supported;
355        self
356    }
357
358    pub(crate) fn send_default_reasoning_summary(&self) -> bool {
359        self.send_default_reasoning_summary
360    }
361
362    pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
363        self.semantic_progress_timeout = Some(timeout);
364        self
365    }
366
367    pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
368        self.semantic_progress_timeout
369    }
370
371    pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
372        self.semantic_progress_timeout
373            .unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
374    }
375
376    pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
377        self.prompt_cache_key = Some(key.into());
378        self
379    }
380
381    pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
382        self.prompt_cache_key.as_deref()
383    }
384
385    pub(crate) fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
386        self.conversation_id = Some(id.into());
387        self
388    }
389
390    pub(crate) fn conversation_id(&self) -> Option<&str> {
391        self.conversation_id.as_deref()
392    }
393
394    pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
395        self.subagents_tool_enabled = enabled;
396        self
397    }
398
399    pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
400        (self.tools_enabled()
401            && self.resolved_tool_catalog.is_none()
402            && self.dynamic_tool_definitions.is_empty()
403            && self.disabled_tool_names.is_empty())
404        .then_some(self.subagents_tool_enabled)
405    }
406
407    pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
408        self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
409        self
410    }
411
412    pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
413        self.disabled_tool_names = Arc::from(names.into_boxed_slice());
414        self
415    }
416
417    pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
418        if !self.tools_enabled() {
419            return None;
420        }
421        let definitions = self.tool_definitions_json();
422        definitions
423            .as_array()
424            .is_some_and(|definitions| !definitions.is_empty())
425            .then_some(definitions)
426    }
427
428    pub(crate) fn with_resolved_tool_catalog(mut self, definitions: Vec<Value>) -> Self {
429        self.resolved_tool_catalog = Some(Arc::from(definitions));
430        self
431    }
432
433    pub(crate) fn tool_definitions_json(&self) -> Value {
434        if let Some(definitions) = &self.resolved_tool_catalog {
435            return Value::Array(definitions.to_vec());
436        }
437        let disabled = self
438            .disabled_tool_names
439            .iter()
440            .cloned()
441            .collect::<HashSet<_>>();
442        tool_definitions_json_with_dynamic(
443            self.subagents_tool_enabled,
444            &self.dynamic_tool_definitions,
445            &disabled,
446        )
447    }
448
449    fn from_messages(
450        model: impl Into<String>,
451        messages: Vec<ChatMessage>,
452        tool_mode: ProviderToolMode,
453    ) -> Self {
454        let conversation_items = messages
455            .into_iter()
456            .map(ProviderConversationItem::Message)
457            .collect();
458        Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
459    }
460
461    fn from_conversation_with_tool_mode(
462        model: impl Into<String>,
463        conversation_items: Vec<ProviderConversationItem>,
464        tool_mode: ProviderToolMode,
465    ) -> Self {
466        Self::from_conversation_storage(
467            model,
468            ProviderConversation::Owned(conversation_items),
469            tool_mode,
470        )
471    }
472
473    fn from_conversation_storage(
474        model: impl Into<String>,
475        conversation_items: ProviderConversation,
476        tool_mode: ProviderToolMode,
477    ) -> Self {
478        let skill_projection = super::skill_projection::project(conversation_items.iter());
479        Self {
480            model: model.into(),
481            conversation_items,
482            skill_projection,
483            stream: true,
484            tool_mode,
485            thinking_level: ThinkingLevel::Default,
486            text_verbosity: None,
487            send_default_reasoning_summary: false,
488            semantic_progress_timeout: None,
489            prompt_cache_key: None,
490            conversation_id: None,
491            codex_turn_context: None,
492            subagents_tool_enabled: true,
493            dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
494            disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
495            resolved_tool_catalog: None,
496        }
497    }
498
499    fn refresh_skill_projection(&mut self) {
500        self.skill_projection = super::skill_projection::project(self.conversation_items.iter());
501    }
502}
503
504#[cfg(test)]
505impl ProviderRequest {
506    // Message-only request constructor for profiling workloads.
507    pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
508        Self::from_messages(model, messages, ProviderToolMode::Enabled)
509    }
510}