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 has_skill_read_provenance(&self) -> bool {
255 self.conversation_items.iter().any(|item| {
256 matches!(item, ProviderConversationItem::ToolResult(result) if !result.skill_reads.is_empty())
257 })
258 }
259
260 pub fn tools_enabled(&self) -> bool {
261 self.tool_mode == ProviderToolMode::Enabled
262 }
263
264 pub fn with_thinking_level(mut self, thinking_level: ThinkingLevel) -> Self {
265 self.thinking_level = thinking_level;
266 self
267 }
268
269 pub(crate) fn with_reasoning_updates(mut self, provider_id: &str) -> Self {
271 let selected_effort = |item: &ProviderConversationItem| match item {
272 ProviderConversationItem::ReasoningSelection {
273 provider,
274 model,
275 effort,
276 } if provider == provider_id && model == &self.model => Some(*effort),
277 _ => None,
278 };
279 let reset_index = self
282 .conversation_items
283 .iter()
284 .enumerate()
285 .filter(|(_, item)| selected_effort(item) == Some(ThinkingLevel::Default))
286 .map(|(index, _)| index)
287 .last()
288 .unwrap_or(0);
289 let mut baseline = None;
290 let mut effective = None;
291 let mut items = Vec::new();
292 for (index, item) in self.conversation_items.iter().enumerate() {
293 if let Some(effort) = selected_effort(item) {
294 if index < reset_index {
295 continue;
296 }
297 if baseline.is_none() {
298 baseline = Some(effort);
299 } else if effective != Some(effort)
300 && let Some(effort) = effort.explicit_effort()
301 {
302 items.push(ProviderConversationItem::ResponseItem(serde_json::json!({
303 "type": "configuration_update", "reasoning": {"effort": effort}
304 })));
305 }
306 effective = Some(effort);
307 } else if !matches!(item, ProviderConversationItem::ReasoningSelection { .. }) {
308 items.push(item.clone());
309 }
310 }
311 if let Some(baseline) = baseline {
312 self.thinking_level = baseline;
313 }
314 self.conversation_items = ProviderConversation::Owned(items);
315 self.refresh_skill_projection();
316 self
317 }
318
319 pub fn with_text_verbosity(mut self, text_verbosity: Option<TextVerbosity>) -> Self {
320 self.text_verbosity = text_verbosity;
321 self
322 }
323
324 pub fn text_verbosity(&self) -> Option<TextVerbosity> {
325 self.text_verbosity
326 }
327
328 pub(crate) fn with_default_reasoning_summary(mut self, supported: bool) -> Self {
329 self.send_default_reasoning_summary = supported;
330 self
331 }
332
333 pub(crate) fn send_default_reasoning_summary(&self) -> bool {
334 self.send_default_reasoning_summary
335 }
336
337 pub(crate) fn with_semantic_progress_timeout(mut self, timeout: Duration) -> Self {
338 self.semantic_progress_timeout = Some(timeout);
339 self
340 }
341
342 pub(crate) fn semantic_progress_timeout(&self) -> Option<Duration> {
343 self.semantic_progress_timeout
344 }
345
346 pub(crate) fn semantic_progress_timeout_or_default(&self) -> Duration {
347 self.semantic_progress_timeout
348 .unwrap_or(PROVIDER_STREAM_NO_SEMANTIC_PROGRESS_TIMEOUT)
349 }
350
351 pub(crate) fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
352 self.prompt_cache_key = Some(key.into());
353 self
354 }
355
356 pub(crate) fn prompt_cache_key(&self) -> Option<&str> {
357 self.prompt_cache_key.as_deref()
358 }
359
360 pub(crate) fn with_conversation_id(mut self, id: impl Into<String>) -> Self {
361 self.conversation_id = Some(id.into());
362 self
363 }
364
365 pub(crate) fn conversation_id(&self) -> Option<&str> {
366 self.conversation_id.as_deref()
367 }
368
369 pub(crate) fn with_subagents_tool_enabled(mut self, enabled: bool) -> Self {
370 self.subagents_tool_enabled = enabled;
371 self
372 }
373
374 pub(crate) fn static_tool_definitions_variant(&self) -> Option<bool> {
375 (self.tools_enabled()
376 && self.resolved_tool_catalog.is_none()
377 && self.dynamic_tool_definitions.is_empty()
378 && self.disabled_tool_names.is_empty())
379 .then_some(self.subagents_tool_enabled)
380 }
381
382 pub(crate) fn with_dynamic_tool_definitions(mut self, definitions: Vec<Value>) -> Self {
383 self.dynamic_tool_definitions = Arc::from(definitions.into_boxed_slice());
384 self
385 }
386
387 pub(crate) fn with_disabled_tool_names(mut self, names: Vec<String>) -> Self {
388 self.disabled_tool_names = Arc::from(names.into_boxed_slice());
389 self
390 }
391
392 pub(crate) fn tool_definitions_json_if_enabled(&self) -> Option<Value> {
393 if !self.tools_enabled() {
394 return None;
395 }
396 let definitions = self.tool_definitions_json();
397 definitions
398 .as_array()
399 .is_some_and(|definitions| !definitions.is_empty())
400 .then_some(definitions)
401 }
402
403 pub(crate) fn with_resolved_tool_catalog(mut self, definitions: Vec<Value>) -> Self {
404 self.resolved_tool_catalog = Some(Arc::from(definitions));
405 self
406 }
407
408 pub(crate) fn tool_definitions_json(&self) -> Value {
409 if let Some(definitions) = &self.resolved_tool_catalog {
410 return Value::Array(definitions.to_vec());
411 }
412 let disabled = self
413 .disabled_tool_names
414 .iter()
415 .cloned()
416 .collect::<HashSet<_>>();
417 tool_definitions_json_with_dynamic(
418 self.subagents_tool_enabled,
419 &self.dynamic_tool_definitions,
420 &disabled,
421 )
422 }
423
424 fn from_messages(
425 model: impl Into<String>,
426 messages: Vec<ChatMessage>,
427 tool_mode: ProviderToolMode,
428 ) -> Self {
429 let conversation_items = messages
430 .into_iter()
431 .map(ProviderConversationItem::Message)
432 .collect();
433 Self::from_conversation_with_tool_mode(model, conversation_items, tool_mode)
434 }
435
436 fn from_conversation_with_tool_mode(
437 model: impl Into<String>,
438 conversation_items: Vec<ProviderConversationItem>,
439 tool_mode: ProviderToolMode,
440 ) -> Self {
441 Self::from_conversation_storage(
442 model,
443 ProviderConversation::Owned(conversation_items),
444 tool_mode,
445 )
446 }
447
448 fn from_conversation_storage(
449 model: impl Into<String>,
450 conversation_items: ProviderConversation,
451 tool_mode: ProviderToolMode,
452 ) -> Self {
453 let skill_projection = super::skill_projection::project(conversation_items.iter());
454 Self {
455 model: model.into(),
456 conversation_items,
457 skill_projection,
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 conversation_id: None,
466 codex_turn_context: None,
467 subagents_tool_enabled: true,
468 dynamic_tool_definitions: Arc::from(Vec::<Value>::new().into_boxed_slice()),
469 disabled_tool_names: Arc::from(Vec::<String>::new().into_boxed_slice()),
470 resolved_tool_catalog: None,
471 }
472 }
473
474 fn refresh_skill_projection(&mut self) {
475 self.skill_projection = super::skill_projection::project(self.conversation_items.iter());
476 }
477}
478
479#[cfg(test)]
480impl ProviderRequest {
481 pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
483 Self::from_messages(model, messages, ProviderToolMode::Enabled)
484 }
485}