1use crate::{
2 config::{ConfigError, EffectiveConfig, TextVerbosity},
3 providers::{
4 ANTHROPIC_PROVIDER, CLAUDE_SUBSCRIPTION_PROVIDER, DEFAULT_ANTHROPIC_MODEL,
5 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;
13use std::{
14 collections::{BTreeMap, HashSet},
15 sync::Arc,
16 time::Duration,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProviderSelection {
21 pub provider: String,
22 pub model: String,
23}
24
25impl ProviderSelection {
26 pub fn from_config(config: &EffectiveConfig) -> Result<Self, ConfigError> {
27 let selection = Self::from_config_without_auth(config);
28 if !config.auth_state().is_ready() {
29 return Err(config.missing_auth_error());
30 }
31 Ok(selection)
32 }
33
34 pub(crate) fn from_config_without_auth(config: &EffectiveConfig) -> Self {
35 let provider = config
36 .provider
37 .clone()
38 .unwrap_or_else(|| OPENAI_CODEX_PROVIDER.to_string());
39 let model = config
40 .model
41 .clone()
42 .unwrap_or_else(|| default_model_for_provider(&provider).to_string());
43 Self { provider, model }
44 }
45}
46
47pub(crate) fn default_model_for_provider(provider: &str) -> &'static str {
48 match provider {
49 ANTHROPIC_PROVIDER => DEFAULT_ANTHROPIC_MODEL,
50 CLAUDE_SUBSCRIPTION_PROVIDER => "claude-opus-5-5",
51 OPENAI_CODEX_PROVIDER => DEFAULT_CODEX_MODEL,
52 _ => DEFAULT_CODEX_MODEL,
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub enum MessageRole {
58 System,
59 User,
60 Assistant,
61 Tool,
62}
63
64impl MessageRole {
65 pub(crate) fn as_api_str(&self) -> &'static str {
66 match self {
67 Self::System => "system",
68 Self::User => "user",
69 Self::Assistant => "assistant",
70 Self::Tool => "tool",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct ChatMessage {
77 pub role: MessageRole,
78 pub content: String,
79}
80
81impl ChatMessage {
82 pub fn system(content: impl Into<String>) -> Self {
83 Self {
84 role: MessageRole::System,
85 content: content.into(),
86 }
87 }
88
89 pub fn user(content: impl Into<String>) -> Self {
90 Self {
91 role: MessageRole::User,
92 content: content.into(),
93 }
94 }
95
96 pub fn assistant(content: impl Into<String>) -> Self {
97 Self {
98 role: MessageRole::Assistant,
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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub(crate) skill_reads: Vec<crate::tools::skill_provenance::SkillReadProvenance>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub enum ProviderConversationItem {
116 Message(ChatMessage),
117 ResponseItem(Value),
118 ToolResult(ProviderToolResult),
119 LegacyReplayNote {
120 event_type: String,
121 content: String,
122 },
123 ReasoningSelection {
125 provider: String,
126 model: String,
127 effort: ThinkingLevel,
128 },
129}
130
131impl ProviderConversationItem {
132 pub(crate) fn legacy_note_text(event_type: &str, content: &str) -> String {
133 format!("Legacy session replay fallback (event_type={event_type}): {content}")
134 }
135
136 pub(crate) fn reasoning_selection_from_payload(payload: &Value) -> Option<Self> {
137 let selection = payload.get("reasoning_selection")?;
138 Some(Self::ReasoningSelection {
139 provider: selection.get("provider")?.as_str()?.to_owned(),
140 model: selection.get("model")?.as_str()?.to_owned(),
141 effort: serde_json::from_value(selection.get("effort")?.clone()).ok()?,
142 })
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ProviderToolMode {
148 Enabled,
149 Disabled,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153enum ProviderConversation {
154 Owned(Vec<ProviderConversationItem>),
155 Shared {
156 base: Arc<[ProviderConversationItem]>,
157 turn: Vec<ProviderConversationItem>,
158 },
159}
160
161impl ProviderConversation {
162 fn iter(&self) -> impl Iterator<Item = &ProviderConversationItem> + Clone {
163 let (base, turn) = match self {
164 Self::Owned(items) => (items.as_slice(), &[][..]),
165 Self::Shared { base, turn } => (base.as_ref(), turn.as_slice()),
166 };
167 base.iter().chain(turn.iter())
168 }
169}
170
171pub(crate) fn conversation_id_for_session(session_id: &str) -> String {
172 use sha2::{Digest, Sha256};
173
174 let digest = Sha256::digest(session_id.as_bytes());
175 format!("magi-code-session-{}", crate::hex::lower_hex(digest))
176 .chars()
177 .take("magi-code-session-".len() + 32)
178 .collect()
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ProviderRequest {
183 pub model: String,
184 conversation_items: ProviderConversation,
185 skill_projection: BTreeMap<usize, ProviderConversationItem>,
187 pub stream: bool,
188 pub tool_mode: ProviderToolMode,
189 pub thinking_level: ThinkingLevel,
190 text_verbosity: Option<TextVerbosity>,
191 send_default_reasoning_summary: bool,
192 semantic_progress_timeout: Option<Duration>,
193 prompt_cache_key: Option<String>,
194 conversation_id: Option<String>,
195 pub(crate) codex_turn_context: Option<super::CodexTurnContext>,
196 subagents_tool_enabled: bool,
197 dynamic_tool_definitions: Arc<[Value]>,
198 disabled_tool_names: Arc<[String]>,
199 resolved_tool_catalog: Option<Arc<[Value]>>,
200}
201
202impl ProviderRequest {
203 pub fn new_without_tools(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
204 Self::from_messages(model, messages, ProviderToolMode::Disabled)
205 }
206
207 pub fn from_conversation(
208 model: impl Into<String>,
209 conversation_items: Vec<ProviderConversationItem>,
210 ) -> Self {
211 Self::from_conversation_with_tool_mode(model, conversation_items, ProviderToolMode::Enabled)
212 }
213
214 pub fn from_conversation_without_tools(
215 model: impl Into<String>,
216 conversation_items: Vec<ProviderConversationItem>,
217 ) -> Self {
218 Self::from_conversation_with_tool_mode(
219 model,
220 conversation_items,
221 ProviderToolMode::Disabled,
222 )
223 }
224
225 pub(crate) fn from_shared_conversation(
226 model: impl Into<String>,
227 base_items: Arc<[ProviderConversationItem]>,
228 turn_items: &[ProviderConversationItem],
229 ) -> Self {
230 Self::from_conversation_storage(
231 model,
232 ProviderConversation::Shared {
233 base: base_items,
234 turn: turn_items.to_vec(),
235 },
236 ProviderToolMode::Enabled,
237 )
238 }
239
240 pub(crate) fn conversation_items_iter(
243 &self,
244 ) -> Box<dyn Iterator<Item = &ProviderConversationItem> + '_> {
245 Box::new(
246 self.conversation_items
247 .iter()
248 .enumerate()
249 .map(|(index, raw)| self.skill_projection.get(&index).unwrap_or(raw)),
250 )
251 }
252
253 pub(crate) fn with_extended_conversation(&self, candidate: &Self) -> Option<Self> {
255 let mut items = candidate.conversation_items_iter();
256 if !self
257 .conversation_items_iter()
258 .all(|item| items.next() == Some(item))
259 {
260 return None;
261 }
262 let mut request = self.clone();
263 request.conversation_items = candidate.conversation_items.clone();
264 request.skill_projection = candidate.skill_projection.clone();
265 Some(request)
266 }
267
268 pub(crate) fn with_appended_user_message(mut self, message: String) -> Self {
270 let item = ProviderConversationItem::Message(ChatMessage::user(message));
271 match &mut self.conversation_items {
272 ProviderConversation::Owned(items) => items.push(item),
273 ProviderConversation::Shared { turn, .. } => turn.push(item),
274 }
275 self
276 }
277
278 pub(crate) fn has_skill_read_provenance(&self) -> bool {
280 self.conversation_items.iter().any(|item| {
281 matches!(item, ProviderConversationItem::ToolResult(result) if !result.skill_reads.is_empty())
282 })
283 }
284
285 pub fn tools_enabled(&self) -> bool {
286 self.tool_mode == ProviderToolMode::Enabled
287 }
288
289 pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
290 self.thinking_level = thinking_level;
291 self
292 }
293
294 pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
296 let selected_effort = |item: &ProviderConversationItem| match item {
297 ProviderConversationItem::ReasoningSelection {
298 provider,
299 model,
300 effort,
301 } if provider == provider_id && model == &self.model => Some(*effort),
302 _ => None,
303 };
304 let reset_index = self
307 .conversation_items
308 .iter()
309 .enumerate()
310 .filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
311 .map(|(index, _)| index)
312 .last()
313 .unwrap_or(0);
314 let mut baseline = None;
315 let mut effective = None;
316 let mut items = Vec::new();
317 for (index, item) in self.conversation_items.iter().enumerate() {
318 if let Some(effort) = selected_effort(item) {
319 if index < reset_index {
320 continue;
321 }
322 if baseline.is_none() {
323 baseline = Some(effort);
324 } else if effective != Some(effort)
325 && let Some(effort) = effort.explicit_effort()
326 {
327 items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
328 "type": "configuration_update", "reasoning": {"effort": effort}
329 })));
330 }
331 effective = Some(effort);
332 } else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
333 items.push(item.clone());
334 }
335 }
336 if let Some(baseline) = baseline {
337 self.thinking_level = baseline;
338 }
339 self.conversation_items = ProviderConversation::Owned(items);
340 self.refresh_skill_projection();
341 self
342 }
343
344 pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
345 self.text_verbosity = text_verbosity;
346 self
347 }
348
349 pub fn text_verbosity(&self) -> Option<TextVerbosity> {
350 self.text_verbosity
351 }
352
353 pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
354 self.send_default_reasoning_summary = supported;
355 self
356 }
357
358 pub(crate) fn send_default_reasoning_summary(&self) -> bool {
359 self.send_default_reasoning_summary
360 }
361
362 pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
363 self.semantic_progress_timeout = Some(timeout);
364 self
365 }
366
367 pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
368 self.semantic_progress_timeout
369 }
370
371 pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
372 self.semantic_progress_timeout
373 .unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
374 }
375
376 pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
377 self.prompt_cache_key = Some(key.into());
378 self
379 }
380
381 pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
382 self.prompt_cache_key.as_deref()
383 }
384
385 pub(crate) fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
386 self.conversation_id = Some(id.into());
387 self
388 }
389
390 pub(crate) fn conversation_id(&self) -> Option<&str> {
391 self.conversation_id.as_deref()
392 }
393
394 pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
395 self.subagents_tool_enabled = enabled;
396 self
397 }
398
399 pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
400 (self.tools_enabled()
401 && self.resolved_tool_catalog.is_none()
402 && self.dynamic_tool_definitions.is_empty()
403 && self.disabled_tool_names.is_empty())
404 .then_some(self.subagents_tool_enabled)
405 }
406
407 pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
408 self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
409 self
410 }
411
412 pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
413 self.disabled_tool_names = Arc::from(names.into_boxed_slice());
414 self
415 }
416
417 pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
418 if !self.tools_enabled() {
419 return None;
420 }
421 let definitions = self.tool_definitions_json();
422 definitions
423 .as_array()
424 .is_some_and(|definitions| !definitions.is_empty())
425 .then_some(definitions)
426 }
427
428 pub(crate) fn with_resolved_tool_catalog(mut self, definitions: Vec<Value>) -> Self {
429 self.resolved_tool_catalog = Some(Arc::from(definitions));
430 self
431 }
432
433 pub(crate) fn tool_definitions_json(&self) -> Value {
434 if let Some(definitions) = &self.resolved_tool_catalog {
435 return Value::Array(definitions.to_vec());
436 }
437 let disabled = self
438 .disabled_tool_names
439 .iter()
440 .cloned()
441 .collect::<HashSet<_>>();
442 tool_definitions_json_with_dynamic(
443 self.subagents_tool_enabled,
444 &self.dynamic_tool_definitions,
445 &disabled,
446 )
447 }
448
449 fn from_messages(
450 model: impl Into<String>,
451 messages: Vec<ChatMessage>,
452 tool_mode: ProviderToolMode,
453 ) -> Self {
454 let conversation_items = messages
455 .into_iter()
456 .map(ProviderConversationItem::Message)
457 .collect();
458 Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
459 }
460
461 fn from_conversation_with_tool_mode(
462 model: impl Into<String>,
463 conversation_items: Vec<ProviderConversationItem>,
464 tool_mode: ProviderToolMode,
465 ) -> Self {
466 Self::from_conversation_storage(
467 model,
468 ProviderConversation::Owned(conversation_items),
469 tool_mode,
470 )
471 }
472
473 fn from_conversation_storage(
474 model: impl Into<String>,
475 conversation_items: ProviderConversation,
476 tool_mode: ProviderToolMode,
477 ) -> Self {
478 let skill_projection = super::skill_projection::project(conversation_items.iter());
479 Self {
480 model: model.into(),
481 conversation_items,
482 skill_projection,
483 stream: true,
484 tool_mode,
485 thinking_level: ThinkingLevel::Default,
486 text_verbosity: None,
487 send_default_reasoning_summary: false,
488 semantic_progress_timeout: None,
489 prompt_cache_key: None,
490 conversation_id: None,
491 codex_turn_context: None,
492 subagents_tool_enabled: true,
493 dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
494 disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
495 resolved_tool_catalog: None,
496 }
497 }
498
499 fn refresh_skill_projection(&mut self) {
500 self.skill_projection = super::skill_projection::project(self.conversation_items.iter());
501 }
502}
503
504#[cfg(test)]
505impl ProviderRequest {
506 pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
508 Self::from_messages(model, messages, ProviderToolMode::Enabled)
509 }
510}