1use std::sync::Arc;
23
24use pi_ai::{Model, ModelThinkingLevel, ThinkingLevelMap};
25
26use crate::core::model_runtime::ModelRuntime;
27use crate::core::sessions::SessionError;
28
29use super::events::{AgentSessionEvent, ModelSelectSource};
30use super::{AgentSession, ScopedModel};
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct ModelCycleResult {
35 pub model: Model,
37 pub thinking_level: ModelThinkingLevel,
39 pub is_scoped: bool,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum CycleDirection {
47 Forward,
49 Backward,
51}
52
53#[derive(Debug, thiserror::Error)]
55pub enum ModelError {
56 #[error("No API key for {0}/{1}")]
58 NoAuth(String, String),
59 #[error(transparent)]
61 Session(#[from] SessionError),
62}
63
64const EXTENDED_THINKING_LEVELS: [ModelThinkingLevel; 7] = [
67 ModelThinkingLevel::Off,
68 ModelThinkingLevel::Minimal,
69 ModelThinkingLevel::Low,
70 ModelThinkingLevel::Medium,
71 ModelThinkingLevel::High,
72 ModelThinkingLevel::Xhigh,
73 ModelThinkingLevel::Max,
74];
75
76fn extended_level_index(level: ModelThinkingLevel) -> Option<usize> {
78 EXTENDED_THINKING_LEVELS
79 .iter()
80 .position(|candidate| *candidate == level)
81}
82
83fn models_are_equal(a: &Model, b: &Model) -> bool {
85 a.id == b.id && a.provider == b.provider
86}
87
88pub(super) fn supported_thinking_levels(model: &Model) -> Vec<ModelThinkingLevel> {
95 if !model.reasoning {
96 return vec![ModelThinkingLevel::Off];
97 }
98 let Some(map) = model.thinking_level_map.as_ref() else {
99 return EXTENDED_THINKING_LEVELS.to_vec();
100 };
101 EXTENDED_THINKING_LEVELS
102 .iter()
103 .filter(|level| level_supported_by_map(**level, map))
104 .copied()
105 .collect()
106}
107
108fn level_supported_by_map(level: ModelThinkingLevel, map: &ThinkingLevelMap) -> bool {
109 match map.get(&level) {
110 Some(None) => false,
112 Some(Some(_)) => true,
114 None => !matches!(level, ModelThinkingLevel::Xhigh | ModelThinkingLevel::Max),
116 }
117}
118
119pub(super) fn clamp_thinking_level(model: &Model, level: ModelThinkingLevel) -> ModelThinkingLevel {
124 let available = supported_thinking_levels(model);
125 let has_mapped_candidates = model
126 .thinking_level_map
127 .as_ref()
128 .is_some_and(|map| map.values().any(Option::is_some));
129 let is_clamp_candidate = |candidate: &ModelThinkingLevel| {
130 if !available.contains(candidate) {
131 return false;
132 }
133 !has_mapped_candidates
134 || model
135 .thinking_level_map
136 .as_ref()
137 .is_some_and(|map| map.get(candidate).is_some_and(Option::is_some))
138 };
139
140 if is_clamp_candidate(&level) {
141 return level;
142 }
143 let Some(requested_index) = extended_level_index(level) else {
144 return available
145 .first()
146 .copied()
147 .unwrap_or(ModelThinkingLevel::Off);
148 };
149 for candidate in &EXTENDED_THINKING_LEVELS[requested_index..] {
150 if is_clamp_candidate(candidate) {
151 return *candidate;
152 }
153 }
154 for candidate in EXTENDED_THINKING_LEVELS[..requested_index].iter().rev() {
155 if is_clamp_candidate(candidate) {
156 return *candidate;
157 }
158 }
159 available
160 .first()
161 .copied()
162 .unwrap_or(ModelThinkingLevel::Off)
163}
164
165impl AgentSession {
166 pub(super) fn model_runtime(&self) -> Option<Arc<ModelRuntime>> {
171 self.model_runtime_handle()
172 }
173
174 pub async fn set_model(&self, model: Model) -> Result<(), ModelError> {
186 if let Some(runtime) = self.model_runtime()
187 && runtime.check_auth(&model.provider).await.is_none()
188 {
189 return Err(ModelError::NoAuth(model.provider.clone(), model.id.clone()));
190 }
191 let previous = self.model();
192 if models_are_equal(&previous, &model) {
193 return Ok(());
194 }
195 let thinking = self.thinking_level_for_model_switch(None);
196 {
199 let mut manager = self.session_manager.lock().await;
200 manager.append_model_change(&model.provider, &model.id)?;
201 }
202 self.agent.set_model(model.clone());
203 self.lock_settings()
204 .set_default_model_and_provider(&model.provider, &model.id);
205 let _committed = self.set_thinking_level(thinking).await;
208 self.emit_model_select(&model, Some(&previous), ModelSelectSource::Set)
209 .await;
210 Ok(())
211 }
212
213 pub async fn cycle_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
219 if self.scoped_models().is_empty() {
220 self.cycle_available_model(direction).await
221 } else {
222 self.cycle_scoped_model(direction).await
223 }
224 }
225
226 async fn cycle_scoped_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
227 let scoped = self.scoped_models();
228 let filtered = self.filter_scoped_by_auth(scoped).await;
229 if filtered.len() <= 1 {
230 return None;
231 }
232 let current = self.model();
233 let current_index = filtered
234 .iter()
235 .position(|scoped| models_are_equal(&scoped.model, ¤t))
236 .unwrap_or(0);
237 let len = filtered.len();
238 let next_index = match direction {
239 CycleDirection::Forward => (current_index + 1) % len,
240 CycleDirection::Backward => (current_index + len - 1) % len,
241 };
242 let next = &filtered[next_index];
243 let thinking = self.thinking_level_for_model_switch(next.thinking_level);
244 {
246 let mut manager = self.session_manager.lock().await;
247 if manager
248 .append_model_change(&next.model.provider, &next.model.id)
249 .is_err()
250 {
251 return None;
252 }
253 }
254 self.agent.set_model(next.model.clone());
255 self.lock_settings()
256 .set_default_model_and_provider(&next.model.provider, &next.model.id);
257 let _committed = self.set_thinking_level(thinking).await;
259 self.emit_model_select(&next.model, Some(¤t), ModelSelectSource::Cycle)
260 .await;
261 Some(ModelCycleResult {
262 model: next.model.clone(),
263 thinking_level: self.thinking_level(),
264 is_scoped: true,
265 })
266 }
267
268 async fn cycle_available_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
269 let runtime = self.model_runtime()?;
270 let available = runtime.get_available_snapshot();
271 if available.len() <= 1 {
272 return None;
273 }
274 let current = self.model();
275 let current_index = available
276 .iter()
277 .position(|model| models_are_equal(model, ¤t))
278 .unwrap_or(0);
279 let len = available.len();
280 let next_index = match direction {
281 CycleDirection::Forward => (current_index + 1) % len,
282 CycleDirection::Backward => (current_index + len - 1) % len,
283 };
284 let next_model = available[next_index].clone();
285 let thinking = self.thinking_level_for_model_switch(None);
286 {
288 let mut manager = self.session_manager.lock().await;
289 if manager
290 .append_model_change(&next_model.provider, &next_model.id)
291 .is_err()
292 {
293 return None;
294 }
295 }
296 self.agent.set_model(next_model.clone());
297 self.lock_settings()
298 .set_default_model_and_provider(&next_model.provider, &next_model.id);
299 let _committed = self.set_thinking_level(thinking).await;
301 self.emit_model_select(&next_model, Some(¤t), ModelSelectSource::Cycle)
302 .await;
303 Some(ModelCycleResult {
304 model: next_model,
305 thinking_level: self.thinking_level(),
306 is_scoped: false,
307 })
308 }
309
310 async fn filter_scoped_by_auth(&self, scoped: Vec<ScopedModel>) -> Vec<ScopedModel> {
313 let Some(runtime) = self.model_runtime() else {
314 return scoped;
315 };
316 let mut keep = Vec::with_capacity(scoped.len());
317 for entry in scoped {
318 if runtime.check_auth(&entry.model.provider).await.is_some() {
319 keep.push(entry);
320 }
321 }
322 keep
323 }
324
325 #[must_use = "a false return means the level change was not durably committed"]
337 pub async fn set_thinking_level(&self, level: ModelThinkingLevel) -> bool {
338 let available = self.available_thinking_levels();
339 let effective = if available.contains(&level) {
340 level
341 } else {
342 clamp_thinking_level(&self.model(), level)
343 };
344 let previous = self.thinking_level();
345 if effective == previous {
346 return true;
347 }
348 {
351 let mut manager = self.session_manager.lock().await;
352 if manager
353 .append_thinking_level_change(level_str(effective))
354 .is_err()
355 {
356 return false;
357 }
358 }
359 self.agent.set_thinking_level(effective);
360 if self.supports_thinking() || effective != ModelThinkingLevel::Off {
364 self.lock_settings().set_default_thinking_level(effective);
365 }
366 self.emit_public(super::events::AgentSessionEvent::ThinkingLevelChanged {
367 level: effective,
368 });
369 let runner = self.hooks.runner();
370 let _ = runner
371 .emit(super::events::AgentSessionEvent::ThinkingLevelChanged { level: effective })
372 .await;
373 true
374 }
375
376 pub async fn cycle_thinking_level(&self) -> Option<ModelThinkingLevel> {
381 if !self.supports_thinking() {
382 return None;
383 }
384 let levels = self.available_thinking_levels();
385 if levels.is_empty() {
386 return None;
387 }
388 let current = self.thinking_level();
389 let current_index = levels
390 .iter()
391 .position(|level| *level == current)
392 .unwrap_or(0);
393 let len = levels.len();
394 let next_index = (current_index + 1) % len;
395 let next_level = levels[next_index];
396 if self.set_thinking_level(next_level).await {
397 Some(next_level)
398 } else {
399 None
400 }
401 }
402
403 #[must_use]
405 pub fn available_thinking_levels(&self) -> Vec<ModelThinkingLevel> {
406 supported_thinking_levels(&self.model())
407 }
408
409 #[must_use]
411 pub fn supports_thinking(&self) -> bool {
412 self.model().reasoning
413 }
414
415 pub(super) fn thinking_level_for_model_switch(
423 &self,
424 scoped_level: Option<ModelThinkingLevel>,
425 ) -> ModelThinkingLevel {
426 if let Some(explicit) = scoped_level {
427 return explicit;
428 }
429 if !self.supports_thinking() {
430 return self
431 .lock_settings()
432 .get_default_thinking_level()
433 .unwrap_or(ModelThinkingLevel::Medium);
434 }
435 self.thinking_level()
436 }
437
438 async fn emit_model_select(
440 &self,
441 next_model: &Model,
442 previous_model: Option<&Model>,
443 source: ModelSelectSource,
444 ) {
445 if matches!(previous_model, Some(prev) if models_are_equal(prev, next_model)) {
446 return;
447 }
448 let runner = self.hooks.runner();
449 if !runner.has_handlers("model_select") {
450 return;
451 }
452 if let Err(error) = runner
453 .emit(AgentSessionEvent::ModelSelect {
454 model: Box::new(next_model.clone()),
455 previous_model: previous_model.map(|model| Box::new(model.clone())),
456 source,
457 })
458 .await
459 {
460 runner.emit_error(error.to_string());
461 }
462 }
463}
464
465fn level_str(level: ModelThinkingLevel) -> &'static str {
467 match level {
468 ModelThinkingLevel::Off => "off",
469 ModelThinkingLevel::Minimal => "minimal",
470 ModelThinkingLevel::Low => "low",
471 ModelThinkingLevel::Medium => "medium",
472 ModelThinkingLevel::High => "high",
473 ModelThinkingLevel::Xhigh => "xhigh",
474 ModelThinkingLevel::Max => "max",
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn model(id: &str, provider: &str, reasoning: bool) -> Model {
483 Model {
484 id: id.to_owned(),
485 name: id.to_owned(),
486 api: "test-api".to_owned(),
487 provider: provider.to_owned(),
488 base_url: String::new(),
489 reasoning,
490 thinking_level_map: None,
491 input: vec![pi_ai::ModelInput::Text],
492 cost: pi_ai::ModelCost::default(),
493 context_window: 8_192,
494 max_tokens: 1_024,
495 headers: None,
496 compat: None,
497 extra: std::collections::BTreeMap::new(),
498 }
499 }
500
501 #[test]
502 fn supported_levels_non_reasoning_is_off_only() {
503 let levels = supported_thinking_levels(&model("a", "p", false));
504 assert_eq!(levels, vec![ModelThinkingLevel::Off]);
505 }
506
507 #[test]
508 fn supported_levels_reasoning_without_map_is_full_set() {
509 let levels = supported_thinking_levels(&model("a", "p", true));
510 assert_eq!(levels, EXTENDED_THINKING_LEVELS.to_vec());
511 }
512
513 #[test]
514 fn clamp_high_to_non_reasoning_is_off() {
515 let m = model("a", "p", false);
516 assert_eq!(
517 clamp_thinking_level(&m, ModelThinkingLevel::High),
518 ModelThinkingLevel::Off
519 );
520 }
521
522 #[test]
523 fn clamp_walks_forward_then_backward() {
524 let mut m = model("a", "p", true);
527 let mut map = ThinkingLevelMap::new();
528 map.insert(ModelThinkingLevel::Off, None);
529 map.insert(ModelThinkingLevel::High, Some("high".to_owned()));
530 m.thinking_level_map = Some(map);
531 assert_eq!(
532 clamp_thinking_level(&m, ModelThinkingLevel::Medium),
533 ModelThinkingLevel::High
534 );
535 }
536
537 #[test]
538 fn clamp_walks_backward_when_no_higher_supported() {
539 let mut m = model("a", "p", true);
543 let mut map = ThinkingLevelMap::new();
544 map.insert(ModelThinkingLevel::Off, None);
545 map.insert(ModelThinkingLevel::Low, Some("low".to_owned()));
546 m.thinking_level_map = Some(map);
547 assert_eq!(
548 clamp_thinking_level(&m, ModelThinkingLevel::Medium),
549 ModelThinkingLevel::Low
550 );
551 }
552
553 #[test]
554 fn models_are_equal_checks_id_and_provider() {
555 let a = model("a", "p", false);
556 let b = model("a", "p", true);
557 let c = model("a", "q", false);
558 assert!(models_are_equal(&a, &b), "id+provider match");
559 assert!(!models_are_equal(&a, &c), "provider differs");
560 }
561
562 #[test]
563 fn xhigh_max_require_explicit_map_entry() {
564 let mut m = model("a", "p", true);
565 let mut map = ThinkingLevelMap::new();
566 map.insert(ModelThinkingLevel::Off, None);
568 map.insert(ModelThinkingLevel::Low, Some("low".to_owned()));
569 m.thinking_level_map = Some(map);
570 let levels = supported_thinking_levels(&m);
571 assert!(!levels.contains(&ModelThinkingLevel::Xhigh));
572 assert!(!levels.contains(&ModelThinkingLevel::Max));
573 let Some(map_ref) = m.thinking_level_map.as_mut() else {
575 unreachable!();
576 };
577 map_ref.insert(ModelThinkingLevel::Xhigh, Some("xhigh".to_owned()));
578 let levels = supported_thinking_levels(&m);
579 assert!(levels.contains(&ModelThinkingLevel::Xhigh));
580 }
581
582 #[test]
583 fn explicit_null_disables_level() {
584 let mut m = model("a", "p", true);
585 let mut map = ThinkingLevelMap::new();
586 map.insert(ModelThinkingLevel::Off, None);
587 map.insert(ModelThinkingLevel::Medium, None);
588 m.thinking_level_map = Some(map);
589 let levels = supported_thinking_levels(&m);
590 assert!(!levels.contains(&ModelThinkingLevel::Medium));
591 }
592
593 #[test]
594 fn level_str_matches_wire_union() {
595 assert_eq!(level_str(ModelThinkingLevel::Off), "off");
596 assert_eq!(level_str(ModelThinkingLevel::Medium), "medium");
597 assert_eq!(level_str(ModelThinkingLevel::Xhigh), "xhigh");
598 assert_eq!(level_str(ModelThinkingLevel::Max), "max");
599 }
600
601 mod append_failures {
602 use super::*;
603 use crate::core::agent_session::AgentSessionConfig;
604 use futures::stream::{self, BoxStream, StreamExt};
605 use pi_ai::{AssistantMessageEvent, Context, Provider, ProviderError, StreamOptions};
606 use std::sync::Mutex as StdMutex;
607
608 type TestResult<T = ()> = Result<T, String>;
609
610 #[derive(Clone)]
611 struct StubProvider;
612
613 impl Provider for StubProvider {
614 fn stream(
615 &self,
616 _model: &Model,
617 _context: Context,
618 _options: StreamOptions,
619 ) -> BoxStream<'static, Result<AssistantMessageEvent, ProviderError>> {
620 stream::empty().boxed()
621 }
622 }
623
624 fn context(error: impl std::fmt::Display, label: &str) -> String {
625 format!("{label}: {error}")
626 }
627
628 fn blocked_append_session(
630 dir: &tempfile::TempDir,
631 session_model: Model,
632 scoped_models: Vec<ScopedModel>,
633 ) -> TestResult<Arc<AgentSession>> {
634 let mut manager = crate::core::sessions::SessionManager::create(
635 dir.path().to_string_lossy().as_ref(),
636 Some(dir.path().to_string_lossy().as_ref()),
637 None,
638 )
639 .map_err(|error| context(error, "session manager"))?;
640 manager
641 .append_message(&pi_agent::user_text("seed", std::iter::empty()))
642 .map_err(|error| context(error, "seed user append"))?;
643 let mut assistant =
646 pi_ai::AssistantMessage::new("test-api", "p", "m", pi_agent::now_millis());
647 assistant.stop_reason = pi_ai::StopReason::Stop;
648 manager
649 .append_message(&pi_agent::AgentMessage::Llm(Box::new(
650 pi_ai::Message::Assistant(assistant),
651 )))
652 .map_err(|error| context(error, "seed assistant append"))?;
653 let session_file = std::path::PathBuf::from(
654 manager
655 .get_session_file()
656 .ok_or_else(|| "missing session file".to_owned())?,
657 );
658 std::fs::remove_file(&session_file)
659 .map_err(|error| context(error, "remove session file"))?;
660 std::fs::create_dir(&session_file)
661 .map_err(|error| context(error, "block append path"))?;
662 let mut config = AgentSessionConfig::test_config(Arc::new(StubProvider), session_model)
663 .map_err(|error| context(error, "test config"))?;
664 config.session_manager = manager;
665 config.scoped_models = scoped_models;
666 AgentSession::new(config).map_err(|error| context(error, "session"))
667 }
668
669 fn record_events(session: &Arc<AgentSession>) -> Arc<StdMutex<Vec<String>>> {
670 let events = Arc::new(StdMutex::new(Vec::new()));
671 let events_clone = Arc::clone(&events);
672 let _unsub = session.subscribe(move |event| {
673 events_clone
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner)
676 .push(event.type_name().to_owned());
677 });
678 events
679 }
680
681 fn recorded(events: &Arc<StdMutex<Vec<String>>>) -> Vec<String> {
682 events
683 .lock()
684 .unwrap_or_else(std::sync::PoisonError::into_inner)
685 .clone()
686 }
687
688 #[tokio::test]
689 async fn set_model_append_failure_returns_error_without_live_drift() -> TestResult {
690 let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
691 let session = blocked_append_session(&dir, model("m", "p", false), Vec::new())?;
692 let events = record_events(&session);
693
694 let result = session.set_model(model("b", "p", false)).await;
695 assert!(
696 matches!(result, Err(ModelError::Session(_))),
697 "expected typed session error: {result:?}"
698 );
699 assert_eq!(session.model().id, "m", "live model must not change");
700 assert!(
701 recorded(&events).is_empty(),
702 "failed append must publish no events: {:?}",
703 recorded(&events)
704 );
705 Ok(())
706 }
707
708 #[tokio::test]
709 async fn scoped_cycle_append_failure_cycles_nothing() -> TestResult {
710 let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
711 let scoped = vec![
712 ScopedModel {
713 model: model("m", "p", false),
714 thinking_level: None,
715 },
716 ScopedModel {
717 model: model("b", "p", false),
718 thinking_level: None,
719 },
720 ];
721 let session = blocked_append_session(&dir, model("m", "p", false), scoped)?;
722 let events = record_events(&session);
723
724 let cycled = session.cycle_model(CycleDirection::Forward).await;
725 assert!(cycled.is_none(), "failed append must not report a cycle");
726 assert_eq!(session.model().id, "m", "live model must not change");
727 assert!(
728 recorded(&events).is_empty(),
729 "failed append must publish no events: {:?}",
730 recorded(&events)
731 );
732 Ok(())
733 }
734
735 #[tokio::test]
736 async fn thinking_level_append_failure_publishes_nothing() -> TestResult {
737 let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
738 let session = blocked_append_session(&dir, model("m", "p", true), Vec::new())?;
739 let before = session.thinking_level();
740 let next = if before == ModelThinkingLevel::High {
741 ModelThinkingLevel::Low
742 } else {
743 ModelThinkingLevel::High
744 };
745 let events = record_events(&session);
746
747 assert!(
748 !session.set_thinking_level(next).await,
749 "failed append must report an uncommitted level change"
750 );
751 assert_eq!(
752 session.thinking_level(),
753 before,
754 "live thinking level must not change"
755 );
756 assert!(
757 session.cycle_thinking_level().await.is_none(),
758 "failed append must not report a successful cycle"
759 );
760 assert_eq!(
761 session.thinking_level(),
762 before,
763 "failed cycle must not change the live level"
764 );
765 assert!(
766 !recorded(&events)
767 .iter()
768 .any(|name| name == "thinking_level_changed"),
769 "failed append must not publish thinking_level_changed: {:?}",
770 recorded(&events)
771 );
772 Ok(())
773 }
774 }
775}