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 let has_effort_variants = prefix_matches
237 .iter()
238 .any(|slug| normalize_slug(slug) != normalized_model);
239
240 let allow_bare =
241 !has_effort_variants || cursor_effort_allows_bare_fallback(&normalized_model);
242
243 if allow_bare
244 && let Some(bare_slug) = prefix_matches
245 .iter()
246 .find(|slug| normalize_slug(slug) == normalized_model)
247 {
248 return Ok(CursorEffortResolution {
249 slug: (*bare_slug).to_string(),
250 candidate_slugs,
251 });
252 }
253
254 return Err(CursorEffortResolutionError::NoEffortMatch {
255 model_id: model_id.to_string(),
256 effort: effort.to_string(),
257 });
258 }
259
260 let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
261
262 Ok(CursorEffortResolution {
263 slug: chosen.to_string(),
264 candidate_slugs,
265 })
266}
267
268fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
271 matches!(normalized_effort, "auto" | "default" | "medium" | "none")
272}
273
274fn cursor_effort_allows_bare_fallback(normalized_model: &str) -> bool {
278 normalized_model.starts_with("composer-")
279}
280
281fn slug_matches_effort(
282 normalized_model: &str,
283 normalized_slug: &str,
284 normalized_effort: &str,
285) -> bool {
286 if normalized_slug == normalized_model {
287 return cursor_effort_is_default_tier(normalized_effort);
288 }
289
290 let Some(suffix) = normalized_slug
291 .strip_prefix(normalized_model)
292 .and_then(|rest| rest.strip_prefix('-'))
293 else {
294 return false;
295 };
296
297 suffix == normalized_effort
298 || suffix.ends_with(&format!("-{normalized_effort}"))
299 || suffix.contains(&format!("-{normalized_effort}-"))
300}
301
302fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
303 if matches.len() == 1 {
304 return matches[0];
305 }
306
307 if normalized_model.starts_with("claude")
308 && let Some(thinking) = matches
309 .iter()
310 .copied()
311 .find(|slug| normalize_slug(slug).contains("-thinking-"))
312 {
313 return thinking;
314 }
315
316 matches[0]
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_parse_models_basic() {
325 let output = r#"gpt-5.5-high - GPT 5.5 (High)
326gpt-5.5-low - GPT 5.5 (Low)
327claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
328
329 let slugs = parse_cursor_models_output(output);
330
331 assert_eq!(
332 slugs,
333 vec![
334 "gpt-5.5-high".to_string(),
335 "gpt-5.5-low".to_string(),
336 "claude-opus-4-7-thinking-high".to_string()
337 ]
338 );
339 }
340
341 #[test]
342 fn test_parse_models_filters_fast() {
343 let output = r#"gpt-5.5-high - GPT 5.5 (High)
344gpt-5.5-fast - GPT 5.5 (Fast)"#;
345 let slugs = parse_cursor_models_output(output);
346 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
347 }
348
349 #[test]
350 fn test_parse_models_skips_header_and_tip() {
351 let output = r#"Available models
352
353gpt-5.5-high - GPT 5.5 (High)
354
355Tip: use --model <id> to select"#;
356 let slugs = parse_cursor_models_output(output);
357 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
358 }
359
360 #[test]
361 fn test_parse_models_strips_ansi() {
362 let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
363 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
364 }
365
366 #[test]
367 fn test_find_prefix_matches() {
368 let slugs = vec![
369 "gpt-5.5-high".to_string(),
370 "gpt-5.5-low".to_string(),
371 "claude-opus-4-7".to_string(),
372 ];
373 let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
374 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
375 }
376
377 #[test]
378 fn test_find_prefix_matches_requires_boundary() {
379 let slugs = vec![
380 "gpt-5.5-high".to_string(),
381 "gpt-55-high".to_string(),
382 "gpt-5".to_string(),
383 ];
384
385 let matches = find_cursor_prefix_matches("gpt-5", &slugs);
386
387 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
388 }
389
390 #[test]
391 fn test_normalize_slug() {
392 assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
393 }
394
395 #[test]
396 fn test_resolve_effort_slug_openai() {
397 let slugs = vec![
398 "gpt-5.5-high".to_string(),
399 "gpt-5.5-low".to_string(),
400 "gpt-55-high".to_string(),
401 ];
402 let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
403 assert_eq!(resolution.slug, "gpt-5.5-high");
404 }
405
406 #[test]
407 fn test_resolve_effort_slug_prefers_thinking_for_claude() {
408 let slugs = vec![
409 "claude-opus-4-7-high".to_string(),
410 "claude-opus-4-7-thinking-high".to_string(),
411 ];
412 let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
413 assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
414 }
415
416 #[test]
417 fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
418 let slugs = vec![
419 "gpt-5.5".to_string(),
420 "gpt-5.5-high".to_string(),
421 "gpt-5.5-low".to_string(),
422 ];
423 for effort in ["medium", "none", "auto"] {
424 let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
425 assert_eq!(
426 resolution.slug, "gpt-5.5",
427 "effort {effort} should resolve to base slug"
428 );
429 }
430 }
431
432 #[test]
433 fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
434 let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
435 let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
436 assert_eq!(
437 err,
438 CursorEffortResolutionError::NoEffortMatch {
439 model_id: "gpt-5.5".to_string(),
440 effort: "medium".to_string(),
441 }
442 );
443 }
444
445 #[test]
446 fn test_resolve_effort_slug_no_effort_match() {
447 let slugs = vec!["gpt-5.5-low".to_string()];
448 let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
449 assert_eq!(
450 err,
451 CursorEffortResolutionError::NoEffortMatch {
452 model_id: "gpt-5.5".to_string(),
453 effort: "high".to_string(),
454 }
455 );
456 }
457
458 #[test]
459 fn test_resolve_effort_slug_bare_model_no_variants_falls_back() {
460 let slugs = vec!["composer".to_string()];
462 for effort in ["high", "low", "medium", "none"] {
463 let resolution = resolve_cursor_effort_slug("composer", effort, &slugs).unwrap();
464 assert_eq!(
465 resolution.slug, "composer",
466 "effort={effort} should fall back to bare composer slug"
467 );
468 }
469 }
470
471 #[test]
472 fn test_resolve_effort_slug_composer_falls_back_to_bare_slug() {
473 let slugs = vec!["composer-2.5".to_string(), "composer-2.5-low".to_string()];
474 let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
475 assert_eq!(resolution.slug, "composer-2.5");
476 }
477
478 #[test]
479 fn test_resolve_effort_slug_composer_prefers_exact_effort_slug_when_present() {
480 let slugs = vec![
481 "composer-2.5".to_string(),
482 "composer-2.5-low".to_string(),
483 "composer-2.5-high".to_string(),
484 ];
485 let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
486 assert_eq!(resolution.slug, "composer-2.5-high");
487 }
488
489 #[test]
490 fn test_probe_result_round_trip() {
491 let result = CursorProbeResult {
492 slugs: vec!["gpt-5.5-high".to_string()],
493 model_probe_success: true,
494 error: None,
495 };
496 let json = serde_json::to_string(&result).unwrap();
497 let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
498 assert_eq!(back.slugs, result.slugs);
499 assert!(back.model_probe_success);
500 assert_eq!(back.error, None);
501 }
502}