diff --git a/MACOS_RESTART_HOTFIX.md b/MACOS_RESTART_HOTFIX.md new file mode 100644 index 0000000..1c3eeb5 --- /dev/null +++ b/MACOS_RESTART_HOTFIX.md @@ -0,0 +1,85 @@ +# Codex++ macOS 重启修复记录 + +记录时间:2026-05-24 + +## 背景 + +问题表现为:点击“重启 Codex”或“启动 Codex++”时,旧 Codex 能被关闭,但新 Codex 有时拉不起来;连续重启时更容易复现。纯 API 模式下插件入口不完整注入,也和这条启动链路有关。 + +## 根因 + +macOS 上旧的 Codex 相关进程退出不完整时,可能继续占用这些端口: + +```text +9229 调试端口 +57321 Codex++ helper / protocol proxy 端口 +8732 launcher guard 端口 +``` + +最典型的一次复现里,`SkyComputerUseService` 占用了 `127.0.0.1:9229`。Codex++ 随后虽然带着 `--remote-debugging-port=9229` 启动 Codex,但 CDP `/json` 连接不到目标 Codex,导致插件注入失败。 + +这个问题不能靠把某一个 sidecar 加进关闭名单解决,因为以后可能是其他 Codex 工具或 sidecar 占端口。正确修法是按端口查占用者,再只关闭命令行属于 Codex、Codex++ 或 `~/.codex` 的进程。 + +## 修补策略 + +1. 启动或重启前先解析目标 Codex app 路径。 +2. 关闭 Codex++ launcher 旧进程。 +3. 关闭目标 Codex app 旧进程。 +4. 检查调试端口、helper 端口和 launcher guard 端口。 +5. 只关闭这些端口上命令行匹配 Codex/Codex++/`~/.codex` 的进程。 +6. 等待端口释放后再启动新 Codex。 +7. macOS `open` 命令加入 `-n`,确保新参数不会被复用到旧实例时吞掉。 + +## 修改位置 + +```text +apps/codex-plus-manager/src-tauri/src/commands.rs +crates/codex-plus-core/src/watcher.rs +crates/codex-plus-core/src/launcher.rs +crates/codex-plus-core/tests/watcher.rs +crates/codex-plus-core/tests/launcher.rs +``` + +## 关键函数 + +启动前清理入口: + +```rust +prepare_launch_environment +wait_for_launch_ports_to_clear +``` + +端口占用者过滤: + +```rust +stop_codex_related_processes_listening_on_ports +filter_macos_codex_related_port_owner_processes +``` + +macOS 新实例启动: + +```rust +build_macos_open_command +``` + +## 验证命令 + +```bash +cargo test -p codex-plus-core --test launcher --test watcher +``` + +本次验证重点: + +```text +launcher: macOS open 命令包含 -n +watcher: 只关闭 Codex 相关端口占用者,不误杀无关进程 +``` + +## 下次复查 + +如果以后重启问题再次出现,先查这几件事: + +1. `build_macos_open_command` 是否仍包含 `-n`。 +2. `prepare_launch_environment` 是否仍会调用 `stop_codex_related_processes_listening_on_ports`。 +3. `wait_for_launch_ports_to_clear` 是否仍检查 9229、57321 和 launcher guard 端口。 +4. 端口占用者过滤是否仍基于 Codex app 路径、Codex++ bundle 路径和 `~/.codex`,而不是硬编码某个 sidecar 名称。 diff --git a/RELAY_IMAGE_GENERATION_HOTFIX.md b/RELAY_IMAGE_GENERATION_HOTFIX.md new file mode 100644 index 0000000..2ce3e6c --- /dev/null +++ b/RELAY_IMAGE_GENERATION_HOTFIX.md @@ -0,0 +1,357 @@ +# Codex++ 混合 API 生图特性热修记录 + +记录时间:2026-05-24 + +## 背景 + +本次问题发生在 Codex++ 的“官方登录 + 混入 API Key”模式。 + +这个模式会保留 ChatGPT 官方登录态,同时把模型请求转到用户配置的中转 API。部分中转 API 不支持 OpenAI Responses API 的 hosted image generation 能力。当 Codex 在 Responses 请求里带上生图工具时,中转会返回类似错误: + +```text +unexpected status 403 Forbidden: Image generation is not enabled for this group +``` + +只要看到这个错误,就说明上游实际收到了 `image_generation` 工具,而不是单纯配置文件显示问题。 + +纯 API 模式不是这条链路,所以本次修补不能把纯 API Responses 强行改成本地代理,也不能给纯 API 额外写入生图禁用字段。 + +## 第一次修补 + +第一次修补只处理了配置层: + +```toml +[features] +image_generation = false +``` + +修改点在: + +```text +crates/codex-plus-core/src/relay_config.rs +crates/codex-plus-core/tests/relay_config.rs +``` + +当 Codex++ 写入混合中转配置时,会自动给 `[features]` 加上 `image_generation = false`。如果用户原本写过 `image_generation = true`,切到混合中转时会保存注释并临时改成 `false`;清除中转或切回纯 API 时会恢复。 + +这个修补仍然保留,因为它是合理的配置表达,也能兼容官方未来修好后的行为。 + +## 二次复现 + +用户再次复现后,纯 API 模式的插件解锁已经连续两次成功,但混合模式仍然报: + +```text +Image generation is not enabled for this group +``` + +这说明 `image_generation = false` 没有完全阻止 Codex app-server 把 hosted image generation tool 放进 Responses 请求。也就是说,仅靠配置字段不够。 + +本地进一步看到 Codex 资源里存在 `imageGeneration` 类型相关痕迹,也看到配置解析里有 `[features]` legacy toggle 提示。因此这次不能继续堆配置项,必须在出站请求层兜住。 + +## 最终根因 + +最终根因分成两层: + +1. 混合 Responses 模式下,`~/.codex/config.toml` 过去直接把 `base_url` 指向真实中转。 +2. Codex 仍可能在发往该 `base_url` 的 Responses 请求体里带上: + +```json +{ + "tools": [ + { "type": "image_generation" } + ] +} +``` + +中转不支持该能力,于是返回 403。 + +因此最终修补点不是“再找一个配置字段”,而是让混合 Responses 请求先走 Codex++ 本地 helper,再由 helper 转发到真实中转;转发前删除 hosted image generation 相关字段。 + +## 最终修补策略 + +本次属于针对混合模式的根因治理,范围保持很窄: + +1. 混合模式,包括“官方登录 + 混入 API Key”的 Responses 协议,统一把 Codex 配置里的 `base_url` 写成本地代理: + +```toml +base_url = "http://127.0.0.1:57321/v1" +``` + +2. 本地 helper 读取 Codex++ 设置里的真实中转地址和 Key,再把请求转发到真实中转: + +```text +https://relay.example.test/v1/responses +``` + +3. 转发前清理请求体里的 hosted image generation: + +```json +{ + "type": "image_generation" +} +``` + +以及兼容 camelCase: + +```json +{ + "type": "imageGeneration" +} +``` + +4. 如果 `tool_choice` 强制选择生图工具,则删除 `tool_choice`。 +5. 如果 `tool_choice` 使用 `allowed_tools` 包住工具列表,则只删除其中的生图工具,保留普通 function tool。 +6. 如果 `include` 里请求 `image_generation_call.*`,也删除对应 include 项。 +7. 纯 API Responses 继续直连真实中转,不进入这个本地 Responses 透传代理。 +8. Chat Completions 转 Responses 的旧本地代理路径继续保留。 + +## 修改位置 + +核心修改: + +```text +crates/codex-plus-core/src/protocol_proxy.rs +crates/codex-plus-core/src/relay_config.rs +crates/codex-plus-core/src/launcher.rs +crates/codex-plus-core/src/settings.rs +apps/codex-plus-manager/src/App.tsx +``` + +相关测试: + +```text +crates/codex-plus-core/tests/protocol_proxy.rs +crates/codex-plus-core/tests/relay_config.rs +crates/codex-plus-core/tests/launcher.rs +``` + +前一次同时修过连续重启时插件解锁失败的问题,相关文件仍在本次改动里: + +```text +apps/codex-plus-manager/src-tauri/src/commands.rs +crates/codex-plus-core/src/watcher.rs +crates/codex-plus-core/tests/watcher.rs +``` + +## 关键代码行为 + +### 1. 混合 Responses 写入本地代理 URL + +`apply_relay_config_to_home_with_protocol` 现在走混合模式专用 URL 选择: + +```rust +let codex_base_url = codex_base_url_for_mixed_relay_protocol(base_url, protocol, proxy_port); +``` + +混合模式下,无论是 Responses 还是 Chat Completions,Codex 看到的都是: + +```text +http://127.0.0.1:57321/v1 +``` + +纯 API 仍然走 `codex_base_url_for_pure_api_protocol`,Responses 保持真实中转 URL。 + +### 2. 完整 config.toml 也会被修正 + +如果供应商保存了完整 `config.toml` / `auth.json`,`apply_relay_files_to_home` 也会在写入前修正 CodexPlusPlus provider 的 `base_url`: + +```rust +relay_config_with_local_responses_proxy_guard(...) +``` + +这样旧设置里已经保存的原始中转 URL 不会继续绕过本地代理。 + +### 3. helper 会为混合 Responses 启动 + +`launcher.rs` 中的代理启动判断从“只有 Chat Completions 才启动”改成: + +```rust +relay.protocol == RelayProtocol::ChatCompletions + || (relay.protocol == RelayProtocol::Responses && relay.uses_official_api_key_mix()) +``` + +这样混合 Responses 即使页面增强处于兼容模式,也会启动本地 helper。 + +### 4. Responses 透传代理会清洗请求体 + +`protocol_proxy.rs` 新增: + +```rust +sanitize_responses_request_for_relay +``` + +它会删除: + +```json +{ "type": "image_generation" } +{ "type": "imageGeneration" } +``` + +同时删除指向生图工具的 `tool_choice`,清理 `allowed_tools` 中的生图工具,以及删除 `include` 中的 `image_generation_call.*`。 + +清洗后,如果请求原本还有普通 function tool,会保留普通工具,只删除生图工具。 + +### 5. Responses 和 Chat 两种代理分开处理返回体 + +本地代理现在区分: + +```rust +UpstreamResponseBodyMode::ResponsesPassthrough +UpstreamResponseBodyMode::ChatCompletionsToResponses +``` + +Responses 上游返回的内容直接透传给 Codex;Chat Completions 上游仍然按原逻辑转换回 Responses。 + +## 验证命令 + +Rust 核心测试: + +```bash +cargo test -p codex-plus-core --test protocol_proxy --test relay_config --test launcher +cargo test -p codex-plus-core --test watcher +``` + +本次结果: + +```text +launcher: 39 passed +protocol_proxy: 17 passed +relay_config: 21 passed +watcher: 8 passed +``` + +前端类型检查: + +```bash +cd apps/codex-plus-manager +npm run check +``` + +结果:通过。 + +前端构建: + +```bash +npm run vite:build +``` + +结果:通过。 + +release 构建: + +```bash +cargo build -p codex-plus-launcher -p codex-plus-manager --release +``` + +结果:通过。 + +测试期间仍有仓库既有 warning,例如 Windows uninstall key 和 proxy 辅助函数未使用;这些 warning 与本次修补无关。 + +## 本机 APP 更新过程 + +构建后生成的二进制: + +```text +target/release/codex-plus-plus +target/release/codex-plus-plus-manager +``` + +本次手动替换到: + +```bash +cp target/release/codex-plus-plus \ + "/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus" + +cp target/release/codex-plus-plus-manager \ + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" +``` + +替换后重新签名: + +```bash +codesign --force --deep --sign - "/Applications/Codex++.app" +codesign --force --deep --sign - "/Applications/Codex++ 管理工具.app" +``` + +验证: + +```bash +codesign --verify --deep --strict "/Applications/Codex++.app" +codesign --verify --deep --strict "/Applications/Codex++ 管理工具.app" +``` + +本次验证结果: + +```text +Codex++ signature ok +Manager signature ok +``` + +两个 app 的可执行文件更新时间: + +```text +2026-05-24 09:33 +``` + +管理工具已退出旧进程并重新打开。当前 Codex 主进程没有被强行关闭,避免打断正在进行的会话;下一次通过管理工具重启 Codex 时,会使用新的 Codex++ 启动器。 + +## 下次如果还要重复修补 + +如果未来官方仍未修复,或上游合并时这段逻辑被覆盖,按以下顺序复查: + +1. 检查混合 Responses 是否写入本地代理 URL: + +```rust +codex_base_url_for_mixed_relay_protocol +relay_config_with_local_responses_proxy_guard +``` + +2. 检查纯 API Responses 是否仍然直连真实中转: + +```rust +codex_base_url_for_pure_api_protocol +``` + +3. 检查 helper 是否会为混合 Responses 启动: + +```rust +relay_protocol_proxy_enabled +RelayProfile::uses_official_api_key_mix +``` + +4. 检查请求体清洗是否仍然存在: + +```rust +sanitize_responses_request_for_relay +UpstreamResponseBodyMode::ResponsesPassthrough +``` + +5. 运行测试: + +```bash +cargo test -p codex-plus-core --test protocol_proxy --test relay_config --test launcher +cargo test -p codex-plus-core --test watcher +``` + +6. 重建并替换本机 app: + +```bash +cargo build -p codex-plus-launcher -p codex-plus-manager --release + +cp target/release/codex-plus-plus \ + "/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus" + +cp target/release/codex-plus-plus-manager \ + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + +codesign --force --deep --sign - "/Applications/Codex++.app" +codesign --force --deep --sign - "/Applications/Codex++ 管理工具.app" +``` + +7. 完全退出旧的 Codex++ 管理工具进程,重新打开管理工具。 + +## 不要记录的内容 + +排查时可以查看 `~/.codex-session-delete/settings.json`、`~/.codex/config.toml` 和 `~/.codex/auth.json`,但不要把真实 API Key、JWT、refresh token 或账号 token 写入文档、issue、commit message 或日志摘录。 + +本记录只描述修补过程和字段,不包含任何真实密钥。 diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index ab13944..2e33478 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; use codex_plus_core::script_market::{self, MarketScript, ScriptMarketManifest}; @@ -235,13 +235,29 @@ pub async fn load_overview() -> CommandResult { #[tauri::command] pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { + if let Err(error) = prepare_launch_environment(&request) { + return failed( + &format!("启动前清理旧进程失败:{error}"), + json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port + }), + ); + } spawn_codex_plus_launch(request, "启动任务已在后台开始,可稍后查看概览状态。") } #[tauri::command] pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { - codex_plus_core::watcher::stop_launcher_processes(); - codex_plus_core::watcher::stop_codex_processes(); + if let Err(error) = prepare_launch_environment(&request) { + return failed( + &format!("重启前清理旧进程失败:{error}"), + json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port + }), + ); + } spawn_codex_plus_launch(request, "Codex 已请求重启,启动任务正在后台运行。") } @@ -297,6 +313,81 @@ fn spawn_silent_launcher(request: &LaunchRequest) -> anyhow::Result<()> { .map_err(|error| anyhow::anyhow!("无法启动 {}:{error}", launcher.to_string_lossy())) } +fn prepare_launch_environment(request: &LaunchRequest) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let settings = SettingsStore::default().load().unwrap_or_default(); + let requested_app_dir = if request.app_path.trim().is_empty() { + None + } else { + Some(Path::new(request.app_path.trim())) + }; + let Some(target_app_dir) = codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( + requested_app_dir, + Some(settings.codex_app_path.as_str()), + ) else { + anyhow::bail!("无法解析 Codex 应用目录"); + }; + + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.launch_cleanup_requested", + json!({ + "debug_port": request.debug_port, + "helper_port": request.helper_port, + "app_path": target_app_dir.to_string_lossy().to_string() + }), + ); + + codex_plus_core::watcher::stop_launcher_processes(); + codex_plus_core::watcher::stop_codex_processes_at(&target_app_dir); + codex_plus_core::watcher::stop_codex_related_processes_listening_on_ports( + &target_app_dir, + &[ + request.debug_port, + request.helper_port, + codex_plus_core::ports::LAUNCHER_GUARD_PORT, + ], + ); + + if !wait_for_launch_ports_to_clear(request.debug_port, request.helper_port) { + anyhow::bail!( + "等待旧的 Codex/launcher 退出超时,端口 {}、{} 或守门端口 {} 仍然忙碌", + request.debug_port, + request.helper_port, + codex_plus_core::ports::LAUNCHER_GUARD_PORT + ); + } + + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.launch_cleanup_complete", + json!({ + "debug_port": request.debug_port, + "helper_port": request.helper_port, + "app_path": target_app_dir.to_string_lossy().to_string() + }), + ); + } + + Ok(()) +} + +fn wait_for_launch_ports_to_clear(debug_port: u16, helper_port: u16) -> bool { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + let launcher_busy = + codex_plus_core::watcher::cdp_listening(codex_plus_core::ports::LAUNCHER_GUARD_PORT); + let debug_busy = codex_plus_core::watcher::cdp_listening(debug_port); + let helper_busy = codex_plus_core::watcher::cdp_listening(helper_port); + if !launcher_busy && !debug_busy && !helper_busy { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(200)); + } +} + #[tauri::command] pub fn load_settings() -> CommandResult { settings_payload("设置已加载。", "设置读取失败") @@ -940,7 +1031,7 @@ pub fn apply_pure_api_injection() -> CommandResult { let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_pure_api_injection", &settings, &relay); if relay_has_complete_files(&relay) { - return match codex_plus_core::relay_config::apply_relay_files_to_home( + return match codex_plus_core::relay_config::apply_pure_api_files_to_home( &home, &relay.config_contents, &relay.auth_contents, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index a00db01..f419a30 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -2777,8 +2777,10 @@ function withGeneratedRelayFiles(profile: RelayProfile): RelayProfile { }; } -function buildRelayConfigToml(profile: Pick): string { - const baseUrl = profile.protocol === "chatCompletions" ? PROTOCOL_PROXY_BASE_URL : profile.baseUrl.trim(); +function buildRelayConfigToml(profile: Pick): string { + const shouldUseProxy = + profile.protocol === "chatCompletions" || (profile.relayMode === "official" && profile.officialMixApiKey); + const baseUrl = shouldUseProxy ? PROTOCOL_PROXY_BASE_URL : profile.baseUrl.trim(); const apiKey = profile.apiKey.trim(); return [ 'model_provider = "CodexPlusPlus"', diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 57cd0e2..a44a84b 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -265,7 +265,10 @@ where } fn relay_protocol_proxy_enabled(settings: &BackendSettings) -> bool { - settings.active_relay_profile().protocol == crate::settings::RelayProtocol::ChatCompletions + let relay = settings.active_relay_profile(); + relay.protocol == crate::settings::RelayProtocol::ChatCompletions + || (relay.protocol == crate::settings::RelayProtocol::Responses + && relay.uses_official_api_key_mix()) } pub trait IntoLaunchHooks { @@ -737,31 +740,32 @@ async fn handle_protocol_proxy_connection( path: &str, remote_addr_text: Option, ) -> anyhow::Result<()> { - let upstream = match crate::protocol_proxy::open_responses_proxy_request(request_body).await { - Ok(upstream) => upstream, - Err(error) => { - let body = serde_json::to_vec(&serde_json::json!({ - "status": "failed", - "message": error.to_string() - }))?; - write_http_response( - stream, - "502 Bad Gateway", - "application/json; charset=utf-8", - &body, - ) - .await?; - log_helper_response( - "helper.protocol_proxy_failed", - method, - path, - "502 Bad Gateway", - remote_addr_text, - ); - stream.shutdown().await?; - return Ok(()); - } - }; + let upstream = + match crate::protocol_proxy::open_responses_proxy_request(request_body, path).await { + Ok(upstream) => upstream, + Err(error) => { + let body = serde_json::to_vec(&serde_json::json!({ + "status": "failed", + "message": error.to_string() + }))?; + write_http_response( + stream, + "502 Bad Gateway", + "application/json; charset=utf-8", + &body, + ) + .await?; + log_helper_response( + "helper.protocol_proxy_failed", + method, + path, + "502 Bad Gateway", + remote_addr_text, + ); + stream.shutdown().await?; + return Ok(()); + } + }; if !upstream.is_success() { let status = upstream.status(); @@ -783,6 +787,41 @@ async fn handle_protocol_proxy_connection( return Ok(()); } + if upstream.body_mode == crate::protocol_proxy::UpstreamResponseBodyMode::ResponsesPassthrough { + let status = upstream.status(); + let content_type = if upstream.content_type.is_empty() { + "application/json; charset=utf-8".to_string() + } else { + upstream.content_type.clone() + }; + if upstream.is_stream { + write_http_stream_headers(stream, &status, &content_type).await?; + let mut bytes_stream = upstream.response.bytes_stream(); + while let Some(chunk) = bytes_stream.next().await { + stream.write_all(&chunk?).await?; + } + log_helper_response( + "helper.responses_proxy_stream_ok", + method, + path, + &status, + remote_addr_text, + ); + } else { + let body = upstream.response.bytes().await?.to_vec(); + write_http_response(stream, &status, &content_type, &body).await?; + log_helper_response( + "helper.responses_proxy_ok", + method, + path, + &status, + remote_addr_text, + ); + } + stream.shutdown().await?; + return Ok(()); + } + if upstream.is_stream { write_http_stream_headers(stream, "200 OK", "text/event-stream; charset=utf-8").await?; let mut converter = crate::protocol_proxy::ChatSseToResponsesConverter::default(); @@ -1138,6 +1177,7 @@ pub fn build_macos_open_command( ) -> Vec { let mut command = vec![ "open".to_string(), + "-n".to_string(), "-W".to_string(), "-a".to_string(), app_dir.to_string_lossy().to_string(), diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 777d231..90fd3c7 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -103,6 +103,83 @@ pub fn responses_to_chat_completions(body: Value) -> anyhow::Result { Ok(result) } +pub fn sanitize_responses_request_for_relay(mut body: Value) -> Value { + let Some(object) = body.as_object_mut() else { + return body; + }; + + if let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) { + tools.retain(|tool| !is_hosted_image_generation_tool(tool)); + if tools.is_empty() { + object.remove("tools"); + } + } + + if let Some(tool_choice) = object.get_mut("tool_choice") { + sanitize_responses_tool_choice(tool_choice); + } + if object.get("tool_choice").is_some_and(|tool_choice| { + is_hosted_image_generation_tool_choice(tool_choice) || is_empty_allowed_tools(tool_choice) + }) { + object.remove("tool_choice"); + } + + if let Some(include) = object.get_mut("include").and_then(Value::as_array_mut) { + include.retain(|item| { + item.as_str() + .map(|value| !contains_hosted_image_generation_reference(value)) + .unwrap_or(true) + }); + if include.is_empty() { + object.remove("include"); + } + } + + body +} + +fn is_hosted_image_generation_tool(tool: &Value) -> bool { + tool.get("type") + .and_then(Value::as_str) + .is_some_and(is_hosted_image_generation_type) +} + +fn is_hosted_image_generation_tool_choice(tool_choice: &Value) -> bool { + match tool_choice { + Value::Object(_) => is_hosted_image_generation_tool(tool_choice), + Value::String(value) => is_hosted_image_generation_type(value), + _ => false, + } +} + +fn sanitize_responses_tool_choice(tool_choice: &mut Value) { + let Some(object) = tool_choice.as_object_mut() else { + return; + }; + if let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) { + tools.retain(|tool| !is_hosted_image_generation_tool(tool)); + } +} + +fn is_empty_allowed_tools(tool_choice: &Value) -> bool { + let Some(object) = tool_choice.as_object() else { + return false; + }; + object.get("type").and_then(Value::as_str) == Some("allowed_tools") + && object + .get("tools") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) +} + +fn is_hosted_image_generation_type(tool_type: &str) -> bool { + matches!(tool_type, "image_generation" | "imageGeneration") +} + +fn contains_hosted_image_generation_reference(value: &str) -> bool { + value.contains("image_generation") || value.contains("imageGeneration") +} + pub fn chat_completion_to_response(body: Value) -> anyhow::Result { let choices = body .get("choices") @@ -152,9 +229,16 @@ pub struct UpstreamProxyResponse { pub status_code: u16, pub content_type: String, pub is_stream: bool, + pub body_mode: UpstreamResponseBodyMode, pub response: reqwest::Response, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpstreamResponseBodyMode { + ResponsesPassthrough, + ChatCompletionsToResponses, +} + impl UpstreamProxyResponse { pub fn status(&self) -> String { http_status_line(self.status_code) @@ -267,30 +351,41 @@ pub fn is_models_proxy_path(path: &str) -> bool { matches!(path, "/models" | "/v1/models") } -pub async fn open_responses_proxy_request(body: &str) -> anyhow::Result { +pub async fn open_responses_proxy_request( + body: &str, + path: &str, +) -> anyhow::Result { let settings = SettingsStore::default().load().unwrap_or_default(); let relay = settings.active_relay_profile(); - if relay.protocol != RelayProtocol::ChatCompletions { - anyhow::bail!("当前中转未启用 Chat Completions 协议代理"); - } if relay.base_url.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Base URL 不能为空"); + anyhow::bail!("中转上游 Base URL 不能为空"); } if relay.api_key.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Key 不能为空"); + anyhow::bail!("中转上游 Key 不能为空"); } - let request_json: Value = serde_json::from_str(body)?; + let request_json = sanitize_responses_request_for_relay(serde_json::from_str(body)?); let is_stream = request_json .get("stream") .and_then(Value::as_bool) .unwrap_or(false); - let chat_request = responses_to_chat_completions(request_json)?; + let (url, payload, body_mode) = match relay.protocol { + RelayProtocol::Responses => ( + responses_url(&relay.base_url, path), + request_json, + UpstreamResponseBodyMode::ResponsesPassthrough, + ), + RelayProtocol::ChatCompletions => ( + chat_completions_url(&relay.base_url), + responses_to_chat_completions(request_json)?, + UpstreamResponseBodyMode::ChatCompletionsToResponses, + ), + }; let upstream = reqwest::Client::new() - .post(chat_completions_url(&relay.base_url)) + .post(url) .bearer_auth(relay.api_key.trim()) .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&chat_request) + .json(&payload) .send() .await?; let status_code = upstream.status().as_u16(); @@ -305,6 +400,7 @@ pub async fn open_responses_proxy_request(body: &str) -> anyhow::Result anyhow::Result anyhow::Result { let settings = SettingsStore::default().load().unwrap_or_default(); let relay = settings.active_relay_profile(); - if relay.protocol != RelayProtocol::ChatCompletions { - anyhow::bail!("当前中转未启用 Chat Completions 协议代理"); - } if relay.base_url.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Base URL 不能为空"); + anyhow::bail!("中转上游 Base URL 不能为空"); } if relay.api_key.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Key 不能为空"); + anyhow::bail!("中转上游 Key 不能为空"); } let upstream = reqwest::Client::new() @@ -339,12 +432,13 @@ pub async fn open_models_proxy_request() -> anyhow::Result anyhow::Result { - let upstream = open_responses_proxy_request(body).await?; + let upstream = open_responses_proxy_request(body, "/responses").await?; let status_code = upstream.status_code; let upstream_content_type = upstream.content_type.clone(); let is_stream = upstream.is_stream; @@ -362,6 +456,18 @@ pub async fn handle_responses_proxy_request(body: &str) -> anyhow::Result String { url } +pub fn responses_url(base_url: &str, proxy_path: &str) -> String { + let skip_version_prefix = base_url.trim().ends_with('#'); + let base = base_url.trim().trim_end_matches('#').trim_end_matches('/'); + let (path, query) = proxy_path + .split_once('?') + .map_or((proxy_path, None), |(path, query)| (path, Some(query))); + let endpoint = match path { + "/responses/compact" | "/v1/responses/compact" => "/responses/compact", + _ => "/responses", + }; + let lower = base.to_ascii_lowercase(); + let mut url = if lower.ends_with("/responses/compact") { + base.to_string() + } else if lower.ends_with("/responses") { + if endpoint == "/responses/compact" { + format!("{base}/compact") + } else { + base.to_string() + } + } else { + let origin_only = base + .split_once("://") + .map_or(!base.contains('/'), |(_, rest)| !rest.contains('/')); + if skip_version_prefix || has_version_suffix(base) || !origin_only { + format!("{base}{endpoint}") + } else { + format!("{base}/v1{endpoint}") + } + }; + while url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + if let Some(query) = query.filter(|query| !query.is_empty()) { + url.push('?'); + url.push_str(query); + } + url +} + pub fn models_url(base_url: &str) -> String { let skip_version_prefix = base_url.trim().ends_with('#'); let mut base = base_url diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index cc457d3..f8c5932 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -6,6 +6,10 @@ use crate::settings::{RelayProfile, RelayProtocol}; const RELAY_PROVIDER: &str = "CodexPlusPlus"; const LEGACY_RELAY_PROVIDER: &str = "CodexPP"; +const RELAY_IMAGE_GENERATION_DISABLED_COMMENT: &str = + "# Codex++ relay mode disables hosted image generation for relay compatibility."; +const RELAY_IMAGE_GENERATION_SAVED_TRUE_COMMENT: &str = + "# Codex++ relay mode saved image_generation = true before override."; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -158,8 +162,12 @@ pub fn apply_relay_config_to_home_with_protocol( std::fs::create_dir_all(home)?; let config_path = home.join("config.toml"); let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); - let codex_base_url = codex_base_url_for_protocol(base_url, protocol, proxy_port); - let updated = upsert_model_provider_config(&existing, &codex_base_url, bearer_token); + let codex_base_url = codex_base_url_for_mixed_relay_protocol(base_url, protocol, proxy_port); + let updated = relay_config_with_image_generation_guard(&upsert_model_provider_config( + &existing, + &codex_base_url, + bearer_token, + )); std::fs::write(&config_path, updated)?; let status = relay_config_status_from_home(home); Ok(RelayApplyResult { @@ -196,7 +204,37 @@ pub fn apply_relay_files_to_home( let config_path = home.join("config.toml"); let auth_path = home.join("auth.json"); - std::fs::write(&config_path, config_contents)?; + let updated_config = + relay_config_with_image_generation_guard(&relay_config_with_local_responses_proxy_guard( + config_contents, + crate::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + )); + std::fs::write(&config_path, updated_config)?; + std::fs::write(&auth_path, auth_contents)?; + + let status = relay_config_status_from_home(home); + Ok(RelayApplyResult { + config_path: status.config_path, + backup_path: None, + configured: status.configured, + }) +} + +pub fn apply_pure_api_files_to_home( + home: &Path, + config_contents: &str, + auth_contents: &str, +) -> anyhow::Result { + if config_contents.trim().is_empty() { + anyhow::bail!("config.toml 内容不能为空"); + } + std::fs::create_dir_all(home)?; + + let config_path = home.join("config.toml"); + let auth_path = home.join("auth.json"); + let updated_config = restore_hosted_image_generation_after_relay(config_contents); + + std::fs::write(&config_path, updated_config)?; std::fs::write(&auth_path, auth_contents)?; let status = relay_config_status_from_home(home); @@ -253,7 +291,7 @@ pub fn apply_pure_api_config_to_home_with_protocol( let config_path = home.join("config.toml"); let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); - let codex_base_url = codex_base_url_for_protocol(base_url, protocol, proxy_port); + let codex_base_url = codex_base_url_for_pure_api_protocol(base_url, protocol, proxy_port); let updated = upsert_model_provider_config(&existing, &codex_base_url, bearer_token); std::fs::write(&config_path, updated)?; let status = relay_config_status_from_home(home); @@ -321,7 +359,23 @@ fn relay_profile_test_payload(protocol: RelayProtocol, model: &str) -> Value { } } -fn codex_base_url_for_protocol(base_url: &str, protocol: RelayProtocol, proxy_port: u16) -> String { +fn codex_base_url_for_mixed_relay_protocol( + _base_url: &str, + protocol: RelayProtocol, + proxy_port: u16, +) -> String { + match protocol { + RelayProtocol::Responses | RelayProtocol::ChatCompletions => { + crate::protocol_proxy::local_responses_proxy_base_url(proxy_port) + } + } +} + +fn codex_base_url_for_pure_api_protocol( + base_url: &str, + protocol: RelayProtocol, + proxy_port: u16, +) -> String { match protocol { RelayProtocol::Responses => base_url.to_string(), RelayProtocol::ChatCompletions => { @@ -342,7 +396,10 @@ pub fn clear_relay_config_to_home(home: &Path) -> anyhow::Result String { updated } +fn upsert_table_key(contents: &str, table: &str, key: &str, value: &str) -> String { + let mut lines = contents + .lines() + .map(ToString::to_string) + .collect::>(); + + if let Some((table_start, table_end)) = table_line_range(&lines, table) { + if let Some(index) = lines[table_start + 1..table_end] + .iter() + .position(|line| root_line_key(line) == Some(key)) + .map(|index| table_start + 1 + index) + { + lines[index] = format!("{key} = {value}"); + } else { + lines.insert(table_end, format!("{key} = {value}")); + } + return finish_lines(lines); + } + + if !lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.push(String::new()); + } + lines.push(format!("[{table}]")); + lines.push(format!("{key} = {value}")); + finish_lines(lines) +} + fn upsert_model_provider_config(contents: &str, base_url: &str, bearer_token: &str) -> String { let mut updated = upsert_root_keys( contents, @@ -527,6 +611,147 @@ fn upsert_model_provider_config(contents: &str, base_url: &str, bearer_token: &s output } +fn relay_config_with_image_generation_guard(contents: &str) -> String { + if root_key_string(contents, "model_provider") + .map(|value| value == RELAY_PROVIDER || value == LEGACY_RELAY_PROVIDER) + .unwrap_or(false) + { + disable_hosted_image_generation_for_relay(contents) + } else { + contents.to_string() + } +} + +fn relay_config_with_local_responses_proxy_guard(contents: &str, proxy_port: u16) -> String { + let Some(provider) = root_key_string(contents, "model_provider") else { + return contents.to_string(); + }; + if provider != RELAY_PROVIDER && provider != LEGACY_RELAY_PROVIDER { + return contents.to_string(); + } + + let table = if table_values(contents, &format!("model_providers.{provider}")).is_some() { + format!("model_providers.{provider}") + } else { + format!("model_providers.{RELAY_PROVIDER}") + }; + upsert_table_key( + contents, + &table, + "base_url", + &format!( + "\"{}\"", + toml_escape(&crate::protocol_proxy::local_responses_proxy_base_url( + proxy_port + )) + ), + ) +} + +fn disable_hosted_image_generation_for_relay(contents: &str) -> String { + let mut lines = contents + .lines() + .map(ToString::to_string) + .collect::>(); + if lines.is_empty() { + return format!( + "[features]\n{RELAY_IMAGE_GENERATION_DISABLED_COMMENT}\nimage_generation = false\n" + ); + } + + let Some((features_start, features_end)) = table_line_range(&lines, "features") else { + if !lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.push(String::new()); + } + lines.push("[features]".to_string()); + lines.push(RELAY_IMAGE_GENERATION_DISABLED_COMMENT.to_string()); + lines.push("image_generation = false".to_string()); + return finish_lines(lines); + }; + + if let Some(image_line_index) = lines[features_start + 1..features_end] + .iter() + .position(|line| root_line_key(line) == Some("image_generation")) + .map(|index| features_start + 1 + index) + { + if line_value_bool(&lines[image_line_index]) == Some(true) { + let owned_comment = image_line_index > 0 + && lines[image_line_index - 1].trim() == RELAY_IMAGE_GENERATION_DISABLED_COMMENT; + if !owned_comment { + lines.insert( + image_line_index, + RELAY_IMAGE_GENERATION_SAVED_TRUE_COMMENT.to_string(), + ); + } + let target_index = if owned_comment { + image_line_index + } else { + image_line_index + 1 + }; + lines[target_index] = "image_generation = false".to_string(); + } + return finish_lines(lines); + } + + lines.splice( + features_end..features_end, + [ + RELAY_IMAGE_GENERATION_DISABLED_COMMENT.to_string(), + "image_generation = false".to_string(), + ], + ); + finish_lines(lines) +} + +fn restore_hosted_image_generation_after_relay(contents: &str) -> String { + let mut output = Vec::new(); + let mut remove_owned_false = false; + let mut restore_saved_true = false; + + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed == RELAY_IMAGE_GENERATION_DISABLED_COMMENT { + remove_owned_false = true; + continue; + } + if trimmed == RELAY_IMAGE_GENERATION_SAVED_TRUE_COMMENT { + restore_saved_true = true; + continue; + } + + if remove_owned_false { + remove_owned_false = false; + if root_line_key(line) == Some("image_generation") + && line_value_bool(line) == Some(false) + { + continue; + } + } + + if restore_saved_true { + restore_saved_true = false; + if root_line_key(line) == Some("image_generation") + && line_value_bool(line) == Some(false) + { + output.push("image_generation = true".to_string()); + continue; + } + } + + output.push(line.to_string()); + } + + finish_lines(output) +} + +fn finish_lines(lines: Vec) -> String { + let mut output = lines.join("\n"); + if !output.ends_with('\n') { + output.push('\n'); + } + output +} + fn remove_table(contents: &str, table: &str) -> String { let header = format!("[{table}]"); let mut lines = Vec::new(); @@ -593,6 +818,29 @@ fn table_values(contents: &str, table: &str) -> Option Option<(usize, usize)> { + let header = format!("[{table}]"); + let start = lines.iter().position(|line| line.trim() == header)?; + let end = lines[start + 1..] + .iter() + .position(|line| { + let trimmed = line.trim(); + trimmed.starts_with('[') && trimmed.ends_with(']') + }) + .map(|offset| start + 1 + offset) + .unwrap_or(lines.len()); + Some((start, end)) +} + +fn line_value_bool(line: &str) -> Option { + let (_, value) = line.split_once('=')?; + match value.trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + fn unquote_toml_string(value: &str) -> String { let value = value.trim(); value diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 388f473..eede310 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -127,6 +127,12 @@ impl Default for BackendSettings { } } +impl RelayProfile { + pub fn uses_official_api_key_mix(&self) -> bool { + self.official_mix_api_key || self.relay_mode == RelayMode::MixedApi + } +} + impl BackendSettings { pub fn active_relay_profile(&self) -> RelayProfile { if self.active_relay_id == default_active_relay_id() diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 2e84231..570cbe2 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -187,7 +187,14 @@ pub fn stop_launcher_processes() { } } -#[cfg(not(windows))] +#[cfg(target_os = "macos")] +pub fn stop_launcher_processes() { + if let Some(bundle_path) = macos_launcher_bundle_path() { + stop_macos_processes_matching(bundle_path.to_string_lossy().as_ref()); + } +} + +#[cfg(all(not(windows), not(target_os = "macos")))] pub fn stop_launcher_processes() {} #[cfg(windows)] @@ -197,9 +204,24 @@ pub fn stop_codex_processes() { } } -#[cfg(not(windows))] +#[cfg(target_os = "macos")] +pub fn stop_codex_processes() { + if let Some(app_dir) = macos_codex_app_dir_from_settings() { + stop_codex_processes_at(&app_dir); + } +} + +#[cfg(all(not(windows), not(target_os = "macos")))] pub fn stop_codex_processes() {} +#[cfg(target_os = "macos")] +pub fn stop_codex_processes_at(app_dir: &Path) { + stop_macos_processes_matching(app_dir.to_string_lossy().as_ref()); +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +pub fn stop_codex_processes_at(_app_dir: &Path) {} + #[cfg(windows)] fn create_startup_shortcut(launcher_path: &Path, arguments: &str) -> anyhow::Result<()> { let Some(shortcut_path) = startup_shortcut_path() else { @@ -244,3 +266,185 @@ fn startup_shortcut_path() -> Option { .join(WATCHER_STARTUP_SHORTCUT_NAME) }) } + +#[cfg(target_os = "macos")] +fn macos_launcher_bundle_path() -> Option { + let launcher = crate::install::companion_binary_path(crate::install::SILENT_BINARY); + launcher.ancestors().nth(3).map(Path::to_path_buf) +} + +#[cfg(target_os = "macos")] +fn macos_codex_app_dir_from_settings() -> Option { + let settings = crate::settings::SettingsStore::default().load().ok()?; + crate::app_paths::resolve_codex_app_dir_with_saved(None, Some(settings.codex_app_path.as_str())) +} + +pub fn filter_macos_codex_related_port_owner_processes<'a>( + processes: impl IntoIterator, + current_process_id: u32, + trusted_fragments: impl IntoIterator, +) -> Vec { + let fragments = trusted_fragments + .into_iter() + .map(str::trim) + .filter(|fragment| !fragment.is_empty()) + .collect::>(); + processes + .into_iter() + .filter(|(process_id, command)| { + *process_id != current_process_id + && fragments.iter().any(|fragment| command.contains(fragment)) + }) + .map(|(process_id, _)| process_id) + .collect() +} + +#[cfg(target_os = "macos")] +pub fn stop_codex_related_processes_listening_on_ports(app_dir: &Path, ports: &[u16]) { + let fragments = macos_codex_related_process_fragments( + app_dir, + &crate::relay_config::default_codex_home_dir(), + ); + stop_macos_port_owner_processes(ports, &fragments, "TERM"); + std::thread::sleep(Duration::from_millis(300)); + stop_macos_port_owner_processes(ports, &fragments, "KILL"); +} + +#[cfg(target_os = "macos")] +fn stop_macos_port_owner_processes(ports: &[u16], trusted_fragments: &[String], signal: &str) { + let owners = macos_listening_port_owner_processes(ports); + let killable = filter_macos_codex_related_port_owner_processes( + owners + .iter() + .map(|(process_id, command)| (*process_id, command.as_str())), + std::process::id(), + trusted_fragments.iter().map(String::as_str), + ); + for process_id in killable { + let _ = macos_signal_process(process_id, signal); + } +} + +#[cfg(target_os = "macos")] +fn macos_codex_related_process_fragments(app_dir: &Path, codex_home: &Path) -> Vec { + let mut fragments = vec![ + app_dir.to_string_lossy().to_string(), + codex_home.to_string_lossy().to_string(), + ]; + if let Some(bundle_path) = macos_launcher_bundle_path() { + fragments.push(bundle_path.to_string_lossy().to_string()); + } + fragments +} + +#[cfg(target_os = "macos")] +fn macos_listening_port_owner_processes(ports: &[u16]) -> Vec<(u32, String)> { + let mut process_ids = HashSet::new(); + for port in ports.iter().copied().filter(|port| *port != 0) { + let Ok(output) = Command::new("lsof") + .arg("-nP") + .arg(format!("-iTCP:{port}")) + .arg("-sTCP:LISTEN") + .arg("-t") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + else { + continue; + }; + if !output.status.success() { + continue; + } + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Ok(process_id) = line.trim().parse::() { + process_ids.insert(process_id); + } + } + } + + process_ids + .into_iter() + .filter_map(|process_id| { + macos_command_for_pid(process_id).map(|command| (process_id, command)) + }) + .collect() +} + +#[cfg(target_os = "macos")] +fn macos_command_for_pid(process_id: u32) -> Option { + let output = Command::new("ps") + .arg("-p") + .arg(process_id.to_string()) + .arg("-o") + .arg("command=") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(target_os = "macos")] +fn stop_macos_processes_matching(command_fragment: &str) { + let command_fragment = command_fragment.trim(); + if command_fragment.is_empty() { + return; + } + + let process_ids = macos_process_ids_matching(command_fragment); + if process_ids.is_empty() { + return; + } + + for process_id in &process_ids { + let _ = macos_signal_process(*process_id, "TERM"); + } + std::thread::sleep(Duration::from_millis(300)); + + for process_id in macos_process_ids_matching(command_fragment) { + let _ = macos_signal_process(process_id, "KILL"); + } +} + +#[cfg(target_os = "macos")] +fn macos_process_ids_matching(command_fragment: &str) -> Vec { + let Ok(output) = Command::new("ps") + .args(["-axww", "-o", "pid=,command="]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + let command_fragment = command_fragment.trim(); + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let process_id = parts.next()?.parse::().ok()?; + let command = parts.collect::>().join(" "); + command.contains(command_fragment).then_some(process_id) + }) + .collect() +} + +#[cfg(target_os = "macos")] +fn macos_signal_process( + process_id: u32, + signal: &str, +) -> std::io::Result { + Command::new("kill") + .arg(format!("-{signal}")) + .arg(process_id.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() +} diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index d403315..7b16fd1 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -17,7 +17,7 @@ use codex_plus_core::launcher::{ use codex_plus_core::launcher::{WindowsProcessControlStrategy, windows_process_control_strategy}; use codex_plus_core::ports::select_platform_loopback_port_with; use codex_plus_core::proxy::has_proxy_environment; -use codex_plus_core::settings::{BackendSettings, RelayProfile, RelayProtocol}; +use codex_plus_core::settings::{BackendSettings, RelayMode, RelayProfile, RelayProtocol}; use codex_plus_core::status::StatusStore; #[test] @@ -127,6 +127,18 @@ fn app_paths_find_macos_codex_app_prefers_first_search_root_and_known_names() { ); } +#[test] +fn app_paths_find_macos_codex_app_ignores_codex_plus_plus_launcher_bundle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("Applications"); + let codex_plus_plus = root.join("Codex++.app"); + let codex = root.join("Codex.app"); + std::fs::create_dir_all(&codex_plus_plus).unwrap(); + std::fs::create_dir_all(&codex).unwrap(); + + assert_eq!(find_macos_codex_app(&[root]).unwrap(), codex); +} + #[test] fn app_paths_build_macos_bundle_executable() { let app = PathBuf::from("/Applications/OpenAI Codex.app"); @@ -273,6 +285,7 @@ fn launcher_macos_open_command_waits_for_app_exit() { let command = build_macos_open_command(Path::new("/Applications/Codex.app"), 9229, &[]); assert_eq!(command[0], "open"); + assert!(command.contains(&"-n".to_string())); assert!(command.contains(&"-W".to_string())); assert!(command.contains(&"-a".to_string())); assert!(command.contains(&"--args".to_string())); @@ -762,6 +775,52 @@ async fn launch_starts_helper_when_chat_protocol_proxy_is_enabled() { assert!(after_stop.contains(&"shutdown-helper:57321".to_string())); } +#[tokio::test] +async fn launch_starts_helper_when_mixed_responses_proxy_is_enabled() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + let status_store = StatusStore::new(temp.path().join("latest-status.json")); + let events = Arc::new(Mutex::new(Vec::::new())); + let settings = BackendSettings { + enhancements_enabled: false, + relay_profiles: vec![RelayProfile { + id: "relay-responses".to_string(), + name: "Responses".to_string(), + base_url: "https://responses.example.test/v1".to_string(), + api_key: "sk-test".to_string(), + protocol: RelayProtocol::Responses, + relay_mode: RelayMode::Official, + official_mix_api_key: true, + test_model: String::new(), + config_contents: String::new(), + auth_contents: String::new(), + }], + active_relay_id: "relay-responses".to_string(), + ..BackendSettings::default() + }; + let hooks = FakeHooks::new(events.clone()).with_settings(settings); + + let handle = launch_and_inject_with_hooks( + LaunchOptions { + app_dir: Some(app_dir), + debug_port: 9229, + helper_port: 58000, + status_store, + }, + &hooks, + ) + .await + .unwrap(); + + let before_stop = events.lock().unwrap().clone(); + assert!(before_stop.contains(&"select-helper:58000".to_string())); + assert!(before_stop.contains(&"start-helper:57321".to_string())); + assert!(!before_stop.contains(&"inject:9229:57321".to_string())); + + handle.wait_for_codex_exit().await.unwrap(); +} + #[tokio::test] async fn launch_lifecycle_cleans_helper_and_codex_when_status_save_fails() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index fd71419..6264a7a 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -1,6 +1,7 @@ use codex_plus_core::protocol_proxy::{ ChatSseToResponsesConverter, chat_completion_to_response, chat_completions_url, chat_sse_to_responses_sse, is_models_proxy_path, models_url, responses_to_chat_completions, + responses_url, sanitize_responses_request_for_relay, }; use serde_json::json; @@ -57,6 +58,78 @@ fn responses_request_converts_to_chat_completions() { ); } +#[test] +fn responses_request_sanitizer_removes_hosted_image_generation_tool() { + let sanitized = sanitize_responses_request_for_relay(json!({ + "model": "gpt-5.4", + "input": "hi", + "tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [ + { "type": "function", "name": "lookup" }, + { "type": "image_generation" } + ] + }, + "include": ["image_generation_call.results", "reasoning.encrypted_content"], + "tools": [ + { "type": "function", "name": "lookup", "parameters": { "type": "object" } }, + { "type": "image_generation", "quality": "low" }, + { "type": "imageGeneration", "size": "1024x1024" } + ] + })); + + assert_eq!( + sanitized["tools"], + json!([{ "type": "function", "name": "lookup", "parameters": { "type": "object" } }]) + ); + assert_eq!( + sanitized["tool_choice"], + json!({ + "type": "allowed_tools", + "mode": "auto", + "tools": [{ "type": "function", "name": "lookup" }] + }) + ); + assert_eq!(sanitized["include"], json!(["reasoning.encrypted_content"])); + assert!( + !serde_json::to_string(&sanitized) + .unwrap() + .contains("image_generation") + ); + assert!( + !serde_json::to_string(&sanitized) + .unwrap() + .contains("imageGeneration") + ); + + let forced = sanitize_responses_request_for_relay(json!({ + "tool_choice": { "type": "image_generation" }, + "tools": [{ "type": "image_generation" }] + })); + assert!(forced.get("tool_choice").is_none()); + assert!(forced.get("tools").is_none()); +} + +#[test] +fn responses_url_preserves_responses_and_compact_paths() { + assert_eq!( + responses_url("https://relay.example.test", "/responses"), + "https://relay.example.test/v1/responses" + ); + assert_eq!( + responses_url("https://relay.example.test/v1", "/v1/responses/compact"), + "https://relay.example.test/v1/responses/compact" + ); + assert_eq!( + responses_url( + "https://relay.example.test/v1/responses", + "/responses/compact?trace=1" + ), + "https://relay.example.test/v1/responses/compact?trace=1" + ); +} + #[test] fn responses_request_matches_ccs_reasoning_and_tool_choice_edges() { let non_reasoning = responses_to_chat_completions(json!({ diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index d8e058b..a7ec208 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1,7 +1,7 @@ use codex_plus_core::relay_config::{ - apply_pure_api_config_to_home, apply_relay_config_file_to_home, apply_relay_config_to_home, - apply_relay_files_to_home, chatgpt_auth_status_from_home, clear_relay_config_to_home, - relay_config_status_from_home, + apply_pure_api_config_to_home, apply_pure_api_files_to_home, apply_relay_config_file_to_home, + apply_relay_config_to_home, apply_relay_files_to_home, chatgpt_auth_status_from_home, + clear_relay_config_to_home, relay_config_status_from_home, }; use codex_plus_core::settings::RelayProtocol; @@ -141,11 +141,166 @@ model = "gpt-5-mini" assert!(updated.contains(r#"name = "CodexPlusPlus""#)); assert!(updated.contains(r#"wire_api = "responses""#)); assert!(updated.contains("requires_openai_auth = true")); - assert!(updated.contains(r#"base_url = "https://relay.example.test/v1""#)); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(!updated.contains("https://relay.example.test/v1")); assert!(updated.contains(r#"experimental_bearer_token = "sk-test-redacted""#)); assert!(updated.contains("[profiles.default]")); } +#[test] +fn apply_relay_config_disables_hosted_image_generation_for_relay_mode() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "gpt-5" +[features] +plugins = true +"#, + ) + .unwrap(); + + let result = apply_relay_config_to_home( + temp.path(), + "https://relay.example.test/v1", + "sk-test-redacted", + ) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(result.configured); + assert!(updated.contains("[features]")); + assert!(updated.contains("plugins = true")); + assert!(updated.contains( + "# Codex++ relay mode disables hosted image generation for relay compatibility." + )); + assert!(updated.contains("image_generation = false")); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(!updated.contains("https://relay.example.test/v1")); +} + +#[test] +fn apply_relay_files_disables_hosted_image_generation_for_relay_mode() { + let temp = tempfile::tempdir().unwrap(); + + let config = r#"model_provider = "CodexPlusPlus" + +[model_providers.CodexPlusPlus] +name = "CodexPlusPlus" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example.test/v1" +experimental_bearer_token = "sk-test-redacted" + +[features] +plugins = true +"#; + let auth = r#"{"auth_mode":"chatgpt","tokens":{"access_token":"token"}}"#; + + let result = apply_relay_files_to_home(temp.path(), config, auth).unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(result.configured); + assert!(updated.contains("[features]")); + assert!(updated.contains("plugins = true")); + assert!(updated.contains( + "# Codex++ relay mode disables hosted image generation for relay compatibility." + )); + assert!(updated.contains("image_generation = false")); +} + +#[test] +fn apply_pure_api_config_keeps_hosted_image_generation_unchanged() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "gpt-5" +[features] +image_generation = true +"#, + ) + .unwrap(); + + apply_pure_api_config_to_home(temp.path(), "https://relay-a.example/v1", "sk-a").unwrap(); + + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(updated.contains("image_generation = true")); + assert!(!updated.contains("image_generation = false")); + assert!(!updated.contains("Codex++ relay mode disables hosted image generation")); +} + +#[test] +fn apply_pure_api_files_restores_relay_owned_image_generation_guard() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model_provider = "CodexPlusPlus" +[model_providers.CodexPlusPlus] +name = "CodexPlusPlus" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example.test/v1" +experimental_bearer_token = "sk-test" + +[features] +# Codex++ relay mode disables hosted image generation for relay compatibility. +image_generation = false +js_repl = false +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"OPENAI_API_KEY":"sk-test"}"#, + ) + .unwrap(); + + let result = apply_pure_api_files_to_home( + temp.path(), + &std::fs::read_to_string(temp.path().join("config.toml")).unwrap(), + &std::fs::read_to_string(temp.path().join("auth.json")).unwrap(), + ) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(result.configured); + assert!(result.config_path.contains("config.toml")); + assert!(updated.contains("[features]")); + assert!(updated.contains("js_repl = false")); + assert!(!updated.contains("image_generation = false")); + assert!(!updated.contains("relay mode disables hosted image generation")); +} + +#[test] +fn clear_relay_config_restores_user_image_generation_true() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "gpt-5" +model_provider = "CodexPlusPlus" +[model_providers.CodexPlusPlus] +name = "CodexPlusPlus" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example.test/v1" +experimental_bearer_token = "sk-test-redacted" + +[features] +plugins = true +# Codex++ relay mode saved image_generation = true before override. +image_generation = false +"#, + ) + .unwrap(); + + clear_relay_config_to_home(temp.path()).unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(updated.contains("[features]")); + assert!(updated.contains("plugins = true")); + assert!(updated.contains("image_generation = true")); + assert!(!updated.contains("Codex++ relay mode saved image_generation")); +} + #[test] fn apply_chat_protocol_relay_points_codex_to_local_responses_proxy() { let temp = tempfile::tempdir().unwrap(); @@ -227,7 +382,8 @@ experimental_bearer_token = "sk-a" assert!(result.configured); assert!(result.backup_path.is_none()); - assert!(config.contains(r#"base_url = "https://relay-a.example/v1""#)); + assert!(config.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(!config.contains("https://relay-a.example/v1")); assert_eq!(auth, r#"{"OPENAI_API_KEY":"sk-a"}"#); assert!(std::fs::read_dir(temp.path()).unwrap().all(|entry| { !entry diff --git a/crates/codex-plus-core/tests/watcher.rs b/crates/codex-plus-core/tests/watcher.rs index 58183ff..3294200 100644 --- a/crates/codex-plus-core/tests/watcher.rs +++ b/crates/codex-plus-core/tests/watcher.rs @@ -1,7 +1,7 @@ use codex_plus_core::watcher::{ build_spawn_launcher_command, build_watcher_install_plan, cdp_listening, codex_process_ids, disable_watcher_at, enable_watcher_at, filter_killable_launcher_processes, - watcher_disabled_flag, + filter_macos_codex_related_port_owner_processes, watcher_disabled_flag, }; #[test] @@ -87,3 +87,31 @@ fn launcher_process_filter_protects_current_process_ancestry() { assert_eq!(filter_killable_launcher_processes(processes, 30), vec![40]); } + +#[test] +fn macos_port_owner_filter_kills_only_codex_related_processes() { + let processes = [ + ( + 10, + "/Users/me/.codex/future-plugin/Future Tool.app/Contents/MacOS/Future Tool", + ), + (11, "/Applications/Codex.app/Contents/MacOS/Codex"), + (12, "/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus"), + (13, "/Applications/Other.app/Contents/MacOS/Other"), + (30, "/Applications/Codex++ Manager.app/Contents/MacOS/test"), + ]; + let fragments = [ + "/Applications/Codex.app".to_string(), + "/Applications/Codex++.app".to_string(), + "/Users/me/.codex".to_string(), + ]; + + assert_eq!( + filter_macos_codex_related_port_owner_processes( + processes, + 30, + fragments.iter().map(String::as_str), + ), + vec![10, 11, 12] + ); +}