Skip to main content

zeph_core/agent/
model_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write as _;
5use std::future::Future;
6use std::pin::Pin;
7
8use zeph_commands::ModelAccess;
9use zeph_llm::provider::LlmProvider;
10
11use super::Agent;
12use crate::channel::Channel;
13
14impl<C: crate::channel::Channel> Agent<C> {
15    /// Switch the active provider to one serving `model_id`.
16    ///
17    /// # Errors
18    ///
19    /// Returns `Err` if the model is not found.
20    pub(crate) fn set_model(&mut self, model_id: &str) -> Result<(), String> {
21        if model_id.is_empty() {
22            return Err("model id must not be empty".to_string());
23        }
24        if model_id.len() > 256 {
25            return Err("model id exceeds maximum length of 256 characters".to_string());
26        }
27        if !model_id
28            .chars()
29            .all(|c| c.is_ascii() && !c.is_ascii_control())
30        {
31            return Err("model id must contain only printable ASCII characters".to_string());
32        }
33        self.runtime.config.model_name = model_id.to_string();
34        tracing::info!(model = model_id, "set_model called");
35        Ok(())
36    }
37
38    /// Refresh the remote model cache, then return a result message.
39    pub(crate) async fn model_refresh_as_string(&mut self) -> String {
40        if let Some(cache_dir) = dirs::cache_dir() {
41            let models_dir = cache_dir.join("zeph").join("models");
42            let _ = tokio::task::spawn_blocking(move || {
43                if let Ok(entries) = std::fs::read_dir(&models_dir) {
44                    for entry in entries.flatten() {
45                        let path = entry.path();
46                        if path.extension().and_then(|e| e.to_str()) == Some("json") {
47                            let _ = std::fs::remove_file(&path);
48                        }
49                    }
50                }
51            })
52            .await;
53        }
54        match self.provider.list_models_remote().await {
55            Ok(models) => format!("Fetched {} models.", models.len()),
56            Err(e) => format!("Error fetching models: {e}"),
57        }
58    }
59
60    /// List available models, returning a formatted string.
61    pub(crate) async fn model_list_as_string(&mut self) -> String {
62        let cache = zeph_llm::model_cache::ModelCache::for_slug(self.provider.name());
63        let cached = if cache.is_stale_async().await {
64            None
65        } else {
66            cache.load_async().await.unwrap_or(None)
67        };
68        let models = if let Some(m) = cached {
69            m
70        } else {
71            match self.provider.list_models_remote().await {
72                Ok(m) => m,
73                Err(e) => return format!("Error fetching models: {e}"),
74            }
75        };
76        if models.is_empty() {
77            return "No models available.".to_owned();
78        }
79        let mut lines = vec!["Available models:".to_string()];
80        for (i, m) in models.iter().enumerate() {
81            lines.push(format!("  {}. {} ({})", i + 1, m.display_name, m.id));
82        }
83        lines.join("\n")
84    }
85
86    /// Switch to a different model, returning a result message.
87    pub(crate) async fn model_switch_as_string(&mut self, model_id: &str) -> String {
88        let cache = zeph_llm::model_cache::ModelCache::for_slug(self.provider.name());
89        let known_models: Option<Vec<zeph_llm::model_cache::RemoteModelInfo>> =
90            if cache.is_stale_async().await {
91                match self.provider.list_models_remote().await {
92                    Ok(m) if !m.is_empty() => Some(m),
93                    _ => None,
94                }
95            } else {
96                cache.load_async().await.unwrap_or(None)
97            };
98        let list_unavailable = known_models.is_none();
99        if let Some(models) = known_models {
100            if !models.iter().any(|m| m.id == model_id) {
101                let mut lines = vec![format!("Unknown model '{model_id}'. Available models:")];
102                for m in &models {
103                    lines.push(format!("  • {} ({})", m.display_name, m.id));
104                }
105                return lines.join("\n");
106            }
107        } else {
108            // Model list unavailable — proceed with a warning.
109            tracing::warn!("model list unavailable, switching to '{model_id}' without validation");
110        }
111        match self.set_model(model_id) {
112            Ok(()) => {
113                let switch_msg = format!("Switched to model: {model_id}");
114                if list_unavailable {
115                    format!(
116                        "Model list unavailable, switching anyway — verify your model name is correct.\n{switch_msg}"
117                    )
118                } else {
119                    switch_msg
120                }
121            }
122            Err(e) => format!("Error: {e}"),
123        }
124    }
125
126    /// Handle `/model`, `/model <id>`, and `/model refresh` commands, returning a string result.
127    pub(crate) async fn handle_model_command_as_string(&mut self, trimmed: &str) -> String {
128        let arg = trimmed.strip_prefix("/model").map_or("", str::trim);
129        if arg == "refresh" {
130            self.model_refresh_as_string().await
131        } else if arg.is_empty() {
132            self.model_list_as_string().await
133        } else {
134            self.model_switch_as_string(arg).await
135        }
136    }
137}
138
139impl<C: Channel + Send + 'static> ModelAccess for Agent<C> {
140    // ----- /caveman -----
141
142    fn handle_caveman<'a>(
143        &'a mut self,
144        arg: &'a str,
145    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
146        Box::pin(async move {
147            let active = &mut self.services.session.caveman_active;
148            match arg.trim() {
149                "on" | "enable" => {
150                    *active = true;
151                    "caveman: on".to_owned()
152                }
153                "off" | "disable" => {
154                    *active = false;
155                    "caveman: off".to_owned()
156                }
157                "status" => {
158                    if *active {
159                        "caveman: on".to_owned()
160                    } else {
161                        "caveman: off".to_owned()
162                    }
163                }
164                _ => {
165                    *active = !*active;
166                    if *active {
167                        "caveman: on".to_owned()
168                    } else {
169                        "caveman: off".to_owned()
170                    }
171                }
172            }
173        })
174    }
175
176    // ----- /model, /provider -----
177
178    fn handle_model<'a>(
179        &'a mut self,
180        arg: &'a str,
181    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
182        Box::pin(async move {
183            let input = if arg.is_empty() {
184                "/model".to_owned()
185            } else {
186                format!("/model {arg}")
187            };
188            self.handle_model_command_as_string(&input).await
189        })
190    }
191
192    fn handle_provider<'a>(
193        &'a mut self,
194        arg: &'a str,
195    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
196        Box::pin(async move { self.handle_provider_command_as_string(arg).await })
197    }
198
199    // ----- /think-tokens, /reasoning-effort -----
200
201    fn handle_think_tokens<'a>(
202        &'a mut self,
203        arg: &'a str,
204    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
205        Box::pin(async move {
206            let arg = arg.trim();
207            let provider_name = self.provider.name().to_owned();
208            if arg.is_empty() {
209                return match self.provider.current_thinking_budget() {
210                    Some(n) => format!("think-tokens: {n} (provider: {provider_name})"),
211                    None => format!("think-tokens: off (provider: {provider_name})"),
212                };
213            }
214
215            let budget = match zeph_commands::handlers::think_tokens::parse_token_budget(arg) {
216                Ok(b) => b,
217                Err(e) => return format!("think-tokens: {e}"),
218            };
219
220            // Captured before the mutation so the cross-override note (Claude's Extended and
221            // Adaptive thinking share one config field) only fires when this call actually
222            // cleared a previously active reasoning-effort level.
223            let had_reasoning_effort = self.provider.current_reasoning_effort().is_some();
224            match self.provider.set_thinking_budget(budget) {
225                Ok(()) => {
226                    let mut msg = match budget {
227                        Some(n) => format!("think-tokens: set to {n} (provider: {provider_name})"),
228                        None => format!("think-tokens: disabled (provider: {provider_name})"),
229                    };
230                    if had_reasoning_effort && self.provider.current_reasoning_effort().is_none() {
231                        msg.push_str(
232                            " Note: this overrides the previously set reasoning-effort level \
233                             — Claude's Extended and Adaptive thinking share one config field.",
234                        );
235                    }
236                    if let Some(advisory) = self.provider.capability_delegation_advisory() {
237                        let _ = write!(msg, " Note: {advisory}.");
238                    }
239                    msg
240                }
241                Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
242                    format!("provider `{provider}` {message}")
243                }
244                Err(e) => format!("think-tokens: {e}"),
245            }
246        })
247    }
248
249    fn handle_reasoning_effort<'a>(
250        &'a mut self,
251        arg: &'a str,
252    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
253        Box::pin(async move {
254            let arg = arg.trim();
255            let provider_name = self.provider.name().to_owned();
256            if arg.is_empty() {
257                return match self.provider.current_reasoning_effort() {
258                    Some(e) => format!("reasoning-effort: {e} (provider: {provider_name})"),
259                    None => format!("reasoning-effort: off (provider: {provider_name})"),
260                };
261            }
262
263            let effort: zeph_llm::any::ReasoningEffort = match arg.parse() {
264                Ok(e) => e,
265                Err(e) => return format!("reasoning-effort: {e}"),
266            };
267
268            // Captured before the mutation — see the matching comment in handle_think_tokens.
269            let had_thinking_budget = self.provider.current_thinking_budget().is_some();
270            match self.provider.apply_reasoning_effort(effort) {
271                Ok(()) => {
272                    let mut msg = format!(
273                        "reasoning-effort: set to {} (provider: {provider_name})",
274                        effort.as_str()
275                    );
276                    if had_thinking_budget && self.provider.current_thinking_budget().is_none() {
277                        msg.push_str(
278                            " Note: this overrides the previously set thinking-token budget \
279                             — Claude's Extended and Adaptive thinking share one config field.",
280                        );
281                    }
282                    if let Some(advisory) = self.provider.capability_delegation_advisory() {
283                        let _ = write!(msg, " Note: {advisory}.");
284                    }
285                    msg
286                }
287                Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
288                    format!("provider `{provider}` {message}")
289                }
290                Err(e) => format!("reasoning-effort: {e}"),
291            }
292        })
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::super::agent_tests::{
299        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
300    };
301    use super::*;
302
303    // ── /think-tokens, /reasoning-effort (#3098) ─────────────────────────
304
305    fn claude_agent() -> Agent<MockChannel> {
306        let provider = zeph_llm::any::AnyProvider::Claude(zeph_llm::claude::ClaudeProvider::new(
307            "key".into(),
308            "claude-sonnet-5".into(),
309            4096,
310        ));
311        Agent::new(
312            provider,
313            MockChannel::new(vec![]),
314            create_test_registry(),
315            None,
316            5,
317            MockToolExecutor::no_tools(),
318        )
319    }
320
321    #[tokio::test]
322    async fn handle_think_tokens_empty_arg_displays_off_by_default() {
323        let mut agent = claude_agent();
324        let out = agent.handle_think_tokens("").await;
325        assert!(out.contains("off"), "{out}");
326        assert!(out.contains("claude"), "{out}");
327    }
328
329    #[tokio::test]
330    async fn handle_think_tokens_sets_and_displays_budget() {
331        let mut agent = claude_agent();
332        let set = agent.handle_think_tokens("8k").await;
333        assert!(set.contains("8000"), "{set}");
334
335        let show = agent.handle_think_tokens("").await;
336        assert!(show.contains("8000"), "{show}");
337    }
338
339    #[tokio::test]
340    async fn handle_think_tokens_off_disables() {
341        let mut agent = claude_agent();
342        agent.handle_think_tokens("8k").await;
343        let out = agent.handle_think_tokens("off").await;
344        assert!(out.contains("disabled"), "{out}");
345        assert!(agent.provider.current_thinking_budget().is_none());
346    }
347
348    #[tokio::test]
349    async fn handle_think_tokens_invalid_parse_returns_error_no_mutation() {
350        let mut agent = claude_agent();
351        let out = agent.handle_think_tokens("1.2.3k").await;
352        assert!(out.contains("think-tokens"), "{out}");
353        assert!(agent.provider.current_thinking_budget().is_none());
354    }
355
356    #[tokio::test]
357    async fn handle_think_tokens_unsupported_provider_returns_explicit_message() {
358        let mut agent = Agent::new(
359            mock_provider(vec![]),
360            MockChannel::new(vec![]),
361            create_test_registry(),
362            None,
363            5,
364            MockToolExecutor::no_tools(),
365        );
366        let out = agent.handle_think_tokens("8k").await;
367        assert!(out.contains("does not support"), "{out}");
368        assert!(out.contains("mock"), "{out}");
369    }
370
371    #[tokio::test]
372    async fn handle_think_tokens_cross_override_note_when_reasoning_effort_was_active() {
373        let mut agent = claude_agent();
374        agent.handle_reasoning_effort("high").await;
375        let out = agent.handle_think_tokens("8k").await;
376        assert!(
377            out.contains("overrides the previously set reasoning-effort"),
378            "{out}"
379        );
380    }
381
382    #[tokio::test]
383    async fn handle_reasoning_effort_empty_arg_displays_off_by_default() {
384        let mut agent = claude_agent();
385        let out = agent.handle_reasoning_effort("").await;
386        assert!(out.contains("off"), "{out}");
387    }
388
389    #[tokio::test]
390    async fn handle_reasoning_effort_sets_and_displays_level() {
391        let mut agent = claude_agent();
392        let set = agent.handle_reasoning_effort("high").await;
393        assert!(set.contains("high"), "{set}");
394
395        let show = agent.handle_reasoning_effort("").await;
396        assert!(show.contains("high"), "{show}");
397    }
398
399    #[tokio::test]
400    async fn handle_reasoning_effort_invalid_parse_returns_error_no_mutation() {
401        let mut agent = claude_agent();
402        let out = agent.handle_reasoning_effort("minimal").await;
403        assert!(out.contains("reasoning-effort"), "{out}");
404        assert!(agent.provider.current_reasoning_effort().is_none());
405    }
406
407    #[tokio::test]
408    async fn handle_reasoning_effort_unsupported_provider_returns_explicit_message() {
409        let mut agent = Agent::new(
410            mock_provider(vec![]),
411            MockChannel::new(vec![]),
412            create_test_registry(),
413            None,
414            5,
415            MockToolExecutor::no_tools(),
416        );
417        let out = agent.handle_reasoning_effort("high").await;
418        assert!(out.contains("does not support"), "{out}");
419    }
420
421    #[tokio::test]
422    async fn handle_reasoning_effort_cross_override_note_when_think_tokens_was_active() {
423        let mut agent = claude_agent();
424        agent.handle_think_tokens("8k").await;
425        let out = agent.handle_reasoning_effort("high").await;
426        assert!(
427            out.contains("overrides the previously set thinking-token budget"),
428            "{out}"
429        );
430    }
431}