Skip to main content

magi_code/providers/
request.rs

1use crate::{
2    config::{ConfigError, EffectiveConfig, TextVerbosity},
3    providers::{
4        ANTHROPIC_PROVIDER, DEFAULT_ANTHROPIC_MODEL, DEFAULT_CODEX_MODEL, OPENAI_CODEX_PROVIDER,
5        openai_stream::PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT,
6    },
7    thinking::ThinkingLevel,
8    tools::tool_definitions_json_with_dynamic,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12#[cfg(test)]
13use serde_json::json;
14#[cfg(test)]
15use std::borrow::Cow;
16use std::{collections::HashSet, sync::Arc, time::Duration};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ProviderSelection {
20    pub provider: String,
21    pub model: String,
22}
23
24impl ProviderSelection {
25    pub fn from_config(config: &EffectiveConfig) -> Result<Self, ConfigError> {
26        let selection = Self::from_config_without_auth(config);
27        if !config.auth_state().is_ready() {
28            return Err(config.missing_auth_error());
29        }
30        Ok(selection)
31    }
32
33    pub(crate) fn from_config_without_auth(config: &EffectiveConfig) -> Self {
34        let provider = config
35            .provider
36            .clone()
37            .unwrap_or_else(|| OPENAI_CODEX_PROVIDER.to_string());
38        let model = config
39            .model
40            .clone()
41            .unwrap_or_else(|| default_model_for_provider(&provider).to_string());
42        Self { provider, model }
43    }
44}
45
46pub(crate) fn default_model_for_provider(provider: &str) -> &'static str {
47    match provider {
48        ANTHROPIC_PROVIDER => DEFAULT_ANTHROPIC_MODEL,
49        OPENAI_CODEX_PROVIDER => DEFAULT_CODEX_MODEL,
50        _ => DEFAULT_CODEX_MODEL,
51    }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub enum MessageRole {
56    System,
57    User,
58    Assistant,
59    Tool,
60}
61
62impl MessageRole {
63    pub(crate) fn as_api_str(&self) -> &'static str {
64        match self {
65            Self::System => "system",
66            Self::User => "user",
67            Self::Assistant => "assistant",
68            Self::Tool => "tool",
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct ChatMessage {
75    pub role: MessageRole,
76    pub content: String,
77}
78
79impl ChatMessage {
80    pub fn system(content: impl Into<String>) -> Self {
81        Self {
82            role: MessageRole::System,
83            content: content.into(),
84        }
85    }
86
87    pub fn user(content: impl Into<String>) -> Self {
88        Self {
89            role: MessageRole::User,
90            content: content.into(),
91        }
92    }
93
94    pub fn assistant(content: impl Into<String>) -> Self {
95        Self {
96            role: MessageRole::Assistant,
97            content: content.into(),
98        }
99    }
100
101    pub fn tool(content: impl Into<String>) -> Self {
102        Self {
103            role: MessageRole::Tool,
104            content: content.into(),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct ProviderToolResult {
111    pub call_id: String,
112    pub tool_name: String,
113    pub success: bool,
114    pub output: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub enum ProviderConversationItem {
119    Message(ChatMessage),
120    ResponseItem(Value),
121    ToolResult(ProviderToolResult),
122    LegacyReplayNote {
123        event_type: String,
124        content: String,
125    },
126    /// Durable local metadata, not a provider wire item.
127    ReasoningSelection {
128        provider: String,
129        model: String,
130        effort: ThinkingLevel,
131    },
132}
133
134impl ProviderConversationItem {
135    pub(crate) fn legacy_note_text(event_type: &str, content: &str) -> String {
136        format!("Legacy session replay fallback (event_type={event_type}): {content}")
137    }
138
139    pub(crate) fn reasoning_selection_from_payload(payload: &Value) -> Option<Self> {
140        let selection = payload.get("reasoning_selection")?;
141        Some(Self::ReasoningSelection {
142            provider: selection.get("provider")?.as_str()?.to_owned(),
143            model: selection.get("model")?.as_str()?.to_owned(),
144            effort: serde_json::from_value(selection.get("effort")?.clone()).ok()?,
145        })
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ProviderToolMode {
151    Enabled,
152    Disabled,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156enum ProviderConversation {
157    Owned(Vec<ProviderConversationItem>),
158    Shared {
159        base: Arc<[ProviderConversationItem]>,
160        turn: Vec<ProviderConversationItem>,
161    },
162}
163
164impl ProviderConversation {
165    fn iter(&self) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
166        match self {
167            Self::Owned(items) => Box::new(items.iter()),
168            Self::Shared { base, turn } => Box::new(base.iter().chain(turn.iter())),
169        }
170    }
171
172    #[cfg(test)]
173    fn to_cow(&self) -> Cow<'_, [ProviderConversationItem]> {
174        match self {
175            Self::Owned(items) => Cow::Borrowed(items.as_slice()),
176            Self::Shared { base, turn } => {
177                Cow::Owned(base.iter().chain(turn.iter()).cloned().collect())
178            }
179        }
180    }
181}
182
183pub(crate) fn conversation_id_for_session(session_id: &str) -> String {
184    use sha2::{Digest, Sha256};
185
186    let digest = Sha256::digest(session_id.as_bytes());
187    format!("magi-code-session-{}", crate::hex::lower_hex(digest))
188        .chars()
189        .take("magi-code-session-".len() + 32)
190        .collect()
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ProviderRequest {
195    pub model: String,
196    conversation_items: ProviderConversation,
197    pub stream: bool,
198    pub tool_mode: ProviderToolMode,
199    pub thinking_level: ThinkingLevel,
200    text_verbosity: Option<TextVerbosity>,
201    send_default_reasoning_summary: bool,
202    semantic_progress_timeout: Option<Duration>,
203    prompt_cache_key: Option<String>,
204    conversation_id: Option<String>,
205    subagents_tool_enabled: bool,
206    dynamic_tool_definitions: Arc<[Value]>,
207    disabled_tool_names: Arc<[String]>,
208}
209
210impl ProviderRequest {
211    // Retained for provider construction characterization tests; runtime request assembly
212    // uses `from_conversation` or `new_without_tools`.
213    #[cfg(test)]
214    pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
215        Self::from_messages(model, messages, ProviderToolMode::Enabled)
216    }
217
218    pub fn new_without_tools(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
219        Self::from_messages(model, messages, ProviderToolMode::Disabled)
220    }
221
222    pub fn from_conversation(
223        model: impl Into<String>,
224        conversation_items: Vec<ProviderConversationItem>,
225    ) -> Self {
226        Self::from_conversation_with_tool_mode(model, conversation_items, ProviderToolMode::Enabled)
227    }
228
229    pub fn from_conversation_without_tools(
230        model: impl Into<String>,
231        conversation_items: Vec<ProviderConversationItem>,
232    ) -> Self {
233        Self::from_conversation_with_tool_mode(
234            model,
235            conversation_items,
236            ProviderToolMode::Disabled,
237        )
238    }
239
240    pub(crate) fn from_shared_conversation(
241        model: impl Into<String>,
242        base_items: Arc<[ProviderConversationItem]>,
243        turn_items: &[ProviderConversationItem],
244    ) -> Self {
245        Self::from_conversation_storage(
246            model,
247            ProviderConversation::Shared {
248                base: base_items,
249                turn: turn_items.to_vec(),
250            },
251            ProviderToolMode::Enabled,
252        )
253    }
254
255    #[cfg(test)]
256    pub fn conversation_items(&self) -> Cow<'_, [ProviderConversationItem]> {
257        self.conversation_items.to_cow()
258    }
259
260    pub(crate) fn conversation_items_iter(
261        &self,
262    ) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
263        self.conversation_items.iter()
264    }
265
266    #[cfg(test)]
267    pub(crate) fn to_owned_request(&self) -> Self {
268        let mut request = Self::from_conversation_with_tool_mode(
269            self.model.clone(),
270            self.conversation_items_iter().cloned().collect(),
271            self.tool_mode,
272        )
273        .with_thinking_level(self.thinking_level)
274        .with_text_verbosity(self.text_verbosity)
275        .with_default_reasoning_summary(self.send_default_reasoning_summary)
276        .with_subagents_tool_enabled(self.subagents_tool_enabled)
277        .with_disabled_tool_names(self.disabled_tool_names.to_vec())
278        .with_dynamic_tool_definitions(self.dynamic_tool_definitions.to_vec());
279        request.conversation_id.clone_from(&self.conversation_id);
280        request.stream = self.stream;
281        request.semantic_progress_timeout = self.semantic_progress_timeout;
282        request.prompt_cache_key.clone_from(&self.prompt_cache_key);
283        request
284    }
285
286    #[cfg(test)]
287    pub(crate) fn messages(&self) -> Vec<ChatMessage> {
288        self.conversation_items_iter()
289            .filter_map(|item| match item {
290                ProviderConversationItem::Message(message) => Some(message.clone()),
291                _ => None,
292            })
293            .collect()
294    }
295
296    #[cfg(test)]
297    pub(crate) fn response_items(&self) -> Vec<Value> {
298        self.conversation_items_iter()
299            .filter_map(|item| match item {
300                ProviderConversationItem::ResponseItem(item) => Some(item.clone()),
301                ProviderConversationItem::ToolResult(result) => {
302                    Some(provider_tool_result_response_item(result))
303                }
304                _ => None,
305            })
306            .collect()
307    }
308
309    #[cfg(test)]
310    pub(crate) fn tool_results(&self) -> Vec<ProviderToolResult> {
311        self.conversation_items_iter()
312            .filter_map(|item| match item {
313                ProviderConversationItem::ToolResult(result) => Some(result.clone()),
314                _ => None,
315            })
316            .collect()
317    }
318
319    pub fn tools_enabled(&self) -> bool {
320        self.tool_mode == ProviderToolMode::Enabled
321    }
322
323    // Retained for provider continuation characterization tests that build legacy
324    // response-item compatibility views directly. Conversation items remain source of truth.
325    #[cfg(test)]
326    pub fn with_response_items(mut self, response_items: Vec<Value>) -> Self {
327        let messages = self.messages();
328        let tool_results = self.tool_results();
329        self.conversation_items = ProviderConversation::Owned(
330            messages
331                .into_iter()
332                .map(ProviderConversationItem::Message)
333                .chain(
334                    response_items
335                        .into_iter()
336                        .map(ProviderConversationItem::ResponseItem),
337                )
338                .chain(
339                    tool_results
340                        .into_iter()
341                        .map(ProviderConversationItem::ToolResult),
342                )
343                .collect(),
344        );
345        self
346    }
347
348    // Retained for provider continuation characterization tests that build legacy
349    // tool-result compatibility views directly. Conversation items remain source of truth.
350    #[cfg(test)]
351    pub fn with_tool_results(mut self, tool_results: Vec<ProviderToolResult>) -> Self {
352        let messages = self.messages();
353        let response_items = self.stored_response_items();
354        self.conversation_items = ProviderConversation::Owned(
355            messages
356                .into_iter()
357                .map(ProviderConversationItem::Message)
358                .chain(
359                    response_items
360                        .into_iter()
361                        .map(ProviderConversationItem::ResponseItem),
362                )
363                .chain(
364                    tool_results
365                        .into_iter()
366                        .map(ProviderConversationItem::ToolResult),
367                )
368                .collect(),
369        );
370        self
371    }
372
373    pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
374        self.thinking_level = thinking_level;
375        self
376    }
377
378    /// Reconstruct updates from ordered durable selections, not process state.
379    pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
380        let selected_effort = |item: &ProviderConversationItem| match item {
381            ProviderConversationItem::ReasoningSelection {
382                provider,
383                model,
384                effort,
385            } if provider == provider_id && model == &self.model => Some(*effort),
386            _ => None,
387        };
388        // Default has no documented update representation. Start a new baseline
389        // instead of guessing a provider default when returning to it.
390        let reset_index = self
391            .conversation_items_iter()
392            .enumerate()
393            .filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
394            .map(|(index, _)| index)
395            .last()
396            .unwrap_or(0);
397        let mut baseline = None;
398        let mut effective = None;
399        let mut items = Vec::new();
400        for (index, item) in self.conversation_items_iter().enumerate() {
401            if let Some(effort) = selected_effort(item) {
402                if index < reset_index {
403                    continue;
404                }
405                if baseline.is_none() {
406                    baseline = Some(effort);
407                } else if effective != Some(effort)
408                    && let Some(effort) = effort.explicit_effort()
409                {
410                    items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
411                        "type": "configuration_update", "reasoning": {"effort": effort}
412                    })));
413                }
414                effective = Some(effort);
415            } else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
416                items.push(item.clone());
417            }
418        }
419        if let Some(baseline) = baseline {
420            self.thinking_level = baseline;
421        }
422        self.conversation_items = ProviderConversation::Owned(items);
423        self
424    }
425
426    pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
427        self.text_verbosity = text_verbosity;
428        self
429    }
430
431    pub fn text_verbosity(&self) -> Option<TextVerbosity> {
432        self.text_verbosity
433    }
434
435    pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
436        self.send_default_reasoning_summary = supported;
437        self
438    }
439
440    pub(crate) fn send_default_reasoning_summary(&self) -> bool {
441        self.send_default_reasoning_summary
442    }
443
444    pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
445        self.semantic_progress_timeout = Some(timeout);
446        self
447    }
448
449    #[cfg(test)]
450    pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
451        self.semantic_progress_timeout
452    }
453
454    pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
455        self.semantic_progress_timeout
456            .unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
457    }
458
459    pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
460        self.prompt_cache_key = Some(key.into());
461        self
462    }
463
464    pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
465        self.prompt_cache_key.as_deref()
466    }
467
468    pub(crate) fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
469        self.conversation_id = Some(id.into());
470        self
471    }
472
473    pub(crate) fn conversation_id(&self) -> Option<&str> {
474        self.conversation_id.as_deref()
475    }
476
477    pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
478        self.subagents_tool_enabled = enabled;
479        self
480    }
481
482    #[cfg(test)]
483    pub(crate) fn subagents_tool_enabled(&self) -> bool {
484        self.subagents_tool_enabled
485    }
486
487    pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
488        (self.tools_enabled()
489            && self.dynamic_tool_definitions.is_empty()
490            && self.disabled_tool_names.is_empty())
491        .then_some(self.subagents_tool_enabled)
492    }
493
494    pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
495        self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
496        self
497    }
498
499    pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
500        self.disabled_tool_names = Arc::from(names.into_boxed_slice());
501        self
502    }
503
504    pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
505        if !self.tools_enabled() {
506            return None;
507        }
508        let definitions = self.tool_definitions_json();
509        definitions
510            .as_array()
511            .is_some_and(|definitions| !definitions.is_empty())
512            .then_some(definitions)
513    }
514
515    pub(crate) fn tool_definitions_json(&self) -> Value {
516        let disabled = self
517            .disabled_tool_names
518            .iter()
519            .cloned()
520            .collect::<HashSet<_>>();
521        tool_definitions_json_with_dynamic(
522            self.subagents_tool_enabled,
523            &self.dynamic_tool_definitions,
524            &disabled,
525        )
526    }
527
528    fn from_messages(
529        model: impl Into<String>,
530        messages: Vec<ChatMessage>,
531        tool_mode: ProviderToolMode,
532    ) -> Self {
533        let conversation_items = messages
534            .into_iter()
535            .map(ProviderConversationItem::Message)
536            .collect();
537        Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
538    }
539
540    fn from_conversation_with_tool_mode(
541        model: impl Into<String>,
542        conversation_items: Vec<ProviderConversationItem>,
543        tool_mode: ProviderToolMode,
544    ) -> Self {
545        Self::from_conversation_storage(
546            model,
547            ProviderConversation::Owned(conversation_items),
548            tool_mode,
549        )
550    }
551
552    fn from_conversation_storage(
553        model: impl Into<String>,
554        conversation_items: ProviderConversation,
555        tool_mode: ProviderToolMode,
556    ) -> Self {
557        Self {
558            model: model.into(),
559            conversation_items,
560            stream: true,
561            tool_mode,
562            thinking_level: ThinkingLevel::Default,
563            text_verbosity: None,
564            send_default_reasoning_summary: false,
565            semantic_progress_timeout: None,
566            prompt_cache_key: None,
567            conversation_id: None,
568            subagents_tool_enabled: true,
569            dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
570            disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
571        }
572    }
573
574    #[cfg(test)]
575    fn stored_response_items(&self) -> Vec<Value> {
576        self.conversation_items_iter()
577            .filter_map(|item| match item {
578                ProviderConversationItem::ResponseItem(item) => Some(item.clone()),
579                _ => None,
580            })
581            .collect()
582    }
583}
584
585#[cfg(test)]
586fn provider_tool_result_response_item(result: &ProviderToolResult) -> Value {
587    json!({
588        "type": "function_call_output",
589        "call_id": result.call_id,
590        "output": result.output,
591    })
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::config::{McPaths, ProviderCredential};
598
599    #[test]
600    fn provider_request_empty_dynamic_tools_preserves_static_definitions_byte_for_byte() {
601        let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
602            .with_dynamic_tool_definitions(Vec::new());
603
604        assert_eq!(
605            request.tool_definitions_json().to_string(),
606            crate::tools::mvp_tool_definitions_json().to_string()
607        );
608    }
609
610    #[test]
611    fn provider_request_shared_conversation_iterates_base_before_turn() {
612        let base = std::sync::Arc::<[ProviderConversationItem]>::from(vec![
613            ProviderConversationItem::Message(ChatMessage::system("sys")),
614            ProviderConversationItem::Message(ChatMessage::user("prompt")),
615        ]);
616        let turn = vec![
617            ProviderConversationItem::ResponseItem(json!({
618                "type": "function_call",
619                "call_id": "call_1",
620                "name": "read",
621                "arguments": "{}"
622            })),
623            ProviderConversationItem::ToolResult(ProviderToolResult {
624                call_id: "call_1".to_string(),
625                tool_name: "read".to_string(),
626                success: true,
627                output: "ok".to_string(),
628            }),
629        ];
630
631        let request =
632            ProviderRequest::from_shared_conversation("model", std::sync::Arc::clone(&base), &turn);
633        let kinds = request
634            .conversation_items_iter()
635            .map(|item| match item {
636                ProviderConversationItem::Message(message) => message.role.as_api_str(),
637                ProviderConversationItem::ResponseItem(_) => "response_item",
638                ProviderConversationItem::ToolResult(_) => "tool_result",
639                ProviderConversationItem::LegacyReplayNote { .. } => "legacy",
640                ProviderConversationItem::ReasoningSelection { .. } => "reasoning_selection",
641            })
642            .collect::<Vec<_>>();
643
644        assert_eq!(
645            kinds,
646            vec!["system", "user", "response_item", "tool_result"]
647        );
648        assert_eq!(request.messages().len(), 2);
649        assert_eq!(request.response_items().len(), 2);
650        assert_eq!(request.tool_results().len(), 1);
651
652        let returned = request.conversation_items();
653        assert_eq!(
654            returned.as_ref(),
655            [base.as_ref(), turn.as_slice()].concat().as_slice()
656        );
657    }
658
659    #[test]
660    fn provider_request_shared_conversation_items_returns_ordered_cow_without_panic() {
661        let base = std::sync::Arc::<[ProviderConversationItem]>::from(vec![
662            ProviderConversationItem::Message(ChatMessage::system("sys")),
663            ProviderConversationItem::Message(ChatMessage::user("prompt")),
664        ]);
665        let turn = vec![ProviderConversationItem::ToolResult(ProviderToolResult {
666            call_id: "call_1".to_string(),
667            tool_name: "read".to_string(),
668            success: true,
669            output: "ok".to_string(),
670        })];
671
672        let request =
673            ProviderRequest::from_shared_conversation("model", std::sync::Arc::clone(&base), &turn);
674        let items = request.conversation_items();
675
676        assert_eq!(
677            items.as_ref(),
678            [base.as_ref(), turn.as_slice()].concat().as_slice()
679        );
680    }
681
682    #[test]
683    fn provider_request_preserves_ordered_conversation_items() {
684        let items = vec![
685            ProviderConversationItem::Message(ChatMessage::system("sys")),
686            ProviderConversationItem::Message(ChatMessage::user("old")),
687            ProviderConversationItem::ResponseItem(json!({
688                "type": "function_call",
689                "call_id": "call_1",
690                "name": "read",
691                "arguments": "{}"
692            })),
693            ProviderConversationItem::ToolResult(ProviderToolResult {
694                call_id: "call_1".to_string(),
695                tool_name: "read".to_string(),
696                success: true,
697                output: "ok".to_string(),
698            }),
699            ProviderConversationItem::Message(ChatMessage::user("new")),
700        ];
701        let request = ProviderRequest::from_conversation("model", items.clone());
702        assert_eq!(request.conversation_items().as_ref(), items.as_slice());
703        assert_eq!(request.messages().len(), 3);
704        assert_eq!(request.response_items().len(), 2);
705        assert_eq!(request.tool_results().len(), 1);
706    }
707
708    #[test]
709    fn provider_request_compat_views_are_derived_from_conversation_items() {
710        let request = ProviderRequest::from_conversation(
711            "model",
712            vec![
713                ProviderConversationItem::Message(ChatMessage::system("sys")),
714                ProviderConversationItem::ResponseItem(json!({
715                    "type": "function_call",
716                    "call_id": "call_1",
717                    "name": "read",
718                    "arguments": "{}"
719                })),
720                ProviderConversationItem::ToolResult(ProviderToolResult {
721                    call_id: "call_1".to_string(),
722                    tool_name: "read".to_string(),
723                    success: true,
724                    output: "ok".to_string(),
725                }),
726            ],
727        );
728
729        assert_eq!(request.messages(), vec![ChatMessage::system("sys")]);
730        assert_eq!(request.tool_results()[0].call_id, "call_1");
731        assert_eq!(request.response_items()[0]["type"], "function_call");
732        assert_eq!(request.response_items()[1]["type"], "function_call_output");
733        assert_eq!(request.response_items()[1]["output"], "ok");
734    }
735
736    #[test]
737    fn provider_request_compat_constructors_seed_conversation_items() {
738        let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
739            .with_response_items(vec![json!({"type":"reasoning"})])
740            .with_tool_results(vec![ProviderToolResult {
741                call_id: "call_1".to_string(),
742                tool_name: "read".to_string(),
743                success: true,
744                output: "ok".to_string(),
745            }]);
746        assert!(matches!(
747            request.conversation_items()[0],
748            ProviderConversationItem::Message(_)
749        ));
750        assert!(matches!(
751            request.conversation_items()[1],
752            ProviderConversationItem::ResponseItem(_)
753        ));
754        assert!(matches!(
755            request.conversation_items()[2],
756            ProviderConversationItem::ToolResult(_)
757        ));
758    }
759
760    #[test]
761    fn provider_request_prompt_cache_key_defaults_to_none() {
762        let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")]);
763
764        assert_eq!(request.prompt_cache_key(), None);
765    }
766
767    #[test]
768    fn provider_request_prompt_cache_key_builder_preserves_metadata_setters() {
769        let request = ProviderRequest::new("model", vec![ChatMessage::user("hello")])
770            .with_prompt_cache_key("magi-code-session-0123456789abcdef0123456789abcdef")
771            .with_thinking_level(crate::thinking::ThinkingLevel::High)
772            .with_default_reasoning_summary(true)
773            .with_semantic_progress_timeout(Duration::from_secs(7));
774
775        assert_eq!(
776            request.prompt_cache_key(),
777            Some("magi-code-session-0123456789abcdef0123456789abcdef")
778        );
779        assert_eq!(request.thinking_level, crate::thinking::ThinkingLevel::High);
780        assert!(request.send_default_reasoning_summary());
781        assert_eq!(
782            request.semantic_progress_timeout(),
783            Some(Duration::from_secs(7))
784        );
785    }
786
787    #[test]
788    fn provider_request_text_verbosity_defaults_and_owned_clone_preserves_value() {
789        let default = ProviderRequest::new("model", vec![ChatMessage::user("hello")]);
790        assert_eq!(default.text_verbosity(), None);
791
792        let high = default
793            .with_text_verbosity(Some(crate::config::TextVerbosity::High))
794            .to_owned_request();
795        assert_eq!(
796            high.text_verbosity(),
797            Some(crate::config::TextVerbosity::High)
798        );
799    }
800
801    #[test]
802    fn provider_selection_accepts_configured_no_auth_custom_provider() {
803        let temp = tempfile::TempDir::new().unwrap();
804        let mut custom_providers = std::collections::BTreeMap::new();
805        custom_providers.insert(
806            "local-ai".to_string(),
807            crate::config::CustomProviderConfig {
808                label: "Local AI".to_string(),
809                base_url: "http://localhost:8080/v1".to_string(),
810                fast_mode: None,
811                api_key_env_var: None,
812                models_dev_provider: None,
813                use_responses_endpoint: false,
814                supports_text_verbosity: false,
815                reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
816                extra_models: Vec::new(),
817                request_headers: Default::default(),
818            },
819        );
820        let config = EffectiveConfig {
821            provider: Some("local-ai".to_string()),
822            model: Some("model-a".to_string()),
823            no_color: false,
824            file_autocomplete_respects_gitignore: true,
825            custom_providers,
826            thinking_level: crate::thinking::ThinkingLevel::Default,
827            auth: Some(ProviderCredential::NoAuth),
828            paths: McPaths::from_root(temp.path().join("mc")),
829        };
830
831        assert_eq!(
832            ProviderSelection::from_config(&config).unwrap(),
833            ProviderSelection {
834                provider: "local-ai".to_string(),
835                model: "model-a".to_string(),
836            }
837        );
838    }
839
840    #[test]
841    fn provider_selection_accepts_anthropic_api_key() {
842        let temp = tempfile::TempDir::new().unwrap();
843        let config = EffectiveConfig {
844            provider: Some(crate::providers::ANTHROPIC_PROVIDER.to_string()),
845            model: Some(crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string()),
846            no_color: false,
847            file_autocomplete_respects_gitignore: true,
848            custom_providers: std::collections::BTreeMap::new(),
849            thinking_level: crate::thinking::ThinkingLevel::Default,
850            auth: Some(ProviderCredential::ApiKey {
851                key: "anthropic-key".to_string(),
852            }),
853            paths: McPaths::from_root(temp.path().join("mc")),
854        };
855
856        assert_eq!(
857            ProviderSelection::from_config(&config).unwrap(),
858            ProviderSelection {
859                provider: crate::providers::ANTHROPIC_PROVIDER.to_string(),
860                model: crate::providers::DEFAULT_ANTHROPIC_MODEL.to_string(),
861            }
862        );
863    }
864
865    #[test]
866    fn provider_selection_rejects_codex_api_key() {
867        let temp = tempfile::TempDir::new().unwrap();
868        let config = EffectiveConfig {
869            provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
870            model: Some(DEFAULT_CODEX_MODEL.to_string()),
871            no_color: false,
872            file_autocomplete_respects_gitignore: true,
873            custom_providers: std::collections::BTreeMap::new(),
874            thinking_level: crate::thinking::ThinkingLevel::Default,
875            auth: Some(ProviderCredential::ApiKey {
876                key: "not-for-codex".to_string(),
877            }),
878            paths: McPaths::from_root(temp.path().join("mc")),
879        };
880
881        let error = ProviderSelection::from_config(&config)
882            .unwrap_err()
883            .to_string();
884        assert!(error.contains("unsupported for openai-codex"), "{error}");
885        assert!(
886            error.contains(&config.paths.auth_file.display().to_string()),
887            "{error}"
888        );
889    }
890}