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