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 #[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 #[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 #[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}