feat(proxy): warn when Codex clients hold an old Stack model list

Codex's app-server reads the model catalog once at startup and keeps it,
while config.toml is re-read per request. Since 0.159 the codex CLI talks
to a managed daemon that only restarts on upgrade, so reopening codex after
a Stack change still shows the old model list; the desktop app and editor
extensions have the same problem with their own app-servers.

In Codex Stack mode the provider list now shows a banner when a running
Codex client started before the current catalog was written:
- CLI daemon: a button restarts it through Codex's own
  `codex app-server daemon restart`, after a confirmation that running
  tasks will be interrupted. Nothing runs when the daemon is not up.
- Desktop app / editor extensions: the banner explains how to fully quit
  and reopen them; CC Switch does not restart them.

Staleness is judged against a device-local history of catalog generations
(codex-catalog-history.json) rather than file times: an unchanged catalog
is not rewritten, and quitting CC Switch removes the catalog pointer and
restores it on the next start, so a daemon that survived a CC Switch
restart still has the current catalog. Every Codex write and startup
records the catalog a newly started Codex would read; processes that
started before the oldest recorded generation count as stale.

Processes are read with ps on macOS and Linux only; Windows shows no
banner for now.
This commit is contained in:
Jason committed 2026-10-01 23:45:51 +08:00
1 parent 5990d144c3
commit 52e0fa8a6c
20 files changed
+1327 -8

No files matched your search

+15 -3
View File
@@ -95,14 +95,26 @@ pub fn get_direct_provider(
crate::mode::controller::direct_provider_id(state.inner(), &app).map_err(|e| e.to_string())
}
/// Stack 模型:名单里的每一家和它发布的模型 id
/// Stack 模型:名单里的每一家和它发布的模型 id,以及还在用旧模型列表的 Codex 客户端
#[tauri::command]
pub fn get_proxy_stack(
pub async fn get_proxy_stack(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<crate::mode::stack::StackView, String> {
let app = require_proxy_app(&app_type)?;
crate::mode::controller::stack_views(state.inner(), &app)
crate::mode::controller::stack_view_with_clients(state.inner(), &app).await
}
/// 重启 Codex 的托管守护进程(`codex` TUI 连的那个),让它重读模型目录。会中断守护进程里
/// 正在运行的任务,只在用户确认之后调。
#[tauri::command]
pub async fn restart_codex_app_server_daemon(
) -> Result<crate::services::provider::codex_client_catalog::RestartOutcome, String> {
crate::services::provider::codex_direct::off_runtime(
crate::services::provider::codex_client_catalog::restart_daemon,
)
.await
.map_err(|error| error.to_string())?
}
/// Stack 模型:把一家加入或移出名单(`enabled` 是目标值)。成功时返回客户端看不到或看不全
+1
View File
@@ -1586,6 +1586,7 @@ pub fn run() {
commands::switch_proxy_provider,
commands::get_proxy_stack,
commands::set_proxy_stack_member,
commands::restart_codex_app_server_daemon,
// Proxy failover commands
commands::get_provider_health,
commands::reset_circuit_breaker,
+9
View File
@@ -550,6 +550,15 @@ pub fn live_catalog_is_foreign(config_text: &str) -> bool {
})
}
/// live 的 `model_catalog_json` 指向 CC Switch 生成的目录:新启动的 Codex 读的是它。
pub fn live_catalog_is_ours(config_text: &str) -> bool {
config_text.parse::<DocumentMut>().ok().is_some_and(|doc| {
doc.get(MODEL_CATALOG_JSON)
.and_then(Item::as_str)
.is_some_and(is_cc_switch_catalog)
})
}
/// 行里自己指定的模型目录指针(投影的 `top` 只收不是 CC Switch 的指针)。它和独有字段
/// 一样跟着这一家走:切走时 live 里的值还相同就删(见 [`CodexConfigPatch::outgoing`]),
/// 否则第 1 步会把它当成用户的指针留下,之后每一家都用它的模型目录。
+149
View File
@@ -31,6 +31,7 @@ use crate::live::project::claude::{
use crate::live::project::gemini::GeminiProjection;
use crate::live::project::grok::GrokProjection;
use crate::provider::Provider;
use crate::services::provider::codex_client_catalog;
use crate::services::provider::codex_direct::{self, Owner};
use crate::services::provider::codex_official_models;
use crate::services::provider::{claude_direct, gemini_direct, grok_direct};
@@ -1008,9 +1009,40 @@ pub fn stack_views(state: &AppState, app: &AppType) -> Result<StackView, String>
active: mode.is_proxy() && stack.enabled,
members: stack::member_views(&members),
notice,
stale_clients: None,
})
}
/// [`stack_views`] 再加上 Codex 客户端是不是还在用旧的模型列表(要读进程表,放到阻塞线程池
/// 里)。路由那家自己管理目录、或者用户自己指定了目录时,Stack 模型本来就不发布或不生效,重启
/// 也看不到,已经有 `notice` 说明,不再查。
pub async fn stack_view_with_clients(state: &AppState, app: &AppType) -> Result<StackView, String> {
let mut view = stack_views(state, app)?;
if matches!(app, AppType::Codex)
&& view.active
&& !matches!(view.notice, Some("routeOwnsCatalog" | "configOwnsCatalog"))
&& codex_publishes_stack_models(state, &settled_stack(app)?)
{
view.stale_clients = codex_direct::off_runtime(|| {
codex_client_catalog::stale_clients(&DeviceStore::for_device())
})
.await
.map_err(err)?;
}
Ok(view)
}
/// Codex 接着代理,名单里有要发布的 Stack 模型。
fn codex_publishes_stack_models(state: &AppState, stack: &StackState) -> bool {
attached_route(state, &AppType::Codex)
.ok()
.flatten()
.is_some_and(|(_, route)| {
stack::published_members(&state.db, &AppType::Codex, stack, Some(&route.id))
.is_ok_and(|published| !published.is_empty())
})
}
/// Codex 在 Stack 模式下有要发布的 Stack 模型,客户端却看不到或看不全:路由那家自己管理模型
/// 目录文件(Stack 模型不发布);`config.toml` 里用户自己指定了模型目录(生成的目录不生效);
/// 或者官方做默认、最近一次写目录时没拿到官方列表。
@@ -1224,6 +1256,8 @@ pub async fn startup(state: &AppState) {
}
// 接上失败退回直连的应用可能已经把代理拉起来了。
stop_server_if_unused(state).await;
// 记一次新启动的 Codex 会读到的目录:兜住启动时补完的操作和 CC Switch 没开时的外部修改。
codex_client_catalog::observe(&DeviceStore::for_device());
}
async fn startup_app(state: &AppState, app: &AppType) -> Result<(), String> {
@@ -5348,6 +5382,121 @@ model_provider = "c"
assert_eq!(notice, None);
}
/// 假的进程表和时钟(见 `codex_client_catalog::Env`),结束时换回真的。
struct FakeClients {
now_ms: Arc<std::sync::atomic::AtomicU64>,
table: Arc<std::sync::Mutex<String>>,
}
impl FakeClients {
const START_MS: u64 = 1_800_000_000_000;
fn install() -> Self {
let now_ms = Arc::new(std::sync::atomic::AtomicU64::new(Self::START_MS));
let table = Arc::new(std::sync::Mutex::new(String::new()));
let (clock, rows) = (now_ms.clone(), table.clone());
codex_client_catalog::set_test_env(codex_client_catalog::Env {
process_table: Box::new(move || Some(rows.lock().unwrap().clone())),
now_ms: Box::new(move || clock.load(std::sync::atomic::Ordering::SeqCst)),
restart: Box::new(|_| Err("not in tests".to_string())),
});
Self { now_ms, table }
}
/// 桌面版的 app-server,已经跑了 `etime`(`ps` 的格式)。
fn desktop_running_for(&self, etime: &str) {
*self.table.lock().unwrap() = format!(
"62347 {etime} /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server"
);
}
fn advance(&self, ms: u64) {
self.now_ms
.fetch_add(ms, std::sync::atomic::Ordering::SeqCst);
}
}
impl Drop for FakeClients {
fn drop(&mut self) {
codex_client_catalog::reset_test_env();
}
}
async fn stale_clients_of(state: &AppState) -> Option<codex_client_catalog::StaleClients> {
stack_view_with_clients(state, &AppType::Codex)
.await
.expect("stack view")
.stale_clients
}
/// 桌面版在目录变化之前启动:Stack 视图带上 `staleClients`;之后启动的不算旧。路由模式、
/// 路由那家自己管理目录(Stack 模型本来就不发布)时不查。
#[tokio::test]
#[serial]
async fn codex_stack_view_reports_clients_on_an_old_catalog() {
let _home = Home::new();
let clients = FakeClients::install();
seed_codex("", None);
clients.desktop_running_for("10:00");
let state = state_with(AppType::Codex, &codex_stack_rows(), "a").await;
enter(&state, &AppType::Codex, true).await.expect("enter");
set_codex_member(&state, "deepseek", true).await;
assert_eq!(
stale_clients_of(&state).await,
Some(codex_client_catalog::StaleClients {
daemon: false,
others: true
})
);
// 普通的 Stack 视图不读进程表。
assert_eq!(
stack_views(&state, &AppType::Codex).unwrap().stale_clients,
None
);
// 桌面版重开之后启动:读到的正是现在这份目录。
clients.advance(10_000);
clients.desktop_running_for("00:05");
assert_eq!(stale_clients_of(&state).await, None);
// 目录又变了(再加一家):刚才那个桌面版又旧了。
clients.advance(10_000);
set_codex_member(&state, "zhipu", true).await;
clients.desktop_running_for("00:15");
assert!(stale_clients_of(&state).await.is_some());
// 换成路由模式:不是 Stack 模式,不查。
enter(&state, &AppType::Codex, false)
.await
.expect("routing");
clients.desktop_running_for("10:00");
assert_eq!(stale_clients_of(&state).await, None);
}
#[tokio::test]
#[serial]
async fn codex_route_with_its_own_catalog_skips_the_client_check() {
let _home = Home::new();
let clients = FakeClients::install();
seed_codex("", None);
clients.desktop_running_for("10:00");
let [_, deepseek, zhipu] = codex_stack_rows();
let route = codex_native(
"a",
"https://a.example/v1",
"model_catalog_json = \"/opt/team/models.json\"\n",
None,
);
let state = state_with(AppType::Codex, &[route, deepseek, zhipu], "a").await;
enter(&state, &AppType::Codex, true).await.expect("enter");
set_codex_member(&state, "deepseek", true).await;
let view = stack_view_with_clients(&state, &AppType::Codex)
.await
.unwrap();
assert_eq!(view.notice, Some("routeOwnsCatalog"));
assert_eq!(view.stale_clients, None);
}
/// 用户直接在 config.toml 里指定的模型目录:写入时照留,生成的目录不生效,同样要提示。
#[tokio::test]
#[serial]
+4
View File
@@ -26,6 +26,7 @@ use crate::live::engine::DeviceStore;
use crate::live::project::claude::{env_string, has_one_m_marker, ONE_M_MARKER_FOR_CLIENT};
use crate::provider::{ClaudeStackModel, Provider};
use crate::proxy::model_mapper::strip_one_m_suffix_for_upstream;
use crate::services::provider::codex_client_catalog::StaleClients;
use super::state::{self, StackState};
@@ -596,6 +597,9 @@ pub struct StackView {
/// 暂不可用。
#[serde(skip_serializing_if = "Option::is_none")]
pub notice: Option<&'static str>,
/// Codex 客户端还在用旧的模型列表(启动时读的目录),Stack 模型看不到,要重启才行。
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_clients: Option<StaleClients>,
}
pub fn member_views(members: &[Member]) -> Vec<StackMemberView> {
@@ -0,0 +1,760 @@
//! Codex 客户端是不是还在用旧的模型目录,以及重启 Codex 的托管守护进程。
//!
//! Codex 的 app-server 只在启动时读一次模型目录(codex-rs `app-server/src/model_catalog.rs`:
//! 「retained startup model catalog」),之后每个请求重读 `config.toml`:路由跟着变,模型列表
//! 不变。0.159 起 `codex` TUI 默认连一个托管守护进程(`codex app-server --managed-daemon`),
//! 它只在 Codex 升级时重启,关掉再开 `codex` 不会重读;桌面版、编辑器插件自带的 app-server
//! 也要整个重开才会重读。
//!
//! 判断一个进程读到的是哪份目录,不能比文件时间:内容没变时引擎不重写文件,时间不动;退出
//! CC Switch 时撤掉目录指针、下次启动再写回,时间变了,早先启动的进程读到的却正是现在这份。
//! 所以记下目录的代次:从哪个时刻起,新启动的 Codex 会读到哪份目录([`HISTORY_FILENAME`])。
//! 一个进程读到的,是它启动之前开始的最后一代。
//!
//! 进程只看不动:用户在界面上确认之后,才调 Codex 自己的 `codex app-server daemon restart`。
//! 桌面版和编辑器插件不替用户重启,只提示彻底退出再开。进程表只在 macOS、Linux 上读(`ps`),
//! Windows 上看不到进程,不出提示。
use std::process::Output;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::codex_config::{get_codex_config_dir, get_codex_model_catalog_path};
use crate::live::engine::{sha256_hex, DeviceStore};
use crate::live::project::codex::live_catalog_is_ours;
use super::codex_direct::read_config_text;
pub(crate) const HISTORY_FILENAME: &str = "codex-catalog-history.json";
/// 最多记这么多代。更早启动的进程判断不了,按旧的算。
const HISTORY_LIMIT: usize = 32;
/// 进程的启动时刻只精确到秒(`ps` 的 etime),每一代又是写完之后才记下的:启动时刻离一代的
/// 开始不到这么久,就当它读到的是上一代。拿不准时宁可多提示一次。
const MARGIN_MS: u64 = 2_000;
/// 新启动的 Codex 不读 CC Switch 的目录(没有目录指针,或者指向别人的目录)。
const NO_CATALOG: &str = "none";
/// 守护进程停机宽限期的缺省值和上限(codex-rs `app-server-daemon/src/settings.rs`)。
const DEFAULT_SHUTDOWN_GRACE_SECS: u64 = 60;
const MAX_SHUTDOWN_GRACE_SECS: u64 = 300;
/// 宽限期之外,起新进程、等它就绪的余量(codex-rs 的 `OPERATION_LOCK_TIMEOUT` 也是在宽限期上
/// 加 75 秒)。
const RESTART_MARGIN: Duration = Duration::from_secs(75);
/// 一代目录:从 `since_ms`(Unix 毫秒)起,新启动的 Codex 读到的目录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Generation {
since_ms: u64,
fingerprint: String,
}
/// 还在用旧模型列表的 Codex 客户端(给前端)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StaleClients {
/// 托管守护进程(`codex` TUI 连的那个):可以替用户重启。
pub daemon: bool,
/// 其余 app-server(桌面版、编辑器插件):要用户自己彻底退出再开。
pub others: bool,
}
/// 重启守护进程的结果(给前端)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum RestartOutcome {
Restarted,
/// 守护进程没在跑,什么都没做:`restart` 会替用户起一个新的,下次开 `codex` 时它自己会起。
NotRunning,
}
/// 正在跑的 app-server 的启动时刻(Unix 毫秒)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct AppServers {
daemon: Option<u64>,
others: Vec<u64>,
}
/// 外部依赖:进程表、时钟、重启命令。测试里换成假的。
pub(crate) struct Env {
/// `ps -Ao pid=,etime=,command=` 的输出;读不到(或在 Windows 上)是 `None`。
pub process_table: Box<dyn Fn() -> Option<String> + Send + Sync>,
/// Unix 毫秒。
pub now_ms: Box<dyn Fn() -> u64 + Send + Sync>,
/// 执行 `codex app-server daemon restart`,参数是超时。
pub restart: Box<dyn Fn(Duration) -> Result<Output, String> + Send + Sync>,
}
impl Env {
fn real() -> Self {
Self {
process_table: Box::new(read_process_table),
now_ms: Box::new(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or_default()
}),
restart: Box::new(run_restart),
}
}
}
fn env_slot() -> &'static Mutex<Option<Arc<Env>>> {
static SLOT: OnceLock<Mutex<Option<Arc<Env>>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
fn env() -> Arc<Env> {
let mut slot = env_slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
slot.get_or_insert_with(|| Arc::new(Env::real())).clone()
}
#[cfg(test)]
pub(crate) fn set_test_env(env: Env) {
*env_slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(env));
}
#[cfg(test)]
pub(crate) fn reset_test_env() {
*env_slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
#[cfg(unix)]
fn read_process_table() -> Option<String> {
// `-ww`:输出不是终端时 Linux 的 procps 也会截断命令行。
let output = std::process::Command::new("ps")
.args(["-ww", "-Ao", "pid=,etime=,command="])
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| log::debug!("读取进程表失败: {error}"))
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(not(unix))]
fn read_process_table() -> Option<String> {
None
}
fn run_restart(timeout: Duration) -> Result<Output, String> {
// 重启的必须是读 CC Switch 写的这份配置的守护进程(配置目录可能被覆盖到别处)。
let codex_dir = get_codex_config_dir();
let extra_env = [("CODEX_HOME", codex_dir.to_string_lossy().into_owned())];
crate::commands::run_detected_tool_command_with_timeout(
"codex",
&["app-server", "daemon", "restart"],
Some(timeout),
&extra_env,
&codex_dir,
)
}
/// 历史文件的读改写只在一个线程里做(写入和查询会同时记)。
fn history_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn read_history(store: &DeviceStore) -> Vec<Generation> {
let path = store.file(HISTORY_FILENAME);
let Ok(bytes) = std::fs::read(&path) else {
return Vec::new();
};
serde_json::from_slice(&bytes).unwrap_or_else(|error| {
log::warn!(
"Codex 模型目录的变化记录 {} 无法解析,当作空的: {error}",
path.display()
);
Vec::new()
})
}
/// 现在的目录和最后一代不同就记成新的一代,返回记完之后的历史。尽力而为:写不进去只打日志,
/// 下次再记(判断会偏向「旧」)。
fn record(store: &DeviceStore, fingerprint: &str, now_ms: u64) -> Vec<Generation> {
let _guard = history_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut history = read_history(store);
if history
.last()
.is_some_and(|last| last.fingerprint == fingerprint)
{
return history;
}
history.push(Generation {
since_ms: now_ms,
fingerprint: fingerprint.to_string(),
});
let excess = history.len().saturating_sub(HISTORY_LIMIT);
history.drain(..excess);
if let Err(error) = crate::config::write_json_file(&store.file(HISTORY_FILENAME), &history) {
log::warn!("记录 Codex 模型目录的变化失败: {error}");
}
history
}
/// 新启动的 Codex 现在会读到的目录:`config.toml` 顶层指向 CC Switch 的目录时是文件内容的
/// hash,否则是 [`NO_CATALOG`]。
fn current_fingerprint() -> String {
if !live_catalog_is_ours(&read_config_text()) {
return NO_CATALOG.to_string();
}
std::fs::read(get_codex_model_catalog_path())
.map(|bytes| sha256_hex(&bytes))
.unwrap_or_else(|_| NO_CATALOG.to_string())
}
/// 记下新启动的 Codex 现在会读到的目录。Codex 的客户端文件每写一次(直连、进出代理、Stack
/// 增删、退出时写回)和启动接上之后各调一次。
pub(crate) fn observe(store: &DeviceStore) {
record(store, &current_fingerprint(), (env().now_ms)());
}
/// 还在用旧目录的 Codex 客户端。都是新的,或者新启动的 Codex 本来就不读 CC Switch 的目录时
/// 为 `None`。要读进程表,放到阻塞线程池里调。
pub(crate) fn stale_clients(store: &DeviceStore) -> Option<StaleClients> {
let env = env();
let current = current_fingerprint();
// 顺手记一次:兜住在 CC Switch 之外改了目录的情况。
let history = record(store, &current, (env.now_ms)());
if current == NO_CATALOG {
return None;
}
judge(&history, &current, &probe(&env))
}
fn judge(history: &[Generation], current: &str, servers: &AppServers) -> Option<StaleClients> {
let daemon = servers
.daemon
.is_some_and(|started| is_stale(history, current, started));
let others = servers
.others
.iter()
.any(|&started| is_stale(history, current, started));
(daemon || others).then_some(StaleClients { daemon, others })
}
/// 启动于 `started_ms` 的进程读到的是不是别的目录。早于记下的第一代、判断不了的按旧的算。
fn is_stale(history: &[Generation], current: &str, started_ms: u64) -> bool {
history
.iter()
.rev()
.find(|generation| generation.since_ms.saturating_add(MARGIN_MS) <= started_ms)
.is_none_or(|generation| generation.fingerprint != current)
}
fn probe(env: &Env) -> AppServers {
let Some(table) = (env.process_table)() else {
return AppServers::default();
};
let plugins = format!("{}/", get_codex_config_dir().join("plugins").display());
classify(&table, daemon_pid(), &plugins, (env.now_ms)())
}
/// 守护进程自己记的 pid(`<Codex 目录>/app-server-daemon/daemon.pid`)。
fn daemon_pid() -> Option<u32> {
let path = get_codex_config_dir()
.join("app-server-daemon")
.join("daemon.pid");
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice::<Value>(&bytes)
.ok()?
.get("pid")?
.as_u64()
.and_then(|pid| u32::try_from(pid).ok())
}
/// 从进程表里挑出 app-server。守护进程要 pid 和 `daemon.pid` 对得上、命令行还是
/// `--managed-daemon`(防止 pid 被别的进程复用),别的配置目录的守护进程不算。`plugins_dir`
/// 下的是 Chrome 插件自带的,没有模型选择器,不算。
fn classify(table: &str, daemon_pid: Option<u32>, plugins_dir: &str, now_ms: u64) -> AppServers {
let mut servers = AppServers::default();
for line in table.lines() {
let Some((pid, elapsed_secs, command)) = split_row(line) else {
continue;
};
let Some(role) = app_server_role(command) else {
continue;
};
let started = now_ms.saturating_sub(elapsed_secs.saturating_mul(1000));
match role {
Role::Daemon if Some(pid) == daemon_pid => servers.daemon = Some(started),
Role::Daemon => {}
Role::Other if command.starts_with(plugins_dir) => {}
Role::Other => servers.others.push(started),
}
}
servers
}
/// `pid etime command` 一行。
fn split_row(line: &str) -> Option<(u32, u64, &str)> {
let (pid, rest) = line.trim_start().split_once(char::is_whitespace)?;
let (etime, command) = rest.trim_start().split_once(char::is_whitespace)?;
Some((pid.parse().ok()?, parse_etime(etime)?, command.trim_start()))
}
/// `ps` 的 etime:`[[dd-]hh:]mm:ss`,换成秒。
fn parse_etime(value: &str) -> Option<u64> {
let (days, clock) = match value.split_once('-') {
Some((days, clock)) => (days.parse::<u64>().ok()?, clock),
None => (0, value),
};
let parts: Vec<&str> = clock.split(':').collect();
if !(2..=3).contains(&parts.len()) {
return None;
}
let mut secs = 0u64;
for part in parts {
secs = secs * 60 + part.parse::<u64>().ok()?;
}
Some(days * 86_400 + secs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Daemon,
Other,
}
/// `…/codex [-c 键=值 …] app-server …` 是 app-server;`app-server daemon …`、`app-server proxy`
/// 是管理守护进程的子命令,不读模型目录。命令行是 `ps` 用空格拼起来的,分不出路径里的空格,
/// 所以先找可执行文件名 `codex`,再看它后面的参数。
fn app_server_role(command: &str) -> Option<Role> {
let mut words = after_codex_executable(command)?.split_whitespace();
let subcommand = loop {
match words.next()? {
"-c" | "--config" => {
words.next()?;
}
flag if flag.starts_with('-') => {}
word => break word,
}
};
if subcommand != "app-server" {
return None;
}
let rest: Vec<&str> = words.collect();
if matches!(rest.first(), Some(&"daemon") | Some(&"proxy")) {
return None;
}
Some(if rest.contains(&"--managed-daemon") {
Role::Daemon
} else {
Role::Other
})
}
/// 命令行里可执行文件 `codex`(`codex` 或 `…/codex`)之后的部分。
fn after_codex_executable(command: &str) -> Option<&str> {
const NAME: &str = "codex";
let mut from = 0;
while let Some(found) = command[from..].find(NAME) {
let start = from + found;
let end = start + NAME.len();
let rest = &command[end..];
if (start == 0 || command[..start].ends_with('/'))
&& (rest.is_empty() || rest.starts_with(' '))
{
return Some(rest);
}
from = end;
}
None
}
/// 用户确认之后重启托管守护进程:Codex 自己的 `codex app-server daemon restart`,先等守护进程
/// 里在跑的任务收尾(最多一个宽限期),再起新的。守护进程没在跑就什么都不做。
pub(crate) fn restart_daemon() -> Result<RestartOutcome, String> {
let env = env();
if probe(&env).daemon.is_none() {
return Ok(RestartOutcome::NotRunning);
}
let output = (env.restart)(restart_timeout())?;
let stdout = crate::commands::decode_command_output(&output.stdout);
if !output.status.success() {
let stderr = crate::commands::decode_command_output(&output.stderr);
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
return Err(if detail.is_empty() {
"重启 Codex 守护进程失败 (Failed to restart the Codex daemon)".to_string()
} else {
format!("重启 Codex 守护进程失败 (Failed to restart the Codex daemon): {detail}")
});
}
Ok(match lifecycle_status(&stdout).as_deref() {
Some("notRunning") => RestartOutcome::NotRunning,
_ => RestartOutcome::Restarted,
})
}
/// `codex app-server daemon` 的子命令在 stdout 最后一行打一个 JSON(codex-rs `LifecycleOutput`)。
fn lifecycle_status(stdout: &str) -> Option<String> {
let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
serde_json::from_str::<Value>(line.trim())
.ok()?
.get("status")?
.as_str()
.map(str::to_string)
}
/// 守护进程的停机宽限期(`<Codex 目录>/app-server-daemon/settings.json` 的
/// `shutdownGraceSeconds`)加上起新进程的余量。
fn restart_timeout() -> Duration {
let grace = std::fs::read(
get_codex_config_dir()
.join("app-server-daemon")
.join("settings.json"),
)
.ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.and_then(|settings| settings.get("shutdownGraceSeconds")?.as_u64())
.unwrap_or(DEFAULT_SHUTDOWN_GRACE_SECS)
.min(MAX_SHUTDOWN_GRACE_SECS);
Duration::from_secs(grace) + RESTART_MARGIN
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
const DAEMON: &str =
"/Users/me/.codex/packages/app-server-daemon/releases/0.159.2-aarch64-apple-darwin/bin/codex";
/// 临时 home(历史文件和 Codex 目录都落在这里),结束时换回真的依赖。
struct Scope {
dir: tempfile::TempDir,
saved: Option<std::ffi::OsString>,
}
impl Scope {
fn new() -> Self {
let dir = tempfile::TempDir::new().unwrap();
let saved = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", dir.path());
std::fs::create_dir_all(get_codex_config_dir()).unwrap();
Self { dir, saved }
}
fn store(&self) -> DeviceStore {
DeviceStore::at(self.dir.path().join(".cc-switch"))
}
}
impl Drop for Scope {
fn drop(&mut self) {
reset_test_env();
match self.saved.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
fn generation(since_ms: u64, fingerprint: &str) -> Generation {
Generation {
since_ms,
fingerprint: fingerprint.to_string(),
}
}
/// 假的时钟和进程表:`clock` 是现在(Unix 毫秒),`table` 是 `ps` 的输出。
fn fake_env(clock: Arc<AtomicU64>, table: Arc<Mutex<String>>, restarted: Arc<AtomicBool>) {
let now = clock.clone();
set_test_env(Env {
process_table: Box::new(move || Some(table.lock().unwrap().clone())),
now_ms: Box::new(move || now.load(Ordering::SeqCst)),
restart: Box::new(move |_| {
restarted.store(true, Ordering::SeqCst);
Ok(success(r#"{"status":"restarted","pid":1}"#))
}),
});
}
#[cfg(unix)]
fn success(stdout: &str) -> Output {
use std::os::unix::process::ExitStatusExt;
Output {
status: std::process::ExitStatus::from_raw(0),
stdout: format!("{stdout}\n").into_bytes(),
stderr: Vec::new(),
}
}
#[cfg(windows)]
fn success(stdout: &str) -> Output {
use std::os::windows::process::ExitStatusExt;
Output {
status: std::process::ExitStatus::from_raw(0),
stdout: format!("{stdout}\n").into_bytes(),
stderr: Vec::new(),
}
}
fn write_daemon_pid(pid: u32) {
let dir = get_codex_config_dir().join("app-server-daemon");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("daemon.pid"), format!(r#"{{"pid":{pid}}}"#)).unwrap();
}
/// 让新启动的 Codex 读 CC Switch 的目录(`catalog` 是目录内容),`None` 是撤掉指针。
fn point_at_catalog(catalog: Option<&str>) {
let config = get_codex_config_dir().join("config.toml");
match catalog {
Some(content) => {
std::fs::write(
&config,
"model_provider = \"cc-switch-official\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\n",
)
.unwrap();
std::fs::write(get_codex_model_catalog_path(), content).unwrap();
}
None => std::fs::write(&config, "model = \"gpt-6-astra\"\n").unwrap(),
}
}
#[test]
fn parses_etime() {
assert_eq!(parse_etime("05:03"), Some(303));
assert_eq!(parse_etime("02:53:10"), Some(2 * 3600 + 53 * 60 + 10));
assert_eq!(parse_etime("3-01:02:03"), Some(3 * 86_400 + 3723));
assert_eq!(
parse_etime("02-11:19:30"),
Some(2 * 86_400 + 11 * 3600 + 19 * 60 + 30)
);
assert_eq!(parse_etime("12"), None);
assert_eq!(parse_etime("a:b"), None);
}
#[test]
fn classifies_app_servers() {
let table = [
format!("35946 02-11:19:30 {DAEMON} app-server daemon pid-update-loop"),
format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon"),
// 另一个配置目录的守护进程:pid 对不上,不算。
format!("70000 00:10 {DAEMON} app-server --listen unix:// --managed-daemon"),
"62347 04:57 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server --analytics-default-enabled -c plugins.codex-app-tools@openai-bundled.mcp_servers.codex_app.enabled=true".to_string(),
"83919 02:11:54 /Users/me/.codex/plugins/.plugin-appserver/codex-cli/CodexCLI.app/Contents/MacOS/codex app-server --analytics-default-enabled".to_string(),
"81234 01:00 /Users/me/Library/Application Support/Code/User/globalStorage/openai.chatgpt/bin/codex app-server".to_string(),
"90000 00:05 /opt/homebrew/bin/codex app-server proxy".to_string(),
"91000 00:05 /opt/homebrew/bin/codex --no-daemon".to_string(),
"92000 00:05 /usr/bin/vim /Users/me/.codex/config.toml".to_string(),
"93000 00:05 /Users/me/codex-tools/run app-server".to_string(),
]
.join("\n");
let now = 10_000_000;
let servers = classify(&table, Some(59013), "/Users/me/.codex/plugins/", now);
assert_eq!(servers.daemon, Some(now - 439_000));
assert_eq!(servers.others, vec![now - 297_000, now - 60_000]);
// daemon.pid 指向的进程不是守护进程(pid 被复用了):不算守护进程。
assert_eq!(
classify(&table, Some(62347), "/Users/me/.codex/plugins/", now).daemon,
None
);
}
#[test]
fn judges_by_generation_started_before() {
let history = [generation(1_000_000, "a"), generation(2_000_000, "b")];
// 在 b 之后启动:读到的是现在这份。
assert!(!is_stale(&history, "b", 2_000_000 + MARGIN_MS));
// 离 b 开始不到余量:当它读到的是 a。
assert!(is_stale(&history, "b", 2_000_000 + MARGIN_MS - 1));
assert!(is_stale(&history, "b", 1_500_000));
// 早于第一代:判断不了,按旧的算。
assert!(is_stale(&history, "b", 500_000));
// 来回切:a → b → a,在第一次 a 时启动的进程读到的正是现在这份。
let history = [
generation(1_000_000, "a"),
generation(2_000_000, "b"),
generation(3_000_000, "a"),
];
assert!(!is_stale(&history, "a", 1_500_000));
assert!(is_stale(&history, "a", 2_500_000));
}
#[test]
#[serial]
fn records_only_changes_and_trims() {
let scope = Scope::new();
let store = scope.store();
assert_eq!(record(&store, "a", 1).len(), 1);
assert_eq!(record(&store, "a", 2), vec![generation(1, "a")]);
for step in 0..40u64 {
record(&store, &format!("f{step}"), 10 + step);
}
let history = read_history(&store);
assert_eq!(history.len(), HISTORY_LIMIT);
assert_eq!(history.last(), Some(&generation(49, "f39")));
assert_eq!(history.first(), Some(&generation(18, "f8")));
}
#[test]
#[serial]
fn fingerprint_follows_the_catalog_pointer() {
let _scope = Scope::new();
assert_eq!(current_fingerprint(), NO_CATALOG);
point_at_catalog(Some("{\"models\":[]}"));
let fingerprint = current_fingerprint();
assert_eq!(fingerprint, sha256_hex(b"{\"models\":[]}"));
// 指向别人的目录:新启动的 Codex 不读 CC Switch 的目录。
std::fs::write(
get_codex_config_dir().join("config.toml"),
"model_catalog_json = \"/elsewhere/models.json\"\n",
)
.unwrap();
assert_eq!(current_fingerprint(), NO_CATALOG);
point_at_catalog(None);
assert_eq!(current_fingerprint(), NO_CATALOG);
}
/// 进 Stack → 守护进程启动 → 退出 CC Switch(撤掉指针)→ 再打开(写回同一份目录):守护进程
/// 读到的正是现在这份,不提示。退出期间重启过的守护进程读到的是没有目录的配置,要提示。
#[test]
#[serial]
fn quitting_and_reopening_cc_switch_is_not_stale() {
let scope = Scope::new();
let store = scope.store();
let clock = Arc::new(AtomicU64::new(1_000_000));
let table = Arc::new(Mutex::new(String::new()));
fake_env(
clock.clone(),
table.clone(),
Arc::new(AtomicBool::new(false)),
);
let daemon_row = |elapsed: &str| {
format!("59013 {elapsed} {DAEMON} app-server --listen unix:// --managed-daemon")
};
write_daemon_pid(59013);
point_at_catalog(Some("stack"));
observe(&store);
// 进 Stack 之后 10 秒守护进程启动。
clock.store(1_010_000, Ordering::SeqCst);
*table.lock().unwrap() = daemon_row("00:00");
clock.store(1_060_000, Ordering::SeqCst);
*table.lock().unwrap() = daemon_row("00:50");
assert_eq!(stale_clients(&store), None);
// 退出 CC Switch,一分钟后再打开。
point_at_catalog(None);
observe(&store);
clock.store(1_120_000, Ordering::SeqCst);
point_at_catalog(Some("stack"));
observe(&store);
*table.lock().unwrap() = daemon_row("01:50");
assert_eq!(stale_clients(&store), None);
// 目录变了(Stack 增删):守护进程还拿着旧的。
clock.store(1_200_000, Ordering::SeqCst);
point_at_catalog(Some("stack + kimi"));
observe(&store);
*table.lock().unwrap() = daemon_row("03:10");
assert_eq!(
stale_clients(&store),
Some(StaleClients {
daemon: true,
others: false
})
);
// 退出期间守护进程重启过(比如 Codex 自动升级)。
point_at_catalog(None);
observe(&store);
clock.store(1_300_000, Ordering::SeqCst);
*table.lock().unwrap() = daemon_row("00:30");
clock.store(1_400_000, Ordering::SeqCst);
point_at_catalog(Some("stack + kimi"));
observe(&store);
*table.lock().unwrap() = daemon_row("02:10");
assert_eq!(
stale_clients(&store),
Some(StaleClients {
daemon: true,
others: false
})
);
}
#[test]
#[serial]
fn desktop_app_servers_are_reported_separately() {
let scope = Scope::new();
let store = scope.store();
let clock = Arc::new(AtomicU64::new(1_000_000));
let table = Arc::new(Mutex::new(String::new()));
fake_env(
clock.clone(),
table.clone(),
Arc::new(AtomicBool::new(false)),
);
point_at_catalog(Some("old"));
observe(&store);
clock.store(1_100_000, Ordering::SeqCst);
point_at_catalog(Some("new"));
observe(&store);
clock.store(1_200_000, Ordering::SeqCst);
// 桌面版在旧目录时启动;守护进程没在跑。
*table.lock().unwrap() =
"62347 02:30 /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex -c features.code_mode_host=true app-server".to_string();
assert_eq!(
stale_clients(&store),
Some(StaleClients {
daemon: false,
others: true
})
);
// 撤掉指针之后不提示(新启动的 Codex 也不读 CC Switch 的目录)。
point_at_catalog(None);
assert_eq!(stale_clients(&store), None);
}
#[test]
#[serial]
fn restart_skips_when_the_daemon_is_not_running() {
let _scope = Scope::new();
let restarted = Arc::new(AtomicBool::new(false));
let table = Arc::new(Mutex::new(String::new()));
fake_env(
Arc::new(AtomicU64::new(1_000_000)),
table.clone(),
restarted.clone(),
);
assert_eq!(restart_daemon(), Ok(RestartOutcome::NotRunning));
assert!(!restarted.load(Ordering::SeqCst));
write_daemon_pid(59013);
*table.lock().unwrap() =
format!("59013 07:19 {DAEMON} app-server --listen unix:// --managed-daemon");
assert_eq!(restart_daemon(), Ok(RestartOutcome::Restarted));
assert!(restarted.load(Ordering::SeqCst));
}
#[test]
fn reads_the_lifecycle_status() {
assert_eq!(
lifecycle_status("warning: x\n{\"status\":\"restarted\",\"pid\":1}\n\n").as_deref(),
Some("restarted")
);
assert_eq!(lifecycle_status("not json"), None);
}
}
@@ -1075,7 +1075,10 @@ pub(crate) fn run_with_edits(
});
}
write.run(op, &changes, pending)
let report = write.run(op, &changes, pending);
// 按磁盘上的实际内容记新启动的 Codex 会读到的目录:失败时可能已经发布了一部分。
super::codex_client_catalog::observe(store);
report
}
/// 直连写入:`prepare` → `plan` → `run`。
+1
View File
@@ -4,6 +4,7 @@
pub(crate) mod claude_direct;
mod claude_editor;
pub(crate) mod codex_client_catalog;
pub(crate) mod codex_direct;
mod codex_editor;
mod codex_login;
@@ -0,0 +1,71 @@
import { useState } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useRestartCodexAppServerDaemon } from "@/lib/query/proxy";
import type { CodexStaleClients } from "@/types/proxy";
interface CodexStaleClientsNoticeProps {
staleClients: CodexStaleClients;
}
/**
* Stack 模式下 Codex 客户端还在用旧的模型列表(它们只在启动时读模型目录)。命令行连的守护
* 进程确认后一键重启;桌面版、编辑器插件只提示用户彻底退出再开。重启会中断守护进程里正在
* 运行的任务,执行期间确认框保持打开。
*/
export function CodexStaleClientsNotice({
staleClients,
}: CodexStaleClientsNoticeProps) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const restart = useRestartCodexAppServerDaemon();
return (
<div
role="alert"
className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
>
<div className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t("proxy.stackMode.codexStale.title")}
</div>
<ul className="mt-2 space-y-1 text-xs leading-relaxed">
{staleClients.daemon && (
<li>{t("proxy.stackMode.codexStale.daemon")}</li>
)}
{staleClients.others && (
<li>{t("proxy.stackMode.codexStale.others")}</li>
)}
</ul>
{staleClients.daemon && (
<Button
size="sm"
variant="outline"
className="mt-3 gap-1.5"
disabled={restart.isPending}
onClick={() => setConfirming(true)}
>
<RotateCw
className={`h-3.5 w-3.5 ${restart.isPending ? "animate-spin" : ""}`}
/>
{t("proxy.stackMode.codexStale.restart")}
</Button>
)}
<ConfirmDialog
isOpen={confirming}
title={t("proxy.stackMode.codexStale.confirmTitle")}
message={t("proxy.stackMode.codexStale.confirmMessage")}
confirmText={t("proxy.stackMode.codexStale.confirm")}
pending={restart.isPending}
onConfirm={() =>
restart.mutate(undefined, {
onSettled: () => setConfirming(false),
})
}
onCancel={() => setConfirming(false)}
/>
</div>
);
}
@@ -34,6 +34,7 @@ import {
import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice";
import {
useAutoFailoverEnabled,
useFailoverQueue,
@@ -178,6 +179,8 @@ export function ProviderList({
isStackAppId(appId) && isProxyTakeover === true && stack?.active === true;
const stackMembers = stack?.members;
const stackNotice = isStackMode ? stack?.notice : undefined;
const codexStaleClients =
isStackMode && appId === "codex" ? stack?.staleClients : undefined;
const setStackMember = useSetProxyStackMember();
const stackMemberOf = useCallback(
(providerId: string): ProxyStackMember | undefined =>
@@ -584,6 +587,9 @@ export function ProviderList({
return (
<div className="mt-4 space-y-4">
{piStateErrorNotice}
{codexStaleClients && (
<CodexStaleClientsNotice staleClients={codexStaleClients} />
)}
{claudeDesktopStatusMessages.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
<div className="flex items-center gap-2 font-medium">
+13 -1
View File
@@ -2918,7 +2918,19 @@
"inactive": "Enter Stack mode: add several providers and all their models appear in the {{appLabel}} model list"
},
"ariaLabel": "{{appLabel}} Stack mode",
"failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode"
"failoverUnavailable": "{{appLabel}} is in Stack mode, which has no failover. The queue and settings are kept and apply again in routing mode",
"codexStale": {
"title": "Codex is still using an old model list, so Stack models won't show",
"daemon": "CLI: reopening codex isn't enough; its background daemon needs a restart.",
"others": "Desktop app: quit it completely and reopen it (⌘Q on macOS; closing the window isn't enough). Editor extensions: reopen the editor window.",
"restart": "Restart Codex daemon",
"confirmTitle": "Restart the Codex daemon?",
"confirmMessage": "Every codex session in your terminals shares this daemon, so running tasks will be interrupted. Reopen codex afterwards to see the Stack models.",
"confirm": "Restart",
"restarted": "Codex daemon restarted. Reopen codex to see the Stack models",
"notRunning": "The Codex daemon isn't running. codex will load the new model list the next time it starts",
"failed": "Failed to restart the Codex daemon: {{error}}"
}
},
"failover": {
"proxyRequired": "Routing service must be started to configure failover",
+13 -1
View File
@@ -2918,7 +2918,19 @@
"inactive": "Stack モードに入る:複数のプロバイダーを追加し、それらのモデルをすべて {{appLabel}} のモデル一覧に表示します"
},
"ariaLabel": "{{appLabel}} の Stack モード",
"failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります"
"failoverUnavailable": "{{appLabel}} は Stack モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります",
"codexStale": {
"title": "Codex が古いモデル一覧を使っているため、Stack モデルが表示されません",
"daemon": "CLI:codex を開き直すだけでは反映されません。バックグラウンドのデーモンを再起動する必要があります。",
"others": "デスクトップ版:完全に終了してから開き直してください(macOS では ⌘Q。ウィンドウを閉じるだけでは不十分です)。エディター拡張:エディターのウィンドウを開き直してください。",
"restart": "Codex デーモンを再起動",
"confirmTitle": "Codex デーモンを再起動しますか?",
"confirmMessage": "ターミナルで動いているすべての codex がこのデーモンを共有しているため、実行中のタスクは中断されます。再起動後に codex を開き直すと Stack モデルが表示されます。",
"confirm": "再起動",
"restarted": "Codex デーモンを再起動しました。codex を開き直すと Stack モデルが表示されます",
"notRunning": "Codex デーモンは動いていません。次に codex を起動したときに新しいモデル一覧が読み込まれます",
"failed": "Codex デーモンの再起動に失敗しました:{{error}}"
}
},
"failover": {
"proxyRequired": "フェイルオーバーを設定するには、ルーティングサービスを先に起動する必要があります",
+13 -1
View File
@@ -2919,7 +2919,19 @@
"inactive": "進入 Stack 模式:新增多家供應商,它們的模型都出現在 {{appLabel}} 的模型清單裡"
},
"ariaLabel": "{{appLabel}} Stack 模式",
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復"
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障轉移。佇列和設定保留,回到路由模式後恢復",
"codexStale": {
"title": "Codex 還在用舊的模型清單,Stack 模型不會出現",
"daemon": "命令列:重開 codex 不夠,要重新啟動背景的守護行程。",
"others": "桌面版:要完全結束再重開(macOS 上按 ⌘Q,只關視窗不行);編輯器擴充功能要重開編輯器視窗。",
"restart": "重新啟動 Codex 守護行程",
"confirmTitle": "重新啟動 Codex 守護行程?",
"confirmMessage": "所有終端機裡的 codex 共用這個守護行程,正在執行的任務會被中斷。重新啟動後重開 codex,就能看到 Stack 模型。",
"confirm": "重新啟動",
"restarted": "Codex 守護行程已重新啟動,重開 codex 就能看到 Stack 模型",
"notRunning": "Codex 守護行程沒有在執行,下次開啟 codex 時會讀到新的模型清單",
"failed": "重新啟動 Codex 守護行程失敗:{{error}}"
}
},
"failover": {
"proxyRequired": "需要先啟動路由服務才能設定故障轉移",
+13 -1
View File
@@ -2918,7 +2918,19 @@
"inactive": "进入 Stack 模式:添加多家供应商,它们的模型都出现在 {{appLabel}} 的模型列表里"
},
"ariaLabel": "{{appLabel}} Stack 模式",
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复"
"failoverUnavailable": "{{appLabel}} 在 Stack 模式,不做故障转移。队列和设置保留,回到路由模式后恢复",
"codexStale": {
"title": "Codex 还在用旧的模型列表,Stack 模型不会出现",
"daemon": "命令行:重开 codex 不够,要重启后台的守护进程。",
"others": "桌面版:要彻底退出再重开(macOS 上按 ⌘Q,只关窗口不行);编辑器插件要重开编辑器窗口。",
"restart": "重启 Codex 守护进程",
"confirmTitle": "重启 Codex 守护进程?",
"confirmMessage": "所有终端里的 codex 共用这个守护进程,正在运行的任务会被中断。重启之后重新打开 codex,就能看到 Stack 模型。",
"confirm": "重启",
"restarted": "Codex 守护进程已重启,重新打开 codex 就能看到 Stack 模型",
"notRunning": "Codex 守护进程没在运行,下次打开 codex 时会读到新的模型列表",
"failed": "重启 Codex 守护进程失败:{{error}}"
}
},
"failover": {
"proxyRequired": "需要先启动路由服务才能配置故障转移",
+7
View File
@@ -7,6 +7,7 @@ import type {
AppProxyConfig,
ProxyStack,
ProxyStackNotice,
CodexDaemonRestartOutcome,
} from "@/types/proxy";
export const proxyApi = {
@@ -76,6 +77,12 @@ export const proxyApi = {
return invoke("set_proxy_stack_member", { appType, providerId, enabled });
},
// 重启 Codex 的托管守护进程(codex 命令行连的那个),让它重读模型目录。会中断正在运行的
// 任务,只在用户确认之后调
async restartCodexAppServerDaemon(): Promise<CodexDaemonRestartOutcome> {
return invoke("restart_codex_app_server_daemon");
},
// ========== v3+ 全局/应用级配置 API ==========
// 获取全局代理配置
+34
View File
@@ -137,6 +137,40 @@ export function useSetProxyStackMember() {
});
}
/**
* 重启 Codex 的托管守护进程,让它重读模型目录。结束后重新查 Stack 名单:重启成功时
* 「还在用旧模型列表」的提示随之消失。
*/
export function useRestartCodexAppServerDaemon() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: () => proxyApi.restartCodexAppServerDaemon(),
onSuccess: (outcome) => {
if (outcome === "notRunning") {
toast.info(t("proxy.stackMode.codexStale.notRunning"), {
closeButton: true,
});
return;
}
toast.success(t("proxy.stackMode.codexStale.restarted"), {
closeButton: true,
});
},
onError: (error: unknown) => {
toast.error(
t("proxy.stackMode.codexStale.failed", {
error: extractErrorMessage(error),
}),
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["providers", "codex"] });
},
});
}
// ========== 代理服务器控制 Hooks ==========
/**
+13
View File
@@ -72,14 +72,27 @@ export type ProxyStackNotice =
| "officialModelsBundled"
| "officialModelsUnavailable";
/**
* 还在用旧模型列表的 Codex 客户端(它们只在启动时读模型目录):`daemon` 是 `codex` 命令行连的
* 托管守护进程,可以一键重启;`others` 是桌面版、编辑器插件,要用户自己彻底退出再开。
*/
export interface CodexStaleClients {
daemon: boolean;
others: boolean;
}
/** Stack 模式的状态、名单和提示。 */
export interface ProxyStack {
/** 在 Stack 模式(代理模式且 Stack 模式开着)。 */
active: boolean;
members: ProxyStackMember[];
notice?: ProxyStackNotice;
staleClients?: CodexStaleClients;
}
/** 重启 Codex 守护进程的结果:`notRunning` 表示它没在运行,什么都没做。 */
export type CodexDaemonRestartOutcome = "restarted" | "notRunning";
/** 增删 Stack 模型失败。`partial` 为真:已部分写入,下次操作或重启 CC Switch 时补完。 */
export interface ProxyStackWriteError {
partial: boolean;
@@ -0,0 +1,133 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { toast } from "sonner";
import { CodexStaleClientsNotice } from "@/components/providers/CodexStaleClientsNotice";
import type { CodexStaleClients } from "@/types/proxy";
import { server } from "../msw/server";
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
const TAURI_ENDPOINT = "http://tauri.local";
const RESTART = `${TAURI_ENDPOINT}/restart_codex_app_server_daemon`;
function renderNotice(staleClients: CodexStaleClients) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
render(
<QueryClientProvider client={queryClient}>
<CodexStaleClientsNotice staleClients={staleClients} />
</QueryClientProvider>,
);
return { invalidate };
}
function clickRestartAndConfirm() {
fireEvent.click(
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
);
fireEvent.click(
screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }),
);
}
describe("CodexStaleClientsNotice", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restarts the daemon only after the user confirms", async () => {
let restarts = 0;
server.use(
http.post(RESTART, () => {
restarts += 1;
return HttpResponse.json("restarted");
}),
);
const { invalidate } = renderNotice({ daemon: true, others: false });
expect(
screen.getByText("proxy.stackMode.codexStale.daemon"),
).toBeInTheDocument();
expect(
screen.queryByText("proxy.stackMode.codexStale.others"),
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
);
// 只是打开确认框:会中断正在运行的任务,没确认之前不重启。
expect(
screen.getByText("proxy.stackMode.codexStale.confirmMessage"),
).toBeInTheDocument();
expect(restarts).toBe(0);
fireEvent.click(
screen.getByRole("button", { name: "proxy.stackMode.codexStale.confirm" }),
);
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
"proxy.stackMode.codexStale.restarted",
expect.anything(),
),
);
expect(restarts).toBe(1);
// 重新查 Stack 名单,提示随之消失。
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["providers", "codex"] });
await waitFor(() =>
expect(
screen.queryByText("proxy.stackMode.codexStale.confirmMessage"),
).not.toBeInTheDocument(),
);
});
it("says so when the daemon is not running", async () => {
server.use(http.post(RESTART, () => HttpResponse.json("notRunning")));
renderNotice({ daemon: true, others: true });
clickRestartAndConfirm();
await waitFor(() =>
expect(toast.info).toHaveBeenCalledWith(
"proxy.stackMode.codexStale.notRunning",
expect.anything(),
),
);
expect(toast.success).not.toHaveBeenCalled();
});
it("shows the failure", async () => {
server.use(
http.post(RESTART, () =>
HttpResponse.json("重启 Codex 守护进程失败: boom", { status: 500 }),
),
);
renderNotice({ daemon: true, others: false });
clickRestartAndConfirm();
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"proxy.stackMode.codexStale.failed",
),
);
});
it("only explains how to reopen the desktop app", () => {
renderNotice({ daemon: false, others: true });
expect(
screen.getByText("proxy.stackMode.codexStale.others"),
).toBeInTheDocument();
expect(
screen.queryByRole("button", {
name: "proxy.stackMode.codexStale.restart",
}),
).not.toBeInTheDocument();
});
});
+64
View File
@@ -527,6 +527,70 @@ describe("ProviderList Component", () => {
});
});
it("warns when Codex clients still use an old model list in Stack mode", async () => {
const route = createProvider({ id: "route", name: "Route" });
useDragSortMock.mockReturnValue({
sortedProviders: [route],
sensors: [],
handleDragEnd: vi.fn(),
});
let active = true;
server.use(
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
HttpResponse.json({
active,
members: [
{ providerId: "route", modelIds: [], route: true },
],
staleClients: { daemon: true, others: false },
}),
),
);
const view = renderWithQueryClient(
<ProviderList
providers={{ route }}
currentProviderId="route"
appId="codex"
isProxyTakeover
onSwitch={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
/>,
);
expect(
await screen.findByText("proxy.stackMode.codexStale.title"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "proxy.stackMode.codexStale.restart" }),
).toBeInTheDocument();
view.unmount();
// 不在 Stack 模式:不提示。
active = false;
renderWithQueryClient(
<ProviderList
providers={{ route }}
currentProviderId="route"
appId="codex"
isProxyTakeover
onSwitch={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
/>,
);
await waitFor(() =>
expect(lastProps("route")?.isStackMode).toBe(false),
);
expect(
screen.queryByText("proxy.stackMode.codexStale.title"),
).not.toBeInTheDocument();
});
it("hides Stack mode outside proxy mode and for apps without it", async () => {
const provider = createProvider({ id: "a", name: "A" });
useDragSortMock.mockReturnValue({
+4
View File
@@ -366,6 +366,10 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/set_proxy_stack_member`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/restart_codex_app_server_daemon`, () =>
success("restarted"),
),
http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () =>
success({
claude: false,