Skip to main content

yapi_core/agent_session/
models.rs

1//! Model selection, thinking levels and the model registry.
2
3use std::sync::Arc;
4
5use yapi_ai::api::Apis;
6use yapi_ai::registry::ModelRegistry;
7use yapi_types::event::AgentEvent;
8use yapi_types::extension_event::ExtensionEvent;
9use yapi_types::message::ThinkingLevel;
10use yapi_types::model::Model;
11use yapi_types::sync::{lock, read, write};
12
13use super::AgentSession;
14use crate::extensions::ModelUpdate;
15
16/// Outcome of a model catalog refresh.
17#[derive(Clone, Debug, Default)]
18pub struct CatalogRefresh {
19    /// Providers whose refresh failed, with why.
20    pub errors: Vec<(String, String)>,
21    /// Whether the refresh was cancelled before it finished.
22    pub aborted: bool,
23}
24
25impl AgentSession {
26    /// pi's `setModel`: switches to `model` and records it, then applies the
27    /// thinking level for it. Fails when its provider has no credential.
28    pub fn set_model(&self, model: Model) -> Result<(), String> {
29        if !self.registry().has_auth(&model.provider) {
30            return Err(format!("No API key for {}/{}", model.provider, model.id));
31        }
32        self.apply_model(model, None, "set");
33        Ok(())
34    }
35
36    /// Takes over the model, thinking level and scope of the session this one
37    /// replaces on `/reload`, which pi keeps without recording anything.
38    pub fn keep_selection(&self, from: &AgentSession) {
39        let (model, level) = {
40            let state = lock(&from.inner.state);
41            (state.model.clone(), state.thinking_level)
42        };
43        {
44            let mut state = lock(&self.inner.state);
45            state.model = model;
46            state.thinking_level = level;
47        }
48        self.set_scoped_models(from.scoped_models());
49    }
50
51    /// The models cycling moves through, from `--models` or the
52    /// `enabledModels` setting, or empty when every available model is in
53    /// scope.
54    pub fn scoped_models(&self) -> Vec<crate::model_resolver::ScopedModel> {
55        lock(&self.inner.scoped_models).clone()
56    }
57
58    /// The scoped models, or every available model when the scope is empty.
59    pub fn models_in_scope(&self) -> Vec<Model> {
60        let scoped = self.scoped_models();
61        if scoped.is_empty() {
62            self.available_models()
63        } else {
64            scoped.into_iter().map(|entry| entry.model).collect()
65        }
66    }
67
68    /// Replaces the scope. An empty list puts every available model in it.
69    pub fn set_scoped_models(&self, models: Vec<crate::model_resolver::ScopedModel>) {
70        *lock(&self.inner.scoped_models) = models;
71    }
72
73    /// Saves `model` as the default in the global settings. A non-empty scope
74    /// gains it, and so does a non-empty `enabledModels` setting, as in pi.
75    pub fn save_default_model(&self, model: &Model) {
76        let _ = self.set_global_setting(
77            "defaultProvider",
78            Some(serde_json::Value::String(model.provider.clone())),
79        );
80        let _ = self.set_global_setting(
81            "defaultModel",
82            Some(serde_json::Value::String(model.id.clone())),
83        );
84        {
85            let mut scoped = lock(&self.inner.scoped_models);
86            if scoped.is_empty()
87                || scoped
88                    .iter()
89                    .any(|entry| entry.model.is(&model.provider, &model.id))
90            {
91                return;
92            }
93            scoped.push(crate::model_resolver::ScopedModel {
94                model: model.clone(),
95                thinking_level: None,
96            });
97        }
98        let Some(enabled) = self
99            .settings()
100            .enabled_models
101            .filter(|list| !list.is_empty())
102        else {
103            return;
104        };
105        let reference = model.reference();
106        if enabled
107            .iter()
108            .any(|pattern| pattern.eq_ignore_ascii_case(&reference))
109        {
110            return;
111        }
112        let mut list: Vec<serde_json::Value> =
113            enabled.into_iter().map(serde_json::Value::String).collect();
114        list.push(serde_json::Value::String(reference));
115        let _ = self.set_global_setting("enabledModels", Some(serde_json::Value::Array(list)));
116    }
117
118    /// Records `model` and applies the thinking level for it: `explicit`,
119    /// else its level in settings, else the default level, else the current
120    /// one. Extensions hear of a new model as pi's `model_select` from
121    /// `source`.
122    fn apply_model(&self, model: Model, explicit: Option<ThinkingLevel>, source: &str) {
123        let settings = self.settings();
124        let level = explicit
125            .or_else(|| {
126                settings
127                    .model_thinking_levels
128                    .as_ref()
129                    .and_then(|levels| levels.get(&model.reference()))
130                    .copied()
131            })
132            .or_else(|| lock(&self.inner.settings).default_thinking_level())
133            .unwrap_or_else(|| self.thinking_level());
134        let previous = {
135            let mut state = lock(&self.inner.state);
136            let _ = state
137                .session
138                .append_model_change(&model.provider, &model.id);
139            state.model.replace(model.clone())
140        };
141        self.set_thinking_level(level);
142        if previous
143            .as_ref()
144            .is_none_or(|previous| !previous.is(&model.provider, &model.id))
145        {
146            self.announce(ExtensionEvent::ModelSelect {
147                model: &model,
148                previous_model: previous.as_ref(),
149                source,
150            });
151        }
152    }
153
154    /// pi's `cycleModel`, forward or backward, over the scoped models that
155    /// have credentials, or over every available model when the scope is
156    /// empty. Returns the new model and whether the scope applied, or `None`
157    /// when there is at most one model to cycle through.
158    pub fn cycle_model(&self, forward: bool) -> Option<(Model, bool)> {
159        let scoped = self.scoped_models();
160        let available = self.available_models();
161        let is_scoped = !scoped.is_empty();
162        let models: Vec<(Model, Option<ThinkingLevel>)> = if is_scoped {
163            scoped
164                .into_iter()
165                .filter(|entry| {
166                    available
167                        .iter()
168                        .any(|m| m.is(&entry.model.provider, &entry.model.id))
169                })
170                .map(|entry| (entry.model, entry.thinking_level))
171                .collect()
172        } else {
173            available.into_iter().map(|model| (model, None)).collect()
174        };
175        if models.len() <= 1 {
176            return None;
177        }
178        let index = self
179            .model()
180            .and_then(|current| {
181                models
182                    .iter()
183                    .position(|(model, _)| model.is(&current.provider, &current.id))
184            })
185            .unwrap_or(0);
186        let next = if forward {
187            (index + 1) % models.len()
188        } else {
189            (index + models.len() - 1) % models.len()
190        };
191        let (model, level) = models[next].clone();
192        self.apply_model(model.clone(), level, "cycle");
193        Some((model, is_scoped))
194    }
195
196    /// Sets the thinking level, clamped to what the model supports, and
197    /// records and announces a change.
198    pub fn set_thinking_level(&self, level: ThinkingLevel) {
199        let mut state = lock(&self.inner.state);
200        let level = match &state.model {
201            Some(model) => yapi_ai::thinking::clamp_level(model, level),
202            None => level,
203        };
204        if level == state.thinking_level {
205            return;
206        }
207        let previous = std::mem::replace(&mut state.thinking_level, level);
208        let _ = state.session.append_thinking_level_change(level.as_str());
209        drop(state);
210        self.emit(&AgentEvent::ThinkingLevelChanged { level });
211        self.announce(ExtensionEvent::ThinkingLevelSelect {
212            level,
213            previous_level: previous,
214        });
215    }
216
217    /// The thinking levels the current model supports, or all of them without
218    /// a model.
219    pub fn available_thinking_levels(&self) -> Vec<ThinkingLevel> {
220        match self.model() {
221            Some(model) => yapi_ai::thinking::supported_levels(&model),
222            None => ThinkingLevel::ALL.to_vec(),
223        }
224    }
225
226    /// pi's `cycleThinkingLevel`: the next supported level, wrapping. `None`
227    /// when the model does not reason.
228    pub fn cycle_thinking_level(&self) -> Option<ThinkingLevel> {
229        let model = self.model()?;
230        if !model.reasoning {
231            return None;
232        }
233        let levels = yapi_ai::thinking::supported_levels(&model);
234        let current = self.thinking_level();
235        let index = levels.iter().position(|level| *level == current);
236        let next = levels[index.map_or(0, |index| (index + 1) % levels.len())];
237        self.set_thinking_level(next);
238        Some(self.thinking_level())
239    }
240
241    /// Models with credentials, in catalog order.
242    pub fn available_models(&self) -> Vec<Model> {
243        read(&self.inner.registry)
244            .available()
245            .into_iter()
246            .cloned()
247            .collect()
248    }
249
250    /// The model registry. Credentials it reads stay current with `auth.json`.
251    pub fn registry(&self) -> Arc<ModelRegistry> {
252        Arc::clone(&read(&self.inner.registry))
253    }
254
255    /// The wire APIs requests stream through.
256    pub fn apis(&self) -> Apis {
257        read(&self.inner.apis).clone()
258    }
259
260    /// Applies `change`, a provider that an extension registers or
261    /// unregisters after extensions load, to the model registry and the wire
262    /// APIs. The current model takes the registry's new definition of it,
263    /// as in pi.
264    pub fn update_providers<T>(
265        &self,
266        change: impl FnOnce(&mut ModelRegistry, &mut Apis) -> T,
267    ) -> T {
268        let result = {
269            let mut slot = write(&self.inner.registry);
270            let mut next = (**slot).clone();
271            let result = change(&mut next, &mut write(&self.inner.apis));
272            *slot = Arc::new(next);
273            result
274        };
275        let registry = self.registry();
276        let mut state = lock(&self.inner.state);
277        if let Some(current) = &state.model
278            && let Some(model) = registry.find(&current.provider, &current.id)
279            && model != current
280        {
281            state.model = Some(model.clone());
282        }
283        result
284    }
285
286    /// Refreshes the model catalogs that change between releases: restores
287    /// the stored ones and, when `options` allow the network, fetches those of
288    /// configured providers, as pi's `ModelRuntime.refresh`. The session then
289    /// lists the new models. Concurrent refreshes run one after another.
290    pub async fn refresh_model_catalogs(
291        &self,
292        options: yapi_ai::model_catalog::RefreshOptions,
293    ) -> CatalogRefresh {
294        let _running = self.inner.catalog_refresh.lock().await;
295        let registry = self.registry();
296        let Some(store) = registry.models_store().cloned() else {
297            return CatalogRefresh::default();
298        };
299        let targets = registry.catalog_targets().await;
300        let refreshed = yapi_ai::model_catalog::refresh(&targets, &store, &options).await;
301        let mut lists = Vec::new();
302        for extension in &self.inner.extensions {
303            lists.extend(extension.refresh_models(&registry, &options).await);
304        }
305        let mut errors = refreshed.errors;
306        // Apply to the registry current now: credentials may have changed.
307        let mut slot = write(&self.inner.registry);
308        let mut next = (**slot).clone();
309        next.apply_catalogs(refreshed.models);
310        for (provider, list) in lists {
311            // A provider unregistered during the refresh keeps none of it,
312            // nor does the provider it had replaced.
313            if !next.is_extension_provider(&provider) {
314                continue;
315            }
316            let applied = list.and_then(|update| match update {
317                ModelUpdate::Definitions(models) => next.replace_models(&provider, models),
318                ModelUpdate::Modified(models) => {
319                    next.modify_models(&provider, models);
320                    Ok(())
321                }
322                ModelUpdate::Availability(availability) => {
323                    next.set_availability(&provider, availability);
324                    Ok(())
325                }
326            });
327            if let Err(error) = applied
328                && !options.cancel.is_cancelled()
329            {
330                errors.push((provider, error));
331            }
332        }
333        *slot = Arc::new(next);
334        drop(slot);
335        CatalogRefresh {
336            errors,
337            aborted: refreshed.aborted || options.cancel.is_cancelled(),
338        }
339    }
340
341    /// Runs `f` with the model registry.
342    pub fn with_registry<T>(&self, f: impl FnOnce(&ModelRegistry) -> T) -> T {
343        f(&read(&self.inner.registry))
344    }
345}