Skip to content

Commit e7513fe

Browse files
lee101cursoragent
andcommitted
Add Cursor Composer 2.5 provider with CURSOR_API_KEY support.
Route composer-2.5 and composer-2.5-fast through openpaths or cursor based on env keys, load sibling openpaths/.env, and use Basic auth for the Cursor API. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ed93dc8 commit e7513fe

10 files changed

Lines changed: 212 additions & 5 deletions

File tree

codex-cli/scripts/deploy_new_binary.sh

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,18 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6161
codex_cli_root="$(cd "$script_dir/.." && pwd)"
6262
repo_root="$(cd "$codex_cli_root/.." && pwd)"
6363
codex_rs_root="$repo_root/codex-rs"
64-
binary_src="$codex_rs_root/target/release/codex"
6564
binary_dest="$codex_cli_root/vendor/x86_64-unknown-linux-gnu/codex/codex"
6665

66+
if ! command -v "${CC:-cc}" >/dev/null 2>&1; then
67+
if command -v gcc >/dev/null 2>&1; then
68+
export CC=gcc
69+
export CXX=g++
70+
else
71+
echo "error: gcc/cc is required to build tree-sitter; install build-essential." >&2
72+
exit 1
73+
fi
74+
fi
75+
6776
if ((!dry_run)); then
6877
if ! (cd "$codex_cli_root" && npm whoami >/dev/null); then
6978
echo "error: npm is not authenticated; run npm login before deploying." >&2
@@ -73,6 +82,8 @@ fi
7382

7483
cd "$codex_rs_root"
7584
cargo build --release -p codex-cli
85+
target_dir="$(cargo metadata --format-version 1 --no-deps | python3 -c "import json, sys; print(json.load(sys.stdin)['target_directory'])")"
86+
binary_src="$target_dir/release/codex"
7687

7788
mkdir -p "$(dirname "$binary_dest")"
7889
install -m 755 "$binary_src" "$binary_dest"

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/arg0/src/lib.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,28 @@ fn build_runtime() -> anyhow::Result<tokio::runtime::Runtime> {
241241

242242
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
243243

244-
/// Load env vars from ~/.codex/.env.
244+
/// Load env vars from ~/.codex/.env and optional sibling `openpaths/.env`.
245245
///
246246
/// Security: Do not allow `.env` files to create or modify any variables
247247
/// with names starting with `CODEX_`.
248248
fn load_dotenv() {
249-
if let Ok(codex_home) = find_codex_home()
250-
&& let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env"))
249+
if let Ok(codex_home) = find_codex_home() {
250+
load_dotenv_file(&codex_home.join(".env"));
251+
}
252+
253+
if let Ok(cwd) = std::env::current_dir() {
254+
for candidate in [
255+
cwd.join("openpaths").join(".env"),
256+
cwd.join("../openpaths").join(".env"),
257+
] {
258+
load_dotenv_file(&candidate);
259+
}
260+
}
261+
}
262+
263+
fn load_dotenv_file(path: &std::path::Path) {
264+
if path.is_file()
265+
&& let Ok(iter) = dotenvy::from_path_iter(path)
251266
{
252267
set_filtered(iter);
253268
}

codex-rs/core/src/model_family.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,16 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
276276
slug, "openpaths-auto",
277277
needs_special_apply_patch_instructions: true,
278278
)
279+
} else if slug == "composer-2.5"
280+
|| slug == "composer-2.5-fast"
281+
|| slug.starts_with("composer-2.5")
282+
|| slug.starts_with("openpaths/composer-2.5")
283+
|| slug.starts_with("cursor/composer-2.5")
284+
{
285+
model_family!(
286+
slug, "composer-2.5",
287+
needs_special_apply_patch_instructions: true,
288+
)
279289
} else if slug.starts_with("glm-5") || slug.starts_with("GLM-5") {
280290
model_family!(
281291
slug, "glm-5",

codex-rs/model-provider-info/src/lib.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const OPENROUTER_PROVIDER_NAME: &str = "OpenRouter";
4040
pub const OPENROUTER_PROVIDER_ID: &str = "openrouter";
4141
const OPENPATHS_PROVIDER_NAME: &str = "OpenPaths";
4242
pub const OPENPATHS_PROVIDER_ID: &str = "openpaths";
43+
pub const CURSOR_PROVIDER_NAME: &str = "Cursor";
44+
pub const CURSOR_PROVIDER_ID: &str = "cursor";
4345
const ZHIPU_PROVIDER_NAME: &str = "Z.AI (Zhipu)";
4446
pub const ZHIPU_PROVIDER_ID: &str = "zhipu";
4547
const DEEPSEEK_PROVIDER_NAME: &str = "DeepSeek";
@@ -405,7 +407,7 @@ impl ModelProviderInfo {
405407
pub fn create_openpaths_provider() -> ModelProviderInfo {
406408
ModelProviderInfo {
407409
name: OPENPATHS_PROVIDER_NAME.into(),
408-
base_url: Some("https://openpaths.io/v1".into()),
410+
base_url: Some(openpaths_base_url()),
409411
env_key: Some("OPENPATHS_API_KEY".into()),
410412
env_key_instructions: Some(
411413
"Get your API key from https://openpaths.io and set OPENPATHS_API_KEY".into(),
@@ -426,6 +428,31 @@ impl ModelProviderInfo {
426428
}
427429
}
428430

431+
pub fn create_cursor_provider() -> ModelProviderInfo {
432+
ModelProviderInfo {
433+
name: CURSOR_PROVIDER_NAME.into(),
434+
base_url: Some(cursor_base_url()),
435+
env_key: Some("CURSOR_API_KEY".into()),
436+
env_key_instructions: Some(
437+
"Get your API key from https://cursor.com/dashboard/integrations and set CURSOR_API_KEY"
438+
.into(),
439+
),
440+
experimental_bearer_token: None,
441+
auth: None,
442+
wire_api: WireApi::Responses,
443+
query_params: None,
444+
http_headers: None,
445+
env_http_headers: None,
446+
request_max_retries: None,
447+
stream_max_retries: None,
448+
stream_idle_timeout_ms: None,
449+
websocket_connect_timeout_ms: None,
450+
requires_openai_auth: false,
451+
supports_websockets: false,
452+
..Default::default()
453+
}
454+
}
455+
429456
pub fn create_zhipu_provider() -> ModelProviderInfo {
430457
ModelProviderInfo {
431458
name: ZHIPU_PROVIDER_NAME.into(),
@@ -511,6 +538,8 @@ impl ModelProviderInfo {
511538
slug.strip_prefix("google/").unwrap_or(slug)
512539
} else if self.name == OPENPATHS_PROVIDER_NAME {
513540
slug.strip_prefix("openpaths/").unwrap_or(slug)
541+
} else if self.name == CURSOR_PROVIDER_NAME {
542+
slug.strip_prefix("cursor/").unwrap_or(slug)
514543
} else if self.name == ZHIPU_PROVIDER_NAME {
515544
slug.strip_prefix("zhipu/")
516545
.or_else(|| slug.strip_prefix("z-ai/"))
@@ -556,6 +585,7 @@ pub fn built_in_model_providers(
556585
(OPENAI_PROVIDER_ID, openai_provider),
557586
(OPENROUTER_PROVIDER_ID, P::create_openrouter_provider()),
558587
(OPENPATHS_PROVIDER_ID, P::create_openpaths_provider()),
588+
(CURSOR_PROVIDER_ID, P::create_cursor_provider()),
559589
(GEMINI_PROVIDER_ID, P::create_gemini_provider()),
560590
(ZHIPU_PROVIDER_ID, P::create_zhipu_provider()),
561591
(DEEPSEEK_PROVIDER_ID, P::create_deepseek_provider()),
@@ -580,6 +610,39 @@ fn non_empty_env_var(name: &str) -> bool {
580610
.is_some_and(|value| !value.trim().is_empty())
581611
}
582612

613+
fn openpaths_base_url() -> String {
614+
std::env::var("OPENPATHS_BASE_URL")
615+
.ok()
616+
.filter(|value| !value.trim().is_empty())
617+
.map(|value| {
618+
let trimmed = value.trim().trim_end_matches('/');
619+
if trimmed.ends_with("/v1") {
620+
trimmed.to_string()
621+
} else {
622+
format!("{trimmed}/v1")
623+
}
624+
})
625+
.unwrap_or_else(|| "https://openpaths.io/v1".to_string())
626+
}
627+
628+
fn cursor_base_url() -> String {
629+
std::env::var("CURSOR_BASE_URL")
630+
.ok()
631+
.filter(|value| !value.trim().is_empty())
632+
.map(|value| value.trim().trim_end_matches('/').to_string())
633+
.unwrap_or_else(|| "https://api.cursor.com".to_string())
634+
}
635+
636+
fn is_composer_model_slug(lower: &str) -> bool {
637+
let slug = lower
638+
.strip_prefix("openpaths/")
639+
.or_else(|| lower.strip_prefix("cursor/"))
640+
.unwrap_or(lower);
641+
slug == "composer-2.5"
642+
|| slug == "composer-2.5-fast"
643+
|| slug.starts_with("composer-2.5-")
644+
}
645+
583646
pub fn infer_builtin_provider_id_for_model(model: &str) -> Option<&'static str> {
584647
let lower = model.to_lowercase();
585648
if (lower.starts_with("glm-")
@@ -611,6 +674,14 @@ pub fn infer_builtin_provider_id_for_model(model: &str) -> Option<&'static str>
611674
{
612675
return Some(OPENPATHS_PROVIDER_ID);
613676
}
677+
if is_composer_model_slug(&lower) {
678+
if non_empty_env_var("OPENPATHS_API_KEY") {
679+
return Some(OPENPATHS_PROVIDER_ID);
680+
}
681+
if non_empty_env_var("CURSOR_API_KEY") {
682+
return Some(CURSOR_PROVIDER_ID);
683+
}
684+
}
614685
match model.split_once('/') {
615686
Some(("google", _)) if non_empty_env_var("GEMINI_API_KEY") => Some(GEMINI_PROVIDER_ID),
616687
Some(("google", _)) if non_empty_env_var("OPENROUTER_API_KEY") => {
@@ -623,6 +694,7 @@ pub fn infer_builtin_provider_id_for_model(model: &str) -> Option<&'static str>
623694
Some(("openpaths", _)) if non_empty_env_var("OPENPATHS_API_KEY") => {
624695
Some(OPENPATHS_PROVIDER_ID)
625696
}
697+
Some(("cursor", _)) if non_empty_env_var("CURSOR_API_KEY") => Some(CURSOR_PROVIDER_ID),
626698
Some(("deepseek", _)) if non_empty_env_var("DEEPSEEK_API_KEY") => {
627699
Some(DEEPSEEK_PROVIDER_ID)
628700
}

codex-rs/model-provider-info/src/model_provider_info_tests.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,30 @@ fn openpaths_provider_normalizes_openpaths_prefix() {
278278
provider.effective_model_name("auto-medium-task"),
279279
"auto-medium-task"
280280
);
281+
assert_eq!(
282+
provider.effective_model_name("openpaths/composer-2.5-fast"),
283+
"composer-2.5-fast"
284+
);
285+
}
286+
287+
#[test]
288+
fn cursor_provider_normalizes_cursor_prefix() {
289+
let provider = ModelProviderInfo::create_cursor_provider();
290+
assert_eq!(
291+
provider.effective_model_name("cursor/composer-2.5-fast"),
292+
"composer-2.5-fast"
293+
);
294+
assert_eq!(
295+
provider.effective_model_name("composer-2.5"),
296+
"composer-2.5"
297+
);
281298
}
282299

283300
#[test]
284301
fn infer_builtin_provider_prefers_env_backed_routes() {
285302
let _gemini_remove_guard = EnvVarGuard::remove("GEMINI_API_KEY");
286303
let _openrouter_remove_guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
304+
let _cursor_remove_guard = EnvVarGuard::remove("CURSOR_API_KEY");
287305
let openpaths_remove_guard = EnvVarGuard::remove("OPENPATHS_API_KEY");
288306
assert_eq!(
289307
infer_builtin_provider_id_for_model("google/gemini-3.1-pro-preview"),
@@ -331,6 +349,25 @@ fn infer_builtin_provider_prefers_env_backed_routes() {
331349
infer_builtin_provider_id_for_model("openpaths/auto-think"),
332350
Some(OPENPATHS_PROVIDER_ID)
333351
);
352+
assert_eq!(
353+
infer_builtin_provider_id_for_model("composer-2.5"),
354+
Some(OPENPATHS_PROVIDER_ID)
355+
);
356+
assert_eq!(
357+
infer_builtin_provider_id_for_model("composer-2.5-fast"),
358+
Some(OPENPATHS_PROVIDER_ID)
359+
);
360+
361+
drop(_openpaths_set_guard);
362+
let _cursor_set_guard = EnvVarGuard::set("CURSOR_API_KEY", "cursor-key");
363+
assert_eq!(
364+
infer_builtin_provider_id_for_model("composer-2.5"),
365+
Some(CURSOR_PROVIDER_ID)
366+
);
367+
assert_eq!(
368+
infer_builtin_provider_id_for_model("cursor/composer-2.5-fast"),
369+
Some(CURSOR_PROVIDER_ID)
370+
);
334371
}
335372

336373
#[test]

codex-rs/model-provider/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ workspace = true
1414

1515
[dependencies]
1616
async-trait = { workspace = true }
17+
base64 = { workspace = true }
1718
codex-api = { workspace = true }
1819
codex-agent-identity = { workspace = true }
1920
codex-aws-auth = { workspace = true }

codex-rs/model-provider/src/auth.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ use codex_model_provider_info::ModelProviderInfo;
1111
use http::HeaderMap;
1212
use http::HeaderValue;
1313

14+
use crate::basic_auth_provider::BasicAuthProvider;
1415
use crate::bearer_auth_provider::BearerAuthProvider;
16+
use codex_model_provider_info::CURSOR_PROVIDER_NAME;
1517

1618
#[derive(Clone, Debug)]
1719
struct AgentIdentityAuthProvider {
@@ -79,6 +81,12 @@ pub(crate) fn resolve_provider_auth(
7981
auth: Option<&CodexAuth>,
8082
provider: &ModelProviderInfo,
8183
) -> codex_protocol::error::Result<SharedAuthProvider> {
84+
if provider.name == CURSOR_PROVIDER_NAME
85+
&& let Some(api_key) = provider.api_key()?
86+
{
87+
return Ok(Arc::new(BasicAuthProvider::new(api_key)));
88+
}
89+
8290
if let Some(auth) = bearer_auth_for_provider(provider)? {
8391
return Ok(Arc::new(auth));
8492
}
@@ -92,6 +100,10 @@ pub(crate) fn resolve_provider_auth(
92100
fn bearer_auth_for_provider(
93101
provider: &ModelProviderInfo,
94102
) -> codex_protocol::error::Result<Option<BearerAuthProvider>> {
103+
if provider.name == CURSOR_PROVIDER_NAME {
104+
return Ok(None);
105+
}
106+
95107
if let Some(api_key) = provider.api_key()? {
96108
return Ok(Some(BearerAuthProvider::new(api_key)));
97109
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use base64::Engine;
2+
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
3+
use codex_api::AuthProvider;
4+
use http::HeaderMap;
5+
use http::HeaderValue;
6+
7+
/// Basic auth provider for APIs that expect `Authorization: Basic <base64(api_key:)>` headers.
8+
#[derive(Clone)]
9+
pub struct BasicAuthProvider {
10+
api_key: String,
11+
}
12+
13+
impl BasicAuthProvider {
14+
pub fn new(api_key: String) -> Self {
15+
Self { api_key }
16+
}
17+
}
18+
19+
impl AuthProvider for BasicAuthProvider {
20+
fn add_auth_headers(&self, headers: &mut HeaderMap) {
21+
let encoded = BASE64_STANDARD.encode(format!("{}:", self.api_key));
22+
if let Ok(header) = HeaderValue::from_str(&format!("Basic {encoded}")) {
23+
let _ = headers.insert(http::header::AUTHORIZATION, header);
24+
}
25+
}
26+
}
27+
28+
#[cfg(test)]
29+
mod tests {
30+
use super::*;
31+
use pretty_assertions::assert_eq;
32+
33+
#[test]
34+
fn basic_auth_provider_adds_auth_header() {
35+
let auth = BasicAuthProvider::new("cursor-test-key".to_string());
36+
let mut headers = HeaderMap::new();
37+
38+
auth.add_auth_headers(&mut headers);
39+
40+
assert_eq!(
41+
headers
42+
.get(http::header::AUTHORIZATION)
43+
.and_then(|value| value.to_str().ok()),
44+
Some("Basic Y3Vyc29yLXRlc3Qta2V5Og==")
45+
);
46+
}
47+
}

codex-rs/model-provider/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod amazon_bedrock;
22
mod auth;
3+
mod basic_auth_provider;
34
mod bearer_auth_provider;
45
mod models_endpoint;
56
mod provider;

0 commit comments

Comments
 (0)