Skip to main content

claude_codex/providers/cursor/
mod.rs

1pub mod auth;
2pub mod client;
3pub mod connect;
4pub mod model;
5pub mod proto;
6pub mod request;
7pub mod response;
8pub mod sse;
9#[cfg(test)]
10pub(crate) mod test_frames;
11pub mod tool_bridge;
12pub mod tool_use_xml;
13
14use async_trait::async_trait;
15use axum::Json;
16use axum::response::{IntoResponse, Response};
17use http::StatusCode;
18
19use crate::anthropic::error::json_error;
20use crate::anthropic::schema::{CountTokensResponse, MessagesRequest};
21use crate::monitor::usage_from_anthropic_sse;
22use crate::provider::{CliHandlers, Provider, RequestContext};
23use crate::providers::cursor::auth::{
24    clear_cursor_auth, expired_auth_message, load_cursor_auth, missing_auth_message,
25    run_cursor_login,
26};
27use crate::providers::cursor::client::CursorHttpClient;
28use crate::providers::cursor::model::resolve_cursor_model;
29use crate::providers::cursor::request::render_cursor_prompt;
30use crate::providers::cursor::response::{
31    CursorDecodeError, decode_cursor_upstream, decode_upstream_response,
32};
33use crate::providers::cursor::tool_bridge::{
34    BridgeRegistry, advertised_tool_names, can_bridge_cursor_native_tools, find_tool_result,
35    resume_cursor_tool_bridge, start_cursor_tool_bridge,
36};
37
38// ---------------------------------------------------------------------------
39// Provider
40// ---------------------------------------------------------------------------
41
42pub struct CursorProvider;
43
44impl Default for CursorProvider {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl CursorProvider {
51    pub fn new() -> Self {
52        Self
53    }
54}
55
56#[async_trait]
57impl Provider for CursorProvider {
58    fn name(&self) -> &'static str {
59        "cursor"
60    }
61
62    fn supported_models(&self) -> Vec<String> {
63        model::cursor_supported_models()
64    }
65
66    fn cli(&self) -> &'static dyn CliHandlers {
67        &CURSOR_CLI
68    }
69
70    async fn handle_messages(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
71        let message_id = format!("msg_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
72        let want_stream = body.stream;
73        let model = body.model.as_deref().unwrap_or("cursor");
74
75        let resolved = resolve_cursor_model(model);
76        if let Err(e) = resolved {
77            return json_error(
78                StatusCode::BAD_REQUEST,
79                "invalid_request_error",
80                format!("Model \"{model}\" is not supported: {e}"),
81            );
82        }
83
84        if let Some(ref session_id) = ctx.session_id {
85            if let Some(pending) = BridgeRegistry::pending_tool(session_id) {
86                if let Some(result) = find_tool_result(&body, pending.tool_use_id()) {
87                    let (_result_messages, sse_bytes) =
88                        resume_cursor_tool_bridge(session_id, &message_id, model, result, &pending);
89                    if let Some(monitor) = ctx.monitor.as_ref() {
90                        let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes);
91                        monitor.stream_progress(
92                            &ctx.req_id,
93                            sse_bytes.len() as u64,
94                            count_sse_events(&sse_bytes),
95                            input_tokens,
96                            output_tokens,
97                        );
98                    }
99                    let headers = [
100                        (http::header::CONTENT_TYPE, "text/event-stream"),
101                        (http::header::CACHE_CONTROL, "no-cache"),
102                        (http::header::CONNECTION, "keep-alive"),
103                    ];
104                    return (headers, sse_bytes).into_response();
105                }
106            }
107        }
108
109        let auth = match load_cursor_auth() {
110            Ok(Some(auth)) => auth,
111            Ok(None) => {
112                return json_error(
113                    StatusCode::UNAUTHORIZED,
114                    "authentication_error",
115                    missing_auth_message(),
116                );
117            }
118            Err(err) => {
119                return json_error(
120                    StatusCode::UNAUTHORIZED,
121                    "authentication_error",
122                    format!("Cursor auth failed: {err}"),
123                );
124            }
125        };
126
127        if matches!(auth.expires, Some(expires) if expires <= now_ms() + 60_000) {
128            return json_error(
129                StatusCode::UNAUTHORIZED,
130                "authentication_error",
131                expired_auth_message(&auth),
132            );
133        }
134
135        let token = auth.access_token;
136
137        let prompt = render_cursor_prompt(&body);
138        let images = request::cursor_selected_images(&body);
139
140        let client = CursorHttpClient::new();
141        if let Some(monitor) = ctx.monitor.as_ref() {
142            monitor.upstream_started(&ctx.req_id);
143        }
144        let upstream = match client.run_agent(&token, &prompt, &model, &images).await {
145            Ok(r) => r,
146            Err(e) => {
147                return map_cursor_error_to_response(&e);
148            }
149        };
150
151        if want_stream {
152            let session_id = ctx.session_id.as_deref();
153            let bridge_eligible = can_bridge_cursor_native_tools(&body, session_id);
154
155            if bridge_eligible {
156                let events = match decode_upstream_response(&upstream.body) {
157                    Ok(e) => e,
158                    Err(e) => return map_cursor_decode_error_to_response(&e),
159                };
160
161                let allowed = advertised_tool_names(&body);
162                let (sse_bytes, _paused) = start_cursor_tool_bridge(
163                    &message_id,
164                    model,
165                    session_id.unwrap(),
166                    &events,
167                    allowed,
168                    Box::new(|| uuid::Uuid::new_v4().to_string().replace('-', "")),
169                );
170                if let Some(monitor) = ctx.monitor.as_ref() {
171                    let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes);
172                    monitor.stream_progress(
173                        &ctx.req_id,
174                        sse_bytes.len() as u64,
175                        count_sse_events(&sse_bytes),
176                        input_tokens,
177                        output_tokens,
178                    );
179                }
180
181                let headers = [
182                    (http::header::CONTENT_TYPE, "text/event-stream"),
183                    (http::header::CACHE_CONTROL, "no-cache"),
184                    (http::header::CONNECTION, "keep-alive"),
185                ];
186                (headers, sse_bytes).into_response()
187            } else {
188                let sse_bytes = sse::frame_cursor_stream(&upstream, &message_id, model);
189                if let Some(monitor) = ctx.monitor.as_ref() {
190                    let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes);
191                    monitor.stream_progress(
192                        &ctx.req_id,
193                        sse_bytes.len() as u64,
194                        count_sse_events(&sse_bytes),
195                        input_tokens,
196                        output_tokens,
197                    );
198                }
199                let headers = [
200                    (http::header::CONTENT_TYPE, "text/event-stream"),
201                    (http::header::CACHE_CONTROL, "no-cache"),
202                    (http::header::CONNECTION, "keep-alive"),
203                ];
204                (headers, sse_bytes).into_response()
205            }
206        } else {
207            match decode_cursor_upstream(&upstream, &message_id, model) {
208                Ok(json) => {
209                    if let Some(monitor) = ctx.monitor.as_ref() {
210                        monitor.usage_updated(
211                            &ctx.req_id,
212                            json.pointer("/usage/input_tokens").and_then(|v| v.as_u64()),
213                            json.pointer("/usage/output_tokens")
214                                .and_then(|v| v.as_u64()),
215                        );
216                    }
217                    (StatusCode::OK, Json(json)).into_response()
218                }
219                Err(e) => map_cursor_decode_error_to_response(&e),
220            }
221        }
222    }
223
224    async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response {
225        let prompt = render_cursor_prompt(&body);
226        let tokens = (prompt.len() / 4) as u64; // rough estimate
227        if let Some(monitor) = ctx.monitor.as_ref() {
228            monitor.usage_updated(&ctx.req_id, Some(tokens), None);
229        }
230        (
231            StatusCode::OK,
232            Json(CountTokensResponse {
233                input_tokens: tokens,
234            }),
235        )
236            .into_response()
237    }
238}
239
240fn count_sse_events(bytes: &[u8]) -> u64 {
241    String::from_utf8_lossy(bytes).matches("event:").count() as u64
242}
243
244fn now_ms() -> u64 {
245    std::time::SystemTime::now()
246        .duration_since(std::time::UNIX_EPOCH)
247        .unwrap_or_default()
248        .as_millis() as u64
249}
250
251// ---------------------------------------------------------------------------
252// Error mapping
253// ---------------------------------------------------------------------------
254
255fn map_cursor_error_to_response(err: &client::CursorError) -> Response {
256    match err.status {
257        401 | 403 => json_error(
258            StatusCode::UNAUTHORIZED,
259            "authentication_error",
260            err.detail.as_deref().unwrap_or("Authentication failed"),
261        ),
262        429 => {
263            let retry_after = err.retry_after.as_deref().unwrap_or("5");
264            let resp = json_error(
265                StatusCode::TOO_MANY_REQUESTS,
266                "rate_limit_error",
267                &err.message,
268            );
269            let headers = [(http::header::RETRY_AFTER, retry_after)];
270            (headers, resp).into_response()
271        }
272        _ => json_error(
273            StatusCode::BAD_GATEWAY,
274            "api_error",
275            err.detail.as_deref().unwrap_or("Upstream error"),
276        ),
277    }
278}
279
280fn map_cursor_decode_error_to_response(err: &CursorDecodeError) -> Response {
281    match err.status() {
282        Some(401 | 403) => json_error(
283            StatusCode::UNAUTHORIZED,
284            "authentication_error",
285            err.to_string(),
286        ),
287        Some(429) => json_error(
288            StatusCode::TOO_MANY_REQUESTS,
289            "rate_limit_error",
290            err.to_string(),
291        ),
292        _ => json_error(
293            StatusCode::BAD_GATEWAY,
294            "api_error",
295            format!("Response decoding error: {err}"),
296        ),
297    }
298}
299
300// ---------------------------------------------------------------------------
301// CLI
302// ---------------------------------------------------------------------------
303
304pub(crate) struct CursorCli;
305
306impl CliHandlers for CursorCli {
307    fn login(&self) -> Result<(), anyhow::Error> {
308        let auth = run_cursor_login()?.ok_or_else(|| anyhow::anyhow!("Cursor login timed out"))?;
309        println!("Cursor auth saved in {}", auth.source);
310        if let Some(ref user_id) = auth.user_id {
311            println!("User: {user_id}");
312        }
313        if let Some(ref email) = auth.email {
314            println!("Email: {email}");
315        }
316        Ok(())
317    }
318
319    fn device(&self) -> Result<(), anyhow::Error> {
320        anyhow::bail!("cursor: device login not yet implemented");
321    }
322
323    fn status(&self) -> Result<(), anyhow::Error> {
324        match load_cursor_auth()? {
325            Some(auth) => {
326                println!("Auth source: {}", auth.source);
327                if let Some(ref user_id) = auth.user_id {
328                    println!("User: {user_id}");
329                }
330                if let Some(ref email) = auth.email {
331                    println!("Email: {email}");
332                }
333                if let Some(expires) = auth.expires {
334                    let remaining = expires.saturating_sub(now_ms()) / 1000;
335                    println!("Access token expires in: {remaining}s");
336                } else {
337                    println!("Access token expiry: unknown");
338                }
339                Ok(())
340            }
341            None => {
342                anyhow::bail!("Not authenticated");
343            }
344        }
345    }
346
347    fn logout(&self) -> Result<(), anyhow::Error> {
348        clear_cursor_auth()?;
349        println!(
350            "Cursor persistent auth cleared. Unset CCP_CURSOR_AUTH_TOKEN or CURSOR_AUTH_TOKEN if using env auth."
351        );
352        Ok(())
353    }
354}
355
356pub(crate) static CURSOR_CLI: CursorCli = CursorCli;
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn supported_models_includes_legacy_and_agent() {
364        let provider = CursorProvider::new();
365        let models = provider.supported_models();
366        assert!(models.contains(&"cursor".to_string()));
367        assert!(models.contains(&"cursor-agent".to_string()));
368        assert!(models.contains(&"cursor-plan".to_string()));
369        assert!(models.contains(&"cursor-ask".to_string()));
370    }
371
372    #[test]
373    fn cursor_cli_logout_does_not_error() {
374        let result = CURSOR_CLI.logout();
375        assert!(result.is_ok());
376    }
377}