diff --git a/.gitignore b/.gitignore index 42c5689..752ceb3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ venv/ build/ dist/ target/ +node_modules/ apps/codex-plus-manager/src-tauri/gen/ .codex_asar_extract/ diff --git a/README.md b/README.md index aa23985..d9919ef 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++` 即可。 + ## 赞助商 想显示在下方?

@@ -100,6 +114,8 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 Codex++ 交流群二维码 +## 赞赏支持 + 如果 Codex++ 帮到了你,可以请我喝杯咖啡,或者随手赞赏支持一下继续维护。

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

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

+ Codex++ contributors +

+ +

+ Codex++ star history +

## 痛点与解决 @@ -148,8 +174,9 @@ Codex++ 启动后会解锁插件入口,并在会话列表悬停时显示删除 1. 确认已经检测到 ChatGPT 登录状态。 2. 添加一个或多个中转配置,填写 Base URL 和 Key。 -3. 选择当前配置并应用中转注入。 -4. 启动 `Codex++`。 +3. 按需打开“允许当前中转使用图片生成”。如果主中转组没有图片权限,保持关闭;如果希望图片生成走单独通道,填写独立图片 Base URL 和 Key。 +4. 选择当前配置并应用中转注入。 +5. 启动 `Codex++`。 Codex++ 会在 `~/.codex/config.toml` 中写入类似配置: @@ -160,10 +187,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 登录模式。 ## 增强功能 @@ -221,6 +254,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 @@ -235,6 +272,9 @@ cd ../.. cargo fmt --check cargo test cargo build --release + +# macOS 打包 +scripts/installer/macos/package-dmg.sh 1.1.1 arm64 ``` 主要结构: diff --git a/README_EN.md b/README_EN.md index f26b5b8..0b23a4b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -102,11 +102,21 @@ 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. - Zed open entry detects remote SSH context and opens the matching remote file in Zed Remote Development from Codex. - 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 @@ -116,8 +126,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`: @@ -128,10 +139,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 @@ -189,6 +206,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 @@ -203,6 +224,9 @@ cd ../.. cargo fmt --check cargo test cargo build --release + +# macOS package +scripts/installer/macos/package-dmg.sh 1.1.1 arm64 ``` Project structure: diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index bb1b7f8..9d8a232 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)] @@ -163,8 +164,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( diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c7ade59..6bd1893 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; @@ -576,11 +577,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( diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 87853b5..bcff999 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -4,6 +4,7 @@ import { Activity, Bell, CheckCircle2, + Image, CircleArrowUp, Info, ExternalLink, @@ -89,6 +90,10 @@ type RelayProfile = { name: string; baseUrl: string; apiKey: string; + imageGenerationEnabled: boolean; + imageGenerationUseSeparateApi: boolean; + imageGenerationBaseUrl: string; + imageGenerationApiKey: string; }; type UserScriptInventory = { @@ -200,6 +205,10 @@ const defaultSettings: BackendSettings = { name: "默认中转", baseUrl: "", apiKey: "", + imageGenerationEnabled: false, + imageGenerationUseSeparateApi: false, + imageGenerationBaseUrl: "", + imageGenerationApiKey: "", }, ], activeRelayId: "default", @@ -955,6 +964,18 @@ function RelayScreen({ +
+
+ + + {profile.imageGenerationEnabled + ? profile.imageGenerationUseSeparateApi + ? "图片独立 API" + : "图片走当前中转" + : "图片关闭"} + +
@@ -1545,6 +1591,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} +
))} @@ -1830,12 +1950,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 { @@ -1846,6 +1971,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 { @@ -1870,6 +2006,10 @@ function addRelayProfile(settings: BackendSettings): BackendSettings { name: `中转 ${settings.relayProfiles.length + 1}`, baseUrl: defaultSettings.relayBaseUrl, apiKey: "", + imageGenerationEnabled: false, + imageGenerationUseSeparateApi: false, + imageGenerationBaseUrl: "", + imageGenerationApiKey: "", }; return syncLegacyRelayFields({ ...settings, diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index ced06ab..99bd045 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -467,6 +467,12 @@ body { align-items: center; } +.relay-profile-actions { + display: flex; + align-items: center; + gap: 8px; +} + .relay-select { display: grid; gap: 3px; @@ -478,6 +484,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)); diff --git a/crates/codex-plus-core/src/app_paths.rs b/crates/codex-plus-core/src/app_paths.rs index 7bb3c6f..0e5ba52 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 } @@ -160,6 +163,9 @@ pub fn normalize_codex_app_path(path: &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"); @@ -187,16 +193,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; } @@ -208,6 +205,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 @@ -224,9 +230,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 { @@ -257,6 +276,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_")?; diff --git a/crates/codex-plus-core/src/cli_wrapper.rs b/crates/codex-plus-core/src/cli_wrapper.rs index 9680e38..0d67240 100644 --- a/crates/codex-plus-core/src/cli_wrapper.rs +++ b/crates/codex-plus-core/src/cli_wrapper.rs @@ -151,6 +151,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++") } 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" + ); + } +} 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" +} diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 9470b54..f763989 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -123,8 +123,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, @@ -157,7 +164,7 @@ pub trait LaunchHooks: Send + Sync { #[derive(Default)] pub struct DefaultLaunchHooks { child: Mutex>, - helper: Mutex>, + runtime: Mutex>, bridge_watchdog: Mutex>, } @@ -166,6 +173,11 @@ struct HelperRuntime { task: tokio::task::JoinHandle<()>, } +struct LauncherRuntime { + helper: Option, + relay_proxy: Option, +} + struct BridgeWatchdogRuntime { shutdown: tokio::sync::oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, @@ -188,7 +200,7 @@ where let settings = hooks.load_settings().await?; let app_dir = hooks.resolve_app_dir(options.app_dir.as_deref(), &settings)?; 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 { @@ -196,9 +208,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?; @@ -228,7 +242,7 @@ where app_dir: app_dir.clone(), launch, status_store: status_store.clone(), - helper_started, + helper_started: runtime_started, hooks: Arc::clone(&hooks), }) } @@ -237,7 +251,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 { @@ -309,39 +323,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(()) } @@ -477,9 +486,14 @@ impl LaunchHooks for DefaultLaunchHooks { let _ = runtime.shutdown.send(()); let _ = runtime.task.await; } - 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; + } } } @@ -522,89 +536,87 @@ 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 matches!(path, "/backend/status" | "/backend/repair") - && matches!(method, "GET" | "POST" | "OPTIONS") - { - ( - "200 OK", - serde_json::to_vec(&serde_json::json!({ - "status": "ok", - "message": "后端已连接", - "version": crate::version::VERSION, - "transport": "http-helper" - }))?, - if path == "/backend/status" { - "helper.backend_status_ok" - } else { - "helper.backend_repair_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| { - serde_json::json!({ - "parse_error": error.to_string(), - "raw": request_body - }) - }); - let event = detail - .get("event") - .and_then(serde_json::Value::as_str) - .map(sanitize_diagnostic_event) - .unwrap_or_else(|| "event".to_string()); - let _ = - crate::diagnostic_log::append_diagnostic_log(&format!("renderer.{event}"), detail); - } - ( - "200 OK", - serde_json::to_vec(&serde_json::json!({ - "status": "ok", - "message": "日志已记录" - }))?, - "helper.diagnostics_log_ok", - ) - } else { - ( - "404 Not Found", - serde_json::to_vec(&serde_json::json!({ - "status": "failed", - "message": "未知后端路径" - }))?, - "helper.unknown_path", - ) - }; + let (status, body, log_event) = + if matches!(request.path.as_str(), "/backend/status" | "/backend/repair") + && matches!(request.method.as_str(), "GET" | "POST" | "OPTIONS") + { + ( + "200 OK", + serde_json::to_vec(&serde_json::json!({ + "status": "ok", + "message": "后端已连接", + "version": crate::version::VERSION, + "transport": "http-helper" + }))?, + if request.path == "/backend/status" { + "helper.backend_status_ok" + } else { + "helper.backend_repair_ok" + }, + ) + } 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": String::from_utf8_lossy(&request.body) + }) + }); + let event = detail + .get("event") + .and_then(serde_json::Value::as_str) + .map(sanitize_diagnostic_event) + .unwrap_or_else(|| "event".to_string()); + let _ = crate::diagnostic_log::append_diagnostic_log( + &format!("renderer.{event}"), + detail, + ); + } + ( + "200 OK", + serde_json::to_vec(&serde_json::json!({ + "status": "ok", + "message": "日志已记录" + }))?, + "helper.diagnostics_log_ok", + ) + } else { + ( + "404 Not Found", + serde_json::to_vec(&serde_json::json!({ + "status": "failed", + "message": "未知后端路径" + }))?, + "helper.unknown_path", + ) + }; 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" ) @@ -615,18 +627,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 { @@ -695,6 +799,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 { diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 5cf7f8f..896c8db 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -14,6 +14,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; diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index fd89842..7e8684f 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 { @@ -187,7 +264,9 @@ pub fn apply_pure_api_config_to_home( } else { None }; - let updated = upsert_model_provider_config(&existing, base_url, bearer_token); + let mut options = RelayApplyOptions::new(base_url, bearer_token); + options.image_generation_enabled = true; + 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 { @@ -367,7 +446,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, &[( @@ -383,18 +467,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') { @@ -403,6 +506,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(); 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" + ); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index eb36e85..3bd3b79 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 = "codexAppPath", default)] @@ -102,6 +129,7 @@ impl BackendSettings { self.relay_base_url.clone() }, api_key: self.relay_api_key.clone(), + ..RelayProfile::default() }; } @@ -126,8 +154,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 { @@ -473,7 +506,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" @@ -486,6 +523,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] diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 4767529..b70ed20 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -114,6 +114,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(); @@ -547,6 +590,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(), @@ -794,7 +843,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(()) } 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] diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 42eaa1c..c377ca7 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 app_paths_normalizes_executable_and_package_paths() { let temp = tempfile::tempdir().unwrap(); @@ -339,7 +399,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 client = reqwest::Client::builder().no_proxy().build().unwrap(); let response = client .post(format!("http://127.0.0.1:{port}/backend/status")) @@ -376,7 +439,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::builder() .no_proxy() .build() @@ -856,7 +922,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(()) } diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 177d395..98ebc87 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1,6 +1,7 @@ use codex_plus_core::relay_config::{ - apply_pure_api_config_to_home, 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_pure_api_config_to_home, + apply_relay_config_to_home, chatgpt_auth_status_from_home, clear_relay_config_to_home, + relay_config_status_from_home, }; #[test] @@ -139,11 +140,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_pure_api_config_writes_openai_api_key_auth_json_and_provider() { let temp = tempfile::tempdir().unwrap(); 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" 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