mars_agents/models/probes/
cursor.rs1use std::io::Read;
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4use std::thread;
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use wait_timeout::ChildExt;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct CursorProbeResult {
13 pub slugs: Vec<String>,
15 pub model_probe_success: bool,
17 pub error: Option<String>,
19}
20
21const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 5;
22
23pub fn probe() -> CursorProbeResult {
25 probe_with_timeout(probe_timeout())
26}
27
28pub fn probe_with_timeout(timeout: Duration) -> CursorProbeResult {
30 let mut result = CursorProbeResult::default();
31
32 match run_command("cursor", &["agent", "--list-models"], timeout) {
33 Ok(stdout) => {
34 result.slugs = parse_cursor_models_output(&stdout);
35 result.model_probe_success = true;
36 }
37 Err(error) => {
38 result.model_probe_success = false;
39 result.error = Some(format!("model probe failed: {error}"));
40 }
41 }
42
43 result
44}
45
46fn probe_timeout() -> Duration {
47 std::env::var("MARS_PROBE_TIMEOUT_SECS")
48 .ok()
49 .and_then(|value| value.parse::<u64>().ok())
50 .map(Duration::from_secs)
51 .unwrap_or(Duration::from_secs(DEFAULT_PROBE_TIMEOUT_SECS))
52}
53
54fn run_command(cmd: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
55 let program = resolve_command(cmd);
56 let mut child = Command::new(&program)
57 .args(args)
58 .stdout(Stdio::piped())
59 .stderr(Stdio::null())
60 .spawn()
61 .map_err(|error| format!("spawn failed: {error}"))?;
62
63 let stdout = child
64 .stdout
65 .take()
66 .ok_or_else(|| "stdout capture unavailable".to_string())?;
67 let stdout_reader = thread::spawn(move || {
68 let mut stdout = stdout;
69 let mut output = Vec::new();
70 stdout
71 .read_to_end(&mut output)
72 .map(|_| output)
73 .map_err(|error| format!("stdout read failed: {error}"))
74 });
75
76 match child
77 .wait_timeout(timeout)
78 .map_err(|error| format!("wait failed: {error}"))?
79 {
80 Some(status) if status.success() => {
81 let stdout = stdout_reader
82 .join()
83 .map_err(|_| "stdout reader panicked".to_string())??;
84 String::from_utf8(stdout).map_err(|error| format!("invalid utf8: {error}"))
85 }
86 Some(status) => {
87 let _ = stdout_reader.join();
88 Err(format!("exit code {}", status.code().unwrap_or(-1)))
89 }
90 None => {
91 let _ = child.kill();
92 let _ = child.wait();
93 let _ = stdout_reader.join();
94 Err("timeout".to_string())
95 }
96 }
97}
98
99fn resolve_command(cmd: &str) -> PathBuf {
100 let resolver = crate::harness::host::PathExecutableResolver;
101 crate::harness::host::resolve_binary_path(cmd, &resolver).unwrap_or_else(|| cmd.into())
102}
103
104fn strip_ansi(s: &str) -> String {
105 let mut result = String::with_capacity(s.len());
106 let mut chars = s.chars().peekable();
107
108 while let Some(ch) = chars.next() {
109 if ch == '\x1b' {
110 while let Some(&next) = chars.peek() {
111 chars.next();
112 if next.is_ascii_alphabetic() {
113 break;
114 }
115 }
116 } else {
117 result.push(ch);
118 }
119 }
120
121 result
122}
123
124fn parse_cursor_models_output(stdout: &str) -> Vec<String> {
126 stdout
127 .lines()
128 .filter_map(|line| {
129 let clean = strip_ansi(line.trim());
130 if clean.is_empty()
131 || clean.eq_ignore_ascii_case("available models")
132 || clean.starts_with("Tip:")
133 {
134 return None;
135 }
136
137 let (slug, _) = clean.split_once(" - ")?;
138 let slug = slug.trim();
139 if slug.is_empty() || slug.ends_with("-fast") {
140 return None;
141 }
142
143 Some(slug.to_string())
144 })
145 .collect()
146}
147
148pub fn normalize_slug(s: &str) -> String {
149 s.to_ascii_lowercase().replace('.', "-")
150}
151
152pub fn find_cursor_prefix_matches<'a>(model_id: &str, slugs: &'a [String]) -> Vec<&'a str> {
153 let normalized_model = normalize_slug(model_id);
154 slugs
155 .iter()
156 .filter(|slug| {
157 let normalized_slug = normalize_slug(slug);
158 normalized_slug == normalized_model
159 || normalized_slug.starts_with(&format!("{normalized_model}-"))
160 })
161 .map(String::as_str)
162 .collect()
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct CursorEffortResolution {
167 pub slug: String,
168 pub candidate_slugs: Vec<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum CursorEffortResolutionError {
173 NoProbeSlugs,
174 NoModelPrefixMatch,
175 NoEffortMatch { model_id: String, effort: String },
176}
177
178pub fn resolve_cursor_effort_slug(
180 model_id: &str,
181 effort: &str,
182 slugs: &[String],
183) -> Result<CursorEffortResolution, CursorEffortResolutionError> {
184 if slugs.is_empty() {
185 return Err(CursorEffortResolutionError::NoProbeSlugs);
186 }
187
188 let prefix_matches = find_cursor_prefix_matches(model_id, slugs);
189 if prefix_matches.is_empty() {
190 return Err(CursorEffortResolutionError::NoModelPrefixMatch);
191 }
192
193 let normalized_model = normalize_slug(model_id);
194 let normalized_effort = normalize_slug(effort);
195 let candidate_slugs: Vec<String> = slugs
196 .iter()
197 .filter(|slug| {
198 let normalized_slug = normalize_slug(slug);
199 normalized_slug == normalized_model
200 || normalized_slug.starts_with(&format!("{normalized_model}-"))
201 })
202 .cloned()
203 .collect();
204
205 if cursor_effort_is_default_tier(&normalized_effort) {
206 if let Some(bare_slug) = prefix_matches
207 .iter()
208 .find(|slug| normalize_slug(slug) == normalized_model)
209 {
210 return Ok(CursorEffortResolution {
211 slug: (*bare_slug).to_string(),
212 candidate_slugs,
213 });
214 }
215
216 return Err(CursorEffortResolutionError::NoEffortMatch {
217 model_id: model_id.to_string(),
218 effort: effort.to_string(),
219 });
220 }
221
222 let effort_matches: Vec<&str> = prefix_matches
223 .iter()
224 .copied()
225 .filter(|slug| {
226 slug_matches_effort(&normalized_model, &normalize_slug(slug), &normalized_effort)
227 })
228 .collect();
229
230 if effort_matches.is_empty() {
231 if cursor_effort_allows_bare_fallback(&normalized_model)
232 && let Some(bare_slug) = prefix_matches
233 .iter()
234 .find(|slug| normalize_slug(slug) == normalized_model)
235 {
236 return Ok(CursorEffortResolution {
237 slug: (*bare_slug).to_string(),
238 candidate_slugs,
239 });
240 }
241
242 return Err(CursorEffortResolutionError::NoEffortMatch {
243 model_id: model_id.to_string(),
244 effort: effort.to_string(),
245 });
246 }
247
248 let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
249
250 Ok(CursorEffortResolution {
251 slug: chosen.to_string(),
252 candidate_slugs,
253 })
254}
255
256fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
259 matches!(normalized_effort, "auto" | "default" | "medium" | "none")
260}
261
262fn cursor_effort_allows_bare_fallback(normalized_model: &str) -> bool {
263 normalized_model.starts_with("composer-")
264}
265
266fn slug_matches_effort(
267 normalized_model: &str,
268 normalized_slug: &str,
269 normalized_effort: &str,
270) -> bool {
271 if normalized_slug == normalized_model {
272 return cursor_effort_is_default_tier(normalized_effort);
273 }
274
275 let Some(suffix) = normalized_slug
276 .strip_prefix(normalized_model)
277 .and_then(|rest| rest.strip_prefix('-'))
278 else {
279 return false;
280 };
281
282 suffix == normalized_effort
283 || suffix.ends_with(&format!("-{normalized_effort}"))
284 || suffix.contains(&format!("-{normalized_effort}-"))
285}
286
287fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
288 if matches.len() == 1 {
289 return matches[0];
290 }
291
292 if normalized_model.starts_with("claude")
293 && let Some(thinking) = matches
294 .iter()
295 .copied()
296 .find(|slug| normalize_slug(slug).contains("-thinking-"))
297 {
298 return thinking;
299 }
300
301 matches[0]
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_parse_models_basic() {
310 let output = r#"gpt-5.5-high - GPT 5.5 (High)
311gpt-5.5-low - GPT 5.5 (Low)
312claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
313
314 let slugs = parse_cursor_models_output(output);
315
316 assert_eq!(
317 slugs,
318 vec![
319 "gpt-5.5-high".to_string(),
320 "gpt-5.5-low".to_string(),
321 "claude-opus-4-7-thinking-high".to_string()
322 ]
323 );
324 }
325
326 #[test]
327 fn test_parse_models_filters_fast() {
328 let output = r#"gpt-5.5-high - GPT 5.5 (High)
329gpt-5.5-fast - GPT 5.5 (Fast)"#;
330 let slugs = parse_cursor_models_output(output);
331 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
332 }
333
334 #[test]
335 fn test_parse_models_skips_header_and_tip() {
336 let output = r#"Available models
337
338gpt-5.5-high - GPT 5.5 (High)
339
340Tip: use --model <id> to select"#;
341 let slugs = parse_cursor_models_output(output);
342 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
343 }
344
345 #[test]
346 fn test_parse_models_strips_ansi() {
347 let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
348 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
349 }
350
351 #[test]
352 fn test_find_prefix_matches() {
353 let slugs = vec![
354 "gpt-5.5-high".to_string(),
355 "gpt-5.5-low".to_string(),
356 "claude-opus-4-7".to_string(),
357 ];
358 let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
359 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
360 }
361
362 #[test]
363 fn test_find_prefix_matches_requires_boundary() {
364 let slugs = vec![
365 "gpt-5.5-high".to_string(),
366 "gpt-55-high".to_string(),
367 "gpt-5".to_string(),
368 ];
369
370 let matches = find_cursor_prefix_matches("gpt-5", &slugs);
371
372 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
373 }
374
375 #[test]
376 fn test_normalize_slug() {
377 assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
378 }
379
380 #[test]
381 fn test_resolve_effort_slug_openai() {
382 let slugs = vec![
383 "gpt-5.5-high".to_string(),
384 "gpt-5.5-low".to_string(),
385 "gpt-55-high".to_string(),
386 ];
387 let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
388 assert_eq!(resolution.slug, "gpt-5.5-high");
389 }
390
391 #[test]
392 fn test_resolve_effort_slug_prefers_thinking_for_claude() {
393 let slugs = vec![
394 "claude-opus-4-7-high".to_string(),
395 "claude-opus-4-7-thinking-high".to_string(),
396 ];
397 let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
398 assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
399 }
400
401 #[test]
402 fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
403 let slugs = vec![
404 "gpt-5.5".to_string(),
405 "gpt-5.5-high".to_string(),
406 "gpt-5.5-low".to_string(),
407 ];
408 for effort in ["medium", "none", "auto"] {
409 let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
410 assert_eq!(
411 resolution.slug, "gpt-5.5",
412 "effort {effort} should resolve to base slug"
413 );
414 }
415 }
416
417 #[test]
418 fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
419 let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
420 let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
421 assert_eq!(
422 err,
423 CursorEffortResolutionError::NoEffortMatch {
424 model_id: "gpt-5.5".to_string(),
425 effort: "medium".to_string(),
426 }
427 );
428 }
429
430 #[test]
431 fn test_resolve_effort_slug_no_effort_match() {
432 let slugs = vec!["gpt-5.5-low".to_string()];
433 let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
434 assert_eq!(
435 err,
436 CursorEffortResolutionError::NoEffortMatch {
437 model_id: "gpt-5.5".to_string(),
438 effort: "high".to_string(),
439 }
440 );
441 }
442
443 #[test]
444 fn test_resolve_effort_slug_composer_falls_back_to_bare_slug() {
445 let slugs = vec!["composer-2.5".to_string(), "composer-2.5-low".to_string()];
446 let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
447 assert_eq!(resolution.slug, "composer-2.5");
448 }
449
450 #[test]
451 fn test_resolve_effort_slug_composer_prefers_exact_effort_slug_when_present() {
452 let slugs = vec![
453 "composer-2.5".to_string(),
454 "composer-2.5-low".to_string(),
455 "composer-2.5-high".to_string(),
456 ];
457 let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
458 assert_eq!(resolution.slug, "composer-2.5-high");
459 }
460
461 #[test]
462 fn test_probe_result_round_trip() {
463 let result = CursorProbeResult {
464 slugs: vec!["gpt-5.5-high".to_string()],
465 model_probe_success: true,
466 error: None,
467 };
468 let json = serde_json::to_string(&result).unwrap();
469 let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
470 assert_eq!(back.slugs, result.slugs);
471 assert!(back.model_probe_success);
472 assert_eq!(back.error, None);
473 }
474}