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