From 237ea5c0944ec4d21753a6c9cedb2061cf208450 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 22:59:10 +0800 Subject: [PATCH 01/28] chore: update gitignore Part of relay image proxy and macOS packaging update. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0f3a068..888b7c2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ venv/ build/ dist/ target/ +node_modules/ apps/codex-plus-manager/src-tauri/gen/ .codex_asar_extract/ From 91e8256d56cd9212260a685b6c5c67526c457d77 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:03:19 +0800 Subject: [PATCH 02/28] update crates/codex-plus-core/src/lib.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 2adee86..ceae48f 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod paths; pub mod ports; pub mod proxy; pub mod relay_config; +pub mod relay_proxy; pub mod routes; pub mod settings; pub mod status; From 93c35474024da79576c93436aec0648726aee59d Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:03:42 +0800 Subject: [PATCH 03/28] update README.md Part of relay image proxy and macOS packaging update. --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 62811e8..d8c58b9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,16 @@ Codex++ 是面向 Codex App 的外部增强启动器和管理工具。它不修改 Codex App 原始安装文件,而是通过外部 launcher 启动 Codex,并使用 Chromium DevTools Protocol 注入增强脚本。 +## 目录 + +- [快速使用](#快速使用) +- [Windows 使用](#windows-使用) +- [中转注入](#中转注入) +- [增强功能](#增强功能) +- [自动更新与安装包](#自动更新与安装包) +- [常见问题](#常见问题) +- [开发](#开发) + ## 快速使用 从 [GitHub Releases](https://github.com/BigPizzaV3/CodexPlusPlus/releases) 下载最新版安装包: @@ -33,6 +43,10 @@ Codex++ 是面向 Codex App 的外部增强启动器和管理工具。它不修 Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安装 `/Applications/Codex++.app` 和 `/Applications/Codex++ 管理工具.app`。 +## Windows 使用 + +Windows 用户下载 `windows-x64-setup.exe` 后直接运行安装包。安装完成后,从桌面或开始菜单启动 `Codex++` 即可。 + ## 赞助商 @@ -59,6 +73,8 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 Codex++ 交流群二维码 +## 赞赏支持 + 如果 Codex++ 帮到了你,可以请我喝杯咖啡,或者随手赞赏支持一下继续维护。

@@ -66,7 +82,7 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 微信赞赏码

-## 主要功能 +## 功能亮点 - Rust 后端和静默 launcher,启动时不依赖 Python 环境。 - Tauri + React 管理工具,支持深色/浅色切换。 @@ -74,10 +90,20 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 - 中转注入模式:支持多个中转配置,写入 `CodexPlusPlus` provider,并可切回官方 ChatGPT 登录态。 - 传统增强模式:插件入口解锁、特殊插件强制安装、会话删除、Markdown 导出、项目移动、Timeline 等。 - 用户脚本独立管理,可在启动时注入自定义脚本。 -- Provider 同步:启动前同步本地会话 metadata,切换供应商后旧会话仍可见。 +- Provider 同步:启动前同步本地会话 metadata,切换 model_provider 后不丢历史会话。 - GitHub Release 自动更新,管理工具和静默启动器都会检测可用更新。 - Windows 单实例、无黑框启动、管理员权限清单、系统桌面路径识别。 -- macOS x64/arm64 分架构 DMG,静默入口隐藏 Dock 图标。 +- macOS x64/arm64 分架构 DMG,自动识别 Codex bundle,静默入口隐藏 Dock 图标。 + +## 项目数据 + +

+ Codex++ contributors +

+ +

+ Codex++ star history +

## 痛点与解决 @@ -106,8 +132,9 @@ Codex++ 启动后会解锁插件入口,并在会话列表悬停时显示删除 1. 确认已经检测到 ChatGPT 登录状态。 2. 添加一个或多个中转配置,填写 Base URL 和 Key。 -3. 选择当前配置并应用中转注入。 -4. 启动 `Codex++`。 +3. 按需打开“允许当前中转使用图片生成”。如果主中转组没有图片权限,保持关闭;如果希望图片生成走单独通道,填写独立图片 Base URL 和 Key。 +4. 选择当前配置并应用中转注入。 +5. 启动 `Codex++`。 Codex++ 会在 `~/.codex/config.toml` 中写入类似配置: @@ -118,10 +145,16 @@ model_provider = "CodexPlusPlus" name = "CodexPlusPlus" wire_api = "responses" requires_openai_auth = true -base_url = "https://example.com/v1" +base_url = "http://127.0.0.1:57323/v1" +disabled_tools = ["image_generation"] +codex_plus_text_base_url = "https://example.com/v1" experimental_bearer_token = "sk-..." ``` +图片生成关闭时,Codex++ 会把 provider 写到本地 `127.0.0.1:57323` 代理。代理会在转发到当前中转前裁剪 `image_generation` 工具,避免部分中转组未开启图片生成权限时,Codex 会话请求被上游以 `Image generation is not enabled for this group` 拒绝。 + +如果在管理工具中开启“图片生成使用独立 API 和 Key”,同一个本地代理会把普通请求转发到当前中转,把包含 `image_generation` 工具的 Responses 请求转发到图片生成 API。 + 如果需要回到官方登录态,在“中转注入”页面点击清除 API 模式即可移除 `OPENAI_API_KEY` 相关配置并切回官方 ChatGPT 登录模式。 ## 增强功能 @@ -179,6 +212,10 @@ Invoke-RestMethod -Method Post -Uri http://127.0.0.1:57321/backend/status -Body 可以。Release 会分别提供 `macos-x64.dmg` 和 `macos-arm64.dmg`。Intel Mac 下载 x64 包,Apple Silicon 下载 arm64 包。 +### macOS 找不到 Codex App + +Codex++ 会优先检查 `/Applications`,再检查 `~/Applications`,并通过 `Info.plist` 中的 OpenAI Codex bundle 标识识别应用。若你把 Codex 放在其他目录,可以在管理工具中填写 Codex App 路径,或通过启动参数指定 `--app-path "/path/to/Codex.app"`。 + ## 开发 ```bash @@ -193,6 +230,9 @@ cd ../.. cargo fmt --check cargo test cargo build --release + +# macOS 打包 +scripts/installer/macos/package-dmg.sh 1.1.1 arm64 ``` 主要结构: From d52661680795ccbe8423448a9e4484feba709607 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:03:48 +0800 Subject: [PATCH 04/28] update README_EN.md Part of relay image proxy and macOS packaging update. --- README_EN.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/README_EN.md b/README_EN.md index 5434c74..09eff4b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -41,10 +41,20 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in - Relay injection mode with multiple relay profiles, `CodexPlusPlus` provider configuration, and a one-click switch back to official ChatGPT login mode. - Traditional enhancement mode with plugin entry unlock, forced plugin install, session delete, Markdown export, project move, Timeline, and more. - Independent user script management with startup injection. -- Provider Sync to keep historical sessions visible after switching providers. +- Provider Sync to switch model_provider without losing historical conversations. - GitHub Release updates. Both the manager and silent launcher can detect available updates. - Windows single instance, no console window, administrator manifest, and system Desktop path detection. -- Separate macOS x64 and arm64 DMGs. The silent launcher hides its Dock icon. +- Separate macOS x64 and arm64 DMGs, automatic Codex bundle detection, and a hidden Dock icon for the silent launcher. + +## Project Stats + +

+ Codex++ contributors +

+ +

+ Codex++ star history +

## Relay Injection @@ -54,8 +64,9 @@ In the manager's Relay Injection page: 1. Make sure ChatGPT login status is detected. 2. Add one or more relay profiles with Base URL and Key. -3. Select the active profile and apply relay injection. -4. Launch `Codex++`. +3. Enable image generation for the current relay only when that relay group has image permission. To use a separate image-generation provider, enter the image Base URL and Key. +4. Select the active profile and apply relay injection. +5. Launch `Codex++`. Codex++ writes configuration similar to this into `~/.codex/config.toml`: @@ -66,10 +77,16 @@ model_provider = "CodexPlusPlus" name = "CodexPlusPlus" wire_api = "responses" requires_openai_auth = true -base_url = "https://example.com/v1" +base_url = "http://127.0.0.1:57323/v1" +disabled_tools = ["image_generation"] +codex_plus_text_base_url = "https://example.com/v1" experimental_bearer_token = "sk-..." ``` +When image generation is disabled, Codex++ writes the provider through a local `127.0.0.1:57323` proxy. The proxy removes the `image_generation` tool before forwarding regular requests to the current relay, preventing relays without image-generation permission from rejecting Codex session requests with `Image generation is not enabled for this group`. + +When the manager is configured to use a separate image-generation API, the same local proxy forwards regular requests to the current relay and forwards Responses requests containing the `image_generation` tool to the image-generation API. + To return to the official login mode, use the clear API mode button in the Relay Injection page. This removes `OPENAI_API_KEY` related configuration and switches Codex back to official ChatGPT authentication. ## Enhancements @@ -127,6 +144,10 @@ Unsigned and unnotarized builds may be blocked by Gatekeeper. Allow the app in S Yes. Releases provide both `macos-x64.dmg` and `macos-arm64.dmg`. Intel Macs should use the x64 package, while Apple Silicon Macs should use the arm64 package. +### Codex++ cannot find Codex App on macOS + +Codex++ checks `/Applications` first, then `~/Applications`, and identifies Codex through the OpenAI Codex bundle identifier in `Info.plist`. If Codex is installed somewhere else, set the Codex App path in the manager or launch with `--app-path "/path/to/Codex.app"`. + ## Development ```bash @@ -141,6 +162,9 @@ cd ../.. cargo fmt --check cargo test cargo build --release + +# macOS package +scripts/installer/macos/package-dmg.sh 1.1.1 arm64 ``` Project structure: From 80146a3e54991eabcb313f2dd88b97798b83d68d Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:03:54 +0800 Subject: [PATCH 05/28] update apps/codex-plus-launcher/src/main.rs Part of relay image proxy and macOS packaging update. --- apps/codex-plus-launcher/src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index cf1e48c..f299939 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -6,6 +6,7 @@ use codex_plus_core::launcher::{ }; use codex_plus_core::models::{DeleteResult, ExportResult, SessionRef}; use codex_plus_core::routes::{BridgeContext, BridgeDataService, BridgeRuntimeService}; +use codex_plus_core::settings::BackendSettings; use codex_plus_core::user_scripts::UserScriptManager; use serde_json::{Value, json}; #[cfg(windows)] @@ -162,8 +163,12 @@ impl LaunchHooks for LauncherHooks { Ok(()) } - async fn start_helper(&self, helper_port: u16) -> anyhow::Result<()> { - self.core.start_helper(helper_port).await + async fn start_helper( + &self, + helper_port: u16, + settings: &BackendSettings, + ) -> anyhow::Result<()> { + self.core.start_helper(helper_port, settings).await } async fn launch_codex( From 3eb1f06bb75712020ecaacda1d60e9a74257b38b Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:00 +0800 Subject: [PATCH 06/28] update apps/codex-plus-manager/src-tauri/src/commands.rs Part of relay image proxy and macOS packaging update. --- apps/codex-plus-manager/src-tauri/src/commands.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index bb72d2a..9df99df 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; +use codex_plus_core::relay_config::RelayApplyOptions; use codex_plus_core::settings::{BackendSettings, SettingsStore}; use codex_plus_core::status::{LaunchStatus, StatusStore}; use codex_plus_core::user_scripts::UserScriptManager; @@ -566,11 +567,15 @@ pub fn apply_relay_injection() -> CommandResult { let settings = SettingsStore::default().load().unwrap_or_default(); let relay = settings.active_relay_profile(); - match codex_plus_core::relay_config::apply_relay_config_to_home( - &home, - &relay.base_url, - &relay.api_key, - ) { + let options = RelayApplyOptions { + base_url: relay.base_url, + bearer_token: relay.api_key, + image_generation_enabled: relay.image_generation_enabled, + image_generation_use_separate_api: relay.image_generation_use_separate_api, + image_generation_base_url: relay.image_generation_base_url, + image_generation_bearer_token: relay.image_generation_api_key, + }; + match codex_plus_core::relay_config::apply_relay_config_to_home_with_options(&home, &options) { Ok(result) => { let status = codex_plus_core::relay_config::relay_status_from_home(&home); ok( From 080e3e3b5e339d94e9337c217ee30f5a8c94162e Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:06 +0800 Subject: [PATCH 07/28] update apps/codex-plus-manager/src/styles.css Part of relay image proxy and macOS packaging update. --- apps/codex-plus-manager/src/styles.css | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index 22c1db1..0407a78 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -457,6 +457,12 @@ body { align-items: center; } +.relay-profile-actions { + display: flex; + align-items: center; + gap: 8px; +} + .relay-select { display: grid; gap: 3px; @@ -468,6 +474,42 @@ body { text-align: left; } +.image-chip { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid hsl(var(--border)); + border-radius: 999px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1; + padding: 6px 9px; + white-space: nowrap; +} + +.image-chip.enabled { + border-color: hsl(172 66% 42%); + color: hsl(var(--foreground)); + background: hsl(172 54% 18% / 0.2); +} + +.image-relay-settings { + display: grid; + gap: 10px; + border-top: 1px solid hsl(var(--border)); + margin-top: 12px; + padding-top: 12px; +} + +.compact-switch { + padding: 0; +} + +.image-fields { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + margin-top: 0; +} + .mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); From 9bb576e8737ec2edec43f1f8d5dd28f520e46c34 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:12 +0800 Subject: [PATCH 08/28] update codex_session_delete/app_paths.py Part of relay image proxy and macOS packaging update. --- codex_session_delete/app_paths.py | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/codex_session_delete/app_paths.py b/codex_session_delete/app_paths.py index 802b3d1..3ea340f 100644 --- a/codex_session_delete/app_paths.py +++ b/codex_session_delete/app_paths.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import plistlib import re import sys import subprocess @@ -71,12 +72,52 @@ def _macos_app_candidates(root: Path) -> list[Path]: return [root / name for name in names] +_MACOS_BUNDLE_IDENTIFIERS = { + "com.openai.codex", + "com.openai.chatgpt.codex", + "com.openai.chatgpt", +} + + +def _macos_bundle_identifier(path: Path) -> str | None: + plist_path = path / "Contents" / "Info.plist" + try: + with plist_path.open("rb") as handle: + plist = plistlib.load(handle) + except (OSError, plistlib.InvalidFileException): + return None + identifier = plist.get("CFBundleIdentifier") + return identifier if isinstance(identifier, str) else None + + +def _is_macos_codex_app(path: Path) -> bool: + if not path.is_dir() or path.suffix != ".app": + return False + identifier = _macos_bundle_identifier(path) + if identifier in _MACOS_BUNDLE_IDENTIFIERS: + return True + name = path.stem.lower() + return "codex" in name and "codex++" not in name + + +def _scan_macos_codex_apps(root: Path) -> list[Path]: + try: + return sorted( + (path for path in root.iterdir() if _is_macos_codex_app(path)), + key=lambda path: path.name.lower(), + ) + except OSError: + return [] + + def find_macos_codex_app(candidates: list[Path] | None = None) -> Path | None: search = candidates or [Path("/Applications"), Path.home() / "Applications"] for root in search: for path in _macos_app_candidates(root): - if path.is_dir(): + if _is_macos_codex_app(path): return path + for path in _scan_macos_codex_apps(root): + return path return None From eabd67b1ab9769591c7713832ad2b2ffce15f4a3 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:18 +0800 Subject: [PATCH 09/28] update codex_session_delete/macos_installer.py Part of relay image proxy and macOS packaging update. --- codex_session_delete/macos_installer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex_session_delete/macos_installer.py b/codex_session_delete/macos_installer.py index cd1ab7d..9217bb3 100644 --- a/codex_session_delete/macos_installer.py +++ b/codex_session_delete/macos_installer.py @@ -12,6 +12,7 @@ from codex_session_delete.app_paths import find_macos_codex_app ICON_ASSET = Path(__file__).resolve().parent / "assets" / "codex-plus-plus.png" +DEFAULT_GUI_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" if TYPE_CHECKING: from codex_session_delete.installers import InstallOptions @@ -58,7 +59,10 @@ def install_macos_app(options: "InstallOptions") -> None: (contents / "Info.plist").write_bytes(plistlib.dumps(plist)) executable = macos / EXECUTABLE_NAME - executable.write_text(f"#!/bin/sh\nexec {_launcher_command(options)}\n", encoding="utf-8") + executable.write_text( + f"#!/bin/sh\nexport PATH=\"${{PATH:-{DEFAULT_GUI_PATH}}}:{DEFAULT_GUI_PATH}\"\nexec {_launcher_command(options)}\n", + encoding="utf-8", + ) executable.chmod(executable.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) _copy_codex_icon(resources) From 864aa9bff95aad4b2687cedb6ca52c037a1f16dc Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:23 +0800 Subject: [PATCH 10/28] update crates/codex-plus-core/src/app_paths.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/app_paths.rs | 85 +++++++++++++++++++++---- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/codex-plus-core/src/app_paths.rs b/crates/codex-plus-core/src/app_paths.rs index 96b4064..70f64f9 100644 --- a/crates/codex-plus-core/src/app_paths.rs +++ b/crates/codex-plus-core/src/app_paths.rs @@ -73,10 +73,13 @@ pub fn user_data_candidates_from(local: Option<&Path>, roaming: Option<&Path>) - pub fn find_macos_codex_app(search_roots: &[PathBuf]) -> Option { for root in search_roots { for candidate in macos_app_candidates(root) { - if candidate.is_dir() { + if is_macos_codex_app(&candidate) { return Some(candidate); } } + for candidate in scan_macos_codex_apps(root) { + return Some(candidate); + } } None } @@ -102,6 +105,9 @@ pub fn resolve_codex_app_dir(app_dir: Option<&Path>) -> Option { pub fn build_codex_executable(app_dir: &Path) -> PathBuf { if app_dir.extension() == Some(OsStr::new("app")) { + if let Some(executable) = macos_bundle_executable(app_dir) { + return executable; + } return app_dir.join("Contents").join("MacOS").join("Codex"); } let upper = app_dir.join("Codex.exe"); @@ -129,16 +135,7 @@ pub fn codex_app_version(app_dir: &Path) -> Option { } pub fn packaged_app_user_model_id(app_dir: &Path) -> Option { - let package_dir = if app_dir - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| name.eq_ignore_ascii_case("app")) - { - app_dir.parent()? - } else { - app_dir - }; - let package_name = package_dir.file_name()?.to_str()?; + let package_name = windows_package_name_from_path(app_dir)?; if !package_name.starts_with("OpenAI.Codex_") || !package_name.contains("__") { return None; } @@ -150,6 +147,15 @@ pub fn packaged_app_user_model_id(app_dir: &Path) -> Option { Some(format!("{identity_name}_{publisher_id}!App")) } +fn windows_package_name_from_path(path: &Path) -> Option { + let text = path.to_string_lossy(); + let mut parts = text.split(['/', '\\']).filter(|part| !part.is_empty()); + match parts.next_back()? { + "app" | "App" | "APP" => parts.next_back().map(ToString::to_string), + package => Some(package.to_string()), + } +} + fn codex_package_version(package_dir: &Path) -> Option { let path = package_dir.to_string_lossy().replace('\\', "/"); let name = path @@ -166,9 +172,22 @@ fn codex_package_version(package_dir: &Path) -> Option { } fn macos_app_version(app_dir: &Path) -> Option { + macos_plist_string_value(app_dir, "CFBundleShortVersionString") + .or_else(|| macos_plist_string_value(app_dir, "CFBundleVersion")) +} + +fn macos_bundle_executable(app_dir: &Path) -> Option { + let executable = macos_plist_string_value(app_dir, "CFBundleExecutable")?; + Some(app_dir.join("Contents").join("MacOS").join(executable)) +} + +fn macos_bundle_identifier(app_dir: &Path) -> Option { + macos_plist_string_value(app_dir, "CFBundleIdentifier") +} + +fn macos_plist_string_value(app_dir: &Path, key: &str) -> Option { let plist = std::fs::read_to_string(app_dir.join("Contents").join("Info.plist")).ok()?; - plist_string_value(&plist, "CFBundleShortVersionString") - .or_else(|| plist_string_value(&plist, "CFBundleVersion")) + plist_string_value(&plist, key) } fn plist_string_value(plist: &str, key: &str) -> Option { @@ -199,6 +218,46 @@ fn macos_app_candidates(root: &Path) -> Vec { .collect() } +fn scan_macos_codex_apps(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut apps = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| is_macos_codex_app(path)) + .collect::>(); + apps.sort_by_key(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default() + }); + apps +} + +fn is_macos_codex_app(path: &Path) -> bool { + if !path.is_dir() || path.extension() != Some(OsStr::new("app")) { + return false; + } + if macos_bundle_identifier(path) + .as_deref() + .is_some_and(|identifier| { + matches!( + identifier, + "com.openai.codex" | "com.openai.chatgpt.codex" | "com.openai.chatgpt" + ) + }) + { + return true; + } + let name = path + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_lowercase(); + name.contains("codex") && !name.contains("codex++") +} + fn version_tuple(path: &Path) -> Option> { let name = path.file_name()?.to_str()?; let rest = name.strip_prefix("OpenAI.Codex_")?; From ae2008f801cf60b9e7f6021dfbbb329cb4a63bac Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:30 +0800 Subject: [PATCH 11/28] update crates/codex-plus-core/src/cli_wrapper.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/cli_wrapper.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/codex-plus-core/src/cli_wrapper.rs b/crates/codex-plus-core/src/cli_wrapper.rs index ff940bf..f6feabd 100644 --- a/crates/codex-plus-core/src/cli_wrapper.rs +++ b/crates/codex-plus-core/src/cli_wrapper.rs @@ -143,6 +143,10 @@ pub fn wrapper_dir() -> PathBuf { } pub fn wrapper_dir_from_roaming(roaming: &Path) -> PathBuf { + let roaming_text = roaming.to_string_lossy(); + if roaming_text.contains('\\') { + return PathBuf::from(format!("{roaming_text}\\Codex++")); + } roaming.join("Codex++") } From cf259db426f5c00849849b1654d12d5ce0d9f825 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:37 +0800 Subject: [PATCH 12/28] update crates/codex-plus-core/src/install/macos.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/install/macos.rs | 44 ++++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/codex-plus-core/src/install/macos.rs b/crates/codex-plus-core/src/install/macos.rs index bbf8db2..96b9fae 100644 --- a/crates/codex-plus-core/src/install/macos.rs +++ b/crates/codex-plus-core/src/install/macos.rs @@ -35,7 +35,12 @@ pub fn build_app_bundle(options: &InstallOptions, manager: bool) -> MacosAppBund MacosAppBundle { app_path: install_root.join(format!("{display_name}.app")), info_plist: info_plist(display_name, executable_name, identifier_suffix), - launch_script: format!("#!/bin/sh\nexec \"{}\"\n", target.to_string_lossy()), + launch_script: format!( + "#!/bin/sh\nexport PATH=\"${{PATH:-{}}}:{}\"\nexec \"{}\"\n", + default_gui_path(), + default_gui_path(), + target.to_string_lossy() + ), } } @@ -87,12 +92,35 @@ fn write_bundle(bundle: &MacosAppBundle) -> anyhow::Result<()> { #[cfg(target_os = "macos")] fn copy_icon(resources: &Path) -> anyhow::Result<()> { - let source = std::env::current_exe() + let Some(bundle_dir) = std::env::current_exe() .ok() .and_then(|path| path.parent().map(Path::to_path_buf)) - .map(|path| path.join("codex-plus-plus.png")); - if let Some(source) = source.filter(|path| path.exists()) { - fs::copy(source, resources.join("codex-plus-plus.png"))?; + else { + return Ok(()); + }; + for icns in [ + bundle_dir.join("codex-plus-plus.icns"), + bundle_dir + .parent() + .map(|contents| contents.join("Resources").join("codex-plus-plus.icns")) + .unwrap_or_default(), + ] { + if icns.exists() { + fs::copy(icns, resources.join("codex-plus-plus.icns"))?; + return Ok(()); + } + } + for png in [ + bundle_dir.join("codex-plus-plus.png"), + bundle_dir + .parent() + .map(|contents| contents.join("Resources").join("codex-plus-plus.png")) + .unwrap_or_default(), + ] { + if png.exists() { + fs::copy(png, resources.join("codex-plus-plus.png"))?; + return Ok(()); + } } Ok(()) } @@ -130,7 +158,7 @@ fn info_plist(display_name: &str, executable_name: &str, identifier_suffix: &str CFBundleExecutable {executable_name} CFBundleIconFile - codex-plus-plus.png + codex-plus-plus LSUIElement LSMinimumSystemVersion @@ -139,3 +167,7 @@ fn info_plist(display_name: &str, executable_name: &str, identifier_suffix: &str "# ) } + +fn default_gui_path() -> &'static str { + "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +} From 3d6167df858c1c143416994b78d1ec3a53ee1357 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:44 +0800 Subject: [PATCH 13/28] update crates/codex-plus-core/src/settings.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/settings.rs | 66 +++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index ec262d2..20aa17c 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -22,6 +22,14 @@ pub struct RelayProfile { pub base_url: String, #[serde(default)] pub api_key: String, + #[serde(default)] + pub image_generation_enabled: bool, + #[serde(default)] + pub image_generation_use_separate_api: bool, + #[serde(default)] + pub image_generation_base_url: String, + #[serde(default)] + pub image_generation_api_key: String, } impl Default for RelayProfile { @@ -31,10 +39,29 @@ impl Default for RelayProfile { name: "默认中转".to_string(), base_url: default_relay_base_url(), api_key: String::new(), + image_generation_enabled: false, + image_generation_use_separate_api: false, + image_generation_base_url: String::new(), + image_generation_api_key: String::new(), } } } +impl RelayProfile { + pub fn uses_separate_image_generation_api(&self) -> bool { + self.image_generation_enabled + && self.image_generation_use_separate_api + && !self.image_generation_base_url.trim().is_empty() + && !self.image_generation_api_key.trim().is_empty() + } + + pub fn needs_local_relay_proxy(&self) -> bool { + !self.base_url.trim().is_empty() + && !self.api_key.trim().is_empty() + && (!self.image_generation_enabled || self.uses_separate_image_generation_api()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct BackendSettings { #[serde(rename = "providerSyncEnabled", default)] @@ -99,6 +126,7 @@ impl BackendSettings { self.relay_base_url.clone() }, api_key: self.relay_api_key.clone(), + ..RelayProfile::default() }; } @@ -123,8 +151,13 @@ impl BackendSettings { self.relay_base_url.clone() }, api_key: self.relay_api_key.clone(), + ..RelayProfile::default() } } + + pub fn requires_helper_runtime(&self) -> bool { + self.enhancements_enabled || self.active_relay_profile().needs_local_relay_proxy() + } } pub fn default_api_key_env() -> String { @@ -463,7 +496,11 @@ mod tests { "id": "relay-b", "name": "中转 B", "baseUrl": "https://relay-b.example/v1", - "apiKey": "sk-b" + "apiKey": "sk-b", + "imageGenerationEnabled": true, + "imageGenerationUseSeparateApi": true, + "imageGenerationBaseUrl": "https://image.example/v1", + "imageGenerationApiKey": "sk-image" } ], "activeRelayId": "relay-b" @@ -476,6 +513,33 @@ mod tests { assert_eq!(active.name, "中转 B"); assert_eq!(active.base_url, "https://relay-b.example/v1"); assert_eq!(active.api_key, "sk-b"); + assert!(active.image_generation_enabled); + assert!(active.image_generation_use_separate_api); + assert_eq!(active.image_generation_base_url, "https://image.example/v1"); + assert_eq!(active.image_generation_api_key, "sk-image"); + assert!(active.uses_separate_image_generation_api()); + } + + #[test] + fn requires_helper_runtime_when_relay_needs_local_proxy_for_image_control() { + let settings = BackendSettings { + launch_mode: LaunchMode::Relay, + enhancements_enabled: false, + relay_profiles: vec![RelayProfile { + id: "relay-a".to_string(), + name: "中转 A".to_string(), + base_url: "https://relay.example/v1".to_string(), + api_key: "sk-relay".to_string(), + image_generation_enabled: false, + image_generation_use_separate_api: false, + image_generation_base_url: String::new(), + image_generation_api_key: String::new(), + }], + active_relay_id: "relay-a".to_string(), + ..BackendSettings::default() + }; + + assert!(settings.requires_helper_runtime()); } #[test] From 8d7e91d5ba7ab4182e67fef295486e861fb44596 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:51 +0800 Subject: [PATCH 14/28] update crates/codex-plus-core/tests/installers.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/tests/installers.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/codex-plus-core/tests/installers.rs b/crates/codex-plus-core/tests/installers.rs index c26f700..d117817 100644 --- a/crates/codex-plus-core/tests/installers.rs +++ b/crates/codex-plus-core/tests/installers.rs @@ -66,6 +66,12 @@ fn macos_bundle_metadata_contains_silent_and_manager_apps() { ); assert!(silent.launch_script.contains("codex-plus-plus")); assert!(manager.launch_script.contains("codex-plus-plus-manager")); + assert!(silent.launch_script.contains("/opt/homebrew/bin")); + assert!( + silent + .info_plist + .contains("codex-plus-plus") + ); } #[test] From cefeac4bcd6b3f037f677f3949e3b2d7f6ea0a2f Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:04:59 +0800 Subject: [PATCH 15/28] update scripts/installer/macos/package-dmg.sh Part of relay image proxy and macOS packaging update. --- scripts/installer/macos/package-dmg.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/installer/macos/package-dmg.sh b/scripts/installer/macos/package-dmg.sh index dda3b81..790439e 100644 --- a/scripts/installer/macos/package-dmg.sh +++ b/scripts/installer/macos/package-dmg.sh @@ -2,16 +2,23 @@ set -euo pipefail VERSION="${1:-0.0.0}" -ARCH="${2:-$(uname -m)}" +INPUT_ARCH="${2:-$(uname -m)}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" DIST="$ROOT/dist/macos" STAGE="$DIST/stage" BINARY_DIR="${BINARY_DIR:-$ROOT/target/release}" -DMG="$DIST/CodexPlusPlus-${VERSION}-macos-${ARCH}.dmg" ICON_SOURCE="$ROOT/apps/codex-plus-manager/src-tauri/icons/icon.png" ICON_NAME="codex-plus-plus.icns" ICON_ICNS="$DIST/$ICON_NAME" +case "$INPUT_ARCH" in + arm64|aarch64) ARCH="arm64" ;; + x86_64|amd64|x64) ARCH="x64" ;; + *) ARCH="$INPUT_ARCH" ;; +esac + +DMG="$DIST/CodexPlusPlus-${VERSION}-macos-${ARCH}.dmg" + rm -rf "$DIST" mkdir -p "$STAGE" @@ -79,6 +86,7 @@ PLIST prepare_icon create_app "Codex++" "CodexPlusPlus" "$BINARY_DIR/codex-plus-plus" "com.bigpizzav3.codexplusplus" "true" create_app "Codex++ 管理工具" "CodexPlusPlusManager" "$BINARY_DIR/codex-plus-plus-manager" "com.bigpizzav3.codexplusplus.manager" "false" +ln -s /Applications "$STAGE/Applications" hdiutil create -volname "Codex++" -srcfolder "$STAGE" -ov -format UDZO "$DMG" echo "$DMG" From f742c99e53d42e7a1498850ad56eedbba69ea2e3 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:05:05 +0800 Subject: [PATCH 16/28] update tests/test_app_paths.py Part of relay image proxy and macOS packaging update. --- tests/test_app_paths.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index cd1ecda..b836d56 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -41,6 +41,38 @@ def test_find_macos_codex_app_detects_openai_codex_bundle(tmp_path): assert result == openai_app +def test_find_macos_codex_app_uses_bundle_identifier_when_name_changes(tmp_path): + renamed_app = tmp_path / "Applications" / "AI Workbench.app" + contents = renamed_app / "Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_text( + """ + + + CFBundleIdentifier + com.openai.codex + + +""", + encoding="utf-8", + ) + + result = find_macos_codex_app([tmp_path / "Applications"]) + + assert result == renamed_app + + +def test_find_macos_codex_app_ignores_codex_plus_plus_bundle(tmp_path): + codex_plus = tmp_path / "Applications" / "Codex++.app" + codex = tmp_path / "Applications" / "Codex.app" + codex_plus.mkdir(parents=True) + codex.mkdir(parents=True) + + result = find_macos_codex_app([tmp_path / "Applications"]) + + assert result == codex + + def test_find_macos_codex_app_returns_none_when_missing(tmp_path): result = find_macos_codex_app([tmp_path / "Applications" / "Codex.app"]) From 704ba18ca0a402a5714fb1e28c08c01e3d0b06d5 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:05:10 +0800 Subject: [PATCH 17/28] update tests/test_macos_installer.py Part of relay image proxy and macOS packaging update. --- tests/test_macos_installer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_macos_installer.py b/tests/test_macos_installer.py index 1fbd186..d095e4b 100644 --- a/tests/test_macos_installer.py +++ b/tests/test_macos_installer.py @@ -31,6 +31,8 @@ def test_install_macos_app_creates_app_bundle(tmp_path): assert (app / "Contents" / "Resources" / "codex-plus-plus.png").exists() script = executable.read_text(encoding="utf-8") + assert "/opt/homebrew/bin" in script + assert "/usr/local/bin" in script assert "python -m codex_session_delete launch" in script assert "exec" in script From f7c4e671fb10bcd6e67a53ac2a287a4bbe527a79 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:05:16 +0800 Subject: [PATCH 18/28] update tests/test_macos_package_dmg.py Part of relay image proxy and macOS packaging update. --- tests/tests/test_macos_package_dmg.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/tests/test_macos_package_dmg.py diff --git a/tests/tests/test_macos_package_dmg.py b/tests/tests/test_macos_package_dmg.py new file mode 100644 index 0000000..d790f89 --- /dev/null +++ b/tests/tests/test_macos_package_dmg.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +def test_macos_package_dmg_normalizes_arch_and_adds_applications_link(): + text = Path("scripts/installer/macos/package-dmg.sh").read_text(encoding="utf-8") + + assert 'arm64|aarch64) ARCH="arm64"' in text + assert 'x86_64|amd64|x64) ARCH="x64"' in text + assert 'ln -s /Applications "$STAGE/Applications"' in text + assert 'CodexPlusPlus-${VERSION}-macos-${ARCH}.dmg' in text From a345bb98d5701cef59aa057c45f9a9c1970fd344 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:05:37 +0800 Subject: [PATCH 19/28] update apps/codex-plus-manager/src/App.tsx Part of relay image proxy and macOS packaging update. --- apps/codex-plus-manager/src/App.tsx | 126 +++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 8acb277..5b49b87 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -3,6 +3,7 @@ import { Activity, Bell, CheckCircle2, + Image, Info, ExternalLink, Hammer, @@ -85,6 +86,10 @@ type RelayProfile = { name: string; baseUrl: string; apiKey: string; + imageGenerationEnabled: boolean; + imageGenerationUseSeparateApi: boolean; + imageGenerationBaseUrl: string; + imageGenerationApiKey: string; }; type UserScriptInventory = { @@ -195,6 +200,10 @@ const defaultSettings: BackendSettings = { name: "默认中转", baseUrl: "", apiKey: "", + imageGenerationEnabled: false, + imageGenerationUseSeparateApi: false, + imageGenerationBaseUrl: "", + imageGenerationApiKey: "", }, ], activeRelayId: "default", @@ -814,6 +823,7 @@ function RelayScreen({ "先在 Codex/ChatGPT 中使用正常 ChatGPT 账号登录,软件只读取 auth.json 判断登录态。", "在下方添加一个中转,填写 Base URL 和 Key,然后点选它作为当前中转。", "点击“写入当前中转”,会把 Codex 配置切到 CodexPlusPlus provider,并启用 ChatGPT 登录态混合中转。", + "图片生成默认关闭;关闭时会经过本地代理裁剪 image_generation 工具,避免无图片权限中转返回 403。", "如果需要回到官方登录态,点击“切回官方登录模式”后再打开 Codex++ 登录官方账号。", "需要切换中转时,点选列表里的另一项并保存,再重新写入即可。", ]} @@ -824,6 +834,16 @@ function RelayScreen({ + @@ -1321,6 +1341,15 @@ function RelayProfileList({ {profile.name || "未命名中转"} {profile.baseUrl || "未填写 URL"} +
+ + + {profile.imageGenerationEnabled + ? profile.imageGenerationUseSeparateApi + ? "图片独立 API" + : "图片走当前中转" + : "图片关闭"} + +
@@ -1354,6 +1384,80 @@ function RelayProfileList({ />
+
+ + {profile.imageGenerationEnabled ? ( + <> + + {profile.imageGenerationUseSeparateApi ? ( +
+ + + onFormChange( + updateRelayProfile(form, profile.id, { + imageGenerationBaseUrl: event.currentTarget.value, + }), + ) + } + placeholder="填写支持图片生成的 Base URL" + /> + + + + onFormChange( + updateRelayProfile(form, profile.id, { + imageGenerationApiKey: event.currentTarget.value, + }), + ) + } + placeholder="输入图片生成 API Key" + /> + +
+ ) : null} + + ) : null} +
))} @@ -1630,12 +1734,17 @@ function normalizeSettings(settings: BackendSettings): BackendSettings { name: "默认中转", baseUrl: settings.relayBaseUrl || defaultSettings.relayBaseUrl, apiKey: settings.relayApiKey || "", + imageGenerationEnabled: false, + imageGenerationUseSeparateApi: false, + imageGenerationBaseUrl: "", + imageGenerationApiKey: "", }, ]; + const normalizedProfiles = profiles.map(normalizeRelayProfile); const activeRelayId = profiles.some((profile) => profile.id === settings.activeRelayId) ? settings.activeRelayId : profiles[0]?.id || "default"; - return syncLegacyRelayFields({ ...defaultSettings, ...settings, relayProfiles: profiles, activeRelayId }); + return syncLegacyRelayFields({ ...defaultSettings, ...settings, relayProfiles: normalizedProfiles, activeRelayId }); } function activeRelayProfile(settings: BackendSettings): RelayProfile { @@ -1646,6 +1755,17 @@ function activeRelayProfile(settings: BackendSettings): RelayProfile { ); } +function normalizeRelayProfile(profile: RelayProfile): RelayProfile { + return { + ...defaultSettings.relayProfiles[0], + ...profile, + imageGenerationEnabled: Boolean(profile.imageGenerationEnabled), + imageGenerationUseSeparateApi: Boolean(profile.imageGenerationUseSeparateApi), + imageGenerationBaseUrl: profile.imageGenerationBaseUrl || "", + imageGenerationApiKey: profile.imageGenerationApiKey || "", + }; +} + function syncLegacyRelayFields(settings: BackendSettings): BackendSettings { const active = activeRelayProfile(settings); return { @@ -1670,6 +1790,10 @@ function addRelayProfile(settings: BackendSettings): BackendSettings { name: `中转 ${settings.relayProfiles.length + 1}`, baseUrl: defaultSettings.relayBaseUrl, apiKey: "", + imageGenerationEnabled: false, + imageGenerationUseSeparateApi: false, + imageGenerationBaseUrl: "", + imageGenerationApiKey: "", }; return syncLegacyRelayFields({ ...settings, From 9b554dace350d2558ff9b6552f8108d7def4e5b8 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:09:36 +0800 Subject: [PATCH 20/28] update crates/codex-plus-core/src/launcher.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/launcher.rs | 261 ++++++++++++++++++------- 1 file changed, 192 insertions(+), 69 deletions(-) diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 539352f..ead2b76 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -118,8 +118,15 @@ pub trait LaunchHooks: Send + Sync { fn select_debug_port(&self, requested: u16) -> u16; fn select_helper_port(&self, requested: u16) -> u16; async fn load_settings(&self) -> anyhow::Result; + async fn ensure_relay_proxy_config(&self, _settings: &BackendSettings) -> anyhow::Result<()> { + Ok(()) + } async fn run_provider_sync(&self) -> anyhow::Result<()>; - async fn start_helper(&self, helper_port: u16) -> anyhow::Result<()>; + async fn start_helper( + &self, + helper_port: u16, + settings: &BackendSettings, + ) -> anyhow::Result<()>; async fn launch_codex(&self, app_dir: &Path, debug_port: u16) -> anyhow::Result; async fn bridge_context( &self, @@ -145,7 +152,7 @@ pub trait LaunchHooks: Send + Sync { #[derive(Default)] pub struct DefaultLaunchHooks { child: Mutex>, - helper: Mutex>, + runtime: Mutex>, } struct HelperRuntime { @@ -153,6 +160,11 @@ struct HelperRuntime { task: tokio::task::JoinHandle<()>, } +struct LauncherRuntime { + helper: Option, + relay_proxy: Option, +} + pub async fn launch_and_inject(options: LaunchOptions) -> anyhow::Result { launch_and_inject_with_hooks(options, DefaultLaunchHooks::shared()).await } @@ -170,7 +182,7 @@ where let helper_port = hooks.select_helper_port(options.helper_port); let settings = hooks.load_settings().await?; let status_store = options.status_store.clone(); - let mut helper_started = false; + let mut runtime_started = false; let mut launched = None; let result: anyhow::Result = async { @@ -178,9 +190,11 @@ where hooks.run_provider_sync().await?; } - if settings.enhancements_enabled { - hooks.start_helper(helper_port).await?; - helper_started = true; + hooks.ensure_relay_proxy_config(&settings).await?; + + if settings.requires_helper_runtime() { + hooks.start_helper(helper_port, &settings).await?; + runtime_started = true; } let launch = hooks.launch_codex(&app_dir, debug_port).await?; @@ -209,7 +223,7 @@ where app_dir: app_dir.clone(), launch, status_store: status_store.clone(), - helper_started, + helper_started: runtime_started, hooks: Arc::clone(&hooks), }) } @@ -218,7 +232,7 @@ where match result { Ok(handle) => Ok(handle), Err(error) => { - if helper_started { + if runtime_started { hooks.shutdown_helper(helper_port).await; } if let Some(launch) = &launched { @@ -283,39 +297,34 @@ impl LaunchHooks for DefaultLaunchHooks { SettingsStore::default().load() } + async fn ensure_relay_proxy_config(&self, settings: &BackendSettings) -> anyhow::Result<()> { + ensure_default_relay_proxy_config(settings) + } + async fn run_provider_sync(&self) -> anyhow::Result<()> { anyhow::bail!("provider sync requires launcher hooks with codex-plus-data integration") } - async fn start_helper(&self, helper_port: u16) -> anyhow::Result<()> { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", helper_port)) - .await - .with_context(|| format!("failed to bind helper runtime on 127.0.0.1:{helper_port}"))?; - let _ = crate::diagnostic_log::append_diagnostic_log( - "helper.listening", - serde_json::json!({ - "helper_port": helper_port, - "address": format!("http://127.0.0.1:{helper_port}") - }), - ); - let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); - let task = tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accepted = listener.accept() => { - if let Ok((stream, addr)) = accepted { - tokio::spawn(async move { - let _ = handle_helper_connection(stream, Some(addr)).await; - }); - } - } - } - } - }); - *self.helper.lock().await = Some(HelperRuntime { - shutdown: shutdown_tx, - task, + async fn start_helper( + &self, + helper_port: u16, + settings: &BackendSettings, + ) -> anyhow::Result<()> { + let helper = if settings.enhancements_enabled { + Some(start_helper_runtime(helper_port).await?) + } else { + None + }; + let relay_proxy = if let Some(config) = + crate::relay_proxy::RelayProxyConfig::from_profile(&settings.active_relay_profile()) + { + Some(crate::relay_proxy::start_local_relay_proxy(config).await?) + } else { + None + }; + *self.runtime.lock().await = Some(LauncherRuntime { + helper, + relay_proxy, }); Ok(()) } @@ -422,9 +431,14 @@ impl LaunchHooks for DefaultLaunchHooks { } async fn shutdown_helper(&self, _helper_port: u16) { - if let Some(runtime) = self.helper.lock().await.take() { - let _ = runtime.shutdown.send(()); - let _ = runtime.task.await; + if let Some(runtime) = self.runtime.lock().await.take() { + if let Some(helper) = runtime.helper { + let _ = helper.shutdown.send(()); + let _ = helper.task.await; + } + if let Some(relay_proxy) = runtime.relay_proxy { + relay_proxy.shutdown().await; + } } } @@ -467,29 +481,22 @@ async fn handle_helper_connection( mut stream: tokio::net::TcpStream, remote_addr: Option, ) -> anyhow::Result<()> { - let mut buffer = vec![0_u8; 4096]; - let read = stream.read(&mut buffer).await?; - let request = String::from_utf8_lossy(&buffer[..read]); - let request_line = request.lines().next().unwrap_or_default(); - let mut parts = request_line.split_whitespace(); - let method = parts.next().unwrap_or_default(); - let path = parts.next().unwrap_or_default(); - let request_body = http_request_body(&request); + let request = read_helper_http_request(&mut stream).await?; let remote_addr_text = remote_addr.map(|addr| addr.to_string()); let _ = crate::diagnostic_log::append_diagnostic_log( "helper.request", serde_json::json!({ - "method": method, - "path": path, - "request_line": request_line, + "method": request.method, + "path": request.path, + "request_line": request.request_line, "remote_addr": remote_addr_text, - "body_bytes": request_body.len() + "body_bytes": request.body.len() }), ); - let (status, body, log_event) = if path == "/backend/status" - && matches!(method, "GET" | "POST" | "OPTIONS") + let (status, body, log_event) = if request.path == "/backend/status" + && matches!(request.method.as_str(), "GET" | "POST" | "OPTIONS") { ( "200 OK", @@ -501,15 +508,18 @@ async fn handle_helper_connection( }))?, "helper.backend_status_ok", ) - } else if path == "/diagnostics/log" && matches!(method, "POST" | "OPTIONS") { - if method == "POST" { - let detail = - serde_json::from_str::(request_body).unwrap_or_else(|error| { + } else if request.path == "/diagnostics/log" + && matches!(request.method.as_str(), "POST" | "OPTIONS") + { + if request.method == "POST" { + let detail = serde_json::from_slice::(&request.body).unwrap_or_else( + |error| { serde_json::json!({ "parse_error": error.to_string(), - "raw": request_body + "raw": String::from_utf8_lossy(&request.body) }) - }); + }, + ); let event = detail .get("event") .and_then(serde_json::Value::as_str) @@ -539,13 +549,13 @@ async fn handle_helper_connection( let _ = crate::diagnostic_log::append_diagnostic_log( log_event, serde_json::json!({ - "method": method, - "path": path, + "method": request.method, + "path": request.path, "status": status, "remote_addr": remote_addr_text }), ); - let response = if method == "OPTIONS" { + let response = if request.method == "OPTIONS" { format!( "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ) @@ -556,18 +566,110 @@ async fn handle_helper_connection( ) }; stream.write_all(response.as_bytes()).await?; - if method != "OPTIONS" { + if request.method != "OPTIONS" { stream.write_all(&body).await?; } stream.shutdown().await?; Ok(()) } -fn http_request_body(request: &str) -> &str { - request - .split_once("\r\n\r\n") - .map(|(_, body)| body) - .unwrap_or_default() +#[derive(Debug, Clone)] +struct HelperHttpRequest { + method: String, + path: String, + request_line: String, + body: Vec, +} + +async fn read_helper_http_request( + stream: &mut tokio::net::TcpStream, +) -> anyhow::Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 4096]; + let header_end; + loop { + let read = stream.read(&mut temp).await?; + if read == 0 { + anyhow::bail!("empty helper request"); + } + buffer.extend_from_slice(&temp[..read]); + if let Some(end) = helper_header_end(&buffer) { + header_end = end; + break; + } + if buffer.len() > 1024 * 1024 { + anyhow::bail!("helper request headers are too large"); + } + } + + let head = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or_default().to_string(); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let path = parts.next().unwrap_or_default().to_string(); + let content_length = lines + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + if name.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + let mut body = buffer[header_end + 4..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut temp).await?; + if read == 0 { + break; + } + body.extend_from_slice(&temp[..read]); + } + body.truncate(content_length); + + Ok(HelperHttpRequest { + method, + path, + request_line, + body, + }) +} + +fn helper_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn start_helper_runtime(helper_port: u16) -> anyhow::Result { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", helper_port)) + .await + .with_context(|| format!("failed to bind helper runtime on 127.0.0.1:{helper_port}"))?; + let _ = crate::diagnostic_log::append_diagnostic_log( + "helper.listening", + serde_json::json!({ + "helper_port": helper_port, + "address": format!("http://127.0.0.1:{helper_port}") + }), + ); + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + if let Ok((stream, addr)) = accepted { + tokio::spawn(async move { + let _ = handle_helper_connection(stream, Some(addr)).await; + }); + } + } + } + } + }); + Ok(HelperRuntime { + shutdown: shutdown_tx, + task, + }) } fn sanitize_diagnostic_event(event: &str) -> String { @@ -636,6 +738,27 @@ pub fn codex_process_environment_from( env } +pub fn ensure_default_relay_proxy_config(settings: &BackendSettings) -> anyhow::Result<()> { + let codex_home = crate::relay_config::default_codex_home_dir(); + if !crate::relay_config::relay_provider_active_from_home(&codex_home) { + return Ok(()); + } + let relay = settings.active_relay_profile(); + if !relay.needs_local_relay_proxy() { + return Ok(()); + } + let options = crate::relay_config::RelayApplyOptions { + base_url: relay.base_url, + bearer_token: relay.api_key, + image_generation_enabled: relay.image_generation_enabled, + image_generation_use_separate_api: relay.image_generation_use_separate_api, + image_generation_base_url: relay.image_generation_base_url, + image_generation_bearer_token: relay.image_generation_api_key, + }; + crate::relay_config::apply_relay_config_to_home_with_options(&codex_home, &options)?; + Ok(()) +} + async fn retry_injection(debug_port: u16, helper_port: u16) -> anyhow::Result<()> { let mut last_error = None; for _ in 0..20 { From 40ac6b9576bed698db02902e68891c612162296f Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:11:48 +0800 Subject: [PATCH 21/28] update crates/codex-plus-core/src/relay_config.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/relay_config.rs | 139 +++++++++++++++++++-- 1 file changed, 130 insertions(+), 9 deletions(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 043504a..02e4fcd 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -6,6 +6,8 @@ use serde_json::Value; const RELAY_PROVIDER: &str = "CodexPlusPlus"; const LEGACY_RELAY_PROVIDER: &str = "CodexPP"; +const IMAGE_GENERATION_TOOL: &str = "image_generation"; +pub const LOCAL_RELAY_PROXY_PORT: u16 = 57323; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -45,6 +47,56 @@ pub struct RelayApplyResult { pub configured: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayApplyOptions { + pub base_url: String, + pub bearer_token: String, + pub image_generation_enabled: bool, + pub image_generation_use_separate_api: bool, + pub image_generation_base_url: String, + pub image_generation_bearer_token: String, +} + +impl RelayApplyOptions { + pub fn new(base_url: &str, bearer_token: &str) -> Self { + Self { + base_url: base_url.to_string(), + bearer_token: bearer_token.to_string(), + image_generation_enabled: false, + image_generation_use_separate_api: false, + image_generation_base_url: String::new(), + image_generation_bearer_token: String::new(), + } + } + + fn effective_base_url(&self) -> String { + if self.uses_local_relay_proxy() { + format!("http://127.0.0.1:{LOCAL_RELAY_PROXY_PORT}/v1") + } else { + normalize_responses_base_url(&self.base_url) + } + } + + fn uses_local_relay_proxy(&self) -> bool { + self.disables_image_generation() || self.uses_separate_image_generation_api() + } + + fn uses_separate_image_generation_api(&self) -> bool { + self.image_generation_enabled + && self.image_generation_use_separate_api + && !self.image_generation_base_url.trim().is_empty() + && !self.image_generation_bearer_token.trim().is_empty() + } + + fn requests_separate_image_generation_api(&self) -> bool { + self.image_generation_enabled && self.image_generation_use_separate_api + } + + fn disables_image_generation(&self) -> bool { + !self.image_generation_enabled + } +} + pub fn default_codex_home_dir() -> PathBuf { directories::BaseDirs::new() .map(|dirs| dirs.home_dir().join(".codex")) @@ -91,9 +143,7 @@ pub fn chatgpt_auth_status_from_home(home: &Path) -> ChatGptAuthStatus { pub fn relay_config_status_from_home(home: &Path) -> RelayConfigStatus { let config_path = home.join("config.toml"); let contents = std::fs::read_to_string(&config_path).unwrap_or_default(); - let root_provider = root_key_string(&contents, "model_provider") - .map(|value| value == RELAY_PROVIDER) - .unwrap_or(false); + let root_provider = relay_provider_active_in_contents(&contents); let provider = table_values(&contents, &format!("model_providers.{RELAY_PROVIDER}")); let requires_openai_auth = provider .as_ref() @@ -119,19 +169,46 @@ pub fn relay_config_status_from_home(home: &Path) -> RelayConfigStatus { } } +pub fn relay_provider_active_from_home(home: &Path) -> bool { + let config_path = home.join("config.toml"); + let contents = std::fs::read_to_string(&config_path).unwrap_or_default(); + relay_provider_active_in_contents(&contents) +} + +fn relay_provider_active_in_contents(contents: &str) -> bool { + root_key_string(contents, "model_provider") + .map(|value| value == RELAY_PROVIDER) + .unwrap_or(false) +} + pub fn apply_relay_config_to_home( home: &Path, base_url: &str, bearer_token: &str, ) -> anyhow::Result { - let base_url = base_url.trim(); + apply_relay_config_to_home_with_options(home, &RelayApplyOptions::new(base_url, bearer_token)) +} + +pub fn apply_relay_config_to_home_with_options( + home: &Path, + options: &RelayApplyOptions, +) -> anyhow::Result { + let base_url = options.effective_base_url(); if base_url.is_empty() { anyhow::bail!("中转 Base URL 不能为空"); } - let bearer_token = bearer_token.trim(); + let bearer_token = options.bearer_token.trim(); if bearer_token.is_empty() { anyhow::bail!("中转 Key 不能为空"); } + if options.requests_separate_image_generation_api() { + if options.image_generation_base_url.trim().is_empty() { + anyhow::bail!("图片 Base URL 不能为空"); + } + if options.image_generation_bearer_token.trim().is_empty() { + anyhow::bail!("图片 Key 不能为空"); + } + } 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(); @@ -142,7 +219,7 @@ pub fn apply_relay_config_to_home( } else { None }; - let updated = upsert_model_provider_config(&existing, base_url, bearer_token); + let updated = upsert_model_provider_config(&existing, &base_url, bearer_token, options); std::fs::write(&config_path, updated)?; let status = relay_config_status_from_home(home); Ok(RelayApplyResult { @@ -301,7 +378,12 @@ fn upsert_root_keys(contents: &str, entries: &[(&str, String)]) -> String { updated } -fn upsert_model_provider_config(contents: &str, base_url: &str, bearer_token: &str) -> String { +fn upsert_model_provider_config( + contents: &str, + base_url: &str, + bearer_token: &str, + options: &RelayApplyOptions, +) -> String { let mut updated = upsert_root_keys( contents, &[( @@ -317,18 +399,37 @@ fn upsert_model_provider_config(contents: &str, base_url: &str, bearer_token: &s let mut lines = updated.lines().map(ToString::to_string).collect::>(); let insert_at = first_non_provider_table_index(&lines).unwrap_or(lines.len()); - let provider_lines = vec![ + let mut provider_lines = vec![ format!("[model_providers.{RELAY_PROVIDER}]"), format!("name = \"{}\"", toml_escape(RELAY_PROVIDER)), "wire_api = \"responses\"".to_string(), "requires_openai_auth = true".to_string(), format!("base_url = \"{}\"", toml_escape(base_url)), + ]; + if options.disables_image_generation() { + provider_lines.push(format!("disabled_tools = [\"{IMAGE_GENERATION_TOOL}\"]")); + } + if options.uses_local_relay_proxy() { + provider_lines.push(format!( + "codex_plus_text_base_url = \"{}\"", + toml_escape(&normalize_responses_base_url(&options.base_url)) + )); + } + if options.uses_separate_image_generation_api() { + provider_lines.push(format!( + "codex_plus_image_base_url = \"{}\"", + toml_escape(&normalize_responses_base_url( + &options.image_generation_base_url + )) + )); + } + provider_lines.extend([ format!( "experimental_bearer_token = \"{}\"", toml_escape(bearer_token) ), String::new(), - ]; + ]); lines.splice(insert_at..insert_at, provider_lines); let mut output = lines.join("\n"); if !output.ends_with('\n') { @@ -337,6 +438,26 @@ fn upsert_model_provider_config(contents: &str, base_url: &str, bearer_token: &s output } +fn normalize_responses_base_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() || base_url_has_path_after_host(trimmed) { + return trimmed.to_string(); + } + + format!("{trimmed}/v1") +} + +fn base_url_has_path_after_host(base_url: &str) -> bool { + let after_scheme = base_url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(base_url); + after_scheme + .split_once('/') + .map(|(_, path)| !path.trim_matches('/').is_empty()) + .unwrap_or(false) +} + fn remove_table(contents: &str, table: &str) -> String { let header = format!("[{table}]"); let mut lines = Vec::new(); From 987e4db8d0359fb66cf480d2b0b9a29b92e5e319 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:12:24 +0800 Subject: [PATCH 22/28] update crates/codex-plus-core/src/relay_proxy.rs Part of relay image proxy and macOS packaging update. --- .../crates/codex-plus-core/src/relay_proxy.rs | 529 ++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 crates/codex-plus-core/src/crates/codex-plus-core/src/relay_proxy.rs diff --git a/crates/codex-plus-core/src/crates/codex-plus-core/src/relay_proxy.rs b/crates/codex-plus-core/src/crates/codex-plus-core/src/relay_proxy.rs new file mode 100644 index 0000000..257a1e9 --- /dev/null +++ b/crates/codex-plus-core/src/crates/codex-plus-core/src/relay_proxy.rs @@ -0,0 +1,529 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use anyhow::Context; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::relay_config::LOCAL_RELAY_PROXY_PORT; +use crate::settings::RelayProfile; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayProxyConfig { + pub text_base_url: String, + pub text_api_key: String, + pub image_base_url: Option, + pub image_api_key: Option, +} + +impl RelayProxyConfig { + pub fn from_profile(profile: &RelayProfile) -> Option { + if !profile.needs_local_relay_proxy() { + return None; + } + let (image_base_url, image_api_key) = if profile.uses_separate_image_generation_api() { + ( + Some(normalize_base_url(&profile.image_generation_base_url)), + Some(profile.image_generation_api_key.trim().to_string()), + ) + } else { + (None, None) + }; + Some(Self { + text_base_url: normalize_base_url(&profile.base_url), + text_api_key: profile.api_key.trim().to_string(), + image_base_url, + image_api_key, + }) + } +} + +pub async fn start_local_relay_proxy(config: RelayProxyConfig) -> anyhow::Result { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", LOCAL_RELAY_PROXY_PORT)) + .await + .with_context(|| { + format!("failed to bind relay proxy on 127.0.0.1:{LOCAL_RELAY_PROXY_PORT}") + })?; + let client = crate::http_client::proxied_client("CodexPlusPlus-RelayProxy/1.0")?; + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + if let Ok((stream, addr)) = accepted { + let config = config.clone(); + let client = client.clone(); + tokio::spawn(async move { + let _ = handle_proxy_connection(stream, addr, config, client).await; + }); + } + } + } + } + }); + Ok(LocalRelayProxy { + shutdown: shutdown_tx, + task, + }) +} + +pub struct LocalRelayProxy { + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle<()>, +} + +impl LocalRelayProxy { + pub async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.task.await; + } +} + +async fn handle_proxy_connection( + mut stream: tokio::net::TcpStream, + remote_addr: SocketAddr, + config: RelayProxyConfig, + client: reqwest::Client, +) -> anyhow::Result<()> { + let request = read_http_request(&mut stream).await?; + let routed = route_request(&request.body, config.image_base_url.is_some()); + let (target_base, target_key) = match routed.route { + RelayRoute::Image => match (&config.image_base_url, &config.image_api_key) { + (Some(base_url), Some(api_key)) => (base_url, api_key), + _ => (&config.text_base_url, &config.text_api_key), + }, + RelayRoute::Text => (&config.text_base_url, &config.text_api_key), + }; + let target_url = target_url(target_base, &request.path); + + let _ = crate::diagnostic_log::append_diagnostic_log( + "relay_proxy.request", + serde_json::json!({ + "method": request.method, + "path": request.path, + "route": routed.route.as_str(), + "remote_addr": remote_addr.to_string(), + "body_bytes": request.body.len(), + }), + ); + + if request.method == "OPTIONS" { + write_raw_response(&mut stream, 204, "No Content", &[], &[]).await?; + return Ok(()); + } + if request.method != "POST" && request.method != "GET" { + let body = br#"{"error":{"message":"Method not allowed"}}"#; + write_json_response(&mut stream, 405, "Method Not Allowed", body).await?; + return Ok(()); + } + + let mut builder = match request.method.as_str() { + "GET" => client.get(&target_url), + _ => client.post(&target_url).body(routed.body), + }; + builder = builder.bearer_auth(target_key); + for (name, value) in request.forward_headers() { + builder = builder.header(name, value); + } + + match builder.send().await { + Ok(response) => { + let status = response.status(); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + let name = name.as_str().to_ascii_lowercase(); + if matches!( + name.as_str(), + "content-type" | "cache-control" | "openai-request-id" | "x-request-id" + ) { + value.to_str().ok().map(|value| (name, value.to_string())) + } else { + None + } + }) + .collect::>(); + let bytes = response.bytes().await.unwrap_or_default(); + write_raw_response( + &mut stream, + status.as_u16(), + status.canonical_reason().unwrap_or("OK"), + &headers, + &bytes, + ) + .await?; + } + Err(error) => { + let body = serde_json::to_vec(&serde_json::json!({ + "error": { + "message": format!("Codex++ relay proxy request failed: {error}") + } + }))?; + write_json_response(&mut stream, 502, "Bad Gateway", &body).await?; + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct ParsedHttpRequest { + method: String, + path: String, + headers: HashMap, + body: Vec, +} + +impl ParsedHttpRequest { + fn forward_headers(&self) -> Vec<(&str, &str)> { + self.headers + .iter() + .filter_map(|(name, value)| { + let lowered = name.to_ascii_lowercase(); + if matches!( + lowered.as_str(), + "authorization" | "host" | "connection" | "content-length" + ) { + None + } else { + Some((name.as_str(), value.as_str())) + } + }) + .collect() + } +} + +async fn read_http_request( + stream: &mut tokio::net::TcpStream, +) -> anyhow::Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 8192]; + let header_end; + loop { + let read = stream.read(&mut temp).await?; + if read == 0 { + anyhow::bail!("empty relay proxy request"); + } + buffer.extend_from_slice(&temp[..read]); + if let Some(end) = find_header_end(&buffer) { + header_end = end; + break; + } + if buffer.len() > 1024 * 1024 { + anyhow::bail!("relay proxy request headers are too large"); + } + } + + let head = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or_default(); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let path = parts.next().unwrap_or("/").to_string(); + let headers = parse_headers(lines); + let content_length = headers + .get("content-length") + .or_else(|| headers.get("Content-Length")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + let mut body = buffer[header_end + 4..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut temp).await?; + if read == 0 { + break; + } + body.extend_from_slice(&temp[..read]); + } + body.truncate(content_length); + + Ok(ParsedHttpRequest { + method, + path, + headers, + body, + }) +} + +fn parse_headers<'a>(lines: impl Iterator) -> HashMap { + let mut headers = HashMap::new(); + for line in lines { + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_string(), value.trim().to_string()); + } + } + headers +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayRoute { + Text, + Image, +} + +impl RelayRoute { + fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Image => "image", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoutedRequest { + pub route: RelayRoute, + pub body: Vec, +} + +pub fn route_request(body: &[u8], allow_image_route: bool) -> RoutedRequest { + let Ok(value) = serde_json::from_slice::(body) else { + return RoutedRequest { + route: RelayRoute::Text, + body: body.to_vec(), + }; + }; + if allow_image_route && request_requires_image_generation(&value) { + return RoutedRequest { + route: RelayRoute::Image, + body: body.to_vec(), + }; + } + let sanitized = remove_image_generation_tools(value); + RoutedRequest { + route: RelayRoute::Text, + body: serde_json::to_vec(&sanitized).unwrap_or_else(|_| body.to_vec()), + } +} + +fn request_requires_image_generation(value: &Value) -> bool { + tool_choice_is_image_generation(value) || prompt_asks_for_image_generation(value) +} + +fn tool_choice_is_image_generation(value: &Value) -> bool { + value + .get("tool_choice") + .is_some_and(value_mentions_image_generation) +} + +fn prompt_asks_for_image_generation(value: &Value) -> bool { + let mut text = String::new(); + collect_text_content(value.get("input").unwrap_or(value), &mut text); + let lower = text.to_ascii_lowercase(); + lower.contains("generate image") + || lower.contains("create image") + || lower.contains("draw ") + || lower.contains("生成图片") + || lower.contains("创建图片") + || lower.contains("画一张") + || lower.contains("绘制") +} + +fn collect_text_content(value: &Value, output: &mut String) { + match value { + Value::String(value) => { + output.push_str(value); + output.push('\n'); + } + Value::Array(items) => { + for item in items { + collect_text_content(item, output); + } + } + Value::Object(map) => { + for (key, value) in map { + if matches!(key.as_str(), "text" | "content" | "input_text") { + collect_text_content(value, output); + } else if matches!(key.as_str(), "input" | "messages") { + collect_text_content(value, output); + } + } + } + _ => {} + } +} + +fn remove_image_generation_tools(mut value: Value) -> Value { + if let Some(tools) = value.get_mut("tools").and_then(Value::as_array_mut) { + tools.retain(|tool| !tool_is_image_generation(tool)); + } + if value + .get("tool_choice") + .is_some_and(value_mentions_image_generation) + { + if let Some(object) = value.as_object_mut() { + object.remove("tool_choice"); + } + } + value +} + +fn tool_is_image_generation(value: &Value) -> bool { + value + .get("type") + .and_then(Value::as_str) + .is_some_and(|value| value == "image_generation") +} + +fn value_mentions_image_generation(value: &Value) -> bool { + match value { + Value::String(value) => value == "image_generation", + Value::Array(items) => items.iter().any(value_mentions_image_generation), + Value::Object(map) => map.iter().any(|(key, value)| { + key == "image_generation" + || (key == "type" && value.as_str() == Some("image_generation")) + || value_mentions_image_generation(value) + }), + _ => false, + } +} + +pub fn target_url(base_url: &str, path: &str) -> String { + let base = normalize_base_url(base_url); + let path = path.split('?').next().unwrap_or(path); + if path.ends_with("/responses") { + format!("{base}/responses") + } else if path.ends_with("/models") { + format!("{base}/models") + } else { + format!( + "{base}{}", + ensure_leading_slash(path.trim_start_matches("/v1")) + ) + } +} + +fn ensure_leading_slash(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +fn normalize_base_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.ends_with("/v1") { + trimmed.to_string() + } else { + format!("{trimmed}/v1") + } +} + +async fn write_json_response( + stream: &mut tokio::net::TcpStream, + status: u16, + reason: &str, + body: &[u8], +) -> anyhow::Result<()> { + write_raw_response( + stream, + status, + reason, + &[("content-type".to_string(), "application/json".to_string())], + body, + ) + .await +} + +async fn write_raw_response( + stream: &mut tokio::net::TcpStream, + status: u16, + reason: &str, + headers: &[(String, String)], + body: &[u8], +) -> anyhow::Result<()> { + let mut response = format!( + "HTTP/1.1 {status} {reason}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (name, value) in headers { + response.push_str(name); + response.push_str(": "); + response.push_str(value); + response.push_str("\r\n"); + } + response.push_str("\r\n"); + stream.write_all(response.as_bytes()).await?; + stream.write_all(body).await?; + stream.shutdown().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn route_request_removes_advertised_image_generation_tool_for_regular_text() { + let body = serde_json::to_vec(&json!({ + "input": "hello", + "tools": [{"type": "image_generation"}, {"type": "web_search"}] + })) + .unwrap(); + let routed = route_request(&body, true); + let value: Value = serde_json::from_slice(&routed.body).unwrap(); + + assert_eq!(routed.route, RelayRoute::Text); + assert_eq!(value["tools"].as_array().unwrap().len(), 1); + assert_eq!(value["tools"][0]["type"], "web_search"); + } + + #[test] + fn route_request_uses_image_api_for_forced_image_tool_choice() { + let body = serde_json::to_vec(&json!({ + "input": "make one", + "tools": [{"type": "image_generation"}], + "tool_choice": {"type": "image_generation"} + })) + .unwrap(); + + assert_eq!(route_request(&body, true).route, RelayRoute::Image); + } + + #[test] + fn route_request_uses_image_api_for_image_prompt() { + let body = serde_json::to_vec(&json!({ + "input": "请生成图片:一辆红色跑车", + "tools": [{"type": "image_generation"}] + })) + .unwrap(); + + assert_eq!(route_request(&body, true).route, RelayRoute::Image); + } + + #[test] + fn route_request_removes_image_tool_when_image_route_is_disabled() { + let body = serde_json::to_vec(&json!({ + "input": "请生成图片:一辆红色跑车", + "tools": [{"type": "image_generation"}, {"type": "web_search"}], + "tool_choice": {"type": "image_generation"} + })) + .unwrap(); + + let routed = route_request(&body, false); + let value: Value = serde_json::from_slice(&routed.body).unwrap(); + + assert_eq!(routed.route, RelayRoute::Text); + assert_eq!(value["tools"].as_array().unwrap().len(), 1); + assert_eq!(value["tools"][0]["type"], "web_search"); + assert!(value.get("tool_choice").is_none()); + } + + #[test] + fn target_url_normalizes_supported_paths() { + assert_eq!( + target_url("https://relay.example", "/v1/responses"), + "https://relay.example/v1/responses" + ); + assert_eq!( + target_url("https://relay.example/v1", "/models"), + "https://relay.example/v1/models" + ); + } +} From 869839b2d21b87cb23dc9b8715b2dd3741f9e4b6 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:12:32 +0800 Subject: [PATCH 23/28] update crates/codex-plus-core/tests/bridge_routes.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/tests/bridge_routes.rs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index bf29250..c5af291 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -99,6 +99,49 @@ async fn settings_routes_use_settings_service() { assert_eq!(loaded, updated); } +#[tokio::test] +async fn settings_routes_preserve_relay_profile_image_generation_fields() { + let ctx = test_context(); + + let updated = handle_bridge_request( + ctx.clone(), + "/settings/set", + json!({ + "relayProfiles": [ + { + "id": "relay-a", + "name": "中转 A", + "baseUrl": "https://relay.example/v1", + "apiKey": "sk-relay", + "imageGenerationEnabled": true, + "imageGenerationUseSeparateApi": true, + "imageGenerationBaseUrl": "https://image.example/v1", + "imageGenerationApiKey": "sk-image" + } + ], + "activeRelayId": "relay-a" + }), + ) + .await; + let loaded = handle_bridge_request(ctx, "/settings/get", json!({})).await; + + assert_eq!(updated["activeRelayId"], "relay-a"); + assert_eq!(updated["relayProfiles"][0]["imageGenerationEnabled"], true); + assert_eq!( + updated["relayProfiles"][0]["imageGenerationUseSeparateApi"], + true + ); + assert_eq!( + updated["relayProfiles"][0]["imageGenerationBaseUrl"], + "https://image.example/v1" + ); + assert_eq!( + updated["relayProfiles"][0]["imageGenerationApiKey"], + "sk-image" + ); + assert_eq!(loaded, updated); +} + #[tokio::test] async fn runtime_routes_keep_user_script_inventory_shape() { let ctx = test_context(); @@ -493,6 +536,12 @@ impl BridgeSettingsService for FakeSettings { if let Some(value) = payload.get("relayApiKey").and_then(Value::as_str) { raw.insert("relayApiKey".to_string(), json!(value)); } + if let Some(value) = payload.get("relayProfiles").and_then(Value::as_array) { + raw.insert("relayProfiles".to_string(), json!(value)); + } + if let Some(value) = payload.get("activeRelayId").and_then(Value::as_str) { + raw.insert("activeRelayId".to_string(), json!(value)); + } if let Some(value) = payload.get("cliWrapperApiKeyEnv").and_then(Value::as_str) { raw.insert( "cliWrapperApiKeyEnv".to_string(), @@ -693,7 +742,11 @@ impl LaunchHooks for ContextHooks { Ok(()) } - async fn start_helper(&self, _helper_port: u16) -> anyhow::Result<()> { + async fn start_helper( + &self, + _helper_port: u16, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { Ok(()) } From 343f647a9e9d8a5a55a1ad0adb04a86f5ce2731d Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:12:40 +0800 Subject: [PATCH 24/28] update crates/codex-plus-core/tests/launcher.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/tests/launcher.rs | 76 +++++++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 9c4c667..c563fc0 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -127,6 +127,41 @@ fn app_paths_find_macos_codex_app_prefers_first_search_root_and_known_names() { ); } +#[test] +fn app_paths_find_macos_codex_app_uses_bundle_identifier_when_name_changes() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("Applications"); + let renamed_app = root.join("AI Workbench.app"); + let contents = renamed_app.join("Contents"); + std::fs::create_dir_all(&contents).unwrap(); + std::fs::write( + contents.join("Info.plist"), + r#" + + + CFBundleIdentifier + com.openai.codex + + +"#, + ) + .unwrap(); + + assert_eq!(find_macos_codex_app(&[root]).unwrap(), renamed_app); +} + +#[test] +fn app_paths_find_macos_codex_app_ignores_codex_plus_plus_bundle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("Applications"); + let codex_plus = root.join("Codex++.app"); + let codex = root.join("Codex.app"); + std::fs::create_dir_all(&codex_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"); @@ -137,6 +172,31 @@ fn app_paths_build_macos_bundle_executable() { ); } +#[test] +fn app_paths_build_macos_bundle_executable_reads_plist() { + let temp = tempfile::tempdir().unwrap(); + let app = temp.path().join("OpenAI Codex.app"); + let contents = app.join("Contents"); + std::fs::create_dir_all(&contents).unwrap(); + std::fs::write( + contents.join("Info.plist"), + r#" + + + CFBundleExecutable + OpenAI Codex + + +"#, + ) + .unwrap(); + + assert_eq!( + build_codex_executable(&app), + app.join("Contents").join("MacOS").join("OpenAI Codex") + ); +} + #[test] fn launcher_builds_debug_arguments_and_commands() { let app_dir = PathBuf::from(r"C:\Codex\app"); @@ -290,7 +350,10 @@ async fn default_helper_serves_backend_status_over_http() { let port = listener.local_addr().unwrap().port(); drop(listener); - hooks.start_helper(port).await.unwrap(); + hooks + .start_helper(port, &BackendSettings::default()) + .await + .unwrap(); let response = reqwest::Client::new() .post(format!("http://127.0.0.1:{port}/backend/status")) .json(&serde_json::json!({})) @@ -314,7 +377,10 @@ async fn default_helper_accepts_diagnostic_log_events_over_http() { let port = listener.local_addr().unwrap().port(); drop(listener); - hooks.start_helper(port).await.unwrap(); + hooks + .start_helper(port, &BackendSettings::default()) + .await + .unwrap(); let response = reqwest::Client::new() .post(format!("http://127.0.0.1:{port}/diagnostics/log")) .json(&serde_json::json!({ @@ -787,7 +853,11 @@ impl LaunchHooks for FakeHooks { Ok(()) } - async fn start_helper(&self, helper_port: u16) -> anyhow::Result<()> { + async fn start_helper( + &self, + helper_port: u16, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { self.event(format!("start-helper:{helper_port}")); Ok(()) } From cc6191839acc825ec6a979a1f9881812a011b3d4 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:12:49 +0800 Subject: [PATCH 25/28] update crates/codex-plus-core/tests/relay_config.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/tests/relay_config.rs | 102 ++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 196b938..450e855 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1,6 +1,6 @@ use codex_plus_core::relay_config::{ - apply_relay_config_to_home, chatgpt_auth_status_from_home, clear_relay_config_to_home, - relay_config_status_from_home, + LOCAL_RELAY_PROXY_PORT, RelayApplyOptions, apply_relay_config_to_home, + chatgpt_auth_status_from_home, clear_relay_config_to_home, relay_config_status_from_home, }; #[test] @@ -139,11 +139,107 @@ 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(&format!( + r#"base_url = "http://127.0.0.1:{LOCAL_RELAY_PROXY_PORT}/v1""# + ))); + assert!(updated.contains(r#"codex_plus_text_base_url = "https://relay.example.test/v1""#)); + assert!(updated.contains(r#"disabled_tools = ["image_generation"]"#)); assert!(updated.contains(r#"experimental_bearer_token = "sk-test-redacted""#)); assert!(updated.contains("[profiles.default]")); } +#[test] +fn apply_relay_config_normalizes_root_base_url_to_v1() { + let temp = tempfile::tempdir().unwrap(); + + apply_relay_config_to_home( + temp.path(), + "https://api.86gamestore.com", + "sk-test-redacted", + ) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(updated.contains(&format!( + r#"base_url = "http://127.0.0.1:{LOCAL_RELAY_PROXY_PORT}/v1""# + ))); + assert!(updated.contains(r#"codex_plus_text_base_url = "https://api.86gamestore.com/v1""#)); + assert!(updated.contains(r#"disabled_tools = ["image_generation"]"#)); +} + +#[test] +fn apply_relay_config_preserves_custom_base_url_path() { + let temp = tempfile::tempdir().unwrap(); + + apply_relay_config_to_home( + temp.path(), + "https://relay.example.test/openai", + "sk-test-redacted", + ) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(updated.contains(&format!( + r#"base_url = "http://127.0.0.1:{LOCAL_RELAY_PROXY_PORT}/v1""# + ))); + assert!(updated.contains(r#"codex_plus_text_base_url = "https://relay.example.test/openai""#)); +} + +#[test] +fn apply_relay_config_allows_image_generation_on_primary_relay() { + let temp = tempfile::tempdir().unwrap(); + let mut options = RelayApplyOptions::new("https://relay.example.test/v1", "sk-test-redacted"); + options.image_generation_enabled = true; + + codex_plus_core::relay_config::apply_relay_config_to_home_with_options(temp.path(), &options) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(updated.contains(r#"base_url = "https://relay.example.test/v1""#)); + assert!(!updated.contains(r#"disabled_tools = ["image_generation"]"#)); +} + +#[test] +fn apply_relay_config_routes_separate_image_generation_api_through_local_proxy() { + let temp = tempfile::tempdir().unwrap(); + let mut options = RelayApplyOptions::new("https://relay.example.test/v1", "sk-test-redacted"); + options.image_generation_enabled = true; + options.image_generation_use_separate_api = true; + options.image_generation_base_url = "https://image.example.test".to_string(); + options.image_generation_bearer_token = "sk-image-redacted".to_string(); + + codex_plus_core::relay_config::apply_relay_config_to_home_with_options(temp.path(), &options) + .unwrap(); + let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert!(updated.contains(&format!( + r#"base_url = "http://127.0.0.1:{LOCAL_RELAY_PROXY_PORT}/v1""# + ))); + assert!(updated.contains(r#"codex_plus_text_base_url = "https://relay.example.test/v1""#)); + assert!(updated.contains(r#"codex_plus_image_base_url = "https://image.example.test/v1""#)); + assert!(!updated.contains(r#"disabled_tools = ["image_generation"]"#)); + assert!(updated.contains(r#"experimental_bearer_token = "sk-test-redacted""#)); + assert!(!updated.contains("sk-image-redacted")); +} + +#[test] +fn apply_relay_config_rejects_incomplete_separate_image_generation_api() { + let temp = tempfile::tempdir().unwrap(); + let mut options = RelayApplyOptions::new("https://relay.example.test/v1", "sk-test-redacted"); + options.image_generation_enabled = true; + options.image_generation_use_separate_api = true; + options.image_generation_bearer_token = "sk-image-redacted".to_string(); + + let err = codex_plus_core::relay_config::apply_relay_config_to_home_with_options( + temp.path(), + &options, + ) + .unwrap_err(); + + assert!(err.to_string().contains("图片 Base URL 不能为空")); + assert!(!temp.path().join("config.toml").exists()); +} + #[test] fn apply_relay_config_points_model_provider_to_codexpp_before_tables() { let temp = tempfile::tempdir().unwrap(); From 70983082e6c745f5f6b42d961082ee8cb41f8fa3 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:15:26 +0800 Subject: [PATCH 26/28] add crates/codex-plus-core/src/relay_proxy.rs Part of relay image proxy and macOS packaging update. --- crates/codex-plus-core/src/relay_proxy.rs | 529 ++++++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 crates/codex-plus-core/src/relay_proxy.rs diff --git a/crates/codex-plus-core/src/relay_proxy.rs b/crates/codex-plus-core/src/relay_proxy.rs new file mode 100644 index 0000000..257a1e9 --- /dev/null +++ b/crates/codex-plus-core/src/relay_proxy.rs @@ -0,0 +1,529 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use anyhow::Context; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::relay_config::LOCAL_RELAY_PROXY_PORT; +use crate::settings::RelayProfile; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayProxyConfig { + pub text_base_url: String, + pub text_api_key: String, + pub image_base_url: Option, + pub image_api_key: Option, +} + +impl RelayProxyConfig { + pub fn from_profile(profile: &RelayProfile) -> Option { + if !profile.needs_local_relay_proxy() { + return None; + } + let (image_base_url, image_api_key) = if profile.uses_separate_image_generation_api() { + ( + Some(normalize_base_url(&profile.image_generation_base_url)), + Some(profile.image_generation_api_key.trim().to_string()), + ) + } else { + (None, None) + }; + Some(Self { + text_base_url: normalize_base_url(&profile.base_url), + text_api_key: profile.api_key.trim().to_string(), + image_base_url, + image_api_key, + }) + } +} + +pub async fn start_local_relay_proxy(config: RelayProxyConfig) -> anyhow::Result { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", LOCAL_RELAY_PROXY_PORT)) + .await + .with_context(|| { + format!("failed to bind relay proxy on 127.0.0.1:{LOCAL_RELAY_PROXY_PORT}") + })?; + let client = crate::http_client::proxied_client("CodexPlusPlus-RelayProxy/1.0")?; + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + if let Ok((stream, addr)) = accepted { + let config = config.clone(); + let client = client.clone(); + tokio::spawn(async move { + let _ = handle_proxy_connection(stream, addr, config, client).await; + }); + } + } + } + } + }); + Ok(LocalRelayProxy { + shutdown: shutdown_tx, + task, + }) +} + +pub struct LocalRelayProxy { + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle<()>, +} + +impl LocalRelayProxy { + pub async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.task.await; + } +} + +async fn handle_proxy_connection( + mut stream: tokio::net::TcpStream, + remote_addr: SocketAddr, + config: RelayProxyConfig, + client: reqwest::Client, +) -> anyhow::Result<()> { + let request = read_http_request(&mut stream).await?; + let routed = route_request(&request.body, config.image_base_url.is_some()); + let (target_base, target_key) = match routed.route { + RelayRoute::Image => match (&config.image_base_url, &config.image_api_key) { + (Some(base_url), Some(api_key)) => (base_url, api_key), + _ => (&config.text_base_url, &config.text_api_key), + }, + RelayRoute::Text => (&config.text_base_url, &config.text_api_key), + }; + let target_url = target_url(target_base, &request.path); + + let _ = crate::diagnostic_log::append_diagnostic_log( + "relay_proxy.request", + serde_json::json!({ + "method": request.method, + "path": request.path, + "route": routed.route.as_str(), + "remote_addr": remote_addr.to_string(), + "body_bytes": request.body.len(), + }), + ); + + if request.method == "OPTIONS" { + write_raw_response(&mut stream, 204, "No Content", &[], &[]).await?; + return Ok(()); + } + if request.method != "POST" && request.method != "GET" { + let body = br#"{"error":{"message":"Method not allowed"}}"#; + write_json_response(&mut stream, 405, "Method Not Allowed", body).await?; + return Ok(()); + } + + let mut builder = match request.method.as_str() { + "GET" => client.get(&target_url), + _ => client.post(&target_url).body(routed.body), + }; + builder = builder.bearer_auth(target_key); + for (name, value) in request.forward_headers() { + builder = builder.header(name, value); + } + + match builder.send().await { + Ok(response) => { + let status = response.status(); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + let name = name.as_str().to_ascii_lowercase(); + if matches!( + name.as_str(), + "content-type" | "cache-control" | "openai-request-id" | "x-request-id" + ) { + value.to_str().ok().map(|value| (name, value.to_string())) + } else { + None + } + }) + .collect::>(); + let bytes = response.bytes().await.unwrap_or_default(); + write_raw_response( + &mut stream, + status.as_u16(), + status.canonical_reason().unwrap_or("OK"), + &headers, + &bytes, + ) + .await?; + } + Err(error) => { + let body = serde_json::to_vec(&serde_json::json!({ + "error": { + "message": format!("Codex++ relay proxy request failed: {error}") + } + }))?; + write_json_response(&mut stream, 502, "Bad Gateway", &body).await?; + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct ParsedHttpRequest { + method: String, + path: String, + headers: HashMap, + body: Vec, +} + +impl ParsedHttpRequest { + fn forward_headers(&self) -> Vec<(&str, &str)> { + self.headers + .iter() + .filter_map(|(name, value)| { + let lowered = name.to_ascii_lowercase(); + if matches!( + lowered.as_str(), + "authorization" | "host" | "connection" | "content-length" + ) { + None + } else { + Some((name.as_str(), value.as_str())) + } + }) + .collect() + } +} + +async fn read_http_request( + stream: &mut tokio::net::TcpStream, +) -> anyhow::Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 8192]; + let header_end; + loop { + let read = stream.read(&mut temp).await?; + if read == 0 { + anyhow::bail!("empty relay proxy request"); + } + buffer.extend_from_slice(&temp[..read]); + if let Some(end) = find_header_end(&buffer) { + header_end = end; + break; + } + if buffer.len() > 1024 * 1024 { + anyhow::bail!("relay proxy request headers are too large"); + } + } + + let head = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or_default(); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let path = parts.next().unwrap_or("/").to_string(); + let headers = parse_headers(lines); + let content_length = headers + .get("content-length") + .or_else(|| headers.get("Content-Length")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + let mut body = buffer[header_end + 4..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut temp).await?; + if read == 0 { + break; + } + body.extend_from_slice(&temp[..read]); + } + body.truncate(content_length); + + Ok(ParsedHttpRequest { + method, + path, + headers, + body, + }) +} + +fn parse_headers<'a>(lines: impl Iterator) -> HashMap { + let mut headers = HashMap::new(); + for line in lines { + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_string(), value.trim().to_string()); + } + } + headers +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayRoute { + Text, + Image, +} + +impl RelayRoute { + fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Image => "image", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoutedRequest { + pub route: RelayRoute, + pub body: Vec, +} + +pub fn route_request(body: &[u8], allow_image_route: bool) -> RoutedRequest { + let Ok(value) = serde_json::from_slice::(body) else { + return RoutedRequest { + route: RelayRoute::Text, + body: body.to_vec(), + }; + }; + if allow_image_route && request_requires_image_generation(&value) { + return RoutedRequest { + route: RelayRoute::Image, + body: body.to_vec(), + }; + } + let sanitized = remove_image_generation_tools(value); + RoutedRequest { + route: RelayRoute::Text, + body: serde_json::to_vec(&sanitized).unwrap_or_else(|_| body.to_vec()), + } +} + +fn request_requires_image_generation(value: &Value) -> bool { + tool_choice_is_image_generation(value) || prompt_asks_for_image_generation(value) +} + +fn tool_choice_is_image_generation(value: &Value) -> bool { + value + .get("tool_choice") + .is_some_and(value_mentions_image_generation) +} + +fn prompt_asks_for_image_generation(value: &Value) -> bool { + let mut text = String::new(); + collect_text_content(value.get("input").unwrap_or(value), &mut text); + let lower = text.to_ascii_lowercase(); + lower.contains("generate image") + || lower.contains("create image") + || lower.contains("draw ") + || lower.contains("生成图片") + || lower.contains("创建图片") + || lower.contains("画一张") + || lower.contains("绘制") +} + +fn collect_text_content(value: &Value, output: &mut String) { + match value { + Value::String(value) => { + output.push_str(value); + output.push('\n'); + } + Value::Array(items) => { + for item in items { + collect_text_content(item, output); + } + } + Value::Object(map) => { + for (key, value) in map { + if matches!(key.as_str(), "text" | "content" | "input_text") { + collect_text_content(value, output); + } else if matches!(key.as_str(), "input" | "messages") { + collect_text_content(value, output); + } + } + } + _ => {} + } +} + +fn remove_image_generation_tools(mut value: Value) -> Value { + if let Some(tools) = value.get_mut("tools").and_then(Value::as_array_mut) { + tools.retain(|tool| !tool_is_image_generation(tool)); + } + if value + .get("tool_choice") + .is_some_and(value_mentions_image_generation) + { + if let Some(object) = value.as_object_mut() { + object.remove("tool_choice"); + } + } + value +} + +fn tool_is_image_generation(value: &Value) -> bool { + value + .get("type") + .and_then(Value::as_str) + .is_some_and(|value| value == "image_generation") +} + +fn value_mentions_image_generation(value: &Value) -> bool { + match value { + Value::String(value) => value == "image_generation", + Value::Array(items) => items.iter().any(value_mentions_image_generation), + Value::Object(map) => map.iter().any(|(key, value)| { + key == "image_generation" + || (key == "type" && value.as_str() == Some("image_generation")) + || value_mentions_image_generation(value) + }), + _ => false, + } +} + +pub fn target_url(base_url: &str, path: &str) -> String { + let base = normalize_base_url(base_url); + let path = path.split('?').next().unwrap_or(path); + if path.ends_with("/responses") { + format!("{base}/responses") + } else if path.ends_with("/models") { + format!("{base}/models") + } else { + format!( + "{base}{}", + ensure_leading_slash(path.trim_start_matches("/v1")) + ) + } +} + +fn ensure_leading_slash(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +fn normalize_base_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.ends_with("/v1") { + trimmed.to_string() + } else { + format!("{trimmed}/v1") + } +} + +async fn write_json_response( + stream: &mut tokio::net::TcpStream, + status: u16, + reason: &str, + body: &[u8], +) -> anyhow::Result<()> { + write_raw_response( + stream, + status, + reason, + &[("content-type".to_string(), "application/json".to_string())], + body, + ) + .await +} + +async fn write_raw_response( + stream: &mut tokio::net::TcpStream, + status: u16, + reason: &str, + headers: &[(String, String)], + body: &[u8], +) -> anyhow::Result<()> { + let mut response = format!( + "HTTP/1.1 {status} {reason}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (name, value) in headers { + response.push_str(name); + response.push_str(": "); + response.push_str(value); + response.push_str("\r\n"); + } + response.push_str("\r\n"); + stream.write_all(response.as_bytes()).await?; + stream.write_all(body).await?; + stream.shutdown().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn route_request_removes_advertised_image_generation_tool_for_regular_text() { + let body = serde_json::to_vec(&json!({ + "input": "hello", + "tools": [{"type": "image_generation"}, {"type": "web_search"}] + })) + .unwrap(); + let routed = route_request(&body, true); + let value: Value = serde_json::from_slice(&routed.body).unwrap(); + + assert_eq!(routed.route, RelayRoute::Text); + assert_eq!(value["tools"].as_array().unwrap().len(), 1); + assert_eq!(value["tools"][0]["type"], "web_search"); + } + + #[test] + fn route_request_uses_image_api_for_forced_image_tool_choice() { + let body = serde_json::to_vec(&json!({ + "input": "make one", + "tools": [{"type": "image_generation"}], + "tool_choice": {"type": "image_generation"} + })) + .unwrap(); + + assert_eq!(route_request(&body, true).route, RelayRoute::Image); + } + + #[test] + fn route_request_uses_image_api_for_image_prompt() { + let body = serde_json::to_vec(&json!({ + "input": "请生成图片:一辆红色跑车", + "tools": [{"type": "image_generation"}] + })) + .unwrap(); + + assert_eq!(route_request(&body, true).route, RelayRoute::Image); + } + + #[test] + fn route_request_removes_image_tool_when_image_route_is_disabled() { + let body = serde_json::to_vec(&json!({ + "input": "请生成图片:一辆红色跑车", + "tools": [{"type": "image_generation"}, {"type": "web_search"}], + "tool_choice": {"type": "image_generation"} + })) + .unwrap(); + + let routed = route_request(&body, false); + let value: Value = serde_json::from_slice(&routed.body).unwrap(); + + assert_eq!(routed.route, RelayRoute::Text); + assert_eq!(value["tools"].as_array().unwrap().len(), 1); + assert_eq!(value["tools"][0]["type"], "web_search"); + assert!(value.get("tool_choice").is_none()); + } + + #[test] + fn target_url_normalizes_supported_paths() { + assert_eq!( + target_url("https://relay.example", "/v1/responses"), + "https://relay.example/v1/responses" + ); + assert_eq!( + target_url("https://relay.example/v1", "/models"), + "https://relay.example/v1/models" + ); + } +} From 5bf7befd73ee23f916ccd4cf0b3d90970e56f5d7 Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:15:43 +0800 Subject: [PATCH 27/28] add tests/test_macos_package_dmg.py Part of relay image proxy and macOS packaging update. --- tests/test_macos_package_dmg.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/test_macos_package_dmg.py diff --git a/tests/test_macos_package_dmg.py b/tests/test_macos_package_dmg.py new file mode 100644 index 0000000..d790f89 --- /dev/null +++ b/tests/test_macos_package_dmg.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +def test_macos_package_dmg_normalizes_arch_and_adds_applications_link(): + text = Path("scripts/installer/macos/package-dmg.sh").read_text(encoding="utf-8") + + assert 'arm64|aarch64) ARCH="arm64"' in text + assert 'x86_64|amd64|x64) ARCH="x64"' in text + assert 'ln -s /Applications "$STAGE/Applications"' in text + assert 'CodexPlusPlus-${VERSION}-macos-${ARCH}.dmg' in text From 4b275c763db9ee89f261e066853f4eb5514e475a Mon Sep 17 00:00:00 2001 From: Meow Date: Mon, 18 May 2026 23:17:22 +0800 Subject: [PATCH 28/28] remove misplaced macos package test Remove mistakenly created nested tests copy. --- tests/tests/test_macos_package_dmg.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 tests/tests/test_macos_package_dmg.py diff --git a/tests/tests/test_macos_package_dmg.py b/tests/tests/test_macos_package_dmg.py deleted file mode 100644 index d790f89..0000000 --- a/tests/tests/test_macos_package_dmg.py +++ /dev/null @@ -1,10 +0,0 @@ -from pathlib import Path - - -def test_macos_package_dmg_normalizes_arch_and_adds_applications_link(): - text = Path("scripts/installer/macos/package-dmg.sh").read_text(encoding="utf-8") - - assert 'arm64|aarch64) ARCH="arm64"' in text - assert 'x86_64|amd64|x64) ARCH="x64"' in text - assert 'ln -s /Applications "$STAGE/Applications"' in text - assert 'CodexPlusPlus-${VERSION}-macos-${ARCH}.dmg' in text