Skip to main content

magi_code/providers/
request.rs

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