diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c97..a0309f0fd6 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -6895,8 +6895,12 @@ fn infer_provider_from_model(model: &str) -> Option { "minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral" | "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity" | "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex" - | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" - | "moonshot" | "openrouter" | "volcengine" | "doubao" | "dashscope" => { + | "codex_app_server" | "codex-app-server" | "claude-code" | "copilot" + | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot" | "openrouter" + | "volcengine" | "doubao" | "dashscope" => { + if prefix == "codex-app-server" { + return Some("codex_app_server".to_string()); + } return Some(prefix.to_string()); } // "kimi" is a brand alias for moonshot @@ -9062,7 +9066,14 @@ mod tests { // The local providers the user did NOT configure must NOT show up. // This is what makes the issue #1031 probe noise go away. - for unwanted in &["vllm", "lmstudio", "lemonade", "claude-code", "qwen-code"] { + for unwanted in &[ + "vllm", + "lmstudio", + "lemonade", + "claude-code", + "codex_app_server", + "qwen-code", + ] { assert!( !referenced.contains(*unwanted), "unconfigured local provider {unwanted:?} must NOT be in the referenced set ({referenced:?})" diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 1671794edb..d37b0b575a 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -234,6 +234,21 @@ pub struct BudgetStatus { /// Order matters: more specific patterns must come before generic ones /// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1"). fn estimate_cost_rates(model: &str) -> (f64, f64) { + // Codex app-server uses ChatGPT subscription auth via the Codex CLI. Keep + // usage events visible while leaving per-token cost at zero for flat-rate + // subscription billing. + // + // Match only the canonical provider id forms (with optional model suffix + // separated by '/'). Substring `.contains` would zero-cost a user-named + // model like `my-codex_app_server-eval`. + if model == "codex_app_server" + || model == "codex-app-server" + || model.starts_with("codex_app_server/") + || model.starts_with("codex-app-server/") + { + return (0.0, 0.0); + } + // ── Requesty (issue #995) ────────────────────────────────── // Router-style gateway. IDs are `requesty//` and // resolve via substring match on the upstream model name below diff --git a/crates/openfang-runtime/src/drivers/codex_app_server.rs b/crates/openfang-runtime/src/drivers/codex_app_server.rs new file mode 100644 index 0000000000..feba58060b --- /dev/null +++ b/crates/openfang-runtime/src/drivers/codex_app_server.rs @@ -0,0 +1,844 @@ +//! OpenAI Codex app-server backend driver. +//! +//! Spawns `codex app-server --listen stdio://` and talks to the documented +//! JSON-line app-server protocol. Authentication is owned by the Codex CLI +//! (`codex login`), so OpenFang does not require or forward an OpenAI API key +//! for this provider. Usage is still reported, but catalog and metering rates +//! are zero because ChatGPT subscription billing is flat-rate rather than +//! per-token API billing. + +use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; +use async_trait::async_trait; +use openfang_types::message::{ContentBlock, Message, MessageContent, Role, StopReason, TokenUsage}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout}; +use tracing::{debug, warn}; + +const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; + +const SENSITIVE_ENV_EXACT: &[&str] = &[ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + "TOGETHER_API_KEY", + "FIREWORKS_API_KEY", + "OPENROUTER_API_KEY", + "PERPLEXITY_API_KEY", + "COHERE_API_KEY", + "AI21_API_KEY", + "CEREBRAS_API_KEY", + "SAMBANOVA_API_KEY", + "HUGGINGFACE_API_KEY", + "XAI_API_KEY", + "REPLICATE_API_TOKEN", + "BRAVE_API_KEY", + "TAVILY_API_KEY", + "ELEVENLABS_API_KEY", +]; + +const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; + +/// LLM driver backed by the local Codex app-server process. +pub struct CodexAppServerDriver { + cli_path: String, + state: tokio::sync::Mutex, + message_timeout_secs: u64, +} + +#[derive(Default)] +struct DriverState { + session: Option, + threads: HashMap, +} + +struct ThreadState { + thread_id: String, + model: String, + sent_message_count: usize, +} + +struct AppServerSession { + child: Child, + stdin: ChildStdin, + lines: Lines>, + next_id: u64, +} + +struct TurnOutcome { + text: String, + usage: TokenUsage, +} + +impl CodexAppServerDriver { + /// Create a new Codex app-server driver. + /// + /// `cli_path` overrides the CLI binary path; defaults to `"codex"` on PATH. + pub fn new(cli_path: Option) -> Self { + Self { + cli_path: cli_path + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "codex".to_string()), + state: tokio::sync::Mutex::new(DriverState::default()), + message_timeout_secs: DEFAULT_MESSAGE_TIMEOUT_SECS, + } + } + + /// Create a new Codex app-server driver with a custom turn timeout. + pub fn with_timeout(cli_path: Option, timeout_secs: u64) -> Self { + let mut driver = Self::new(cli_path); + driver.message_timeout_secs = timeout_secs; + driver + } + + /// Detect if the Codex CLI app-server command is available on PATH. + pub fn detect() -> Option { + let output = std::process::Command::new("codex") + .arg("app-server") + .arg("--help") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + } + + fn model_flag(model: &str) -> Option { + let stripped = model + .strip_prefix("codex_app_server/") + .or_else(|| model.strip_prefix("codex-app-server/")) + .unwrap_or(model); + + match stripped { + "" | "default" => None, + other => Some(other.to_string()), + } + } + + fn conversation_key(request: &CompletionRequest) -> String { + request + .messages + .iter() + .find(|m| m.role != Role::System) + .map(|m| m.msg_id.clone()) + .unwrap_or_else(|| "default".to_string()) + } + + fn messages_for_turn<'a>( + request: &'a CompletionRequest, + thread_state: Option<&ThreadState>, + ) -> Vec<&'a Message> { + if let Some(thread) = thread_state { + if request.messages.len() > thread.sent_message_count { + return request.messages[thread.sent_message_count..].iter().collect(); + } + if let Some(last_user) = request.messages.iter().rev().find(|m| m.role == Role::User) { + return vec![last_user]; + } + } + request.messages.iter().collect() + } + + fn build_prompt(messages: &[&Message], system: Option<&str>) -> String { + let mut parts = Vec::new(); + + if let Some(sys) = system { + if !sys.trim().is_empty() { + parts.push(format!("[System]\n{sys}")); + } + } + + for msg in messages { + let role_label = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + Role::System => "System", + }; + let rendered = Self::render_content(&msg.content); + if !rendered.is_empty() { + parts.push(format!("[{role_label}]\n{rendered}")); + } + } + + parts.join("\n\n") + } + + fn render_content(content: &MessageContent) -> String { + match content { + MessageContent::Text(s) => s.clone(), + MessageContent::Blocks(blocks) => blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text, .. } => { + if text.is_empty() { + None + } else { + Some(text.clone()) + } + } + ContentBlock::Image { media_type, data } => { + let approx_kb = (data.len().saturating_mul(3) / 4) / 1024; + Some(format!( + "[attachment: {media_type} image, ~{approx_kb} KB - not viewable on this provider]" + )) + } + ContentBlock::ToolResult { + tool_name, + content, + is_error, + .. + } => Some(format!( + "[tool result: {tool_name}{}]\n{content}", + if *is_error { " error" } else { "" } + )), + ContentBlock::ToolUse { name, input, .. } => { + Some(format!("[tool use: {name}]\n{input}")) + } + ContentBlock::Thinking { .. } + | ContentBlock::RedactedThinking { .. } + | ContentBlock::Unknown => None, + }) + .collect::>() + .join("\n"), + } + } + + async fn ensure_session<'a>( + &'a self, + state: &'a mut DriverState, + ) -> Result<&'a mut AppServerSession, LlmError> { + let needs_spawn = match state.session.as_mut() { + Some(session) => match session.child.try_wait() { + Ok(Some(status)) => { + warn!(%status, "Codex app-server exited; respawning on next request"); + true + } + Ok(None) => false, + Err(e) => { + warn!(error = %e, "Failed to inspect Codex app-server child; respawning"); + true + } + }, + None => true, + }; + + if needs_spawn { + state.session = None; + let mut session = self.spawn_session().await?; + self.initialize(&mut session).await?; + state.session = Some(session); + } + + state.session.as_mut().ok_or_else(|| { + LlmError::Http("Codex app-server session was not available after spawn".to_string()) + }) + } + + async fn spawn_session(&self) -> Result { + let mut cmd = tokio::process::Command::new(&self.cli_path); + cmd.arg("app-server").arg("--listen").arg("stdio://"); + Self::apply_env_filter(&mut cmd); + if let Some(home) = home_dir() { + cmd.env("HOME", home); + } + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + debug!(cli = %self.cli_path, "Spawning Codex app-server"); + let mut child = cmd.spawn().map_err(|e| { + LlmError::Http(format!( + "Codex CLI app-server not found or failed to start ({e}). \ + Install Codex CLI, verify `codex --version`, then run `codex login`." + )) + })?; + + if let Some(mut stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut reader = BufReader::new(&mut stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + debug!(stderr = %trimmed, "Codex app-server stderr"); + } + } + Err(_) => break, + } + } + }); + } + + let stdin = child + .stdin + .take() + .ok_or_else(|| LlmError::Http("No stdin for Codex app-server".to_string()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| LlmError::Http("No stdout from Codex app-server".to_string()))?; + + Ok(AppServerSession { + child, + stdin, + lines: BufReader::new(stdout).lines(), + next_id: 1, + }) + } + + async fn initialize(&self, session: &mut AppServerSession) -> Result<(), LlmError> { + let params = json!({ + "clientInfo": { + "name": "openfang", + "title": "OpenFang", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "experimentalApi": true, + "requestAttestation": false, + "optOutNotificationMethods": [] + } + }); + self.request(session, "initialize", params).await.map(|_| ()) + } + + async fn start_thread( + &self, + session: &mut AppServerSession, + request: &CompletionRequest, + ) -> Result { + let cwd = std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())); + let model = Self::model_flag(&request.model); + let mut params = json!({ + "model": model, + "modelProvider": Value::Null, + "cwd": cwd, + "approvalPolicy": "never", + "sandbox": "read-only", + "serviceName": "openfang", + "baseInstructions": request.system, + "ephemeral": true, + "experimentalRawEvents": false, + "persistExtendedHistory": false + }); + + if request.system.is_none() { + params["baseInstructions"] = Value::Null; + } + + let response = self.request(session, "thread/start", params).await?; + response + .pointer("/thread/id") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| { + LlmError::Parse(format!( + "Codex app-server thread/start response missing thread id: {response}" + )) + }) + } + + async fn start_turn( + &self, + session: &mut AppServerSession, + thread_id: &str, + request: &CompletionRequest, + prompt: String, + ) -> Result { + let params = json!({ + "threadId": thread_id, + "input": [{ + "type": "text", + "text": prompt, + "text_elements": [] + }], + "model": Self::model_flag(&request.model), + "cwd": std::env::current_dir().ok().and_then(|p| p.to_str().map(|s| s.to_string())), + "approvalPolicy": "never" + }); + let response = self.request(session, "turn/start", params).await?; + response + .pointer("/turn/id") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| { + LlmError::Parse(format!( + "Codex app-server turn/start response missing turn id: {response}" + )) + }) + } + + async fn run_turn( + &self, + request: CompletionRequest, + tx: Option>, + ) -> Result { + // Driver declares supports_tools: false in the catalog. If a caller + // still passes tools, surface that loudly rather than silently + // dropping them and producing text-only output the caller didn't ask + // for. + if !request.tools.is_empty() { + tracing::warn!( + tools_dropped = request.tools.len(), + model = %request.model, + "codex_app_server driver does not support tools; ignoring \ + {n} tool(s) in this request", + n = request.tools.len() + ); + } + let timeout = std::time::Duration::from_secs(self.message_timeout_secs); + let result = tokio::time::timeout(timeout, self.run_turn_inner(request, tx)).await; + match result { + Ok(result) => result, + Err(_) => Err(LlmError::Http(format!( + "Codex app-server turn timed out after {}s", + self.message_timeout_secs + ))), + } + } + + async fn run_turn_inner( + &self, + request: CompletionRequest, + tx: Option>, + ) -> Result { + let key = Self::conversation_key(&request); + let model_key = request.model.clone(); + let mut state = self.state.lock().await; + + let existing_thread = state + .threads + .get(&key) + .filter(|thread| thread.model == model_key); + let messages = Self::messages_for_turn(&request, existing_thread); + let prompt = Self::build_prompt(&messages, request.system.as_deref()); + + let thread_id = match existing_thread { + Some(thread) => thread.thread_id.clone(), + None => { + let session = self.ensure_session(&mut state).await?; + let thread_id = self.start_thread(session, &request).await?; + state.threads.insert( + key.clone(), + ThreadState { + thread_id: thread_id.clone(), + model: model_key.clone(), + sent_message_count: 0, + }, + ); + thread_id + } + }; + + let outcome_result = { + let session = self.ensure_session(&mut state).await?; + let turn_id = self + .start_turn(session, &thread_id, &request, prompt) + .await?; + self.read_turn(session, &thread_id, &turn_id, tx.clone()) + .await + }; + if outcome_result.is_err() { + state.session = None; + } + let outcome = outcome_result?; + + if let Some(thread) = state.threads.get_mut(&key) { + thread.sent_message_count = request.messages.len().saturating_add(1); + } + + if let Some(tx) = tx { + let _ = tx + .send(StreamEvent::ContentComplete { + stop_reason: StopReason::EndTurn, + usage: outcome.usage, + }) + .await; + } + + Ok(CompletionResponse { + content: vec![ContentBlock::Text { + text: outcome.text, + provider_metadata: None, + }], + stop_reason: StopReason::EndTurn, + tool_calls: Vec::new(), + usage: outcome.usage, + }) + } + + async fn request( + &self, + session: &mut AppServerSession, + method: &str, + params: Value, + ) -> Result { + let id = session.next_id; + session.next_id += 1; + let frame = json!({ + "id": id, + "method": method, + "params": params + }); + Self::write_frame(session, &frame).await?; + + loop { + let msg = Self::read_frame(session).await?; + if let Some(request_id) = msg.get("id").and_then(Value::as_u64) { + if request_id == id { + if let Some(error) = msg.get("error") { + return Err(Self::jsonrpc_error(error)); + } + return Ok(msg.get("result").cloned().unwrap_or(Value::Null)); + } + if msg.get("method").and_then(Value::as_str).is_some() { + Self::reject_server_request(session, &msg).await?; + } + } + } + } + + async fn read_turn( + &self, + session: &mut AppServerSession, + thread_id: &str, + turn_id: &str, + tx: Option>, + ) -> Result { + let mut text = String::new(); + let mut usage = TokenUsage::default(); + + loop { + let msg = Self::read_frame(session).await?; + if msg.get("id").is_some() && msg.get("method").is_some() { + Self::reject_server_request(session, &msg).await?; + continue; + } + + let method = msg.get("method").and_then(Value::as_str).unwrap_or_default(); + let params = msg.get("params").unwrap_or(&Value::Null); + match method { + "item/agentMessage/delta" + if params.get("threadId").and_then(Value::as_str) == Some(thread_id) + && params.get("turnId").and_then(Value::as_str) == Some(turn_id) => + { + if let Some(delta) = params.get("delta").and_then(Value::as_str) { + text.push_str(delta); + if let Some(ref tx) = tx { + let _ = tx + .send(StreamEvent::TextDelta { + text: delta.to_string(), + }) + .await; + } + } + } + "thread/tokenUsage/updated" + if params.get("threadId").and_then(Value::as_str) == Some(thread_id) + && params.get("turnId").and_then(Value::as_str) == Some(turn_id) => + { + usage = Self::parse_usage(params); + } + "turn/completed" + if params.get("threadId").and_then(Value::as_str) == Some(thread_id) => + { + if text.is_empty() { + text = Self::extract_turn_text(params); + } + return Ok(TurnOutcome { text, usage }); + } + "error" + if params.get("threadId").and_then(Value::as_str) == Some(thread_id) + && params.get("turnId").and_then(Value::as_str) == Some(turn_id) => + { + return Err(Self::turn_error(params)); + } + "account/rateLimits/updated" if Self::rate_limit_reached(params) => { + return Err(LlmError::RateLimited { + retry_after_ms: 60_000, + }); + } + _ => {} + } + } + } + + async fn write_frame(session: &mut AppServerSession, frame: &Value) -> Result<(), LlmError> { + let encoded = + serde_json::to_string(frame).map_err(|e| LlmError::Parse(e.to_string()))?; + session + .stdin + .write_all(encoded.as_bytes()) + .await + .map_err(|e| LlmError::Http(format!("Failed to write to Codex app-server: {e}")))?; + session + .stdin + .write_all(b"\n") + .await + .map_err(|e| LlmError::Http(format!("Failed to write to Codex app-server: {e}")))?; + session + .stdin + .flush() + .await + .map_err(|e| LlmError::Http(format!("Failed to flush Codex app-server stdin: {e}"))) + } + + async fn read_frame(session: &mut AppServerSession) -> Result { + loop { + match session.lines.next_line().await { + Ok(Some(line)) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + return serde_json::from_str(trimmed).map_err(|e| { + LlmError::Parse(format!( + "Failed to parse Codex app-server JSON frame: {e}: {trimmed}" + )) + }); + } + Ok(None) => { + return Err(LlmError::Http( + "Codex app-server closed stdout unexpectedly".to_string(), + )); + } + Err(e) => { + return Err(LlmError::Http(format!( + "Failed to read from Codex app-server: {e}" + ))); + } + } + } + } + + async fn reject_server_request( + session: &mut AppServerSession, + msg: &Value, + ) -> Result<(), LlmError> { + let Some(id) = msg.get("id").cloned() else { + return Ok(()); + }; + let method = msg + .get("method") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let response = match method { + "item/commandExecution/requestApproval" => { + json!({"id": id, "result": {"decision": "decline"}}) + } + "item/fileChange/requestApproval" => { + json!({"id": id, "result": {"decision": "decline"}}) + } + "applyPatchApproval" | "execCommandApproval" => { + json!({"id": id, "result": {"decision": "denied"}}) + } + "item/tool/requestUserInput" => json!({"id": id, "result": {"answers": {}}}), + "item/tool/call" => json!({ + "id": id, + "result": { + "contentItems": [], + "success": false + } + }), + _ => json!({ + "id": id, + "error": { + "code": -32601, + "message": format!("OpenFang Codex app-server driver does not support server request `{method}`") + } + }), + }; + Self::write_frame(session, &response).await + } + + fn parse_usage(params: &Value) -> TokenUsage { + let last = params.pointer("/tokenUsage/last"); + let total = params.pointer("/tokenUsage/total"); + let source = last.or(total).unwrap_or(&Value::Null); + TokenUsage { + input_tokens: source + .get("inputTokens") + .and_then(Value::as_u64) + .unwrap_or(0), + output_tokens: source + .get("outputTokens") + .and_then(Value::as_u64) + .unwrap_or(0), + } + } + + fn extract_turn_text(params: &Value) -> String { + params + .pointer("/turn/items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("agentMessage")) + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default() + } + + fn jsonrpc_error(error: &Value) -> LlmError { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Codex app-server request failed") + .to_string(); + Self::map_error(&message, error.get("code").and_then(Value::as_i64)) + } + + fn turn_error(params: &Value) -> LlmError { + let error = params.get("error").unwrap_or(params); + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Codex app-server turn failed") + .to_string(); + if error.pointer("/codexErrorInfo").and_then(Value::as_str) == Some("unauthorized") { + return LlmError::AuthenticationFailed(format!( + "Codex CLI is not authenticated. Run `codex login`. Detail: {message}" + )); + } + if error.pointer("/codexErrorInfo").and_then(Value::as_str) == Some("usageLimitExceeded") { + return LlmError::RateLimited { + retry_after_ms: 60_000, + }; + } + Self::map_error(&message, None) + } + + fn map_error(message: &str, code: Option) -> LlmError { + let lower = message.to_lowercase(); + if lower.contains("unauthorized") + || lower.contains("not authenticated") + || lower.contains("login") + || lower.contains("auth") + { + return LlmError::AuthenticationFailed(format!( + "Codex CLI is not authenticated. Run `codex login`. Detail: {message}" + )); + } + if lower.contains("rate limit") || lower.contains("usage limit") { + return LlmError::RateLimited { + retry_after_ms: 60_000, + }; + } + LlmError::Api { + status: code.unwrap_or(0).max(0) as u16, + message: message.to_string(), + } + } + + fn rate_limit_reached(params: &Value) -> bool { + params + .pointer("/rateLimits/rateLimitReachedType") + .and_then(Value::as_str) + .is_some() + } + + fn apply_env_filter(cmd: &mut tokio::process::Command) { + for key in SENSITIVE_ENV_EXACT { + cmd.env_remove(key); + } + for (key, _) in std::env::vars() { + if key.starts_with("CODEX_") { + continue; + } + let upper = key.to_uppercase(); + for suffix in SENSITIVE_SUFFIXES { + if upper.ends_with(suffix) { + cmd.env_remove(&key); + break; + } + } + } + } +} + +#[async_trait] +impl LlmDriver for CodexAppServerDriver { + async fn complete(&self, request: CompletionRequest) -> Result { + self.run_turn(request, None).await + } + + async fn stream( + &self, + request: CompletionRequest, + tx: tokio::sync::mpsc::Sender, + ) -> Result { + self.run_turn(request, Some(tx)).await + } +} + +/// Detect if Codex app-server is available. +pub fn codex_app_server_available() -> bool { + CodexAppServerDriver::detect().is_some() +} + +fn home_dir() -> Option { + std::env::var("HOME") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_flag_strips_provider_prefix() { + assert_eq!( + CodexAppServerDriver::model_flag("codex_app_server/gpt-5.4"), + Some("gpt-5.4".to_string()) + ); + assert_eq!( + CodexAppServerDriver::model_flag("codex-app-server/gpt-5.4"), + Some("gpt-5.4".to_string()) + ); + assert_eq!(CodexAppServerDriver::model_flag("default"), None); + } + + #[test] + fn test_build_prompt_renders_roles() { + let user = Message::user("hello"); + let assistant = Message::assistant("hi"); + let prompt = CodexAppServerDriver::build_prompt(&[&user, &assistant], Some("sys")); + assert!(prompt.contains("[System]\nsys")); + assert!(prompt.contains("[User]\nhello")); + assert!(prompt.contains("[Assistant]\nhi")); + } + + #[test] + fn test_parse_usage_prefers_last_turn() { + let params = json!({ + "tokenUsage": { + "total": {"inputTokens": 100, "outputTokens": 50}, + "last": {"inputTokens": 10, "outputTokens": 5} + } + }); + let usage = CodexAppServerDriver::parse_usage(¶ms); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 5); + } +} diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index ca1e0701c7..6200fd1b43 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -7,6 +7,7 @@ pub mod anthropic; pub mod bedrock; pub mod claude_code; +pub mod codex_app_server; pub mod copilot; pub mod fallback; pub mod gemini; @@ -17,13 +18,14 @@ pub mod vertex; use crate::llm_driver::{DriverConfig, LlmDriver, LlmError}; use openfang_types::model_catalog::{ AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, - COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, - HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, - MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL, - OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, - QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, - VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, - ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, + CODEX_APP_SERVER_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, + GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, + LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, + NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, + PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, + SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, + VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, + ZHIPU_CODING_BASE_URL, }; use std::sync::Arc; @@ -216,6 +218,11 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "", key_required: false, }), + "codex_app_server" | "codex-app-server" => Some(ProviderDefaults { + base_url: CODEX_APP_SERVER_BASE_URL, + api_key_env: "", + key_required: false, + }), "moonshot" | "kimi" | "kimi2" => Some(ProviderDefaults { base_url: MOONSHOT_BASE_URL, api_key_env: "MOONSHOT_API_KEY", @@ -412,6 +419,19 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr })); } + // Codex app-server — subprocess-based, uses ChatGPT/Codex CLI auth. + if provider == "codex_app_server" || provider == "codex-app-server" { + let cli_path = config.base_url.clone(); + let timeout = std::env::var("OPENFANG_SUBPROCESS_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .or(config.subprocess_timeout_secs); + return Ok(Arc::new(match timeout { + Some(secs) => codex_app_server::CodexAppServerDriver::with_timeout(cli_path, secs), + None => codex_app_server::CodexAppServerDriver::new(cli_path), + })); + } + // Qwen Code CLI — subprocess-based, uses Qwen OAuth (free tier) if provider == "qwen-code" { let cli_path = config.base_url.clone(); @@ -589,7 +609,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr "Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, bedrock, groq, \ openrouter, deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, \ perplexity, cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, \ - github-copilot, chutes, venice, nvidia, codex, claude-code. \ + github-copilot, chutes, venice, nvidia, codex, claude-code, codex_app_server. \ Or set base_url for a custom OpenAI-compatible endpoint.", provider ), @@ -692,6 +712,7 @@ pub fn known_providers() -> &'static [&'static str] { "novita", "codex", "claude-code", + "codex_app_server", "qwen-code", "azure", ] @@ -843,9 +864,10 @@ mod tests { assert!(providers.contains(&"novita")); assert!(providers.contains(&"codex")); assert!(providers.contains(&"claude-code")); + assert!(providers.contains(&"codex_app_server")); assert!(providers.contains(&"qwen-code")); assert!(providers.contains(&"azure")); - assert_eq!(providers.len(), 38); + assert_eq!(providers.len(), 39); } #[test] @@ -1140,6 +1162,19 @@ mod tests { assert!(driver.is_ok(), "claude-code driver should construct"); } + #[test] + fn test_codex_app_server_driver_constructs() { + let config = DriverConfig { + provider: "codex_app_server".to_string(), + api_key: None, + base_url: None, + skip_permissions: true, + subprocess_timeout_secs: None, + }; + let driver = create_driver(&config); + assert!(driver.is_ok(), "codex_app_server driver should construct"); + } + #[test] fn test_claude_code_driver_constructs_with_config_timeout() { // Timeout set via config field → with_timeout path is exercised. diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index 500192c839..bc23149894 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -203,7 +203,8 @@ pub struct DriverConfig { /// `OPENFANG_SUBPROCESS_TIMEOUT_SECS` env var, which wins over both /// this field and the driver default. /// - /// **Scope:** Currently only honored by `provider = "claude-code"`. + /// **Scope:** Currently honored by subprocess drivers such as + /// `provider = "claude-code"` and `provider = "codex_app_server"`. /// Other providers (`default`, `qwen-code`, `openai`, `bedrock`, etc.) /// accept the field for forward-compatibility but silently ignore it /// today. As additional subprocess-based drivers are added, they will diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 4bf6fc7ec8..314fec6021 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -6,13 +6,14 @@ use openfang_types::model_catalog::{ AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL, - DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, - HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, - MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, - OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, - VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, - ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, + CODEX_APP_SERVER_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, + GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, + LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, + NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, + PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, + SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, + VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, + ZHIPU_CODING_BASE_URL, }; use std::collections::HashMap; @@ -74,6 +75,15 @@ impl ModelCatalog { }; continue; } + if provider.id == "codex_app_server" { + provider.auth_status = + if crate::drivers::codex_app_server::codex_app_server_available() { + AuthStatus::Configured + } else { + AuthStatus::Missing + }; + continue; + } // GitHub Copilot: check for persisted OAuth tokens if provider.id == "github-copilot" || provider.id == "copilot" { @@ -941,6 +951,16 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, + // ── Codex app-server ───────────────────────────────────────── + ProviderInfo { + id: "codex_app_server".into(), + display_name: "Codex App Server".into(), + api_key_env: String::new(), + base_url: CODEX_APP_SERVER_BASE_URL.into(), + key_required: false, + auth_status: AuthStatus::NotRequired, + model_count: 0, + }, // ── Claude Code CLI ───────────────────────────────────────── ProviderInfo { id: "claude-code".into(), @@ -1028,6 +1048,10 @@ fn builtin_aliases() -> HashMap { ("codex-5.4", "codex/gpt-5.4"), ("codex-4.1", "codex/gpt-4.1"), ("codex-o4", "codex/o4-mini"), + // Codex app-server aliases + ("codex-app-server", "codex_app_server/gpt-5.4"), + ("codex_app_server", "codex_app_server/gpt-5.4"), + ("codex-app-server-5.4", "codex_app_server/gpt-5.4"), // NVIDIA NIM aliases ("nemotron", "nvidia/llama-3.1-nemotron-70b-instruct"), // Venice aliases @@ -3860,6 +3884,27 @@ fn builtin_models() -> Vec { aliases: vec!["codex-o4".into()], }, // ══════════════════════════════════════════════════════════════ + // Codex app-server (1) — subprocess-based, ChatGPT subscription auth + // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "codex_app_server/gpt-5.4".into(), + display_name: "GPT-5.4 (Codex App Server)".into(), + provider: "codex_app_server".into(), + tier: ModelTier::Frontier, + context_window: 1_047_576, + max_output_tokens: 32_768, + input_cost_per_m: 0.0, + output_cost_per_m: 0.0, + supports_tools: false, + supports_vision: false, + supports_streaming: true, + aliases: vec![ + "codex_app_server".into(), + "codex-app-server".into(), + "codex-app-server-5.4".into(), + ], + }, + // ══════════════════════════════════════════════════════════════ // Claude Code CLI (3) — subprocess-based // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { @@ -4083,7 +4128,7 @@ mod tests { #[test] fn test_catalog_has_providers() { let catalog = ModelCatalog::new(); - assert_eq!(catalog.list_providers().len(), 42); + assert_eq!(catalog.list_providers().len(), 43); } #[test] @@ -4448,6 +4493,32 @@ mod tests { assert!(!cc.key_required); } + #[test] + fn test_codex_app_server_provider() { + let catalog = ModelCatalog::new(); + let provider = catalog.get_provider("codex_app_server").unwrap(); + assert_eq!(provider.display_name, "Codex App Server"); + assert!(!provider.key_required); + assert!(provider.api_key_env.is_empty()); + } + + #[test] + fn test_codex_app_server_models() { + let catalog = ModelCatalog::new(); + let models = catalog.models_by_provider("codex_app_server"); + assert_eq!(models.len(), 1); + assert!(models + .iter() + .any(|m| m.id == "codex_app_server/gpt-5.4")); + } + + #[test] + fn test_codex_app_server_aliases() { + let catalog = ModelCatalog::new(); + let entry = catalog.find_model("codex-app-server").unwrap(); + assert_eq!(entry.id, "codex_app_server/gpt-5.4"); + } + #[test] fn test_claude_code_models() { let catalog = ModelCatalog::new(); diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index 1eb5985ee4..9bb8ca9618 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -40,6 +40,10 @@ pub const NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1"; // ── GitHub Copilot ────────────────────────────────────────────── pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; +// ── Codex app-server ─────────────────────────────────────────── +pub const CODEX_APP_SERVER_PROVIDER_ID: &str = "codex_app_server"; +pub const CODEX_APP_SERVER_BASE_URL: &str = ""; + // ── Chinese providers ───────────────────────────────────────────── pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; /// Global endpoint. For China mainland, override via `[provider_urls] minimax = "https://api.minimaxi.com/v1"`.