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++ 帮到了你,可以请我喝杯咖啡,或者随手赞赏支持一下继续维护。
@@ -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 图标。
+
+## 项目数据
+
+
+
+
+
+
+
+
## 痛点与解决
@@ -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
+
+
+
+
+
+
+
+
## 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({
+
void actions.switchOfficialMode()} type="button">
官方登录
@@ -981,6 +1002,21 @@ function RelayScreen({
+
+
+
+
+
+
@@ -1512,6 +1548,15 @@ function RelayProfileList({
{profile.name || "未命名中转"}
{profile.baseUrl || "未填写 URL"}
+
+
+
+ {profile.imageGenerationEnabled
+ ? profile.imageGenerationUseSeparateApi
+ ? "图片独立 API"
+ : "图片走当前中转"
+ : "图片关闭"}
+
onFormChange(removeRelayProfile(form, profile.id))}
@@ -1521,6 +1566,7 @@ function RelayProfileList({
>
+
@@ -1545,6 +1591,80 @@ function RelayProfileList({
/>
+
+
+
+ onFormChange(
+ updateRelayProfile(form, profile.id, {
+ imageGenerationEnabled: event.currentTarget.checked,
+ imageGenerationUseSeparateApi: event.currentTarget.checked
+ ? profile.imageGenerationUseSeparateApi
+ : false,
+ }),
+ )
+ }
+ type="checkbox"
+ />
+
+ 允许当前中转使用图片生成
+ 关闭时会走本地代理裁剪 image_generation 工具,避免未开通图片权限的中转组返回 403。
+
+
+ {profile.imageGenerationEnabled ? (
+ <>
+
+
+ onFormChange(
+ updateRelayProfile(form, profile.id, {
+ imageGenerationUseSeparateApi: event.currentTarget.checked,
+ }),
+ )
+ }
+ type="checkbox"
+ />
+
+ 图片生成使用独立 API 和 Key
+ 包含图片生成工具的 Responses 请求会由 Codex++ 本地代理转发到下方图片 API。
+
+
+ {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