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 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 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 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 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 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 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 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 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 .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 if streaming {
197 self.context_total
198 .map_or(estimate, |total| total.max(estimate))
199 } else {
200 estimate
201 }
202 }
203
204 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 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 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 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 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 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 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 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 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 pub fn start_codex_login(&mut self) {
344 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}