Skip to main content

nexus_core/app/
models.rs

1use anyhow::Result;
2use chrono::Utc;
3
4use crate::provider::BackendTag;
5use crate::provider::openrouter::OpenRouter;
6
7use super::{App, ModelPickTarget, Popup};
8
9impl App {
10    /// Rebuild every backend from the on-disk saved credentials (after boot,
11    /// or after a settings change). Keeps `saved` and `backends` in sync.
12    pub fn rebuild_all_backends(&mut self) {
13        self.backends = super::Backends::default();
14        if let Some(k) = self.saved.openrouter_key.clone() {
15            self.backends
16                .set(BackendTag::OpenRouter, OpenRouter::openrouter_flavor(k));
17        }
18        if let Some(k) = self.saved.openai_key.clone() {
19            self.backends.set(BackendTag::OpenAi, OpenRouter::openai(k));
20        }
21        if let Some(k) = self.saved.opencode_key.clone() {
22            self.backends
23                .set(BackendTag::OpencodeGo, OpenRouter::opencode_go(k));
24        }
25        if let Some(c) = self.saved.codex.clone() {
26            self.backends
27                .set(BackendTag::Codex, OpenRouter::openai_codex(c.access));
28        }
29        if self.backends.any() {
30            self.push_status("loading models…  (/model to pick, /help for commands)".to_string());
31        }
32    }
33
34    pub fn resolve_model_backend(&self, id: &str) -> Option<(OpenRouter, String)> {
35        self.backends.resolve(id)
36    }
37
38    /// Resolve a feature (non-session) model that may be a bare wire id or a
39    /// composite id. Feature models default to the session provider's
40    /// research-class default; a composite id picks its own backend.
41    pub fn resolve_utility_model_backend(
42        &self,
43        configured_id: &str,
44    ) -> Option<(OpenRouter, String)> {
45        self.resolve_feature_model_backend(configured_id, OpenRouter::default_utility_model)
46    }
47
48    /// Resolve a feature model by name for a backend that may not be
49    /// `OpenRouter` (used for image/video gen where the user may have picked a
50    /// non-OpenRouter model).
51    pub fn resolve_feature_model_backend(
52        &self,
53        configured_id: &str,
54        default: fn(&OpenRouter) -> &'static str,
55    ) -> Option<(OpenRouter, String)> {
56        let configured_id = configured_id.trim();
57        if !configured_id.is_empty()
58            && let Some((provider, raw)) = self.resolve_model_backend(configured_id)
59            && self.resolved_model_looks_valid(configured_id, provider.backend_tag(), &raw)
60        {
61            return Some((provider, raw));
62        }
63
64        let provider = self
65            .current_model
66            .as_deref()
67            .and_then(|id| self.resolve_model_backend(id).map(|(provider, _)| provider))
68            .or_else(|| {
69                self.backends
70                    .configured_tags()
71                    .first()
72                    .and_then(|tag| self.backends.get(*tag).cloned())
73            })?;
74        Some((provider.clone(), default(&provider).to_string()))
75    }
76
77    /// Whether a resolved feature model id actually exists in the catalog
78    /// (or the catalog is empty/unknown — never silently drop a feature just
79    /// because the catalog isn't fetched yet).
80    fn resolved_model_looks_valid(
81        &self,
82        original_id: &str,
83        backend: BackendTag,
84        raw: &str,
85    ) -> bool {
86        if self.models.is_empty() {
87            // The classic bad state is a legacy OpenRouter id like
88            // `google/gemini-*` being resolved against OpenAI/Codex/Go because
89            // OpenRouter is not configured. Those backends' built-in defaults
90            // do not contain `/`, so treat slashy bare ids as OpenRouter-only.
91            return backend == BackendTag::OpenRouter || !original_id.contains('/');
92        }
93        self.models
94            .iter()
95            .any(|m| m.backend == backend && m.id == raw)
96    }
97
98    /// Fetch every configured backend's catalog concurrently and merge them
99    /// into one list. A backend that fails is dropped from the merge (its
100    /// error is only surfaced if *every* backend failed) — one flaky login
101    /// shouldn't blank out the models of the others. Public so the view layer
102    /// can re-trigger a fetch from the model picker.
103    pub fn fetch_models(&mut self) {
104        let providers: Vec<OpenRouter> = [
105            self.backends.openrouter.clone(),
106            self.backends.openai.clone(),
107            self.backends.opencode.clone(),
108            self.backends.codex.clone(),
109        ]
110        .into_iter()
111        .flatten()
112        .collect();
113        if providers.is_empty() {
114            return;
115        }
116        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
117        self.models_rx = Some(rx);
118        tokio::spawn(async move {
119            let mut set = tokio::task::JoinSet::new();
120            for p in providers {
121                set.spawn(async move { p.list_models().await });
122            }
123            let mut merged = Vec::new();
124            let mut errors = Vec::new();
125            while let Some(joined) = set.join_next().await {
126                match joined {
127                    Ok(Ok(models)) => merged.extend(models),
128                    Ok(Err(e)) => errors.push(e.to_string()),
129                    Err(e) => errors.push(e.to_string()),
130                }
131            }
132            let result = if merged.is_empty() && !errors.is_empty() {
133                Err(errors.join("; "))
134            } else {
135                merged.sort_by(|a, b| a.id.cmp(&b.id));
136                Ok(merged)
137            };
138            let _ = tx.send(result);
139        });
140    }
141
142    /// Context window of the active model, if known.
143    pub fn context_limit(&self) -> Option<u64> {
144        let id = self.current_model.as_deref()?;
145        self.models
146            .iter()
147            .find(|m| super::composite_id(m) == id)
148            .and_then(|m| m.context_length)
149    }
150
151    /// Tokens used by the current session. Exact (from the provider's usage on
152    /// the last response) when idle; a ~4-chars/token estimate while streaming or
153    /// before the first response.
154    /// Estimate is what would actually be *sent* — system/memory prompt, the
155    /// compaction digest (if any), and only the tail after it, not the full
156    /// (possibly much larger) on-screen scrollback.
157    pub fn context_used(&self) -> u64 {
158        if !self.is_streaming()
159            && let Some(total) = self.context_total
160        {
161            return total;
162        }
163        let streaming = self.is_streaming();
164        let mut chars = self.system_prompt().chars().count();
165        if let Some(s) = self
166            .session
167            .as_ref()
168            .and_then(|s| s.compact_summary.as_deref())
169        {
170            chars += s.chars().count();
171        }
172        if let Some(name) = &self.forced_skill
173            && let Some(skill) = self.skills.iter().find(|s| &s.name == name)
174        {
175            chars += std::fs::read_to_string(skill.dir.join("SKILL.md"))
176                .map_or(0, |md| crate::skills::skill_body(&md).chars().count());
177        }
178        chars += self
179            .effective_messages()
180            .iter()
181            // The digest transcript row duplicates `compact_summary` (counted
182            // above) — never double-count it.
183            .filter(|m| m.role != "compaction")
184            .map(|m| m.content.chars().count())
185            .sum::<usize>();
186        if let Some(buf) = self.active_streaming_text() {
187            chars += buf.chars().count();
188        }
189        let estimate = (chars / 4) as u64;
190        // The character estimate is intentionally conservative about wire
191        // overhead and provider tokenization. Do not let it make the status
192        // bar visibly shrink at the start of a new request: the previous
193        // completed request is a better lower bound until this one reports
194        // exact usage. Compaction/model/session boundaries clear
195        // `context_total` before a genuinely smaller context is shown.
196        if streaming {
197            self.context_total
198                .map_or(estimate, |total| total.max(estimate))
199        } else {
200            estimate
201        }
202    }
203
204    /// Whether the active model accepts image input (unknown model → false).
205    pub fn current_model_supports_images(&self) -> bool {
206        self.current_model.as_deref().is_some_and(|id| {
207            self.models
208                .iter()
209                .any(|m| super::composite_id(m) == id && m.supports_images)
210        })
211    }
212
213    pub fn reasoning_of(&self, id: &str) -> Option<&str> {
214        let effort = self.reasoning.get(id)?.as_str();
215        self.effort_accepted(id, effort).then_some(effort)
216    }
217
218    /// Whether `effort` is in `model`'s accepted reasoning set, so a stored
219    /// value is only sent when the model actually accepts it. Unknown models
220    /// (not in the loaded catalog) accept anything — never silently drop a
221    /// stored value just because the catalog isn't fetched yet.
222    pub fn effort_accepted(&self, model: &str, effort: &str) -> bool {
223        self.models
224            .iter()
225            .find(|m| super::composite_id(m) == model)
226            .is_none_or(|m| m.reasoning_efforts.iter().any(|e| e.as_str() == effort))
227    }
228
229    /// Set the active model (or a feature model, per the pick target). The
230    /// view layer owns the popup routing; this is the domain half.
231    pub fn pick_model(&mut self, id: &str) -> Result<()> {
232        match self.model_pick_target {
233            ModelPickTarget::Session => {
234                if self.current_model.as_deref() != Some(id) {
235                    self.bump_cache_epoch();
236                }
237                self.current_model = Some(id.to_string());
238                if let Some(session) = &self.session {
239                    self.db.set_session_model(&session.id, id)?;
240                }
241                self.db.mark_model_used(id)?;
242                self.last_used
243                    .insert(id.to_string(), Utc::now().to_rfc3339());
244                self.push_status(format!("model: {id}"));
245            }
246            ModelPickTarget::Memory => {
247                self.memory_model = id.to_string();
248                self.db.set_setting("memory_model", id)?;
249                self.push_status(format!("memory model: {id}"));
250            }
251            ModelPickTarget::Transcriber => {
252                self.transcriber_model = id.to_string();
253                self.db.set_setting("transcriber_model", id)?;
254                self.push_status(format!("image model: {id}"));
255            }
256            ModelPickTarget::Ocr => {
257                self.ocr_model = id.to_string();
258                self.db.set_setting("ocr_model", id)?;
259                self.push_status(format!("OCR model: {id}"));
260            }
261            ModelPickTarget::ImageGen => {
262                self.image_gen_model = id.to_string();
263                self.db.set_setting("image_gen_model", id)?;
264                self.push_status(format!("image gen model: {id}"));
265            }
266            ModelPickTarget::VideoGen => {
267                self.video_gen_model = id.to_string();
268                self.db.set_setting("video_gen_model", id)?;
269                self.push_status(format!("video gen model: {id}"));
270            }
271            ModelPickTarget::SwarmPersona(row) => {
272                if let Some(p) = self.swarm_cache.get_mut(row) {
273                    p.model = id.to_string();
274                }
275                if let Some(session) = &self.session {
276                    let _ = self.db.save_swarm_personas(&session.id, &self.swarm_cache);
277                }
278                self.push_status(format!("persona model: {id}"));
279            }
280        }
281        Ok(())
282    }
283
284    /// The popup a confirmed pick should return to, given the pick target —
285    /// view-layer helper (the picker opens from `/config` for feature models).
286    pub fn popup_after_pick(target: ModelPickTarget) -> Popup {
287        match target {
288            ModelPickTarget::Session => Popup::None,
289            ModelPickTarget::Memory
290            | ModelPickTarget::Transcriber
291            | ModelPickTarget::Ocr
292            | ModelPickTarget::ImageGen
293            | ModelPickTarget::VideoGen => Popup::Settings,
294            ModelPickTarget::SwarmPersona(_) => Popup::Swarm,
295        }
296    }
297
298    /// Disable memory extraction entirely (Backspace on the memory-model row
299    /// in `/config`).
300    pub fn clear_memory_model(&mut self) -> Result<()> {
301        self.memory_model.clear();
302        self.db.set_setting("memory_model", "")?;
303        self.push_status("memory model cleared — extraction disabled".to_string());
304        Ok(())
305    }
306
307    /// Disable image transcription entirely (Backspace on the
308    /// transcriber-model row in `/config`).
309    pub fn clear_transcriber_model(&mut self) -> Result<()> {
310        self.transcriber_model.clear();
311        self.db.set_setting("transcriber_model", "")?;
312        self.push_status("image model cleared — image descriptions disabled".to_string());
313        Ok(())
314    }
315
316    /// Disable VLM OCR (Backspace on the OCR-model row in `/config`).
317    pub fn clear_ocr_model(&mut self) -> Result<()> {
318        self.ocr_model.clear();
319        self.db.set_setting("ocr_model", "")?;
320        self.push_status("OCR model cleared — scanned PDFs use tesseract".to_string());
321        Ok(())
322    }
323
324    /// Disable image generation (Backspace on the image gen model row in `/config`).
325    pub fn clear_image_gen_model(&mut self) -> Result<()> {
326        self.image_gen_model.clear();
327        self.db.set_setting("image_gen_model", "")?;
328        self.push_status("image gen model cleared — generation disabled".to_string());
329        Ok(())
330    }
331
332    /// Disable video generation (Backspace on the video gen model row in `/config`).
333    pub fn clear_video_gen_model(&mut self) -> Result<()> {
334        self.video_gen_model.clear();
335        self.db.set_setting("video_gen_model", "")?;
336        self.push_status("video gen model cleared — generation disabled".to_string());
337        Ok(())
338    }
339
340    /// `/login`: start the `OpenAI` Codex device-code login (the only backend
341    /// without a plain API key). Domain side: spawns the task and owns the
342    /// result channel; the view layer shows the selector.
343    pub fn start_codex_login(&mut self) {
344        // A previous login task can be left around after cancellation/timeout while
345        // the UI has no useful way to resume it. Starting again should replace the
346        // receiver instead of trapping the user behind a stale "already running" gate.
347        self.login_rx = None;
348        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
349        self.login_rx = Some(rx);
350        self.push_status("starting OpenAI Codex login…".to_string());
351        tokio::spawn(async move {
352            let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
353            let forward = tx.clone();
354            tokio::spawn(async move {
355                while let Some(s) = status_rx.recv().await {
356                    let _ = forward.send(super::LoginMsg::Status(s));
357                }
358            });
359            let result = crate::config::login_openai_codex_device(status_tx)
360                .await
361                .map_err(|e| e.to_string());
362            let _ = tx.send(super::LoginMsg::Done(result));
363        });
364    }
365
366    pub fn on_login_result(&mut self, msg: Option<super::LoginMsg>) {
367        match msg {
368            Some(super::LoginMsg::Status(s)) => self.push_status(s),
369            Some(super::LoginMsg::Done(Ok(creds))) => {
370                self.login_rx = None;
371                self.backends.set(
372                    BackendTag::Codex,
373                    OpenRouter::openai_codex(creds.access.clone()),
374                );
375                self.saved.codex = Some(creds);
376                self.push_status("OpenAI Codex login saved, loading models…".to_string());
377                self.fetch_models();
378                self.refresh_toolbox();
379            }
380            Some(super::LoginMsg::Done(Err(e))) => {
381                self.login_rx = None;
382                self.push_status(format!("OpenAI Codex login failed: {e}"));
383            }
384            None => self.login_rx = None,
385        }
386    }
387}