Skip to main content

oino_models/resolver/
cycle.rs

1//! Model cycling selection rules and thinking level on model switches.
2
3use indexmap::IndexMap;
4use oino_ai::{Model, ThinkingLevel};
5
6use super::defaults::DEFAULT_THINKING_LEVEL;
7use super::initial::model_thinking_level_key;
8use super::scope::ScopedModel;
9
10/// Cycle direction.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum CycleDirection {
13    /// Next model.
14    #[default]
15    Forward,
16    /// Previous model.
17    Backward,
18}
19
20fn same_model(a: &Model, b: &Model) -> bool {
21    a.id == b.id && a.provider == b.provider
22}
23
24fn step(len: usize, current: Option<usize>, direction: CycleDirection) -> usize {
25    let current = current.unwrap_or(0);
26    match direction {
27        CycleDirection::Forward => (current + 1) % len,
28        CycleDirection::Backward => (current + len - 1) % len,
29    }
30}
31
32/// The next scoped model among those still available; `None` when at
33/// most one remains.
34#[must_use]
35pub fn next_scoped_model(
36    scoped: &[ScopedModel],
37    available: &[Model],
38    current: Option<&Model>,
39    direction: CycleDirection,
40) -> Option<ScopedModel> {
41    let candidates: Vec<&ScopedModel> = scoped
42        .iter()
43        .filter(|s| available.iter().any(|m| same_model(m, &s.model)))
44        .collect();
45    if candidates.len() <= 1 {
46        return None;
47    }
48    let index = current.and_then(|c| candidates.iter().position(|s| same_model(&s.model, c)));
49    Some(candidates[step(candidates.len(), index, direction)].clone())
50}
51
52/// C y c l e   A v a i l a b l e   M o d e l.
53#[must_use]
54pub fn next_available_model(
55    available: &[Model],
56    current: Option<&Model>,
57    direction: CycleDirection,
58) -> Option<Model> {
59    if available.len() <= 1 {
60        return None;
61    }
62    let index = current.and_then(|c| available.iter().position(|m| same_model(m, c)));
63    Some(available[step(available.len(), index, direction)].clone())
64}
65
66/// Inputs of [`thinking_level_for_model_switch`].
67#[derive(Clone, Copy, Debug, Default)]
68pub struct ThinkingSwitch<'a> {
69    /// Level given explicitly (scoped `model:high`).
70    pub explicit: Option<ThinkingLevel>,
71    /// Settings `modelThinkingLevels`.
72    pub model_thinking_levels: Option<&'a IndexMap<String, ThinkingLevel>>,
73    /// Settings `defaultThinkingLevel`.
74    pub default_thinking_level: Option<ThinkingLevel>,
75    /// The session's current level.
76    pub current: Option<ThinkingLevel>,
77}
78
79/// Explicit > per-model setting > default setting >
80/// current > `medium` (the caller clamps to the model).
81#[must_use]
82pub fn thinking_level_for_model_switch(
83    target: Option<&Model>,
84    inputs: &ThinkingSwitch<'_>,
85) -> ThinkingLevel {
86    if let Some(explicit) = inputs.explicit {
87        return explicit;
88    }
89    if let Some(level) = target.and_then(|model| {
90        inputs
91            .model_thinking_levels?
92            .get(&model_thinking_level_key(&model.provider, &model.id))
93            .copied()
94    }) {
95        return level;
96    }
97    inputs
98        .default_thinking_level
99        .or(inputs.current)
100        .unwrap_or(DEFAULT_THINKING_LEVEL)
101}