diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 4482d12b..9579cd0d 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -525,7 +525,7 @@ const defaultSettings: BackendSettings = { zedRemoteSyncToZedSettings: false, codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, - codexAppServiceTierControls: false, + codexAppServiceTierControls: true, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, @@ -1973,7 +1973,7 @@ function EnhanceScreen({ setEnhanceFlag("codexAppPluginEntryUnlock", value)} /> setEnhanceFlag("codexAppForcePluginInstall", value)} /> setEnhanceFlag("codexAppModelWhitelistUnlock", value)} /> - setEnhanceFlag("codexAppServiceTierControls", value)} /> + {}} /> setEnhanceFlag("codexAppSessionDelete", value)} /> setEnhanceFlag("codexAppMarkdownExport", value)} /> setEnhanceFlag("codexAppProjectMove", value)} /> diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 7a6af428..f88963d5 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -931,7 +931,7 @@ } function defaultCodexPlusSettings() { - return { pluginEntryUnlock: true, pluginMarketplaceUnlock: true, forcePluginInstall: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, projectMove: true, conversationTimeline: true, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false }; + return { pluginEntryUnlock: true, pluginMarketplaceUnlock: true, forcePluginInstall: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, projectMove: true, conversationTimeline: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: true }; } const codexPlusBackendSettingMap = { @@ -989,6 +989,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } catch { const settings = { ...defaultCodexPlusSettings(), ...backendCodexPlusSettings() }; @@ -997,6 +998,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } } @@ -1736,10 +1738,7 @@ function applyCodexServiceTierRequestOverride(method, params, threadIdHint = "") { const override = codexServiceTierOverrideForRequest(method, params, threadIdHint); if (!override) return params; - const nextParams = { ...(params || {}), serviceTier: override.serviceTier }; - if (Object.prototype.hasOwnProperty.call(nextParams, "service_tier") || override.fastBlocked) { - nextParams.service_tier = override.serviceTier; - } + const nextParams = { ...(params || {}), serviceTier: override.serviceTier, service_tier: override.serviceTier }; sendCodexPlusDiagnostic("service_tier_request_override_applied", { method, threadId: override.threadId || "", @@ -2160,8 +2159,8 @@
-
Fast 按钮
显示服务模式切换按钮;Fast 仅支持 ${codexServiceTierFastModelListLabel()},其他模型按 Standard 发送。
- +
系统 Fast 开关
是否开启系统 Fast 开关:已默认开启,API Key 登录复用 Codex 原生速度选项与标识;具体 Fast / Standard 在 Codex 界面选择,Fast 仅支持 ${codexServiceTierFastModelListLabel()}。
+
服务模式
继承使用 config.toml 的 service tier;全局模式覆盖全部 thread;自定义允许按 thread 覆盖。
diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 9111d9bd..aa47a655 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::env; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -143,7 +144,7 @@ pub trait LaunchHooks: Send + Sync { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result; async fn bridge_context( &self, @@ -266,9 +267,7 @@ where helper_started = true; } - let launch = hooks - .launch_codex(&app_dir, debug_port, &settings.codex_extra_args) - .await?; + let launch = hooks.launch_codex(&app_dir, debug_port, &settings).await?; launched = Some(launch.clone()); keep_launched_on_error = true; if settings.computer_use_guard_enabled { @@ -492,8 +491,9 @@ impl LaunchHooks for DefaultLaunchHooks { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result { + let extra_args = &settings.codex_extra_args; if cfg!(windows) { if let Some(activation) = build_packaged_activation(app_dir, debug_port, extra_args) { let CodexLaunch::PackagedActivation { @@ -564,20 +564,46 @@ impl LaunchHooks for DefaultLaunchHooks { } else { MacosCleanupPolicy::QuitIfNotPreviouslyRunning }; - let command = build_macos_open_command(app_dir, debug_port, extra_args); + let command = build_codex_command(app_dir, debug_port, extra_args); let executable = command .first() - .ok_or_else(|| anyhow::anyhow!("macOS open command is empty"))?; - let child = Command::new(executable) + .ok_or_else(|| anyhow::anyhow!("macOS Codex command is empty"))?; + let mut child_command = Command::new(executable); + child_command .args(&command[1..]) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + if settings.enhancements_enabled { + let preload_path = crate::service_tier_preload::ensure_service_tier_preload() + .context("failed to prepare service tier preload")?; + let node_options = + crate::service_tier_preload::node_options_with_service_tier_preload( + env::var("NODE_OPTIONS").ok().as_deref(), + &preload_path.to_string_lossy(), + ); + child_command.env("NODE_OPTIONS", node_options.clone()); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_enabled", + serde_json::json!({ + "preload_path": preload_path.to_string_lossy(), + "node_options": node_options, + }), + ); + } else { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_disabled", + serde_json::json!({ + "enhancements_enabled": settings.enhancements_enabled, + }), + ); + } + let child = child_command .spawn() .context("failed to launch macOS Codex app")?; *self.child.lock().await = Some(child); return Ok(CodexLaunch::Process { command, - wait_strategy: ProcessWaitStrategy::ExternalWaitCommand, + wait_strategy: ProcessWaitStrategy::TrackedChild, macos_cleanup_policy: Some(cleanup_policy), }); } diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 152338e0..7abbc9e5 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod relay_config; pub mod relay_switch; pub mod routes; pub mod script_market; +pub mod service_tier_preload; pub mod settings; pub mod status; pub mod update; diff --git a/crates/codex-plus-core/src/service_tier_preload.rs b/crates/codex-plus-core/src/service_tier_preload.rs new file mode 100644 index 00000000..58f6fea1 --- /dev/null +++ b/crates/codex-plus-core/src/service_tier_preload.rs @@ -0,0 +1,235 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Context; + +const PRELOAD_FILE: &str = "service-tier-preload.js"; + +pub fn ensure_service_tier_preload() -> anyhow::Result { + let dir = crate::paths::default_app_state_dir().join("preload"); + fs::create_dir_all(&dir).with_context(|| { + format!( + "failed to create service tier preload directory {}", + dir.display() + ) + })?; + let path = dir.join(PRELOAD_FILE); + fs::write(&path, service_tier_preload_script()).with_context(|| { + format!( + "failed to write service tier preload script {}", + path.display() + ) + })?; + Ok(path) +} + +pub fn node_options_with_service_tier_preload( + existing: Option<&str>, + preload_path: &str, +) -> String { + let require_arg = format!("--require={preload_path}"); + match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(existing) if existing.contains(&require_arg) => existing.to_string(), + Some(existing) => format!("{require_arg} {existing}"), + None => require_arg, + } +} + +pub fn service_tier_preload_script() -> &'static str { + r#""use strict"; + +const fs = require("fs"); +const path = require("path"); +const Module = require("module"); + +const PATCH_MARK = Symbol.for("codex-plus.service-tier-protocol-handle-patched"); +const PATCH_VERSION = "protocol-handle-2"; +const SERVICE_TIER_SETTINGS_ASSET_RE = /^use-service-tier-settings-.*\.js$/; +const READ_SERVICE_TIER_ASSET_RE = /^read-service-tier-for-request-.*\.js$/; +const LOG_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "codex-plus.log"); +const SETTINGS_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "settings.json"); + +function log(event, detail) { + try { + fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); + fs.appendFileSync(LOG_PATH, JSON.stringify({ + timestamp_ms: Date.now(), + pid: process.pid, + event, + detail: detail || {}, + }) + "\n"); + } catch {} +} + +function patchServiceTierSettingsAsset(source) { + let patched = source; + patched = replaceOnce( + patched, + "s=o?.authMethod===`chatgpt`", + "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + "service tier settings auth gate" + ); + patched = replaceOnce( + patched, + "s&&!f&&u!=null", + "s&&!f", + "service tier settings API key config requirement" + ); + return patched; +} + +function patchReadServiceTierAsset(source) { + let patched = source; + patched = replaceOnce( + patched, + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + "read service tier auth gate" + ); + patched = replaceOnce( + patched, + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(null,d.service_tier,s)", + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(await m(o,c??d.model),d.service_tier,s)", + "read service tier explicit config model lookup" + ); + return patched; +} + +function replaceOnce(source, from, to, label) { + if (source.includes(to)) return source; + if (!source.includes(from)) throw new Error(`${label} pattern not found`); + return source.replace(from, to); +} + +function appProtocolAssetName(url) { + if (typeof url !== "string") return ""; + try { + const parsed = new URL(url); + if (parsed.protocol !== "app:" || parsed.host !== "-") return ""; + const segments = decodeURIComponent(parsed.pathname).split("/").filter(Boolean); + return segments.length >= 2 && segments[0] === "assets" ? segments[segments.length - 1] : ""; + } catch { + return ""; + } +} + +function serviceTierControlsEnabled() { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")); + return settings && settings.enhancementsEnabled !== false; + } catch { + return true; + } +} + +function discoverPatchedAssets() { + if (!serviceTierControlsEnabled()) { + log("service_tier_preload_disabled_by_settings", {}); + return new Map(); + } + const assetsDir = path.join(process.resourcesPath, "app.asar", "webview", "assets"); + const assets = new Map(); + for (const name of fs.readdirSync(assetsDir)) { + const filePath = path.join(assetsDir, name); + if (SERVICE_TIER_SETTINGS_ASSET_RE.test(name)) { + assets.set(name, { + kind: "native-service-tier-settings", + patched: patchServiceTierSettingsAsset(fs.readFileSync(filePath, "utf8")), + }); + } else if (READ_SERVICE_TIER_ASSET_RE.test(name)) { + assets.set(name, { + kind: "native-read-service-tier", + patched: patchReadServiceTierAsset(fs.readFileSync(filePath, "utf8")), + }); + } + } + if (assets.size === 0) throw new Error("target native speed UI assets were not found"); + return assets; +} + +function installProtocolHandlePatch(electron) { + const protocol = electron && electron.protocol; + if (!protocol || typeof protocol.handle !== "function") { + log("service_tier_preload_protocol_unavailable", {}); + return; + } + if (protocol.handle[PATCH_MARK] === PATCH_VERSION) return; + + const patchedAssets = discoverPatchedAssets(); + if (patchedAssets.size === 0) return; + const originalHandle = protocol.handle; + const wrappedHandle = function codexPlusServiceTierProtocolHandle(scheme, handler) { + if (String(scheme) !== "app" || typeof handler !== "function") { + return originalHandle.apply(this, arguments); + } + const wrappedHandler = async function codexPlusServiceTierAppProtocolHandler(request) { + const asset = patchedAssets.get(appProtocolAssetName(request && request.url)); + if (!asset) return handler.call(this, request); + log("service_tier_preload_asset_patched", { kind: asset.kind, url: request && request.url, version: PATCH_VERSION }); + return new Response(Buffer.from(asset.patched, "utf8"), { + headers: { + "Content-Length": String(Buffer.byteLength(asset.patched, "utf8")), + "Content-Type": "text/javascript; charset=utf-8", + "X-Codex-Plus-Patch": PATCH_VERSION, + }, + }); + }; + return originalHandle.call(this, scheme, wrappedHandler); + }; + + Object.defineProperty(wrappedHandle, PATCH_MARK, { + configurable: false, + enumerable: false, + value: PATCH_VERSION, + }); + protocol.handle = wrappedHandle; + log("service_tier_preload_protocol_patch_installed", { + assets: Array.from(patchedAssets.keys()), + version: PATCH_VERSION, + }); +} + +const originalLoad = Module._load; +Module._load = function codexPlusServiceTierModuleLoad(request, parent, isMain) { + const result = originalLoad.apply(this, arguments); + if (request === "electron") { + try { + installProtocolHandlePatch(result); + } catch (error) { + log("service_tier_preload_protocol_patch_failed", { message: String(error) }); + } + } + return result; +}; + +log("service_tier_preload_loaded", { version: PATCH_VERSION }); +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_options_prepends_preload() { + assert_eq!( + node_options_with_service_tier_preload(Some("--trace-warnings"), "/tmp/preload.js"), + "--require=/tmp/preload.js --trace-warnings" + ); + } + + #[test] + fn preload_script_wraps_electron_module_load_and_app_protocol() { + let script = service_tier_preload_script(); + + assert!(script.contains("Module._load")); + assert!(script.contains("protocol.handle")); + assert!(script.contains("serviceTierControlsEnabled")); + assert!(script.contains("settings.enhancementsEnabled !== false")); + assert!(script.contains("service_tier_preload_disabled_by_settings")); + assert!(script.contains("use-service-tier-settings-")); + assert!(script.contains("read-service-tier-for-request-")); + assert!(script.contains("s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`")); + assert!(script.contains("n===`apikey`")); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index c3f837d7..7a0526a4 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -193,7 +193,7 @@ pub struct BackendSettings { pub codex_app_upstream_worktree_create: bool, #[serde(rename = "codexAppNativeMenuPlacement", default = "default_true")] pub codex_app_native_menu_placement: bool, - #[serde(rename = "codexAppServiceTierControls", default)] + #[serde(rename = "codexAppServiceTierControls", default = "default_true")] pub codex_app_service_tier_controls: bool, #[serde(rename = "codexAppImageOverlayEnabled", default)] pub codex_app_image_overlay_enabled: bool, @@ -265,7 +265,7 @@ impl Default for BackendSettings { zed_remote_sync_to_zed_settings: false, codex_app_upstream_worktree_create: true, codex_app_native_menu_placement: true, - codex_app_service_tier_controls: false, + codex_app_service_tier_controls: true, codex_app_image_overlay_enabled: false, codex_app_image_overlay_path: String::new(), codex_app_image_overlay_opacity: default_image_overlay_opacity(), @@ -792,6 +792,7 @@ fn normalize_settings_config_sections(mut settings: BackendSettings) -> BackendS } settings.codex_app_image_overlay_opacity = clamp_image_overlay_opacity(settings.codex_app_image_overlay_opacity); + settings.codex_app_service_tier_controls = true; settings } diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 7ca2f2c6..6873e5c9 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -1340,7 +1340,7 @@ impl LaunchHooks for ContextHooks { &self, _app_dir: &std::path::Path, _debug_port: u16, - _extra_args: &[String], + _settings: &BackendSettings, ) -> anyhow::Result { Ok(CodexLaunch::Process { command: vec!["codex".to_string()], diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 64634790..f1c6c1e9 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -439,8 +439,8 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("codexServiceTierMaybeLoadModelCatalog")); assert!(script.contains("fastBlocked")); assert!(script.contains("data-tier=\"unsupported\"")); - assert!(script.contains("nextParams.service_tier = override.serviceTier")); - assert!(script.contains("serviceTierControls: false")); + assert!(script.contains("service_tier: override.serviceTier")); + assert!(script.contains("serviceTierControls: true")); assert!(script.contains("data-codex-plus-setting=\"serviceTierControls\"")); assert!(script.contains("data-codex-service-tier-controls")); assert!(script.contains("removeCodexServiceTierBadges")); @@ -509,6 +509,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["turnWithoutModel"]["serviceTier"], "priority"); + assert_eq!(cases["turnWithoutModel"]["service_tier"], "priority"); assert_eq!(cases["turnWithoutModelDiagnosticModel"], "gpt-5.4"); assert_eq!( @@ -521,6 +522,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["startConversation"]["serviceTier"], "priority"); + assert_eq!(cases["startConversation"]["service_tier"], "priority"); } fn run_service_tier_contract_harness() -> serde_json::Value { @@ -563,6 +565,7 @@ globalThis.document = {{ documentElement: node(), body: node(), createElement: () => node(), + getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], addEventListener() {{}}, diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 8532ab60..41ff1773 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -209,10 +209,13 @@ fn launcher_builds_debug_arguments_and_commands() { } #[test] -fn launcher_does_not_override_codex_app_environment() { +fn launcher_uses_gated_startup_preload_without_proxy_environment_override() { let source = include_str!("../src/launcher.rs"); - assert!(!source.contains(".envs(codex_process_environment())")); + assert!(source.contains("NODE_OPTIONS")); + assert!(source.contains("ensure_service_tier_preload")); + assert!(source.contains("settings.enhancements_enabled")); + assert!(source.contains("launcher.service_tier_preload_disabled")); assert!(!source.contains("activate_packaged_app_with_environment")); assert!(!source.contains("with_temporary_proxy_environment")); } @@ -1179,9 +1182,10 @@ impl LaunchHooks for FakeHooks { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result { assert!(app_dir.ends_with("Codex.app")); + let extra_args = &settings.codex_extra_args; if extra_args.is_empty() { self.event(format!("launch:{debug_port}")); } else {