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
196 if cursor_effort_is_default_tier(&normalized_effort) {
197 if let Some(bare_slug) = prefix_matches
198 .iter()
199 .find(|slug| normalize_slug(slug) == normalized_model)
200 {
201 let candidate_slugs = slugs
202 .iter()
203 .filter(|slug| {
204 let normalized_slug = normalize_slug(slug);
205 normalized_slug == normalized_model
206 || normalized_slug.starts_with(&format!("{normalized_model}-"))
207 })
208 .cloned()
209 .collect();
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 .into_iter()
224 .filter(|slug| {
225 slug_matches_effort(&normalized_model, &normalize_slug(slug), &normalized_effort)
226 })
227 .collect();
228
229 if effort_matches.is_empty() {
230 return Err(CursorEffortResolutionError::NoEffortMatch {
231 model_id: model_id.to_string(),
232 effort: effort.to_string(),
233 });
234 }
235
236 let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
237 let candidate_slugs = slugs
238 .iter()
239 .filter(|slug| {
240 let normalized_slug = normalize_slug(slug);
241 normalized_slug == normalized_model
242 || normalized_slug.starts_with(&format!("{normalized_model}-"))
243 })
244 .cloned()
245 .collect();
246
247 Ok(CursorEffortResolution {
248 slug: chosen.to_string(),
249 candidate_slugs,
250 })
251}
252
253fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
256 matches!(normalized_effort, "auto" | "default" | "medium" | "none")
257}
258
259fn slug_matches_effort(
260 normalized_model: &str,
261 normalized_slug: &str,
262 normalized_effort: &str,
263) -> bool {
264 if normalized_slug == normalized_model {
265 return cursor_effort_is_default_tier(normalized_effort);
266 }
267
268 let Some(suffix) = normalized_slug
269 .strip_prefix(normalized_model)
270 .and_then(|rest| rest.strip_prefix('-'))
271 else {
272 return false;
273 };
274
275 suffix == normalized_effort
276 || suffix.ends_with(&format!("-{normalized_effort}"))
277 || suffix.contains(&format!("-{normalized_effort}-"))
278}
279
280fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
281 if matches.len() == 1 {
282 return matches[0];
283 }
284
285 if normalized_model.starts_with("claude")
286 && let Some(thinking) = matches
287 .iter()
288 .copied()
289 .find(|slug| normalize_slug(slug).contains("-thinking-"))
290 {
291 return thinking;
292 }
293
294 matches[0]
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_parse_models_basic() {
303 let output = r#"gpt-5.5-high - GPT 5.5 (High)
304gpt-5.5-low - GPT 5.5 (Low)
305claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
306
307 let slugs = parse_cursor_models_output(output);
308
309 assert_eq!(
310 slugs,
311 vec![
312 "gpt-5.5-high".to_string(),
313 "gpt-5.5-low".to_string(),
314 "claude-opus-4-7-thinking-high".to_string()
315 ]
316 );
317 }
318
319 #[test]
320 fn test_parse_models_filters_fast() {
321 let output = r#"gpt-5.5-high - GPT 5.5 (High)
322gpt-5.5-fast - GPT 5.5 (Fast)"#;
323 let slugs = parse_cursor_models_output(output);
324 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
325 }
326
327 #[test]
328 fn test_parse_models_skips_header_and_tip() {
329 let output = r#"Available models
330
331gpt-5.5-high - GPT 5.5 (High)
332
333Tip: use --model <id> to select"#;
334 let slugs = parse_cursor_models_output(output);
335 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
336 }
337
338 #[test]
339 fn test_parse_models_strips_ansi() {
340 let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
341 assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
342 }
343
344 #[test]
345 fn test_find_prefix_matches() {
346 let slugs = vec![
347 "gpt-5.5-high".to_string(),
348 "gpt-5.5-low".to_string(),
349 "claude-opus-4-7".to_string(),
350 ];
351 let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
352 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
353 }
354
355 #[test]
356 fn test_find_prefix_matches_requires_boundary() {
357 let slugs = vec![
358 "gpt-5.5-high".to_string(),
359 "gpt-55-high".to_string(),
360 "gpt-5".to_string(),
361 ];
362
363 let matches = find_cursor_prefix_matches("gpt-5", &slugs);
364
365 assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
366 }
367
368 #[test]
369 fn test_normalize_slug() {
370 assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
371 }
372
373 #[test]
374 fn test_resolve_effort_slug_openai() {
375 let slugs = vec![
376 "gpt-5.5-high".to_string(),
377 "gpt-5.5-low".to_string(),
378 "gpt-55-high".to_string(),
379 ];
380 let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
381 assert_eq!(resolution.slug, "gpt-5.5-high");
382 }
383
384 #[test]
385 fn test_resolve_effort_slug_prefers_thinking_for_claude() {
386 let slugs = vec![
387 "claude-opus-4-7-high".to_string(),
388 "claude-opus-4-7-thinking-high".to_string(),
389 ];
390 let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
391 assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
392 }
393
394 #[test]
395 fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
396 let slugs = vec![
397 "gpt-5.5".to_string(),
398 "gpt-5.5-high".to_string(),
399 "gpt-5.5-low".to_string(),
400 ];
401 for effort in ["medium", "none", "auto"] {
402 let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
403 assert_eq!(
404 resolution.slug, "gpt-5.5",
405 "effort {effort} should resolve to base slug"
406 );
407 }
408 }
409
410 #[test]
411 fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
412 let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
413 let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
414 assert_eq!(
415 err,
416 CursorEffortResolutionError::NoEffortMatch {
417 model_id: "gpt-5.5".to_string(),
418 effort: "medium".to_string(),
419 }
420 );
421 }
422
423 #[test]
424 fn test_resolve_effort_slug_no_effort_match() {
425 let slugs = vec!["gpt-5.5-low".to_string()];
426 let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
427 assert_eq!(
428 err,
429 CursorEffortResolutionError::NoEffortMatch {
430 model_id: "gpt-5.5".to_string(),
431 effort: "high".to_string(),
432 }
433 );
434 }
435
436 #[test]
437 fn test_probe_result_round_trip() {
438 let result = CursorProbeResult {
439 slugs: vec!["gpt-5.5-high".to_string()],
440 model_probe_success: true,
441 error: None,
442 };
443 let json = serde_json::to_string(&result).unwrap();
444 let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
445 assert_eq!(back.slugs, result.slugs);
446 assert!(back.model_probe_success);
447 assert_eq!(back.error, None);
448 }
449}