diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 0cff5f853cb8..90e21a28d7e6 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -824,6 +824,10 @@ impl Config { config_value!(CLAUDE_CODE_COMMAND, OsString, "claude"); config_value!(GEMINI_CLI_COMMAND, OsString, "gemini"); config_value!(CURSOR_AGENT_COMMAND, OsString, "cursor-agent"); +config_value!(CODEX_COMMAND, OsString, "codex"); +config_value!(CODEX_REASONING_EFFORT, String, "high"); +config_value!(CODEX_ENABLE_SKILLS, String, "true"); +config_value!(CODEX_SKIP_GIT_CHECK, String, "false"); config_value!(GOOSE_SEARCH_PATHS, Vec); config_value!(GOOSE_MODE, GooseMode); diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index 9a9f8eb546dc..558e1e3a266e 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -18,7 +18,10 @@ pub enum ConfigError { static MODEL_SPECIFIC_LIMITS: Lazy> = Lazy::new(|| { vec![ // openai - ("gpt-5", 272_000), + ("gpt-5.2-codex", 400_000), // auto-compacting context + ("gpt-5.2", 400_000), // auto-compacting context + ("gpt-5.1-codex-max", 256_000), + ("gpt-5.1-codex-mini", 256_000), ("gpt-4-turbo", 128_000), ("gpt-4.1", 1_000_000), ("gpt-4-1", 1_000_000), diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs new file mode 100644 index 000000000000..abfa54c299d8 --- /dev/null +++ b/crates/goose/src/providers/codex.rs @@ -0,0 +1,955 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde_json::json; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage}; +use super::errors::ProviderError; +use super::utils::{filter_extensions_from_system_prompt, RequestLog}; +use crate::config::base::{ + CodexCommand, CodexEnableSkills, CodexReasoningEffort, CodexSkipGitCheck, +}; +use crate::config::search_path::SearchPaths; +use crate::config::{Config, GooseMode}; +use crate::conversation::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::subprocess::configure_command_no_window; +use rmcp::model::Role; +use rmcp::model::Tool; + +pub const CODEX_DEFAULT_MODEL: &str = "gpt-5.2-codex"; +pub const CODEX_KNOWN_MODELS: &[&str] = &[ + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", +]; +pub const CODEX_DOC_URL: &str = "https://developers.openai.com/codex/cli"; + +/// Valid reasoning effort levels for Codex +pub const CODEX_REASONING_LEVELS: &[&str] = &["low", "medium", "high"]; + +#[derive(Debug, serde::Serialize)] +pub struct CodexProvider { + command: PathBuf, + model: ModelConfig, + #[serde(skip)] + name: String, + /// Reasoning effort level (low, medium, high) + reasoning_effort: String, + /// Whether to enable skills + enable_skills: bool, + /// Whether to skip git repo check + skip_git_check: bool, +} + +impl CodexProvider { + pub async fn from_env(model: ModelConfig) -> Result { + let config = Config::global(); + let command: OsString = config.get_codex_command().unwrap_or_default().into(); + let resolved_command = SearchPaths::builder().with_npm().resolve(command)?; + + // Get reasoning effort from config, default to "high" + let reasoning_effort = config + .get_codex_reasoning_effort() + .map(|r| r.to_string()) + .unwrap_or_else(|_| "high".to_string()); + + // Validate reasoning effort + let reasoning_effort = if CODEX_REASONING_LEVELS.contains(&reasoning_effort.as_str()) { + reasoning_effort + } else { + tracing::warn!( + "Invalid CODEX_REASONING_EFFORT '{}', using 'high'", + reasoning_effort + ); + "high".to_string() + }; + + // Get enable_skills from config, default to true + let enable_skills = config + .get_codex_enable_skills() + .map(|s| s.to_lowercase() == "true") + .unwrap_or(true); + + // Get skip_git_check from config, default to false + let skip_git_check = config + .get_codex_skip_git_check() + .map(|s| s.to_lowercase() == "true") + .unwrap_or(false); + + Ok(Self { + command: resolved_command, + model, + name: Self::metadata().name, + reasoning_effort, + enable_skills, + skip_git_check, + }) + } + + /// Convert goose messages to a simple text prompt format + /// Similar to Gemini CLI, we use Human:/Assistant: prefixes + fn messages_to_prompt(&self, system: &str, messages: &[Message]) -> String { + let mut full_prompt = String::new(); + + let filtered_system = filter_extensions_from_system_prompt(system); + if !filtered_system.is_empty() { + full_prompt.push_str(&filtered_system); + full_prompt.push_str("\n\n"); + } + + // Add conversation history + for message in messages.iter().filter(|m| m.is_agent_visible()) { + let role_prefix = match message.role { + Role::User => "Human: ", + Role::Assistant => "Assistant: ", + }; + full_prompt.push_str(role_prefix); + + for content in &message.content { + if let MessageContent::Text(text_content) = content { + full_prompt.push_str(&text_content.text); + full_prompt.push('\n'); + } + } + full_prompt.push('\n'); + } + + full_prompt.push_str("Assistant: "); + full_prompt + } + + /// Apply permission flags based on GOOSE_MODE setting + fn apply_permission_flags(cmd: &mut Command) -> Result<(), ProviderError> { + let config = Config::global(); + let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + + match goose_mode { + GooseMode::Auto => { + // --yolo is shorthand for --dangerously-bypass-approvals-and-sandbox + cmd.arg("--yolo"); + } + GooseMode::SmartApprove => { + // --full-auto applies workspace-write sandbox and approvals only on failure + cmd.arg("--full-auto"); + } + GooseMode::Approve => { + // Default codex behavior - interactive approvals + // No special flags needed + } + GooseMode::Chat => { + // Read-only sandbox mode + cmd.arg("--sandbox").arg("read-only"); + } + } + Ok(()) + } + + /// Execute codex CLI command + async fn execute_command( + &self, + system: &str, + messages: &[Message], + _tools: &[Tool], + ) -> Result, ProviderError> { + let prompt = self.messages_to_prompt(system, messages); + + if std::env::var("GOOSE_CODEX_DEBUG").is_ok() { + println!("=== CODEX PROVIDER DEBUG ==="); + println!("Command: {:?}", self.command); + println!("Model: {}", self.model.model_name); + println!("Reasoning effort: {}", self.reasoning_effort); + println!("Enable skills: {}", self.enable_skills); + println!("Skip git check: {}", self.skip_git_check); + println!("Prompt length: {} chars", prompt.len()); + println!("Prompt: {}", prompt); + println!("============================"); + } + + let mut cmd = Command::new(&self.command); + configure_command_no_window(&mut cmd); + + // Use 'exec' subcommand for non-interactive mode + cmd.arg("exec"); + + // Only pass model parameter if it's in the known models list + // This allows users to set GOOSE_PROVIDER=codex without needing to specify a model + if CODEX_KNOWN_MODELS.contains(&self.model.model_name.as_str()) { + cmd.arg("-m").arg(&self.model.model_name); + } + + // Reasoning effort configuration + cmd.arg("-c").arg(format!( + "model_reasoning_effort=\"{}\"", + self.reasoning_effort + )); + + // Enable skills if configured + if self.enable_skills { + cmd.arg("--enable").arg("skills"); + } + + // JSON output format for structured parsing + cmd.arg("--json"); + + // Apply permission mode based on GOOSE_MODE + Self::apply_permission_flags(&mut cmd)?; + + // Skip git repo check if configured + if self.skip_git_check { + cmd.arg("--skip-git-repo-check"); + } + + // Pass the prompt via stdin using '-' argument + cmd.arg("-"); + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| { + ProviderError::RequestFailed(format!( + "Failed to spawn Codex CLI command '{:?}': {}. \ + Make sure the Codex CLI is installed (npm i -g @openai/codex) \ + and available in the configured search paths.", + self.command, e + )) + })?; + + // Write prompt to stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin.write_all(prompt.as_bytes()).await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to write to stdin: {}", e)) + })?; + // Close stdin to signal end of input + drop(stdin); + } + + let stdout = child + .stdout + .take() + .ok_or_else(|| ProviderError::RequestFailed("Failed to capture stdout".to_string()))?; + + let mut reader = BufReader::new(stdout); + let mut lines = Vec::new(); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } + } + Err(e) => { + return Err(ProviderError::RequestFailed(format!( + "Failed to read output: {}", + e + ))); + } + } + } + + let exit_status = child.wait().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to wait for command: {}", e)) + })?; + + if !exit_status.success() { + return Err(ProviderError::RequestFailed(format!( + "Codex command failed with exit code: {:?}", + exit_status.code() + ))); + } + + tracing::debug!("Codex CLI executed successfully, got {} lines", lines.len()); + + Ok(lines) + } + + /// Extract text content from an item.completed event (agent_message only, skip reasoning) + fn extract_text_from_item(item: &serde_json::Value) -> Option { + let item_type = item.get("type").and_then(|t| t.as_str()); + if item_type == Some("agent_message") { + item.get("text") + .and_then(|t| t.as_str()) + .filter(|text| !text.trim().is_empty()) + .map(|s| s.to_string()) + } else { + None + } + } + + /// Extract usage information from a JSON object + fn extract_usage(usage_info: &serde_json::Value, usage: &mut Usage) { + if usage.input_tokens.is_none() { + usage.input_tokens = usage_info + .get("input_tokens") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + } + if usage.output_tokens.is_none() { + usage.output_tokens = usage_info + .get("output_tokens") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + } + } + + /// Extract error message from an error event + fn extract_error(parsed: &serde_json::Value) -> Option { + parsed + .get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + parsed + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + } + + /// Extract text from legacy message formats + fn extract_legacy_text(parsed: &serde_json::Value) -> Vec { + let mut texts = Vec::new(); + if let Some(content) = parsed.get("content").and_then(|c| c.as_array()) { + for item in content { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + texts.push(text.to_string()); + } + } + } + if let Some(text) = parsed.get("text").and_then(|t| t.as_str()) { + texts.push(text.to_string()); + } + if let Some(text) = parsed.get("result").and_then(|r| r.as_str()) { + texts.push(text.to_string()); + } + texts + } + + /// Build fallback text from non-JSON lines + fn build_fallback_text(lines: &[String]) -> Option { + let response_text: String = lines + .iter() + .filter(|line| { + !line.starts_with('{') + || serde_json::from_str::(line) + .map(|v| v.get("type").is_none()) + .unwrap_or(true) + }) + .cloned() + .collect::>() + .join("\n"); + if response_text.trim().is_empty() { + None + } else { + Some(response_text) + } + } + + /// Parse newline-delimited JSON response from Codex CLI + fn parse_response(&self, lines: &[String]) -> Result<(Message, Usage), ProviderError> { + let mut all_text_content = Vec::new(); + let mut usage = Usage::default(); + let mut error_message: Option = None; + + for line in lines { + if let Ok(parsed) = serde_json::from_str::(line) { + if let Some(event_type) = parsed.get("type").and_then(|t| t.as_str()) { + match event_type { + "item.completed" => { + if let Some(item) = parsed.get("item") { + if let Some(text) = Self::extract_text_from_item(item) { + all_text_content.push(text); + } + } + } + "turn.completed" | "result" | "done" => { + if let Some(usage_info) = parsed.get("usage") { + Self::extract_usage(usage_info, &mut usage); + } + all_text_content.extend(Self::extract_legacy_text(&parsed)); + } + "error" | "turn.failed" => { + error_message = Self::extract_error(&parsed); + } + "message" | "assistant" => { + all_text_content.extend(Self::extract_legacy_text(&parsed)); + } + _ => {} + } + } + } + } + + if let Some(err) = error_message { + if all_text_content.is_empty() { + return Err(ProviderError::RequestFailed(format!( + "Codex CLI error: {}", + err + ))); + } + } + + if all_text_content.is_empty() { + if let Some(fallback) = Self::build_fallback_text(lines) { + all_text_content.push(fallback); + } + } + + if let (Some(input), Some(output)) = (usage.input_tokens, usage.output_tokens) { + usage.total_tokens = Some(input + output); + } + + let combined_text = all_text_content.join("\n\n"); + if combined_text.is_empty() { + return Err(ProviderError::RequestFailed( + "Empty response from Codex CLI".to_string(), + )); + } + + let message = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::text(combined_text)], + ); + + Ok((message, usage)) + } + + /// Generate a simple session description without calling subprocess + fn generate_simple_session_description( + &self, + messages: &[Message], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Extract the first user message text + let description = messages + .iter() + .find(|m| m.role == Role::User) + .and_then(|m| { + m.content.iter().find_map(|c| match c { + MessageContent::Text(text_content) => Some(&text_content.text), + _ => None, + }) + }) + .map(|text| { + // Take first few words, limit to 4 words + text.split_whitespace() + .take(4) + .collect::>() + .join(" ") + }) + .unwrap_or_else(|| "Simple task".to_string()); + + if std::env::var("GOOSE_CODEX_DEBUG").is_ok() { + println!("=== CODEX PROVIDER DEBUG ==="); + println!("Generated simple session description: {}", description); + println!("Skipped subprocess call for session description"); + println!("============================"); + } + + let message = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::text(description.clone())], + ); + + let usage = Usage::default(); + + Ok(( + message, + ProviderUsage::new(self.model.model_name.clone(), usage), + )) + } +} + +#[async_trait] +impl Provider for CodexProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "codex", + "OpenAI Codex CLI", + "Execute OpenAI models via Codex CLI tool. Requires codex CLI installed.", + CODEX_DEFAULT_MODEL, + CODEX_KNOWN_MODELS.to_vec(), + CODEX_DOC_URL, + vec![ + ConfigKey::from_value_type::(true, false), + ConfigKey::from_value_type::(false, false), + ConfigKey::from_value_type::(false, false), + ConfigKey::from_value_type::(false, false), + ], + ) + } + + fn get_name(&self) -> &str { + &self.name + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, model_config, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete_with_model( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Check if this is a session description request + if system.contains("four words or less") || system.contains("4 words or less") { + return self.generate_simple_session_description(messages); + } + + let lines = self.execute_command(system, messages, tools).await?; + + let (message, usage) = self.parse_response(&lines)?; + + // Create a payload for debug tracing + let payload = json!({ + "command": self.command, + "model": model_config.model_name, + "reasoning_effort": self.reasoning_effort, + "enable_skills": self.enable_skills, + "system_length": system.len(), + "messages_count": messages.len() + }); + + let mut log = RequestLog::start(model_config, &payload).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to start request log: {}", e)) + })?; + + let response = json!({ + "lines": lines.len(), + "usage": usage + }); + + log.write(&response, Some(&usage)).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to write request log: {}", e)) + })?; + + Ok(( + message, + ProviderUsage::new(model_config.model_name.clone(), usage), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codex_metadata() { + let metadata = CodexProvider::metadata(); + assert_eq!(metadata.name, "codex"); + assert_eq!(metadata.default_model, CODEX_DEFAULT_MODEL); + assert!(!metadata.known_models.is_empty()); + // Check that the default model is in the known models + assert!(metadata + .known_models + .iter() + .any(|m| m.name == CODEX_DEFAULT_MODEL)); + } + + #[test] + fn test_messages_to_prompt_empty() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let prompt = provider.messages_to_prompt("", &[]); + assert_eq!(prompt, "Assistant: "); + } + + #[test] + fn test_messages_to_prompt_with_system() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let prompt = provider.messages_to_prompt("You are a helpful assistant.", &[]); + assert!(prompt.starts_with("You are a helpful assistant.")); + assert!(prompt.ends_with("Assistant: ")); + } + + #[test] + fn test_messages_to_prompt_with_messages() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let messages = vec![ + Message::new( + Role::User, + chrono::Utc::now().timestamp(), + vec![MessageContent::text("Hello")], + ), + Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![MessageContent::text("Hi there!")], + ), + ]; + + let prompt = provider.messages_to_prompt("", &messages); + assert!(prompt.contains("Human: Hello")); + assert!(prompt.contains("Assistant: Hi there!")); + } + + #[test] + fn test_parse_response_plain_text() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec!["Hello, world!".to_string()]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + assert_eq!(message.role, Role::Assistant); + assert!(message.content.len() == 1); + } + + #[test] + fn test_parse_response_json_events() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + // Test with actual Codex CLI output format + let lines = vec![ + r#"{"type":"thread.started","thread_id":"test-123"}"#.to_string(), + r#"{"type":"turn.started"}"#.to_string(), + r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Thinking..."}}"#.to_string(), + r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello there!"}}"#.to_string(), + r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":30}}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (message, usage) = result.unwrap(); + // Should only contain agent_message text, not reasoning + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("Hello there!")); + assert!(!text.text.contains("Thinking")); + } + assert_eq!(usage.input_tokens, Some(100)); + assert_eq!(usage.output_tokens, Some(50)); + assert_eq!(usage.total_tokens, Some(150)); + } + + #[test] + fn test_parse_response_empty() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines: Vec = vec![]; + let result = provider.parse_response(&lines); + assert!(result.is_err()); + } + + #[test] + fn test_reasoning_level_validation() { + assert!(CODEX_REASONING_LEVELS.contains(&"low")); + assert!(CODEX_REASONING_LEVELS.contains(&"medium")); + assert!(CODEX_REASONING_LEVELS.contains(&"high")); + assert!(!CODEX_REASONING_LEVELS.contains(&"invalid")); + } + + #[test] + fn test_known_models() { + assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.2-codex")); + assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.2")); + assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.1-codex-max")); + assert!(CODEX_KNOWN_MODELS.contains(&"gpt-5.1-codex-mini")); + } + + #[test] + fn test_parse_response_item_completed() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec![ + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hello from codex"}}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("Hello from codex")); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_parse_response_turn_completed_usage() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec![ + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Response"}}"#.to_string(), + r#"{"type":"turn.completed","usage":{"input_tokens":5000,"output_tokens":100,"cached_input_tokens":3000}}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (_message, usage) = result.unwrap(); + assert_eq!(usage.input_tokens, Some(5000)); + assert_eq!(usage.output_tokens, Some(100)); + assert_eq!(usage.total_tokens, Some(5100)); + } + + #[test] + fn test_parse_response_error_event() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec![ + r#"{"type":"thread.started","thread_id":"test"}"#.to_string(), + r#"{"type":"error","message":"Model not supported"}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Model not supported")); + } + + #[test] + fn test_parse_response_skips_reasoning() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec![ + r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Let me think about this..."}}"#.to_string(), + r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"The answer is 42"}}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("The answer is 42")); + assert!(!text.text.contains("Let me think")); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_session_description_generation() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let messages = vec![Message::new( + Role::User, + chrono::Utc::now().timestamp(), + vec![MessageContent::text( + "This is a very long message that should be truncated to four words", + )], + )]; + + let result = provider.generate_simple_session_description(&messages); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + if let MessageContent::Text(text) = &message.content[0] { + // Should be truncated to 4 words + let word_count = text.text.split_whitespace().count(); + assert!(word_count <= 4); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_session_description_empty_messages() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let messages: Vec = vec![]; + + let result = provider.generate_simple_session_description(&messages); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Simple task"); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_config_keys() { + let metadata = CodexProvider::metadata(); + assert_eq!(metadata.config_keys.len(), 4); + + // First key should be CODEX_COMMAND (required) + assert_eq!(metadata.config_keys[0].name, "CODEX_COMMAND"); + assert!(metadata.config_keys[0].required); + assert!(!metadata.config_keys[0].secret); + + // Second key should be CODEX_REASONING_EFFORT (optional) + assert_eq!(metadata.config_keys[1].name, "CODEX_REASONING_EFFORT"); + assert!(!metadata.config_keys[1].required); + + // Third key should be CODEX_ENABLE_SKILLS (optional) + assert_eq!(metadata.config_keys[2].name, "CODEX_ENABLE_SKILLS"); + assert!(!metadata.config_keys[2].required); + + // Fourth key should be CODEX_SKIP_GIT_CHECK (optional) + assert_eq!(metadata.config_keys[3].name, "CODEX_SKIP_GIT_CHECK"); + assert!(!metadata.config_keys[3].required); + } + + #[test] + fn test_messages_to_prompt_filters_non_text() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + // Create messages with both text and non-text content + let messages = vec![Message::new( + Role::User, + chrono::Utc::now().timestamp(), + vec![ + MessageContent::text("Hello"), + // Tool requests would be filtered out as they're not text + ], + )]; + + let prompt = provider.messages_to_prompt("System prompt", &messages); + assert!(prompt.contains("System prompt")); + assert!(prompt.contains("Human: Hello")); + } + + #[test] + fn test_parse_response_multiple_agent_messages() { + let provider = CodexProvider { + command: PathBuf::from("codex"), + model: ModelConfig::new("gpt-5.2-codex").unwrap(), + name: "codex".to_string(), + reasoning_effort: "high".to_string(), + enable_skills: true, + skip_git_check: false, + }; + + let lines = vec![ + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"First part"}}"#.to_string(), + r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Second part"}}"#.to_string(), + ]; + let result = provider.parse_response(&lines); + assert!(result.is_ok()); + + let (message, _usage) = result.unwrap(); + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("First part")); + assert!(text.text.contains("Second part")); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_doc_url() { + assert_eq!(CODEX_DOC_URL, "https://developers.openai.com/codex/cli"); + } + + #[test] + fn test_default_model() { + assert_eq!(CODEX_DEFAULT_MODEL, "gpt-5.2-codex"); + } +} diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 4dab2b93b9b7..b051087c120a 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -6,6 +6,7 @@ use super::{ base::{Provider, ProviderMetadata}, bedrock::BedrockProvider, claude_code::ClaudeCodeProvider, + codex::CodexProvider, cursor_agent::CursorAgentProvider, databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, @@ -47,6 +48,7 @@ async fn init_registry() -> RwLock { registry.register::(|m| Box::pin(BedrockProvider::from_env(m)), false); registry .register::(|m| Box::pin(ClaudeCodeProvider::from_env(m)), true); + registry.register::(|m| Box::pin(CodexProvider::from_env(m)), true); registry.register::( |m| Box::pin(CursorAgentProvider::from_env(m)), false, diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 04b5a491a608..4f83f683c2df 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -7,6 +7,7 @@ pub mod base; pub mod bedrock; pub mod canonical; pub mod claude_code; +pub mod codex; pub mod cursor_agent; pub mod databricks; pub mod embedding; diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 8f106013792e..b9a4b203e6a2 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -49,6 +49,7 @@ goose also supports special "pass-through" providers that work with existing CLI | Provider | Description | Requirements | |-----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [Claude Code](https://www.anthropic.com/claude-code) (`claude-code`) | Uses Anthropic's Claude CLI tool with your Claude Code subscription. Provides access to Claude with 200K context limit. | Claude CLI installed and authenticated, active Claude Code subscription | +| [OpenAI Codex](https://developers.openai.com/codex/cli) (`codex`) | Uses OpenAI's Codex CLI tool with your ChatGPT Plus/Pro subscription. Provides access to GPT-5 models with up to 400K context limit. | Codex CLI installed and authenticated, active ChatGPT Plus/Pro subscription | | [Cursor Agent](https://docs.cursor.com/en/cli/overview) (`cursor-agent`) | Uses Cursor's AI CLI tool with your Cursor subscription. Provides access to GPT-5, Claude 4, and other models through the cursor-agent command-line interface. | cursor-agent CLI installed and authenticated | | [Gemini CLI](https://ai.google.dev/gemini-api/docs) (`gemini-cli`) | Uses Google's Gemini CLI tool with your Google AI subscription. Provides access to Gemini with 1M context limit. | Gemini CLI installed and authenticated | diff --git a/documentation/docs/guides/cli-providers.md b/documentation/docs/guides/cli-providers.md index b4e35cd12689..cf2f9c57de7a 100644 --- a/documentation/docs/guides/cli-providers.md +++ b/documentation/docs/guides/cli-providers.md @@ -2,12 +2,12 @@ sidebar_position: 45 title: CLI Providers sidebar_label: CLI Providers -description: Use Claude Code, Cursor Agent, or Gemini CLI subscriptions in goose +description: Use Claude Code, Codex, Cursor Agent, or Gemini CLI subscriptions in goose --- # CLI Providers -goose can make use of pass-through providers that integrate with existing CLI tools from Anthropic, Cursor, and Google. These providers allow you to use your existing Claude Code, Cursor Agent, and Google Gemini CLI subscriptions through goose's interface, adding session management, persistence, and workflow integration capabilities to these tools. +goose can make use of pass-through providers that integrate with existing CLI tools from Anthropic, OpenAI, Cursor, and Google. These providers allow you to use your existing Claude Code, Codex, Cursor Agent, and Google Gemini CLI subscriptions through goose's interface, adding session management, persistence, and workflow integration capabilities to these tools. :::warning Limitations These providers don’t fully support all goose features, may have platform or capability limitations, and can sometimes require advanced debugging if issues arise. They’re included here purely as a convenience. @@ -17,7 +17,7 @@ These providers don’t fully support all goose features, may have platform or c CLI providers are useful if you: -- already have a Claude Code, Cursor, or Google Gemini CLI subscription and want to use it through goose instead of paying per token +- already have a Claude Code, Codex, Cursor, or Google Gemini CLI subscription and want to use it through goose instead of paying per token - need session persistence to save, resume, and export conversation history - want to use goose recipes and scheduled tasks to create repeatable workflows - prefer unified commands across different AI providers @@ -61,6 +61,23 @@ The Claude Code provider integrates with Anthropic's [Claude CLI tool](https://c - Active Claude Code subscription - CLI tool authenticated with your Anthropic account +### OpenAI Codex + +The Codex provider integrates with OpenAI's [Codex CLI tool](https://developers.openai.com/codex/cli), allowing you to use OpenAI models through your existing ChatGPT Plus/Pro subscription or API credits. + +**Features:** +- Uses OpenAI's GPT-5 series models (gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex-mini) +- Configurable reasoning effort levels (low, medium, high) +- Optional skills support for enhanced capabilities +- JSON output parsing for structured responses +- Automatic filtering of goose extensions from system prompts + +**Requirements:** +- Codex CLI tool installed (`npm i -g @openai/codex` or `brew install --cask codex`) +- Active ChatGPT Plus/Pro subscription or OpenAI API credits +- CLI tool authenticated with your OpenAI account +- By default, Codex requires running from a git repository. Set `CODEX_SKIP_GIT_CHECK=true` to bypass this requirement + ### Cursor Agent The Cursor provider integrates with Cursor's [CLI agent](https://docs.cursor.com/en/cli/installation), providing access to through your existing subscription. @@ -121,7 +138,46 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go ◇ Enter a model from that provider: │ default ``` -## Cursor Agent +### OpenAI Codex + +1. **Install Codex CLI Tool** + + Install the Codex CLI using npm or Homebrew: + ```bash + npm i -g @openai/codex + # or + brew install --cask codex + ``` + +2. **Authenticate with OpenAI** + + Run `codex` and follow the authentication prompts. You can use your ChatGPT account or API key. + +3. **Configure goose** + + Set the provider environment variable: + ```bash + export GOOSE_PROVIDER=codex + ``` + + Or configure through the goose CLI using `goose configure`: + + ```bash + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Configure Providers + │ + ◇ Which model provider should we use? + │ OpenAI Codex CLI + │ + ◇ Model fetch complete + │ + ◇ Enter a model from that provider: + │ gpt-5.2-codex + ``` + +### Cursor Agent 1. **Install Cursor agent Tool** @@ -136,7 +192,7 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go Set the provider environment variable: ```bash - export goose_provider=cursor-agent + export GOOSE_PROVIDER=cursor-agent ``` Or configure through the goose CLI using `goose configure`: @@ -145,7 +201,7 @@ The Gemini CLI provider integrates with Google's [Gemini CLI tool](https://ai.go ┌ goose-configure │ ◇ What would you like to configure? - │ configure providers + │ Configure Providers │ ◇ Which model provider should we use? │ Cursor Agent @@ -221,8 +277,24 @@ goose session | Environment Variable | Description | Default | |---------------------|-------------|---------| | `GOOSE_PROVIDER` | Set to `claude-code` to use this provider | None | +| `GOOSE_MODEL` | Model to use (only `sonnet` or `opus` are passed to CLI) | `claude-sonnet-4-20250514` | | `CLAUDE_CODE_COMMAND` | Path to the Claude CLI command | `claude` | +**Known Models:** + +The following models are recognized and passed to the Claude CLI via the `--model` flag. If `GOOSE_MODEL` is set to a value not in this list, no model flag is passed and Claude Code uses its default: + +- `sonnet` +- `opus` + +**Permission Modes (`GOOSE_MODE`):** + +| Mode | Claude Code Flag | Behavior | +|------|------------------|----------| +| `auto` | `--dangerously-skip-permissions` | Bypasses all permission prompts | +| `smart-approve` | `--permission-mode acceptEdits` | Auto-accepts edits, prompts for other actions | +| `approve` | Not supported | Returns an error | +| `chat` | (none) | Default Claude Code behavior | ### Cursor Agent Configuration @@ -231,6 +303,39 @@ goose session | `GOOSE_PROVIDER` | Set to `cursor-agent` to use this provider | None | | `CURSOR_AGENT_COMMAND` | Path to the Cursor Agent command | `cursor-agent` | +### OpenAI Codex Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `GOOSE_PROVIDER` | Set to `codex` to use this provider | None | +| `GOOSE_MODEL` | Model to use (only known models are passed to CLI) | `gpt-5.2-codex` | +| `CODEX_COMMAND` | Path to the Codex CLI command | `codex` | +| `CODEX_REASONING_EFFORT` | Reasoning effort level: `low`, `medium`, or `high` | `high` | +| `CODEX_ENABLE_SKILLS` | Enable Codex skills: `true` or `false` | `true` | +| `CODEX_SKIP_GIT_CHECK` | Skip git repository requirement: `true` or `false` | `false` | + +**Known Models:** + +The following models are recognized and passed to the Codex CLI via the `-m` flag. If `GOOSE_MODEL` is set to a value not in this list, no model flag is passed and Codex uses its default: + +- `gpt-5.2-codex` (400K context, auto-compacting) +- `gpt-5.2` (400K context, auto-compacting) +- `gpt-5.1-codex-max` (256K context) +- `gpt-5.1-codex-mini` (256K context) + +:::note Legacy Models +These are the default models supported by Codex CLI v0.77.0. To access older or legacy models, you can run `codex -m ` directly or configure them in Codex's `config.toml`. See the [Codex CLI documentation](https://developers.openai.com/codex/cli) for details. +::: + +**Permission Modes (`GOOSE_MODE`):** + +| Mode | Codex Flag | Behavior | +|------|------------|----------| +| `auto` | `--yolo` | Bypasses all approvals and sandbox restrictions | +| `smart-approve` | `--full-auto` | Workspace-write sandbox, approvals only on failure | +| `approve` | (none) | Interactive approvals (Codex default behavior) | +| `chat` | `--sandbox read-only` | Read-only sandbox mode | + ### Gemini CLI Configuration | Environment Variable | Description | Default | @@ -247,12 +352,14 @@ The CLI providers automatically filter out goose's extension information from sy ### Message Translation - **Claude Code**: Converts goose messages to Claude's JSON message format, handling tool calls and responses appropriately +- **Codex**: Converts messages to simple text prompts with role prefixes (Human:/Assistant:), similar to Gemini CLI - **Cursor Agent**: Converts goose messages to Cursor's JSON message format, handling tool calls and responses appropriately - **Gemini CLI**: Converts messages to simple text prompts with role prefixes (Human:/Assistant:) ### Response Processing - **Claude Code**: Parses JSON responses to extract text content and usage information +- **Codex**: Parses newline-delimited JSON events to extract text content and usage information - **Cursor Agent**: Parses JSON responses to extract text content and usage information - **Gemini CLI**: Processes plain text responses from the CLI tool @@ -263,6 +370,7 @@ CLI providers depend on external tools, so ensure: - CLI tools are properly installed and in your PATH - Authentication is maintained and valid - Subscription limits are not exceeded +- For Codex: you're in a git repository, or set `CODEX_SKIP_GIT_CHECK=true` --- diff --git a/documentation/docs/quickstart.md b/documentation/docs/quickstart.md index 751232cb9346..f7436a7c5f1d 100644 --- a/documentation/docs/quickstart.md +++ b/documentation/docs/quickstart.md @@ -160,7 +160,8 @@ goose works with [supported LLM providers](/docs/getting-started/providers) that │ ○ Anthropic │ ○ Azure OpenAI │ ○ Amazon Bedrock - │ ○ Claude Code + │ ○ Claude Code + │ ○ Codex CLI │ ○ Databricks │ ○ Gemini CLI | ● Tetrate Agent Router Service (Enterprise router for AI models)