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