//! Implements Cursor HTTP endpoints outside the Agent Run stream. use axum::{ body::{to_bytes, Body, Bytes}, extract::{DefaultBodyLimit, Extension, State}, http::{header, HeaderMap, HeaderValue, Request, Response, StatusCode}, routing::{get, post}, Router, }; use tower_http::decompression::RequestDecompressionLayer; use crate::{ api::cursor::{ bidi, proxy::{self, CursorProxy}, run_sse, }, cursor::{ protocol::{ connect, proto::{agent::v1 as agent, aiserver::v1 as ai}, }, services::{ account, analytics, commit_message, compatibility, entitlement::FreeEntitlementCache, knowledge, model_catalog, server_config, tab, }, transport::{TransportParent, TransportRegistry}, }, Result, }; pub fn router( registry: TransportRegistry, clients: crate::network::NetworkClients, ) -> Result { let proxy = CursorProxy::cursor(clients); let knowledge = knowledge::KnowledgeService::managed()?; Ok(router_with_proxy(registry, proxy, knowledge)) } fn router_with_proxy( registry: TransportRegistry, proxy: CursorProxy, knowledge_service: knowledge::KnowledgeService, ) -> Router { let web_cache = registry.web_cache().router(); let free_entitlements = FreeEntitlementCache::default(); Router::new() .route("/__byok-api__/healthz", get(health)) .route("/agent.v1.AgentService/RunSSE", post(run_sse_handler)) .route("/aiserver.v1.BidiService/BidiAppend", post(bidi_handler)) .route( "/aiserver.v1.AiService/AvailableDocs", post(compatibility::available_docs), ) .route( "/aiserver.v1.DashboardService/GetEffectiveUserPlugins", post(compatibility::effective_user_plugins), ) .route( "/aiserver.v1.DashboardService/GetUserPrivacyMode", post(compatibility::user_privacy_mode), ) .route( "/agent.v1.AgentService/UpdateConversationMetadata", post(compatibility::update_conversation_metadata), ) .route( "/aiserver.v1.AiService/GetServerConfig", post(server_config::get), ) .route( "/aiserver.v1.ServerConfigService/GetServerConfig", post(server_config::get), ) .route( "/aiserver.v1.AiService/AvailableModels", post(model_catalog::available_models), ) .route( "/agent.v1.AgentService/GetUsableModels", post(model_catalog::usable_models), ) .route( "/aiserver.v1.AiService/GetUsableModels", post(model_catalog::usable_models), ) .route( "/aiserver.v1.AiService/WriteGitCommitMessage", post(commit_message::write_git_commit_message), ) .route( "/aiserver.v1.NetworkService/IsConnected", post(is_connected), ) .route( "/agent.v1.AgentService/GetDefaultModelForCli", post(model_catalog::default_model_for_cli), ) .route( "/aiserver.v1.AiService/GetDefaultModelForCli", post(model_catalog::default_model_for_cli), ) .route( "/aiserver.v1.AiService/GetDefaultModel", post(model_catalog::default_model), ) .route( "/aiserver.v1.AiService/GetDefaultModelNudgeData", post(model_catalog::default_model_nudge), ) .route( "/aiserver.v1.AuthService/GetEmail", post(account::get_email), ) .route( "/aiserver.v1.AuthService/GetUserMeta", post(account::get_user_meta), ) .route("/aiserver.v1.DashboardService/GetMe", post(account::get_me)) .route( "/aiserver.v1.DashboardService/GetTeams", post(account::get_teams), ) .route( "/aiserver.v1.DashboardService/GetUserProfile", post(account::get_user_profile), ) .route( "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", post(account::current_period_usage), ) .route( "/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants", post(account::usage_limit_status), ) .route( "/aiserver.v1.AiService/KnowledgeBaseAdd", post(knowledge::add), ) .route( "/aiserver.v1.AiService/KnowledgeBaseList", post(knowledge::list), ) .route( "/aiserver.v1.AiService/KnowledgeBaseUpdate", post(knowledge::update), ) .route( "/aiserver.v1.AiService/KnowledgeBaseRemove", post(knowledge::remove), ) .route( analytics::BOOTSTRAP_STATSIG_PATH, post(analytics::bootstrap_statsig), ) .route("/auth/full_stripe_profile", get(account::stripe_profile)) .route("/auth/stripe_profile", get(account::stripe_profile)) .merge(tab::router()) .route_layer(DefaultBodyLimit::disable()) .route_layer(RequestDecompressionLayer::new()) .fallback(proxy::forward) .method_not_allowed_fallback(proxy::forward) .layer(Extension(proxy)) .layer(Extension(knowledge_service)) .layer(Extension(free_entitlements)) .with_state(registry) .merge(web_cache) } async fn health() -> StatusCode { StatusCode::NO_CONTENT } /// `NetworkService/IsConnected` probe. Cursor's always-local extension checks /// connectivity roughly 10s after any slow request starts; a 404/error here is /// treated as "network disconnected" and aborts in-flight work (e.g. commit /// message generation) even while the model is still streaming. Always answer /// connected with an empty `IsConnectedResponse` so local BYOK generation is /// never cancelled by this probe. async fn is_connected() -> Result> { let payload = connect::encode_message(&ai::IsConnectedResponse {})?; let mut response = Response::new(Body::from(payload)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("application/proto"), ); Ok(response) } async fn run_sse_handler( State(registry): State, Extension(proxy): Extension, request: Request, ) -> Result> { let (parts, body) = buffered(request).await?; let request: agent::BidiRequestId = connect::decode_unary(&body)?; let route = registry.wait_route(&request.request_id).await; let trace = registry.trace(&request.request_id); trace.resume(); trace.request( "run_sse_request", body.clone(), serde_json::json!({"request_id": request.request_id}), ); match route { crate::cursor::transport::TransportRoute::Local => { run_sse::stream(®istry, &request.request_id).await } crate::cursor::transport::TransportRoute::Upstream(generation) => { let response = proxy::forward( Extension(proxy), Request::from_parts(parts, Body::from(body)), ) .await?; Ok(run_sse::upstream( registry, request.request_id, generation, response, Some(trace), ) .await) } } } async fn bidi_handler( State(registry): State, Extension(proxy): Extension, request: Request, ) -> Result> { let (parts, body) = buffered(request).await?; let request: ai::BidiAppendRequest = connect::decode_unary(&body)?; let decoded = bidi::decode(&request)?; let first_model = decoded.model_id().map(str::to_owned); let conversation_id = decoded.conversation_id().map(str::to_owned); let trace_metadata = decoded.trace_metadata(); let trace = registry.trace(&decoded.request_id); let local = if let Some(model_id) = decoded.model_id() { // 插件模型 ID 只在本地有意义,永远不转发到 Cursor 官方上游。 if model_id.starts_with(crate::plugin::ADAPTER_ID_PREFIX) || registry.store().model(model_id).await?.is_some() { tracing::info!( request_id = decoded.request_id, model_id, "routing Cursor Run to BYOK provider" ); true } else { tracing::info!( request_id = decoded.request_id, model_id, "routing Cursor Run to Cursor upstream" ); false } } else if registry.local(&decoded.request_id).await.is_some() { true } else if registry.upstream(&decoded.request_id).await { false } else { trace.resume(); trace.request( "bidi_request", body.clone(), trace_outcome(trace_metadata, false, "missing_transport", None), ); return Err(crate::Error::Protocol( "first BidiAppend message must select a model".into(), )); }; if first_model.is_some() { trace.begin( conversation_id.as_deref(), if local { "local_byok" } else { "cursor_official" }, first_model.as_deref(), ); } else { trace.resume(); } if !local { if first_model.is_some() { registry.mark_upstream(&decoded.request_id).await; } trace.request( "bidi_request", body.clone(), trace_outcome(trace_metadata, true, "upstream", None), ); return proxy::forward( Extension(proxy), Request::from_parts(parts, Body::from(body)), ) .await; } let parent = match parent_headers(&parts.headers) { Ok(parent) => parent, Err(error) => { trace.request( "bidi_request", body, trace_outcome( trace_metadata, false, "invalid_parent", Some(error.to_string()), ), ); return Err(error); } }; match bidi::append(®istry, decoded, parent).await { Ok(_) => trace.request( "bidi_request", body, trace_outcome(trace_metadata, true, "local", None), ), Err(error) => { trace.request( "bidi_request", body, trace_outcome( trace_metadata, false, "command_rejected", Some(error.to_string()), ), ); return Err(error); } } let mut response = Response::new(axum::body::Body::empty()); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("application/proto"), ); Ok(response) } fn trace_outcome( mut metadata: serde_json::Value, accepted: bool, route_outcome: &str, error: Option, ) -> serde_json::Value { if let Some(metadata) = metadata.as_object_mut() { metadata.insert("accepted".into(), accepted.into()); metadata.insert("route_outcome".into(), route_outcome.into()); if let Some(error) = error { metadata.insert("error".into(), error.into()); } } metadata } async fn buffered(request: Request) -> Result<(axum::http::request::Parts, Bytes)> { let (parts, body) = request.into_parts(); let body = to_bytes(body, usize::MAX) .await .map_err(|error| crate::Error::Protocol(format!("cannot read request body: {error}")))?; Ok((parts, body)) } fn parent_headers(headers: &HeaderMap) -> Result> { let request_id = header_text(headers, "x-parent-request-id")?; let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?; match (request_id, tool_call_id) { (None, None) => Ok(None), (Some(request_id), Some(tool_call_id)) => Ok(Some(TransportParent { request_id: request_id.into(), tool_call_id: tool_call_id.into(), })), _ => Err(crate::Error::Protocol( "Cursor subagent request must include both parent headers".into(), )), } } fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Result> { headers .get(name) .map(|value| value.to_str()) .transpose() .map_err(|error| crate::Error::Protocol(format!("invalid {name} header: {error}"))) }