1use 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 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 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 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 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 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 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 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 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 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 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 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 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}