Skip to main content

claude_codex/providers/cursor/
client.rs

1use prost::Message;
2
3use crate::config;
4use crate::providers::cursor::connect::{
5    ConnectFrame, ConnectFrameDecoder, FLAG_END, FLAG_GZIP, encode_connect_frame,
6    parse_connect_error,
7};
8use crate::providers::cursor::model::CursorModelResolution;
9use crate::providers::cursor::proto::{self, AgentClientMessage, RunRequest};
10use crate::providers::cursor::request::CursorSelectedImage;
11
12/// Upstream response from the Cursor API.
13///
14/// Contains the raw response bytes (or body bytes for streaming) and the
15/// HTTP status.
16pub struct CursorUpstreamResponse {
17    pub status: u16,
18    pub body: Vec<u8>,
19    pub error_detail: Option<String>,
20}
21
22impl CursorUpstreamResponse {
23    pub fn is_success(&self) -> bool {
24        self.status >= 200 && self.status < 300
25    }
26}
27
28/// HTTP/2 client for the Cursor AgentService/Run endpoint.
29pub struct CursorHttpClient {
30    client: reqwest::Client,
31    base_url: String,
32}
33
34impl Default for CursorHttpClient {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl CursorHttpClient {
41    pub fn new() -> Self {
42        // Use HTTP/2 prior knowledge for cleartext URLs (mock testing) and
43        // standard TLS for https URLs.
44        let base_url = config::cursor_base_url();
45        let is_cleartext = base_url.starts_with("http://");
46
47        let mut builder = reqwest::Client::builder()
48            .http2_keep_alive_timeout(std::time::Duration::from_secs(30))
49            .http2_keep_alive_while_idle(true);
50
51        if is_cleartext {
52            builder = builder.http2_prior_knowledge();
53        }
54
55        let client = builder.build().expect("CursorHttpClient: reqwest client");
56
57        Self { client, base_url }
58    }
59
60    /// Run the Cursor agent with the given prompt and token.
61    ///
62    /// Builds the prost RunRequest, encodes it in a Connect frame, and
63    /// sends it via HTTP/2 POST.
64    pub async fn run_agent(
65        &self,
66        token: &str,
67        prompt: &str,
68        model: &str,
69        images: &[CursorSelectedImage],
70    ) -> Result<CursorUpstreamResponse, CursorError> {
71        let resolved = super::model::resolve_cursor_model(model)
72            .map_err(|e| CursorError::internal(format!("model resolution: {e}")))?;
73
74        let request_id = uuid::Uuid::new_v4().to_string();
75
76        // Build the prost RunRequest
77        let run_request = build_run_request(prompt, &resolved, images, &request_id);
78
79        let msg = AgentClientMessage {
80            run_request: Some(run_request),
81            client_heartbeat: None,
82        };
83
84        let mut payload = Vec::new();
85        msg.encode(&mut payload)
86            .map_err(|e| CursorError::internal(format!("prost encode: {e}")))?;
87
88        let body = encode_connect_frame(&payload, 0);
89
90        let url = format!(
91            "{}/agent.v1.AgentService/Run",
92            self.base_url.trim_end_matches('/')
93        );
94
95        let client_version = config::cursor_client_version();
96
97        let resp = self
98            .client
99            .post(&url)
100            .bearer_auth(token)
101            .header("content-type", "application/connect+proto")
102            .header("connect-protocol-version", "1")
103            .header("connect-accept-encoding", "gzip,br")
104            .header("x-cursor-client-type", "cli")
105            .header("x-cursor-client-version", &client_version)
106            .header("x-ghost-mode", "true")
107            .header("x-request-id", &request_id)
108            .header("x-original-request-id", &request_id)
109            .header("x-cursor-streaming", "true")
110            .header("te", "trailers")
111            .body(body)
112            .send()
113            .await
114            .map_err(|e| CursorError::from_reqwest(e))?;
115
116        let status = resp.status().as_u16();
117        let headers = resp.headers().clone();
118        let error_detail = resp
119            .headers()
120            .get("grpc-message")
121            .and_then(|v| v.to_str().ok())
122            .map(|s| s.to_string());
123
124        let body_bytes = resp
125            .bytes()
126            .await
127            .map_err(|e| CursorError::internal(format!("read body: {e}")))?;
128
129        if status >= 400 {
130            // Try to extract Connect error from body
131            let detail = parse_error_body(&body_bytes, &headers);
132            return Err(CursorError::new(status, "Cursor upstream error", detail));
133        }
134
135        Ok(CursorUpstreamResponse {
136            status,
137            body: body_bytes.to_vec(),
138            error_detail,
139        })
140    }
141}
142
143fn build_run_request(
144    prompt: &str,
145    resolved: &CursorModelResolution,
146    images: &[CursorSelectedImage],
147    request_id: &str,
148) -> RunRequest {
149    let selected_images: Vec<proto::SelectedImage> = images
150        .iter()
151        .map(|img| proto::SelectedImage {
152            data: img.data.clone(),
153            uuid: img.uuid.clone(),
154            path: img.path.clone(),
155            mime_type: img.mime_type.clone(),
156        })
157        .collect();
158
159    RunRequest {
160        conversation_state: Some(proto::ConversationState {
161            messages: Vec::new(),
162        }),
163        action: Some(proto::Action {
164            user_message_action: Some(proto::UserMessageAction {
165                user_message: Some(proto::UserMessage {
166                    text: prompt.to_string(),
167                    message_id: request_id.to_string(),
168                    selected_context: if selected_images.is_empty() {
169                        None
170                    } else {
171                        Some(proto::SelectedContext { selected_images })
172                    },
173                    mode: resolved.mode.as_str().to_string(),
174                }),
175            }),
176        }),
177        mcp_tools: None,
178        conversation_id: String::new(),
179        requested_model: Some(proto::CursorModel {
180            model_id: resolved.model_id.clone(),
181            parameters: Vec::new(),
182        }),
183        exclude_workspace_context: false,
184        selected_subagent_models: vec![],
185        conversation_group_id: String::new(),
186        client_supports_inline_images: true,
187    }
188}
189
190fn parse_error_body(body_bytes: &[u8], _headers: &reqwest::header::HeaderMap) -> Option<String> {
191    if body_bytes.len() < 5 {
192        return None;
193    }
194    // Try to parse as Connect end frame with JSON error
195    if body_bytes.len() >= 5 {
196        let flags = body_bytes[0];
197        let len = u32::from_be_bytes([body_bytes[1], body_bytes[2], body_bytes[3], body_bytes[4]])
198            as usize;
199        if flags & FLAG_END != 0 && body_bytes.len() >= 5 + len {
200            let payload = &body_bytes[5..5 + len];
201            let err = parse_connect_error(payload);
202            if err.is_some() {
203                return err.map(|e| e.detail);
204            }
205        }
206    }
207
208    // Try plain text error
209    if let Ok(text) = String::from_utf8(body_bytes.to_vec()) {
210        if !text.is_empty() {
211            return Some(text);
212        }
213    }
214    None
215}
216
217/// Decode upstream response bytes into Connect frames containing
218/// AgentServerMessage values.
219pub fn decode_upstream_frames(body: &[u8]) -> Result<Vec<ConnectFrame>, CursorError> {
220    let mut decoder = ConnectFrameDecoder::new();
221    let frames = decoder
222        .push(body)
223        .map_err(|e| CursorError::internal(format!("frame decode: {e}")))?;
224    Ok(frames)
225}
226
227/// Decode a single Connect frame payload into an AgentServerMessage.
228/// Handles gzip decompression if the FLAG_GZIP bit is set.
229pub fn decode_frame_payload(
230    frame: &ConnectFrame,
231) -> Result<proto::AgentServerMessage, CursorError> {
232    let payload = if frame.flags & FLAG_GZIP != 0 {
233        let decompressed = super::connect::decode_gzip_frame(&frame.payload)
234            .map_err(|e| CursorError::internal(format!("gzip decompress: {e}")))?;
235        decompressed
236    } else {
237        frame.payload.to_vec()
238    };
239
240    proto::AgentServerMessage::decode(&payload[..])
241        .map_err(|e| CursorError::internal(format!("prost decode: {e}")))
242}
243
244// ---------------------------------------------------------------------------
245// Error type
246// ---------------------------------------------------------------------------
247
248#[derive(Debug, Clone)]
249pub struct CursorError {
250    pub status: u16,
251    pub message: String,
252    pub detail: Option<String>,
253    pub retry_after: Option<String>,
254}
255
256impl CursorError {
257    pub fn new(status: u16, message: impl Into<String>, detail: Option<String>) -> Self {
258        Self {
259            status,
260            message: message.into(),
261            detail,
262            retry_after: None,
263        }
264    }
265
266    pub fn internal(message: impl Into<String>) -> Self {
267        Self {
268            status: 502,
269            message: message.into(),
270            detail: None,
271            retry_after: None,
272        }
273    }
274
275    pub fn from_reqwest(e: reqwest::Error) -> Self {
276        let status = e.status().map(|s| s.as_u16()).unwrap_or(502);
277        Self {
278            status,
279            message: e.to_string(),
280            detail: None,
281            retry_after: None,
282        }
283    }
284}
285
286impl std::fmt::Display for CursorError {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        write!(f, "Cursor error {}: {}", self.status, self.message)
289    }
290}
291
292impl std::error::Error for CursorError {}