claude_codex/providers/cursor/
client.rs1use 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
12pub 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
28pub 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 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 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 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 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 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 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
217pub 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
227pub 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#[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 {}