Kaynağa Gözat

feat(cursor-cli): 按 ACP catalog 过滤可选模型

CLI 目录里的 medium/low 变体账单会对到 ACP 唯一 SKU。拉取时并行拿 availableModels,只保留能对上的 CLI id。
有凭证时跳过 cursor_login,避免每次拉取弹登录。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 hafta önce
ebeveyn
işleme
fd05b0e4df

+ 411 - 2
src-tauri/src/commands/cursor_cli.rs

@@ -9,9 +9,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 use std::time::Duration;
 
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use tauri::State;
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
 use tokio::net::TcpStream;
 use tokio::process::{Child, Command};
 use tokio::sync::Mutex;
@@ -28,8 +28,11 @@ const PROXY_TIMEOUT_MS: u64 = 30 * 60 * 1000;
 const AGENT_ABOUT_TIMEOUT: Duration = Duration::from_secs(20);
 const AGENT_UPDATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
 const AGENT_TRUST_TIMEOUT: Duration = Duration::from_secs(45);
+const ACP_MODELS_TIMEOUT: Duration = Duration::from_secs(30);
+const ACP_MODELS_CACHE_TTL: Duration = Duration::from_secs(6 * 60 * 60);
 const QMAI_WORKSPACE_TRUST_MARKER: &str = ".qmai-workspace-trusted";
 const QMAI_ACP_MODEL_FILE: &str = "qmai-acp-model";
+const QMAI_ACP_MODELS_CACHE: &str = "qmai-acp-models.json";
 const QMAI_CURSOR_AGENT_WRAPPER: &str = include_str!("../../scripts/qmai-cursor-agent.cjs");
 static AGENT_UPDATE_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
 
@@ -89,6 +92,22 @@ pub struct ProxyStatus {
     error: Option<String>,
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct AcpCatalogModel {
+    pub name: String,
+    #[serde(rename = "modelId")]
+    pub model_id: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct AcpModelsCacheFile {
+    fetched_at_ms: u64,
+    models: Vec<AcpCatalogModel>,
+}
+
+static ACP_MODELS_MEMORY: std::sync::Mutex<Option<Vec<AcpCatalogModel>>> =
+    std::sync::Mutex::new(None);
+
 fn suppress_windows_console(_cmd: &mut Command) {
     #[cfg(windows)]
     {
@@ -316,6 +335,313 @@ pub async fn cursor_cli_apply_acp_model(
     )
 }
 
+fn parse_acp_available_models(value: &serde_json::Value) -> Vec<AcpCatalogModel> {
+    let models = value
+        .pointer("/result/models/availableModels")
+        .or_else(|| value.pointer("/models/availableModels"))
+        .and_then(|item| item.as_array())
+        .cloned()
+        .unwrap_or_default();
+    models
+        .into_iter()
+        .filter_map(|item| {
+            let model_id = item.get("modelId")?.as_str()?.trim();
+            if model_id.is_empty() {
+                return None;
+            }
+            let name = item
+                .get("name")
+                .and_then(|v| v.as_str())
+                .map(str::trim)
+                .filter(|v| !v.is_empty())
+                .unwrap_or(model_id);
+            Some(AcpCatalogModel {
+                name: name.to_string(),
+                model_id: model_id.to_string(),
+            })
+        })
+        .collect()
+}
+
+fn unix_now_ms() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
+
+fn memory_acp_models() -> Option<Vec<AcpCatalogModel>> {
+    ACP_MODELS_MEMORY.lock().ok()?.clone()
+}
+
+fn remember_acp_models(models: &[AcpCatalogModel]) {
+    if let Ok(mut guard) = ACP_MODELS_MEMORY.lock() {
+        *guard = Some(models.to_vec());
+    }
+}
+
+fn cli_config_has_auth(config_dir: &Path) -> bool {
+    let path = config_dir.join("cli-config.json");
+    let Ok(raw) = std::fs::read_to_string(path) else {
+        return false;
+    };
+    let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
+        return false;
+    };
+    value
+        .get("authInfo")
+        .and_then(|auth| auth.get("authId").or_else(|| auth.get("email")))
+        .and_then(|v| v.as_str())
+        .map(|v| !v.trim().is_empty())
+        .unwrap_or(false)
+}
+
+fn has_cursor_api_key() -> bool {
+    read_nonempty_env(&["CURSOR_API_KEY"]).is_some()
+        || read_cursor_api_key_from_user_files().is_some()
+}
+
+fn should_skip_acp_authenticate(config_dir: &Path) -> bool {
+    has_cursor_api_key() || cli_config_has_auth(config_dir)
+}
+
+fn is_acp_auth_error(message: &str) -> bool {
+    let lower = message.to_ascii_lowercase();
+    lower.contains("authentication required")
+        || lower.contains("not authenticated")
+        || lower.contains("unauthorized")
+        || lower.contains("cursor_login")
+        || lower.contains("please run 'agent login'")
+}
+
+fn read_cached_acp_models(config_dir: &Path) -> Option<Vec<AcpCatalogModel>> {
+    let path = config_dir.join(QMAI_ACP_MODELS_CACHE);
+    let raw = std::fs::read_to_string(path).ok()?;
+    let cache: AcpModelsCacheFile = serde_json::from_str(&raw).ok()?;
+    if cache.models.is_empty() {
+        return None;
+    }
+    let now = unix_now_ms();
+    if now.saturating_sub(cache.fetched_at_ms) > ACP_MODELS_CACHE_TTL.as_millis() as u64 {
+        return None;
+    }
+    Some(cache.models)
+}
+
+fn write_cached_acp_models(config_dir: &Path, models: &[AcpCatalogModel]) {
+    if models.is_empty() {
+        return;
+    }
+    let payload = AcpModelsCacheFile {
+        fetched_at_ms: unix_now_ms(),
+        models: models.to_vec(),
+    };
+    if let Ok(raw) = serde_json::to_string_pretty(&payload) {
+        let _ = std::fs::write(config_dir.join(QMAI_ACP_MODELS_CACHE), raw);
+    }
+}
+
+async fn send_acp_request<W: AsyncWriteExt + Unpin>(
+    stdin: &mut W,
+    id: u64,
+    method: &str,
+    params: serde_json::Value,
+) -> Result<(), String> {
+    let mut raw = serde_json::to_string(&serde_json::json!({
+        "jsonrpc": "2.0",
+        "id": id,
+        "method": method,
+        "params": params
+    }))
+    .map_err(|e| format!("ACP serialize {method}: {e}"))?;
+    raw.push('\n');
+    stdin
+        .write_all(raw.as_bytes())
+        .await
+        .map_err(|e| format!("ACP write {method}: {e}"))?;
+    stdin
+        .flush()
+        .await
+        .map_err(|e| format!("ACP flush {method}: {e}"))
+}
+
+fn acp_response_id(value: &serde_json::Value) -> Option<u64> {
+    let id = value.get("id")?;
+    id.as_u64()
+        .or_else(|| id.as_i64().and_then(|v| u64::try_from(v).ok()))
+        .or_else(|| id.as_str()?.parse().ok())
+}
+
+async fn wait_acp_response<R: AsyncBufReadExt + Unpin>(
+    reader: &mut R,
+    id: u64,
+) -> Result<serde_json::Value, String> {
+    let mut line = String::new();
+    loop {
+        line.clear();
+        let n = reader
+            .read_line(&mut line)
+            .await
+            .map_err(|e| format!("ACP read: {e}"))?;
+        if n == 0 {
+            return Err("ACP stdout closed".to_string());
+        }
+        let trimmed = line.trim();
+        if trimmed.is_empty() {
+            continue;
+        }
+        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
+            continue;
+        };
+        if acp_response_id(&value) != Some(id) {
+            continue;
+        }
+        if let Some(error) = value.get("error") {
+            let message = error
+                .get("message")
+                .and_then(|v| v.as_str())
+                .unwrap_or("ACP error");
+            return Err(message.to_string());
+        }
+        return Ok(value);
+    }
+}
+
+async fn acp_authenticate<W, R>(stdin: &mut W, reader: &mut R, id: u64) -> Result<(), String>
+where
+    W: AsyncWriteExt + Unpin,
+    R: AsyncBufReadExt + Unpin,
+{
+    send_acp_request(
+        stdin,
+        id,
+        "authenticate",
+        serde_json::json!({ "methodId": "cursor_login" }),
+    )
+    .await?;
+    wait_acp_response(reader, id).await?;
+    Ok(())
+}
+
+async fn acp_session_models<W, R>(
+    stdin: &mut W,
+    reader: &mut R,
+    id: u64,
+    cwd: &str,
+) -> Result<Vec<AcpCatalogModel>, String>
+where
+    W: AsyncWriteExt + Unpin,
+    R: AsyncBufReadExt + Unpin,
+{
+    send_acp_request(
+        stdin,
+        id,
+        "session/new",
+        serde_json::json!({ "cwd": cwd, "mcpServers": [] }),
+    )
+    .await?;
+    let sess = wait_acp_response(reader, id).await?;
+    let models = parse_acp_available_models(&sess);
+    if models.is_empty() {
+        return Err("ACP session/new 未返回 availableModels".to_string());
+    }
+    Ok(models)
+}
+
+async fn list_cursor_acp_models() -> Result<Vec<AcpCatalogModel>, String> {
+    if let Some(cached) = memory_acp_models() {
+        return Ok(cached);
+    }
+    let (config_dir, workspace) = qmai_proxy_home_dirs()?;
+    if let Some(cached) = read_cached_acp_models(&config_dir) {
+        remember_acp_models(&cached);
+        return Ok(cached);
+    }
+    ensure_qmai_cli_config(&config_dir)?;
+    ensure_qmai_workspace_trusted(&workspace, &config_dir).await;
+    let agent = find_agent_command().await?;
+    let cwd = workspace.to_string_lossy().into_owned();
+    let skip_authenticate = should_skip_acp_authenticate(&config_dir);
+
+    let mut cmd = Command::new(&agent);
+    suppress_windows_console(&mut cmd);
+    apply_local_cli_environment(&mut cmd);
+    if let Some(path_env) = child_path_env().await {
+        cmd.env("PATH", path_env);
+    }
+    apply_cursor_auth_env(&mut cmd);
+    cmd.env("CURSOR_CONFIG_DIR", &config_dir);
+    cmd.env_remove("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE");
+    cmd.kill_on_drop(true);
+    cmd.stdin(std::process::Stdio::piped());
+    cmd.stdout(std::process::Stdio::piped());
+    cmd.stderr(std::process::Stdio::piped());
+    cmd.current_dir(&workspace);
+    cmd.args(["--workspace", &cwd, "acp", "--mode", "ask"]);
+
+    let mut child = cmd
+        .spawn()
+        .map_err(|e| format!("Failed to spawn `agent acp`: {e}"))?;
+    let mut stdin = child
+        .stdin
+        .take()
+        .ok_or_else(|| "agent acp stdin missing".to_string())?;
+    let stdout = child
+        .stdout
+        .take()
+        .ok_or_else(|| "agent acp stdout missing".to_string())?;
+    let mut reader = BufReader::new(stdout);
+
+    let run = async {
+        send_acp_request(
+            &mut stdin,
+            1,
+            "initialize",
+            serde_json::json!({
+                "protocolVersion": 1,
+                "clientCapabilities": {
+                    "fs": { "readTextFile": false, "writeTextFile": false },
+                    "terminal": false
+                },
+                "clientInfo": { "name": "qmai", "version": "0" }
+            }),
+        )
+        .await?;
+        wait_acp_response(&mut reader, 1).await?;
+        let mut next_id = 2_u64;
+        if !skip_authenticate {
+            acp_authenticate(&mut stdin, &mut reader, next_id).await?;
+            next_id += 1;
+        }
+        match acp_session_models(&mut stdin, &mut reader, next_id, &cwd).await {
+            Ok(models) => Ok(models),
+            Err(error) if skip_authenticate && is_acp_auth_error(&error) => {
+                next_id += 1;
+                acp_authenticate(&mut stdin, &mut reader, next_id).await?;
+                next_id += 1;
+                acp_session_models(&mut stdin, &mut reader, next_id, &cwd).await
+            }
+            Err(error) => Err(error),
+        }
+    };
+
+    let result = tokio::time::timeout(ACP_MODELS_TIMEOUT, run).await;
+    let _ = child.kill().await;
+    let _ = child.wait().await;
+    let models = match result {
+        Ok(inner) => inner?,
+        Err(_) => return Err("agent acp session/new timed out".to_string()),
+    };
+    remember_acp_models(&models);
+    write_cached_acp_models(&config_dir, &models);
+    Ok(models)
+}
+
+#[tauri::command]
+pub async fn cursor_cli_acp_models() -> Result<Vec<AcpCatalogModel>, String> {
+    list_cursor_acp_models().await
+}
+
 fn ensure_qmai_agent_wrapper(
     config_dir: &Path,
     agent_bin: Option<&Path>,
@@ -1266,4 +1592,87 @@ tail"#,
         assert_eq!(latest.as_deref(), Some("2026.09.01-aaaaaaa"));
         assert!(parse_agent_about_json(r#"{"latestStatus":"up_to_date"}"#).is_none());
     }
+
+    #[test]
+    fn parses_session_new_available_models() {
+        let raw = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": 3,
+            "result": {
+                "sessionId": "s",
+                "models": {
+                    "availableModels": [
+                        { "modelId": "default[]", "name": "Auto" },
+                        { "modelId": "gemini-3.7-flash[effort=high]", "name": "gemini-3.7-flash" },
+                        { "modelId": "", "name": "skip" },
+                        { "name": "no-id" }
+                    ]
+                }
+            }
+        });
+        let models = parse_acp_available_models(&raw);
+        assert_eq!(models.len(), 2);
+        assert_eq!(models[0].model_id, "default[]");
+        assert_eq!(models[0].name, "Auto");
+        assert_eq!(models[1].model_id, "gemini-3.7-flash[effort=high]");
+        assert_eq!(
+            parse_acp_available_models(&serde_json::json!({ "models": { "availableModels": [] } }))
+                .len(),
+            0
+        );
+    }
+
+    #[test]
+    fn skips_authenticate_when_cli_config_has_auth() {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai-acp-auth-{}",
+            std::process::id()
+        ));
+        let _ = std::fs::create_dir_all(&dir);
+        std::fs::write(
+            dir.join("cli-config.json"),
+            r#"{"authInfo":{"email":"user@example.com","authId":"github|x"}}"#,
+        )
+        .unwrap();
+        assert!(cli_config_has_auth(&dir));
+        std::fs::write(dir.join("cli-config.json"), r#"{"authInfo":{}}"#).unwrap();
+        assert!(!cli_config_has_auth(&dir));
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn detects_acp_auth_errors() {
+        assert!(is_acp_auth_error("Authentication required. Please run 'agent login'"));
+        assert!(is_acp_auth_error("ACP Unauthorized"));
+        assert!(!is_acp_auth_error("ACP session/new 未返回 availableModels"));
+    }
+
+    #[test]
+    fn acp_models_disk_cache_roundtrip() {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai-acp-cache-{}",
+            std::process::id()
+        ));
+        let _ = std::fs::create_dir_all(&dir);
+        let models = vec![AcpCatalogModel {
+            name: "gemini-3.7-flash".into(),
+            model_id: "gemini-3.7-flash[effort=high]".into(),
+        }];
+        write_cached_acp_models(&dir, &models);
+        assert_eq!(read_cached_acp_models(&dir), Some(models));
+        let stale = AcpModelsCacheFile {
+            fetched_at_ms: 1,
+            models: vec![AcpCatalogModel {
+                name: "old".into(),
+                model_id: "old[]".into(),
+            }],
+        };
+        std::fs::write(
+            dir.join(QMAI_ACP_MODELS_CACHE),
+            serde_json::to_string(&stale).unwrap(),
+        )
+        .unwrap();
+        assert!(read_cached_acp_models(&dir).is_none());
+        let _ = std::fs::remove_dir_all(&dir);
+    }
 }

+ 1 - 0
src-tauri/src/lib.rs

@@ -111,6 +111,7 @@ pub fn run() {
             commands::cursor_cli::cursor_cli_about,
             commands::cursor_cli::cursor_cli_update,
             commands::cursor_cli::cursor_cli_apply_acp_model,
+            commands::cursor_cli::cursor_cli_acp_models,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,
             commands::cursor_cli::cursor_proxy_stop,

+ 3 - 9
src/components/settings/llm-presets.ts

@@ -131,18 +131,12 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     hint: "Local `agent` via cursor-api-proxy — no official API key needed",
     provider: "cursor-cli",
     baseUrl: "http://127.0.0.1:8765/v1",
-    defaultModel: "composer-2-fast",
+    defaultModel: "composer-2.5-fast",
     apiMode: "chat_completions",
-    // Picks aligned with cursor-api-proxy docs/cursor-models-picks.md.
+    // Only ids that survive ACP-catalog filtering of `agent --list-models`.
     suggestedModels: [
       "auto",
-      "composer-2-fast",
-      "claude-opus-4-7-medium-fast",
-      "claude-opus-4-7-high",
-      "gpt-5.3-codex-high",
-      "gpt-5.3-codex-xhigh",
-      "gpt-5.5-medium",
-      "claude-opus-4-7-thinking-max",
+      "composer-2.5-fast",
     ],
     suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },

+ 1 - 1
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -153,7 +153,7 @@ describe("QMAI model settings", () => {
     )
     expect(cursor.provider).toBe("cursor-cli")
     expect(cursor.customEndpoint).toBe("http://127.0.0.1:8765/v1")
-    expect(cursor.model).toBe("composer-2-fast")
+    expect(cursor.model).toBe("composer-2.5-fast")
     expect(cursor.apiKey).toBe("")
 
     const cursorMapped = resolveConfig(

+ 114 - 0
src/lib/cursor-acp-models.spec.ts

@@ -1,8 +1,11 @@
 import { afterEach, describe, expect, it } from "vitest"
 import {
+  cliIdMatchesAcp,
   cliModelToAcpValue,
+  filterCursorCliByAcp,
   inferCursorEffortFromModel,
   inferCursorSpeedModeFromModel,
+  parseCursorAcpModelId,
   pickCursorCliCatalogId,
   rememberCursorCliCatalog,
   resolveCursorSpeedMode,
@@ -154,3 +157,114 @@ describe("toCursorHttpModel", () => {
     expect(toCursorHttpModel("auto")).toBe("default")
   })
 })
+
+describe("parseCursorAcpModelId", () => {
+  it("reads family plus effort/fast/reasoning", () => {
+    expect(parseCursorAcpModelId("grok-4.6[effort=high,fast=true]")).toEqual({
+      name: "grok-4.6",
+      effort: "high",
+      fast: true,
+    })
+    expect(parseCursorAcpModelId("gemini-3.7-flash[effort=high]")).toEqual({
+      name: "gemini-3.7-flash",
+      effort: "high",
+    })
+    expect(parseCursorAcpModelId("gpt-5.3-codex[reasoning=medium,fast=false]")).toEqual({
+      name: "gpt-5.3-codex",
+      reasoning: "medium",
+      fast: false,
+    })
+    expect(parseCursorAcpModelId("default[]")).toEqual({ name: "default" })
+  })
+})
+
+const ACP_CATALOG = [
+  { name: "Auto", modelId: "default[]" },
+  { name: "grok-4.6", modelId: "grok-4.6[effort=high,fast=true]" },
+  { name: "composer-2.5", modelId: "composer-2.5[fast=true]" },
+  { name: "claude-opus-5", modelId: "claude-opus-5[thinking=true,context=300k,effort=high,fast=false]" },
+  { name: "claude-opus-4-8", modelId: "claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]" },
+  { name: "gpt-5.6-sol", modelId: "gpt-5.6-sol[context=272k,reasoning=medium,fast=false]" },
+  { name: "gpt-5.5", modelId: "gpt-5.5[context=272k,reasoning=medium,fast=false]" },
+  { name: "claude-fable-5-1", modelId: "claude-fable-5-1[thinking=true,context=300k,effort=high]" },
+  { name: "claude-fable-5", modelId: "claude-fable-5[thinking=true,context=300k,effort=high]" },
+  { name: "grok-4.5", modelId: "grok-4.5[effort=high,fast=true]" },
+  { name: "gemini-3.7-flash", modelId: "gemini-3.7-flash[effort=high]" },
+  { name: "gpt-5.6-terra", modelId: "gpt-5.6-terra[context=272k,reasoning=medium,fast=false]" },
+  { name: "claude-sonnet-5", modelId: "claude-sonnet-5[thinking=true,context=300k,effort=high]" },
+  { name: "claude-sonnet-4-6", modelId: "claude-sonnet-4-6[thinking=true,context=200k,effort=medium]" },
+  { name: "gpt-5.3-codex", modelId: "gpt-5.3-codex[reasoning=medium,fast=false]" },
+  { name: "claude-opus-4-7", modelId: "claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]" },
+  { name: "gpt-5.4", modelId: "gpt-5.4[context=272k,reasoning=medium,fast=false]" },
+  { name: "claude-opus-4-6", modelId: "claude-opus-4-6[thinking=true,context=200k,effort=high]" },
+  { name: "claude-opus-4-5", modelId: "claude-opus-4-5[thinking=true]" },
+  { name: "gpt-5.2", modelId: "gpt-5.2[reasoning=medium,fast=false]" },
+  { name: "gpt-5.6-luna", modelId: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]" },
+  { name: "gemini-3.6-flash", modelId: "gemini-3.6-flash[effort=high]" },
+  { name: "gemini-3.1-pro", modelId: "gemini-3.1-pro[]" },
+  { name: "gpt-5.4-mini", modelId: "gpt-5.4-mini[reasoning=medium]" },
+  { name: "gpt-5.4-nano", modelId: "gpt-5.4-nano[reasoning=medium]" },
+  { name: "claude-haiku-4-5", modelId: "claude-haiku-4-5[thinking=true]" },
+  { name: "claude-sonnet-4-5", modelId: "claude-sonnet-4-5[thinking=true,context=200k]" },
+  { name: "gpt-5.1", modelId: "gpt-5.1[reasoning=medium]" },
+  { name: "gemini-3.5-flash", modelId: "gemini-3.5-flash[]" },
+  { name: "claude-sonnet-4", modelId: "claude-sonnet-4[thinking=false,context=200k]" },
+  { name: "gpt-5-mini", modelId: "gpt-5-mini[]" },
+  { name: "gemini-2.5-flash", modelId: "gemini-2.5-flash[]" },
+  { name: "kimi-k3", modelId: "kimi-k3[reasoning=max]" },
+  { name: "kimi-k2.7-code", modelId: "kimi-k2.7-code[]" },
+  { name: "glm-5.2", modelId: "glm-5.2[reasoning=high]" },
+  { name: "gemini-3-flash", modelId: "gemini-3-flash[]" },
+]
+
+const CLI_WITH_PHANTOMS = [
+  "auto",
+  "cursor-grok-4.6-medium-fast",
+  "cursor-grok-4.6-high-fast",
+  "cursor-grok-4.6-low",
+  "composer-2-fast",
+  "composer-2.5-fast",
+  "gemini-3.7-flash-low",
+  "gemini-3.7-flash-medium",
+  "gemini-3.7-flash-high",
+  "gemini-3.1-pro",
+  "claude-opus-4-7-medium-fast",
+  "claude-opus-4-7-thinking-max",
+  "gpt-5.3-codex-medium",
+  "kimi-k3-max",
+  "acp-only-missing-from-cli",
+]
+
+describe("cliIdMatchesAcp", () => {
+  it("does not treat gemini-high as fast just because Fast defaults to on", () => {
+    const high = parseCursorAcpModelId("gemini-3.7-flash[effort=high]")
+    expect(cliIdMatchesAcp("gemini-3.7-flash-high", high)).toBe(true)
+    expect(cliIdMatchesAcp("gemini-3.7-flash-medium", high)).toBe(false)
+    expect(cliIdMatchesAcp("gemini-3.7-flash-high-fast", high)).toBe(false)
+  })
+})
+
+describe("filterCursorCliByAcp", () => {
+  it("keeps the CLI id that realizes each ACP variant and drops phantom effort/fast", () => {
+    const filtered = filterCursorCliByAcp(CLI_WITH_PHANTOMS, ACP_CATALOG)
+    expect(filtered).toContain("auto")
+    expect(filtered).toContain("cursor-grok-4.6-high-fast")
+    expect(filtered).toContain("gemini-3.7-flash-high")
+    expect(filtered).toContain("composer-2.5-fast")
+    expect(filtered).toContain("claude-opus-4-7-thinking-max")
+    expect(filtered).toContain("gpt-5.3-codex-medium")
+    expect(filtered).toContain("kimi-k3-max")
+    expect(filtered).toContain("gemini-3.1-pro")
+    expect(filtered).not.toContain("cursor-grok-4.6-medium-fast")
+    expect(filtered).not.toContain("cursor-grok-4.6-low")
+    expect(filtered).not.toContain("gemini-3.7-flash-low")
+    expect(filtered).not.toContain("gemini-3.7-flash-medium")
+    expect(filtered).not.toContain("claude-opus-4-7-medium-fast")
+    expect(filtered).not.toContain("composer-2-fast")
+    expect(filtered.every((id) => !id.includes("["))).toBe(true)
+  })
+
+  it("throws when the ACP catalog is empty", () => {
+    expect(() => filterCursorCliByAcp(CLI_WITH_PHANTOMS, [])).toThrow("ACP catalog 为空")
+  })
+})

+ 111 - 0
src/lib/cursor-acp-models.ts

@@ -209,3 +209,114 @@ export function toCursorHttpModel(model: string): string {
   if (!trimmed || trimmed === "auto" || trimmed === "default") return "default"
   return trimmed
 }
+
+export interface CursorAcpCatalogModel {
+  name?: string
+  modelId: string
+}
+
+export interface ParsedCursorAcpModel {
+  name: string
+  effort?: string
+  reasoning?: string
+  fast?: boolean
+  thinking?: boolean
+}
+
+function parseAcpBool(raw: string): boolean | undefined {
+  const value = raw.trim().toLowerCase()
+  if (value === "true") return true
+  if (value === "false") return false
+  return undefined
+}
+
+function cliDeclaresFast(model: string): boolean {
+  return FAST_SUFFIX.test(model.replace(/\[.*$/, ""))
+}
+
+/** Parse ACP `availableModels[].modelId` such as `grok-4.6[effort=high,fast=true]`. */
+export function parseCursorAcpModelId(modelId: string): ParsedCursorAcpModel {
+  const trimmed = modelId.trim()
+  const bracket = trimmed.indexOf("[")
+  const rawName = (bracket >= 0 ? trimmed.slice(0, bracket) : trimmed).trim()
+  const name = toCursorAcpModelId(rawName) || rawName
+  if (bracket < 0) return { name }
+
+  const parsed: ParsedCursorAcpModel = { name }
+  const params = trimmed.slice(bracket + 1).replace(/\]\s*$/, "")
+  for (const part of params.split(",")) {
+    const eq = part.indexOf("=")
+    if (eq < 0) continue
+    const key = part.slice(0, eq).trim().toLowerCase()
+    const value = part.slice(eq + 1).trim()
+    if (key === "effort") parsed.effort = normalizeEffortToken(value)
+    else if (key === "reasoning") parsed.reasoning = normalizeEffortToken(value)
+    else if (key === "fast") parsed.fast = parseAcpBool(value)
+    else if (key === "thinking") parsed.thinking = parseAcpBool(value)
+  }
+  return parsed
+}
+
+function requiredAcpEffort(acp: ParsedCursorAcpModel): string | undefined {
+  return acp.effort ?? acp.reasoning
+}
+
+export function cliIdMatchesAcp(cliId: string, acp: ParsedCursorAcpModel): boolean {
+  const family = toCursorAcpModelId(cliId)
+  if (!family || family !== acp.name) return false
+
+  const requiredEffort = requiredAcpEffort(acp)
+  const cliEffort = inferCursorEffortFromModel(cliId)
+  if (requiredEffort) {
+    if (cliEffort !== requiredEffort) return false
+  } else if (cliEffort) {
+    return false
+  }
+
+  return cliDeclaresFast(cliId) === (acp.fast === true)
+}
+
+function scoreCliAgainstAcp(cliId: string, acp: ParsedCursorAcpModel): number {
+  let score = 0
+  const base = cliId.replace(/\[.*$/, "").toLowerCase()
+  if (acp.name && base.includes(acp.name.toLowerCase())) score += 2
+  const thinkingStem = base.replace(FAST_SUFFIX, "").replace(EFFORT_SUFFIX, "")
+  if (acp.thinking === true && THINKING_SUFFIX.test(thinkingStem)) score += 1
+  else if (acp.thinking !== true && !THINKING_SUFFIX.test(thinkingStem)) score += 1
+  return score
+}
+
+/**
+ * Keep at most one CLI catalog id per ACP `modelId`.
+ * Drops CLI variants that cannot realize the ACP params (e.g. gemini medium).
+ */
+export function filterCursorCliByAcp(
+  cliIds: readonly string[],
+  acpModels: readonly CursorAcpCatalogModel[],
+): string[] {
+  if (acpModels.length === 0) {
+    throw new Error("ACP catalog 为空,无法过滤 Cursor CLI 模型。")
+  }
+
+  const catalog = cliIds.map((id) => id.trim()).filter(Boolean)
+  const picked: string[] = []
+  const seen = new Set<string>()
+
+  for (const model of acpModels) {
+    const modelId = model.modelId?.trim()
+    if (!modelId) continue
+    const parsed = parseCursorAcpModelId(modelId)
+    if (!parsed.name) continue
+    const matches = catalog.filter((id) => cliIdMatchesAcp(id, parsed))
+    if (matches.length === 0) continue
+    const best = matches.slice().sort((left, right) => {
+      const delta = scoreCliAgainstAcp(right, parsed) - scoreCliAgainstAcp(left, parsed)
+      return delta !== 0 ? delta : left.localeCompare(right)
+    })[0]!
+    if (seen.has(best)) continue
+    seen.add(best)
+    picked.push(best)
+  }
+
+  return picked
+}

+ 55 - 13
src/lib/settings-model-list.spec.ts

@@ -142,21 +142,32 @@ describe("settings model list", () => {
     expect(result.models).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])
   })
 
-  it("rewrites cursor-cli model list to ACP catalog names", async () => {
-    vi.mocked(invoke)
-      .mockResolvedValueOnce({
-        healthy: true,
-        base_url: "http://127.0.0.1:8765",
-        managed: true,
-        error: null,
-      })
-      .mockResolvedValueOnce(undefined)
+  it("filters the cursor-cli list through ACP params and keeps CLI ids", async () => {
+    vi.mocked(invoke).mockImplementation(async (command: string) => {
+      if (command === "cursor_proxy_ensure") {
+        return {
+          healthy: true,
+          base_url: "http://127.0.0.1:8765",
+          managed: true,
+          error: null,
+        }
+      }
+      if (command === "cursor_cli_apply_acp_model") return undefined
+      if (command === "cursor_cli_acp_models") {
+        return [
+          { name: "grok-4.6", modelId: "grok-4.6[effort=high,fast=true]" },
+          { name: "composer-2.5", modelId: "composer-2.5[fast=true]" },
+        ]
+      }
+      throw new Error(`unexpected invoke ${command}`)
+    })
     fetchMock.mockImplementation(() =>
       Promise.resolve(
         new Response(
           JSON.stringify({
             data: [
               { id: "cursor-grok-4.6-medium-fast" },
+              { id: "cursor-grok-4.6-high-fast" },
               { id: "cursor-grok-4.6-high" },
               { id: "composer-2-fast" },
             ],
@@ -174,6 +185,7 @@ describe("settings model list", () => {
       customEndpoint: "http://127.0.0.1:8765/v1",
     }))
 
+    expect(invoke).toHaveBeenCalledWith("cursor_cli_acp_models")
     expect(invoke).toHaveBeenCalledWith("cursor_cli_apply_acp_model", {
       model: "grok-4.6",
       cliModel: "cursor-grok-4.6-medium-fast",
@@ -182,16 +194,46 @@ describe("settings model list", () => {
     })
     expect(result.models).toEqual([
       "composer-2-fast",
-      "cursor-grok-4.6-high",
-      "cursor-grok-4.6-medium-fast",
+      "cursor-grok-4.6-high-fast",
     ])
     expect(getCursorCliCatalog()).toEqual([
+      "cursor-grok-4.6-high-fast",
       "composer-2-fast",
-      "cursor-grok-4.6-high",
-      "cursor-grok-4.6-medium-fast",
     ])
   })
 
+  it("fails closed when the ACP catalog is empty", async () => {
+    vi.mocked(invoke).mockImplementation(async (command: string) => {
+      if (command === "cursor_proxy_ensure") {
+        return {
+          healthy: true,
+          base_url: "http://127.0.0.1:8765",
+          managed: true,
+          error: null,
+        }
+      }
+      if (command === "cursor_cli_apply_acp_model") return undefined
+      if (command === "cursor_cli_acp_models") return []
+      throw new Error(`unexpected invoke ${command}`)
+    })
+    fetchMock.mockImplementation(() =>
+      Promise.resolve(
+        new Response(JSON.stringify({ data: [{ id: "cursor-grok-4.6-high-fast" }] }), {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        }),
+      ),
+    )
+
+    const { fetchLlmModelList } = await import("./settings-model-list")
+    await expect(fetchLlmModelList(customConfig({
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "cursor-grok-4.6-high-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }))).rejects.toThrow("ACP catalog 为空")
+  })
+
   it("rejects a Codex CLI without app-server dynamic tools", async () => {
     vi.mocked(invoke).mockResolvedValueOnce({
       installed: true,

+ 41 - 11
src/lib/settings-model-list.ts

@@ -1,6 +1,11 @@
+import { invoke } from "@tauri-apps/api/core"
 import { getProviderConfig, withCustomOriginHeader } from "@/lib/llm-providers"
 import { detectLocalCliConfig } from "@/lib/local-cli-config"
-import { rememberCursorCliCatalog } from "@/lib/cursor-acp-models"
+import {
+  filterCursorCliByAcp,
+  rememberCursorCliCatalog,
+  type CursorAcpCatalogModel,
+} from "@/lib/cursor-acp-models"
 import { ensureCursorProxyRunning, restartCursorProxyWithAuth, withCursorProxyEndpoint } from "@/lib/cursor-cli-proxy"
 import { isDirectRerankEndpoint } from "@/lib/rerank-api"
 import { getHttpFetch } from "@/lib/tauri-fetch"
@@ -167,6 +172,34 @@ async function fetchModelList(url: string, headers: Record<string, string>, _cur
   return toModelListResult(parseModelListResponse(await response.json()))
 }
 
+async function fetchCursorCliAcpCatalog(): Promise<CursorAcpCatalogModel[]> {
+  const raw = await invoke<CursorAcpCatalogModel[] | { models?: CursorAcpCatalogModel[] }>(
+    "cursor_cli_acp_models",
+  )
+  const models = Array.isArray(raw) ? raw : raw?.models ?? []
+  return models.filter((model) => typeof model?.modelId === "string" && model.modelId.trim())
+}
+
+async function fetchCursorCliFilteredModels(
+  url: string,
+  headers: Record<string, string>,
+  currentModel: string,
+): Promise<LlmModelListResult> {
+  const [cli, acpModels] = await Promise.all([
+    fetchModelList(url, headers, currentModel),
+    fetchCursorCliAcpCatalog(),
+  ])
+  if (acpModels.length === 0) {
+    throw new Error("ACP catalog 为空,无法过滤 Cursor CLI 模型。")
+  }
+  const filtered = filterCursorCliByAcp(cli.models, acpModels)
+  if (filtered.length === 0) {
+    throw new Error("ACP catalog 与 CLI 目录没有可运行的交集。")
+  }
+  rememberCursorCliCatalog(filtered)
+  return toModelListResult(filtered)
+}
+
 async function fetchLocalCliModel(config: LlmConfig): Promise<LlmModelListResult> {
   const explicitModel = config.model.trim()
   if (explicitModel && config.provider !== "codex-cli") return { models: [explicitModel] }
@@ -205,20 +238,19 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
 
   const { url, headers } = buildModelsUrl(runtimeConfig)
 
-  const load = async () => {
-    const result = await fetchModelList(url, headers, runtimeConfig.model)
+  const load = async (modelsUrl: string, modelsHeaders: Record<string, string>) => {
+    if (runtimeConfig.provider === "cursor-cli") {
+      return fetchCursorCliFilteredModels(modelsUrl, modelsHeaders, runtimeConfig.model)
+    }
+    const result = await fetchModelList(modelsUrl, modelsHeaders, runtimeConfig.model)
     if (runtimeConfig.provider === "google") {
       return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
     }
-    if (runtimeConfig.provider === "cursor-cli") {
-      rememberCursorCliCatalog(result.models)
-      return toModelListResult(result.models)
-    }
     return result
   }
 
   try {
-    return await load()
+    return await load(url, headers)
   } catch (error) {
     if (runtimeConfig.provider !== "cursor-cli") throw error
     const message = error instanceof Error ? error.message : String(error)
@@ -228,9 +260,7 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
     const endpoint = await restartCursorProxyWithAuth(runtimeConfig)
     runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
     const retry = buildModelsUrl(runtimeConfig)
-    const result = await fetchModelList(retry.url, retry.headers, runtimeConfig.model)
-    rememberCursorCliCatalog(result.models)
-    return toModelListResult(result.models)
+    return fetchCursorCliFilteredModels(retry.url, retry.headers, runtimeConfig.model)
   }
 }