feat: add routing failover and fix config/session sync for v0.3.19

This commit is contained in:
sky
2026-09-18 11:26:36 +08:00
parent 50af2b4969
commit e1e8f3c7c2
44 changed files with 11116 additions and 237 deletions
+1
View File
@@ -355,6 +355,7 @@ jobs:
rm -rf "$portable_root"
mkdir -p "$portable_dir"
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
cp THIRD_PARTY_NOTICES.md "$portable_dir/THIRD_PARTY_NOTICES.md"
touch "$portable_dir/Codex-X.portable"
cat > "$portable_dir/README-portable.txt" <<'EOF'
Codex-X Windows Portable
+5 -1
View File
@@ -2,7 +2,11 @@
记录 Codex-X 的新功能、体验改进和问题修复。
## [Unreleased]
## [v0.3.19] - 2026-09-18
- 新增「设置 → 路由与故障转移」:可管理本地路由、安排供应商优先顺序,并在服务异常时自动切换;支持调整超时和重试参数,官方登录也可使用本地路由。
- 修复添加或切换第三方供应商时,MCP、桌面设置等配置丢失的问题。
- 修复内部任务被误同步,以及回退后的正常会话被误报异常的问题。
## [v0.3.18] - 2026-09-17
+31
View File
@@ -0,0 +1,31 @@
# Third-party notices
## CC Switch
Source: https://github.com/farion1231/cc-switch
Reference commit: `06082e189d65e6d6dbadc35dacdac1ce6c79d89a`.
Codex-X adapts the circuit-breaker implementation and Codex routing, failover, timeout and configuration behavior from CC Switch. The blocking/local HTTP integration, application state and UI styling are adapted to this project. Relevant source files carry provenance comments.
MIT License
Copyright (c) 2025 Jason Young
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.18",
"version": "0.3.19",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+46 -1
View File
@@ -438,6 +438,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -490,11 +492,14 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.18"
version = "0.3.19"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"flate2",
"getrandom 0.2.17",
"httparse",
"percent-encoding",
"reqwest",
"rfd",
@@ -510,9 +515,11 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"thiserror 2.0.18",
"tokio",
"toml_edit 0.22.27",
"ureq",
"zip 2.4.2",
"zstd",
]
[[package]]
@@ -1954,6 +1961,16 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.103"
@@ -5637,6 +5654,34 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.0+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.13.1"
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.18"
version = "0.3.19"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -40,3 +40,8 @@ percent-encoding = "2.3"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal"] }
tauri-plugin-single-instance = "=2.4.3"
httparse = "1"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
flate2 = "1"
zstd = "0.13"
+20 -2
View File
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 5;
const APP_DB_SCHEMA_VERSION: i64 = 7;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
@@ -208,6 +208,14 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
provider_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provider_failover (
codex_dir TEXT PRIMARY KEY,
record_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provider_common_config_state (
codex_dir TEXT PRIMARY KEY,
handled_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS official_profiles (
codex_dir TEXT NOT NULL,
id TEXT NOT NULL,
@@ -392,7 +400,17 @@ mod tests {
PRAGMA user_version = 4;").unwrap();
drop(legacy);
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
assert_eq!(schema_version(&migrated).unwrap(), 5);
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
assert_eq!(
migrated
.query_row(
"SELECT COUNT(*) FROM provider_common_config_state",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
0
);
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
assert_eq!(stored.len(), 1);
assert!(stored[0].model_mappings.is_empty());
@@ -1,7 +1,7 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
Emitter, Manager, WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
@@ -123,7 +123,37 @@ pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
}
}
pub(crate) fn report_failover_lifecycle_error(app: &tauri::AppHandle, title: &str, reason: &str) {
let title = title.to_string();
let reason = reason.to_string();
eprintln!("{title}: {reason}");
let _ = app.emit("provider-failover-error", &reason);
tauri::async_runtime::spawn(async move {
rfd::AsyncMessageDialog::new()
.set_title(title)
.set_description(reason)
.set_level(rfd::MessageLevel::Error)
.set_buttons(rfd::MessageButtons::Ok)
.show()
.await;
});
}
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
if let Err(error) = crate::failover::shutdown_all() {
// Keep the listener alive when restoring the direct route failed.
// Exiting here would strand Codex on a local address with no server.
api.prevent_exit();
restore_main_window(app);
report_failover_lifecycle_error(
app,
"暂时无法退出 Codex-X",
&format!("自动切换的连接配置还未恢复,Codex-X 将继续运行。\n\n{error}\n\n请检查配置文件是否被占用,再重试退出。"),
);
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = event {
restore_main_window(app);
@@ -0,0 +1,638 @@
// Adapted from CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a,
// src-tauri/src/proxy/circuit_breaker.rs. The state transitions and counters
// retain the upstream behavior; locks are synchronous and logs are omitted.
//
// MIT License
//
// Copyright (c) 2025 Jason Young
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//! 熔断器模块
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
use super::config::RoutingTuning;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
// The upstream state machine is retained; Codex-X forwards on bounded blocking
// workers, so its short state locks use std rather than awaiting Tokio locks.
struct RwLock<T>(std::sync::RwLock<T>);
impl<T> RwLock<T> {
fn new(value: T) -> Self {
Self(std::sync::RwLock::new(value))
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, T> {
self.0.read().unwrap_or_else(|error| error.into_inner())
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, T> {
self.0.write().unwrap_or_else(|error| error.into_inner())
}
}
/// 熔断器状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CircuitState {
/// 关闭状态 - 正常工作
Closed,
/// 打开状态 - 熔断激活,拒绝请求
Open,
/// 半开状态 - 尝试恢复,允许部分请求通过
HalfOpen,
}
impl std::fmt::Display for CircuitState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CircuitState::Closed => write!(f, "closed"),
CircuitState::Open => write!(f, "open"),
CircuitState::HalfOpen => write!(f, "half_open"),
}
}
}
/// 熔断器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CircuitBreakerConfig {
/// 失败阈值 - 连续失败多少次后打开熔断器
pub failure_threshold: u32,
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
pub success_threshold: u32,
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
pub timeout_seconds: u64,
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
pub error_rate_threshold: f64,
/// 最小请求数 - 计算错误率前的最小请求数
pub min_requests: u32,
}
impl From<&RoutingTuning> for CircuitBreakerConfig {
fn from(config: &RoutingTuning) -> Self {
Self {
failure_threshold: config.circuit_failure_threshold,
success_threshold: config.circuit_success_threshold,
timeout_seconds: config.circuit_timeout_seconds as u64,
error_rate_threshold: config.circuit_error_rate_threshold,
min_requests: config.circuit_min_requests,
}
}
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 4,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.6,
min_requests: 10,
}
}
}
/// 熔断器实例
pub struct CircuitBreaker {
/// 当前状态
state: Arc<RwLock<CircuitState>>,
/// 连续失败计数
consecutive_failures: Arc<AtomicU32>,
/// 连续成功计数(半开状态)
consecutive_successes: Arc<AtomicU32>,
/// 总请求计数
total_requests: Arc<AtomicU32>,
/// 失败请求计数
failed_requests: Arc<AtomicU32>,
/// 上次打开时间
last_opened_at: Arc<RwLock<Option<Instant>>>,
/// 配置(支持热更新)
config: Arc<RwLock<CircuitBreakerConfig>>,
/// 半开状态已放行的请求数(用于限流)
half_open_requests: Arc<AtomicU32>,
}
/// 熔断器放行结果
///
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
#[derive(Debug, Clone, Copy)]
pub struct AllowResult {
pub allowed: bool,
pub used_half_open_permit: bool,
}
impl CircuitBreaker {
/// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self {
Self {
state: Arc::new(RwLock::new(CircuitState::Closed)),
consecutive_failures: Arc::new(AtomicU32::new(0)),
consecutive_successes: Arc::new(AtomicU32::new(0)),
total_requests: Arc::new(AtomicU32::new(0)),
failed_requests: Arc::new(AtomicU32::new(0)),
last_opened_at: Arc::new(RwLock::new(None)),
config: Arc::new(RwLock::new(config)),
half_open_requests: Arc::new(AtomicU32::new(0)),
}
}
/// 更新熔断器配置(热更新,不重置状态)
pub fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write() = new_config;
}
/// 判断当前 Provider 是否“可被纳入候选链路”
///
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
/// - Closed / HalfOpen:可用(返回 true)
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
///
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
pub fn is_available(&self) -> bool {
let state = *self.state.read();
let config = self.config.read();
match state {
CircuitState::Closed | CircuitState::HalfOpen => true,
CircuitState::Open => {
if let Some(opened_at) = *self.last_opened_at.read() {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
self.transition_to_half_open();
return true;
}
}
false
}
}
}
/// 检查是否允许请求通过
pub fn allow_request(&self) -> AllowResult {
let state = *self.state.read();
match state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::Open => {
let config = self.config.read();
// 检查是否应该尝试半开
if let Some(opened_at) = *self.last_opened_at.read() {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
self.transition_to_half_open();
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
let current_state = *self.state.read();
return match current_state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::HalfOpen => self.allow_half_open_probe(),
CircuitState::Open => AllowResult {
allowed: false,
used_half_open_permit: false,
},
};
}
}
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
CircuitState::HalfOpen => self.allow_half_open_probe(),
}
}
/// 记录成功
pub fn record_success(&self, used_half_open_permit: bool) {
let state = *self.state.read();
let config = self.config.read();
if used_half_open_permit {
self.release_half_open_permit();
}
// 重置失败计数
self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst);
if state == CircuitState::HalfOpen {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
self.transition_to_closed();
}
}
}
/// 记录失败
pub fn record_failure(&self, used_half_open_permit: bool) {
let state = *self.state.read();
let config = self.config.read();
if used_half_open_permit {
self.release_half_open_permit();
}
// 更新计数器
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
self.total_requests.fetch_add(1, Ordering::SeqCst);
self.failed_requests.fetch_add(1, Ordering::SeqCst);
// 重置成功计数
self.consecutive_successes.store(0, Ordering::SeqCst);
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
drop(config);
self.transition_to_open();
}
CircuitState::Closed => {
// 检查连续失败次数
if failures >= config.failure_threshold {
drop(config); // 释放读锁再转换状态
self.transition_to_open();
} else {
// 检查错误率
let total = self.total_requests.load(Ordering::SeqCst);
let failed = self.failed_requests.load(Ordering::SeqCst);
if total >= config.min_requests {
let error_rate = failed as f64 / total as f64;
if error_rate >= config.error_rate_threshold {
drop(config); // 释放读锁再转换状态
self.transition_to_open();
}
}
}
}
_ => {}
}
}
/// 获取当前状态
#[allow(dead_code)]
pub fn get_state(&self) -> CircuitState {
*self.state.read()
}
/// 获取统计信息
#[allow(dead_code)]
pub fn get_stats(&self) -> CircuitBreakerStats {
CircuitBreakerStats {
state: *self.state.read(),
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
total_requests: self.total_requests.load(Ordering::SeqCst),
failed_requests: self.failed_requests.load(Ordering::SeqCst),
}
}
pub(crate) fn cooldown_seconds(&self) -> u64 {
if *self.state.read() != CircuitState::Open {
return 0;
}
let config = self.config.read();
self.last_opened_at.read().as_ref().map_or(0, |opened| {
config
.timeout_seconds
.saturating_sub(opened.elapsed().as_secs())
})
}
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub fn reset(&self) {
self.transition_to_closed();
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
AllowResult {
allowed: true,
used_half_open_permit: true,
}
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
}
/// 仅释放 HalfOpen permit,不影响健康统计
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
return;
}
match self.half_open_requests.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return,
Err(actual) => current = actual,
}
}
}
/// 转换到打开状态
fn transition_to_open(&self) {
*self.state.write() = CircuitState::Open;
*self.last_opened_at.write() = Some(Instant::now());
self.consecutive_failures.store(0, Ordering::SeqCst);
self.consecutive_successes.store(0, Ordering::SeqCst);
}
/// 转换到半开状态
fn transition_to_half_open(&self) {
let mut state = self.state.write();
if *state != CircuitState::Open {
return;
}
*state = CircuitState::HalfOpen;
self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置半开状态的请求限流计数
self.half_open_requests.store(0, Ordering::SeqCst);
}
/// 转换到关闭状态
fn transition_to_closed(&self) {
*self.state.write() = CircuitState::Closed;
self.consecutive_failures.store(0, Ordering::SeqCst);
self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置计数器
self.total_requests.store(0, Ordering::SeqCst);
self.failed_requests.store(0, Ordering::SeqCst);
}
}
/// 熔断器统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CircuitBreakerStats {
pub state: CircuitState,
pub consecutive_failures: u32,
pub consecutive_successes: u32,
pub total_requests: u32,
pub failed_requests: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circuit_breaker_closed_to_open() {
let config = CircuitBreakerConfig {
failure_threshold: 3,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 初始状态应该是关闭
assert_eq!(breaker.get_state(), CircuitState::Closed);
assert!(breaker.allow_request().allowed);
// 记录 3 次失败
for _ in 0..3 {
breaker.record_failure(false);
}
// 应该转换到打开状态
assert_eq!(breaker.get_state(), CircuitState::Open);
assert!(!breaker.allow_request().allowed);
}
#[test]
fn test_circuit_breaker_half_open_to_closed() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
success_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Open);
// 手动转换到半开状态
breaker.transition_to_half_open();
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
// 记录 2 次成功
breaker.record_success(false);
breaker.record_success(false);
// 应该转换到关闭状态
assert_eq!(breaker.get_state(), CircuitState::Closed);
}
#[test]
fn test_half_open_transition_does_not_reset_inflight_permit() {
let config = CircuitBreakerConfig {
timeout_seconds: 0,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 进入 Open,然后由于 timeout_seconds=0,allow_request 会立即切换到 HalfOpen 并占用探测名额
breaker.transition_to_open();
let first = breaker.allow_request();
assert!(first.allowed);
assert!(first.used_half_open_permit);
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
breaker.transition_to_half_open();
// 由于名额仍被占用,第二次请求应被拒绝
let second = breaker.allow_request();
assert!(!second.allowed);
assert!(!second.used_half_open_permit);
}
#[test]
fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Open);
// 重置
breaker.reset();
assert_eq!(breaker.get_state(), CircuitState::Closed);
assert!(breaker.allow_request().allowed);
}
#[test]
fn error_rate_waits_for_minimum_requests_and_uses_inclusive_threshold() {
let config = CircuitBreakerConfig {
failure_threshold: 20,
min_requests: 5,
error_rate_threshold: 0.6,
..Default::default()
};
let breaker = CircuitBreaker::new(config.clone());
breaker.record_failure(false);
breaker.record_success(false);
breaker.record_failure(false);
breaker.record_success(false);
assert_eq!(breaker.get_stats().total_requests, 4);
assert_eq!(breaker.get_state(), CircuitState::Closed);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Open);
let higher = CircuitBreaker::new(CircuitBreakerConfig {
error_rate_threshold: 0.8,
..config.clone()
});
let minimum = CircuitBreaker::new(CircuitBreakerConfig {
min_requests: 10,
..config
});
for breaker in [&higher, &minimum] {
breaker.record_failure(false);
breaker.record_success(false);
breaker.record_failure(false);
breaker.record_success(false);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Closed);
}
}
#[test]
fn successes_break_consecutive_failures_without_clearing_error_rate_history() {
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
failure_threshold: 2,
min_requests: 100,
..Default::default()
});
breaker.record_failure(false);
breaker.record_success(false);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Closed);
assert_eq!(breaker.get_stats().failed_requests, 2);
assert_eq!(breaker.get_stats().consecutive_failures, 1);
breaker.record_failure(false);
assert_eq!(breaker.get_state(), CircuitState::Open);
assert_eq!(breaker.get_stats().total_requests, 4);
}
#[test]
fn recovery_wait_and_hot_configuration_preserve_existing_evidence() {
let mut config = CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config.clone());
breaker.record_failure(false);
*breaker.last_opened_at.write() = Some(Instant::now() - std::time::Duration::from_secs(1));
assert!(!breaker.allow_request().allowed);
assert_eq!(breaker.cooldown_seconds(), 1);
config.timeout_seconds = 1;
breaker.update_config(config);
assert_eq!(breaker.get_stats().failed_requests, 1);
let probe = breaker.allow_request();
assert!(probe.allowed && probe.used_half_open_permit);
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
assert!(!breaker.allow_request().allowed);
breaker.release_half_open_permit();
let probe = breaker.allow_request();
assert!(probe.allowed);
breaker.record_failure(probe.used_half_open_permit);
assert_eq!(breaker.get_state(), CircuitState::Open);
assert!(!breaker.allow_request().allowed);
}
#[test]
fn half_open_requires_configured_successes_and_reset_clears_all_statistics() {
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
failure_threshold: 1,
success_threshold: 3,
timeout_seconds: 0,
..Default::default()
});
breaker.record_failure(false);
for completed in 0..3 {
let probe = breaker.allow_request();
assert!(probe.allowed && probe.used_half_open_permit);
assert!(!breaker.allow_request().allowed);
breaker.record_success(probe.used_half_open_permit);
assert_eq!(
breaker.get_state(),
if completed < 2 {
CircuitState::HalfOpen
} else {
CircuitState::Closed
}
);
}
assert_eq!(breaker.get_stats().total_requests, 0);
breaker.record_failure(false);
breaker.reset();
let stats = breaker.get_stats();
assert_eq!(
(
stats.total_requests,
stats.failed_requests,
stats.consecutive_failures,
stats.consecutive_successes
),
(0, 0, 0, 0)
);
assert!(breaker.allow_request().allowed);
}
}
@@ -0,0 +1,264 @@
//! Codex defaults/ranges follow CC Switch 06082e1 AppProxyConfig and its
//! AutoFailoverConfigPanel. Both IPC and the UI validate these settings.
use crate::error::{CodexxError, Result};
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
pub(crate) const DEFAULT_LISTEN_ADDRESS: &str = "127.0.0.1";
pub(crate) const DEFAULT_LISTEN_PORT: u16 = 15721;
pub(crate) const MAX_QUEUE: usize = 64;
pub(crate) const ROUTE_TOKEN_HEADER: &str = "x-codex-x-route-token";
pub(crate) const ROUTE_GENERATION_HEADER: &str = "x-codex-x-route-generation";
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub(crate) struct RoutingTuning {
pub(crate) max_retries: u32,
pub(crate) streaming_first_byte_timeout: u64,
pub(crate) streaming_idle_timeout: u64,
pub(crate) non_streaming_timeout: u64,
pub(crate) circuit_failure_threshold: u32,
pub(crate) circuit_success_threshold: u32,
pub(crate) circuit_timeout_seconds: u64,
pub(crate) circuit_error_rate_threshold: f64,
pub(crate) circuit_min_requests: u32,
}
impl Default for RoutingTuning {
fn default() -> Self {
Self {
max_retries: 3,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10,
}
}
}
impl RoutingTuning {
pub(crate) fn validate(&self) -> Result<()> {
for (valid, message) in [
(self.max_retries <= 10, "最大重试次数须为 0–10"),
(
(1..=120).contains(&self.streaming_first_byte_timeout),
"流式首字节超时须为 1–120 秒",
),
(
self.streaming_idle_timeout <= 600,
"流式静默超时须为 0–600 秒,0 表示禁用",
),
(
(60..=1200).contains(&self.non_streaming_timeout),
"非流式超时须为 60–1200 秒",
),
(
(1..=20).contains(&self.circuit_failure_threshold),
"失败阈值须为 1–20",
),
(
(1..=10).contains(&self.circuit_success_threshold),
"恢复成功阈值须为 1–10",
),
(
self.circuit_timeout_seconds <= 300,
"恢复等待时间须为 0–300 秒",
),
(
self.circuit_error_rate_threshold.is_finite()
&& (0.0..=1.0).contains(&self.circuit_error_rate_threshold),
"错误率阈值须为 0–100%",
),
(
(5..=100).contains(&self.circuit_min_requests),
"最小请求数须为 5–100",
),
] {
if !valid {
return Err(CodexxError::Config(message.into()));
}
}
Ok(())
}
}
pub(crate) fn listen_ip(address: &str) -> Result<IpAddr> {
let address = address.trim();
if address.eq_ignore_ascii_case("localhost") {
return Ok(IpAddr::V4(Ipv4Addr::LOCALHOST));
}
let address = address
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(address);
address
.parse()
.map_err(|_| CodexxError::Config("监听地址须为 IPv4、IPv6 或 localhost".into()))
}
pub(crate) fn client_ip(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
address => address,
}
}
pub(crate) fn local_url(address: IpAddr, port: u16) -> String {
match client_ip(address) {
IpAddr::V4(ip) => format!("http://{ip}:{port}/v1"),
IpAddr::V6(ip) => format!("http://[{ip}]:{port}/v1"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_defaults_and_valid_boundaries_match_reference() {
let defaults = RoutingTuning::default();
defaults.validate().unwrap();
assert_eq!(
(
defaults.max_retries,
defaults.streaming_first_byte_timeout,
defaults.streaming_idle_timeout,
defaults.non_streaming_timeout
),
(3, 60, 120, 600)
);
assert_eq!(
(
defaults.circuit_failure_threshold,
defaults.circuit_success_threshold,
defaults.circuit_timeout_seconds,
defaults.circuit_min_requests
),
(4, 2, 60, 10)
);
assert_eq!(defaults.circuit_error_rate_threshold, 0.6);
RoutingTuning {
max_retries: 0,
streaming_first_byte_timeout: 1,
streaming_idle_timeout: 0,
non_streaming_timeout: 60,
circuit_failure_threshold: 1,
circuit_success_threshold: 1,
circuit_timeout_seconds: 0,
circuit_error_rate_threshold: 0.0,
circuit_min_requests: 5,
}
.validate()
.unwrap();
RoutingTuning {
max_retries: 10,
streaming_first_byte_timeout: 120,
streaming_idle_timeout: 600,
non_streaming_timeout: 1200,
circuit_failure_threshold: 20,
circuit_success_threshold: 10,
circuit_timeout_seconds: 300,
circuit_error_rate_threshold: 1.0,
circuit_min_requests: 100,
}
.validate()
.unwrap();
}
#[test]
fn invalid_tuning_is_rejected_in_backend() {
for invalid in [
RoutingTuning {
max_retries: 11,
..Default::default()
},
RoutingTuning {
streaming_first_byte_timeout: 0,
..Default::default()
},
RoutingTuning {
streaming_first_byte_timeout: 121,
..Default::default()
},
RoutingTuning {
streaming_idle_timeout: 601,
..Default::default()
},
RoutingTuning {
non_streaming_timeout: 59,
..Default::default()
},
RoutingTuning {
non_streaming_timeout: 1201,
..Default::default()
},
RoutingTuning {
circuit_failure_threshold: 0,
..Default::default()
},
RoutingTuning {
circuit_failure_threshold: 21,
..Default::default()
},
RoutingTuning {
circuit_success_threshold: 0,
..Default::default()
},
RoutingTuning {
circuit_success_threshold: 11,
..Default::default()
},
RoutingTuning {
circuit_timeout_seconds: 301,
..Default::default()
},
RoutingTuning {
circuit_error_rate_threshold: f64::NAN,
..Default::default()
},
RoutingTuning {
circuit_error_rate_threshold: 1.1,
..Default::default()
},
RoutingTuning {
circuit_min_requests: 4,
..Default::default()
},
RoutingTuning {
circuit_min_requests: 101,
..Default::default()
},
] {
assert!(invalid.validate().is_err(), "{invalid:?}");
}
}
#[test]
fn listen_addresses_and_client_urls_support_ipv4_ipv6_and_wildcards() {
assert_eq!(listen_ip("localhost").unwrap().to_string(), "127.0.0.1");
assert_eq!(
local_url(listen_ip("0.0.0.0").unwrap(), 15721),
"http://127.0.0.1:15721/v1"
);
assert_eq!(
local_url(listen_ip("::").unwrap(), 15721),
"http://[::1]:15721/v1"
);
assert_eq!(
local_url(listen_ip("[::1]").unwrap(), 15721),
"http://[::1]:15721/v1"
);
for value in [
"256.0.0.1",
"host.example",
"127.0.0.1:15721",
"::1/path",
"",
] {
assert!(listen_ip(value).is_err());
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
mod circuit_breaker;
pub(crate) mod config;
mod controller;
pub(crate) mod native_official;
mod proxy;
pub(crate) use controller::{
attach_app_handle, direct_document, get_status, initialize, recover_stale_route,
refresh_saved_routes, reset_health, save_settings, shutdown_all, with_provider_change,
FailoverSettings, FailoverStatus,
};
@@ -0,0 +1,577 @@
//! Native Codex official routing. Codex remains the owner of its login and token
//! refresh; this layer only verifies the selected live login and forwards it to
//! its fixed official origin. No credential is loaded from an inactive profile.
use super::proxy::ProxyRoute;
use crate::error::{CodexxError, Result};
use crate::providers::document_is_official;
use crate::providers::official_profiles::{list_official_profiles_inner, selected_profile_id};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use toml_edit::DocumentMut;
const CHATGPT_BASE: &str = "https://chatgpt.com/backend-api/codex";
const OPENAI_API_BASE: &str = "https://api.openai.com/v1";
const MAX_CONFIG_BYTES: usize = 2 * 1024 * 1024;
const MAX_AUTH_BYTES: usize = 1024 * 1024;
const MAX_CATALOG_BYTES: usize = 1024 * 1024;
const MAX_TOKEN_BYTES: usize = 32 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OfficialRouteSpec {
pub(crate) codex_dir: PathBuf,
pub(crate) profile_id: String,
}
// Intentionally not Debug/Serialize: these values must never appear in status,
// frontend events, or error logs.
pub(crate) struct OfficialRequestAuth {
pub(crate) authorization: String,
pub(crate) account_id: Option<String>,
pub(crate) base_url: String,
}
struct LiveAuth {
token: String,
account_id: Option<String>,
base_url: &'static str,
}
fn error(message: &str) -> CodexxError {
CodexxError::Config(message.to_owned())
}
fn read_bounded(path: &Path, limit: usize, missing_allowed: bool) -> Result<Vec<u8>> {
let file = match fs::File::open(path) {
Ok(file) => file,
Err(err) if missing_allowed && err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new())
}
Err(_) => return Err(error("无法读取当前官方登录配置,请检查文件后重试")),
};
if !file
.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.len() <= limit as u64)
{
return Err(error("当前官方登录配置无法读取,请检查文件大小和格式"));
}
let mut bytes = Vec::new();
file.take(limit as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| error("读取当前官方登录配置失败,请重试"))?;
if bytes.len() > limit {
return Err(error("当前官方登录配置过大,请检查文件"));
}
Ok(bytes)
}
fn logical_document(dir: &Path) -> Result<(Vec<u8>, DocumentMut)> {
let bytes = read_bounded(&crate::config_path(dir), MAX_CONFIG_BYTES, true)?;
let text = std::str::from_utf8(&bytes)
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
let doc = text
.parse::<DocumentMut>()
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
let doc = super::direct_document(dir, &doc)
.map_err(|_| error("无法确认当前官方路由,请检查本地路由状态"))?;
Ok((bytes, doc))
}
fn checked_secret(value: Option<&Value>) -> Option<String> {
let value = value?.as_str()?.trim();
(!value.is_empty()
&& value.len() <= MAX_TOKEN_BYTES
&& value.bytes().all(|byte| byte.is_ascii_graphic())
&& value != "PROXY_MANAGED")
.then(|| value.to_owned())
}
fn checked_account(value: Option<&str>) -> Result<Option<String>> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > 512 || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(error("当前官方账号信息无效,请重新登录 Codex"));
}
Ok(Some(value.to_owned()))
}
fn parse_live_auth(bytes: &[u8]) -> Result<LiveAuth> {
let auth: Value = serde_json::from_slice(bytes)
.map_err(|_| error("官方账号尚未登录或认证无效,请在 Codex 中重新登录"))?;
if !auth.is_object()
|| ["base_url", "baseUrl", "api_base", "endpoint"]
.iter()
.any(|key| auth.get(key).is_some())
{
return Err(error("当前官方认证格式无效,请在 Codex 中重新登录"));
}
let mode = auth.get("auth_mode").and_then(Value::as_str);
let api_key = checked_secret(auth.get("OPENAI_API_KEY"));
let tokens = auth.get("tokens");
let has_tokens = tokens.is_some_and(|value| {
!value.is_null() && value.as_object().is_none_or(|values| !values.is_empty())
});
if let Some(token) = api_key {
if mode.is_some_and(|value| !value.eq_ignore_ascii_case("apikey")) || has_tokens {
return Err(error("官方登录与 API Key 认证混用,请在 Codex 中重新登录"));
}
return Ok(LiveAuth {
token,
account_id: None,
base_url: OPENAI_API_BASE,
});
}
if auth.get("OPENAI_API_KEY").is_some_and(|value| {
!value.is_null() && value.as_str().is_none_or(|text| !text.trim().is_empty())
}) {
return Err(error("当前 OpenAI API Key 无效,请重新设置"));
}
if mode.is_some_and(|value| {
!value.eq_ignore_ascii_case("chatgpt") && !value.eq_ignore_ascii_case("chatgptAuthTokens")
}) {
return Err(error(
"此登录方式暂不支持本地路由,请使用 ChatGPT 登录或 OpenAI API Key",
));
}
let token = checked_secret(auth.pointer("/tokens/access_token"))
.ok_or_else(|| error("官方账号尚未登录,请在 Codex 中完成 ChatGPT 登录"))?;
let account_id = checked_account(auth.pointer("/tokens/account_id").and_then(Value::as_str))?;
Ok(LiveAuth {
token,
account_id,
base_url: CHATGPT_BASE,
})
}
fn ensure_selected(spec: &OfficialRouteSpec) -> Result<(Vec<u8>, DocumentMut)> {
let (bytes, doc) = logical_document(&spec.codex_dir)?;
if !document_is_official(&doc)
|| selected_profile_id(&spec.codex_dir)
.map_err(|_| error("无法确认当前官方账号,请刷新后重试"))?
!= spec.profile_id
{
return Err(error("当前官方账号已切换,请重启 Codex 或新建会话后重试"));
}
Ok((bytes, doc))
}
pub(crate) fn route_for_current(dir: &Path) -> Result<Option<ProxyRoute>> {
let (_, doc) = logical_document(dir)?;
if !document_is_official(&doc) {
return Ok(None);
}
let profile_id = selected_profile_id(dir)?;
let name = list_official_profiles_inner(Some(dir.display().to_string()))?
.into_iter()
.find(|profile| profile.id == profile_id)
.map(|profile| profile.provider_name)
.unwrap_or_else(|| "OpenAI Official".to_owned());
// A logged-out official route can still be taken over. Codex may complete
// login or refresh after startup; every request re-reads and verifies it.
let base_url = read_bounded(&crate::auth_path(dir), MAX_AUTH_BYTES, true)
.ok()
.and_then(|bytes| parse_live_auth(&bytes).ok())
.map_or(CHATGPT_BASE, |auth| auth.base_url)
.to_owned();
let models = crate::string_value(&doc, "model")
.into_iter()
.collect::<HashSet<_>>();
Ok(Some(ProxyRoute {
id: format!("official:{profile_id}"),
name,
base_url,
api_key: None,
headers: Vec::new(),
models,
official: Some(OfficialRouteSpec {
codex_dir: dir.to_path_buf(),
profile_id,
}),
}))
}
pub(crate) fn verify_request(
spec: &OfficialRouteSpec,
authorization: &str,
account_id: Option<&str>,
) -> Result<OfficialRequestAuth> {
let (config_before, _) = ensure_selected(spec)?;
if authorization.len() > MAX_TOKEN_BYTES + 32 || authorization.chars().any(char::is_control) {
return Err(error("官方请求认证无效,请在 Codex 中重新登录"));
}
let parts: Vec<_> = authorization.split_whitespace().collect();
if parts.len() != 2 || !parts[0].eq_ignore_ascii_case("Bearer") {
return Err(error("缺少官方请求认证,请在 Codex 中完成登录"));
}
let auth_before = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
let auth = parse_live_auth(&auth_before)?;
if parts[1] != auth.token || checked_account(account_id)? != auth.account_id {
return Err(error(
"此会话没有加载当前官方账号,请重启 Codex 或新建会话后重试",
));
}
// Avoid trusting a token across a simultaneous account switch/logout/refresh.
// This reads files only; acquiring the live mutation lock here would deadlock
// callers constructing routes while holding their own configuration guard.
let (config_after, _) = ensure_selected(spec)?;
let auth_after = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
if config_before != config_after || auth_before != auth_after {
return Err(error("官方登录正在更新,请稍后重试"));
}
Ok(OfficialRequestAuth {
authorization: format!("Bearer {}", auth.token),
account_id: auth.account_id,
base_url: auth.base_url.to_owned(),
})
}
fn is_link(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
metadata.file_type().is_symlink()
}
}
pub(crate) fn native_models(spec: &OfficialRouteSpec) -> Result<Value> {
let (_, doc) = ensure_selected(spec)?;
let Some(pointer) = doc.get("model_catalog_json").and_then(|item| item.as_str()) else {
return Ok(json!({"models":[]}));
};
let path = Path::new(pointer);
if path
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Ok(json!({"models":[]}));
}
let path = if path.is_absolute() {
path.to_owned()
} else {
spec.codex_dir.join(path)
};
let owned = spec.codex_dir.join(".codex-x").join("model-catalogs");
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return Ok(json!({"models":[]}));
};
if !name
.strip_suffix(".json")
.is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Ok(json!({"models":[]}));
}
for directory in [spec.codex_dir.join(".codex-x"), owned.clone()] {
if !fs::symlink_metadata(directory)
.is_ok_and(|metadata| !is_link(&metadata) && metadata.is_dir())
{
return Ok(json!({"models":[]}));
}
}
if !fs::symlink_metadata(&path).is_ok_and(|metadata| !is_link(&metadata) && metadata.is_file())
|| !path
.parent()
.and_then(|parent| parent.canonicalize().ok())
.zip(owned.canonicalize().ok())
.is_some_and(|(parent, owned)| parent == owned)
{
return Ok(json!({"models":[]}));
}
let Ok(bytes) = read_bounded(&path, MAX_CATALOG_BYTES, false) else {
return Ok(json!({"models":[]}));
};
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
return Ok(json!({"models":[]}));
};
let mut digest = Sha256::new();
digest.update(b"codex-x-provider-model-catalog-v2\0");
digest.update(serde_json::to_vec(&value).map_err(|_| error("本地模型目录格式无效"))?);
if value
.pointer("/_codex_x_model_catalog/source")
.and_then(Value::as_str)
!= Some("codex-x")
|| !value.get("models").is_some_and(Value::is_array)
|| format!("{:x}.json", digest.finalize()) != name
{
return Ok(json!({"models":[]}));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::official_profiles::{
save_official_profile_inner, switch_official_profile_inner, OfficialProfileInput,
DEFAULT_OFFICIAL_PROFILE_ID,
};
use std::sync::atomic::{AtomicU64, Ordering};
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new() -> Self {
static COUNT: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"codex-x-native-official-{}-{}",
std::process::id(),
COUNT.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(crate::config_path(&dir), "model_provider = 'custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nsupports_websockets=true\nwire_api='responses'\n[mcp_servers.keep]\ncommand='fixture-mcp'\n").unwrap();
Self { dir }
}
fn oauth(&self, access: &str, account: &str) {
fs::write(crate::auth_path(&self.dir), serde_json::to_vec(&json!({"auth_mode":"chatgpt","tokens":{"access_token":access,"refresh_token":"unused-refresh-fixture","account_id":account}})).unwrap()).unwrap();
}
fn spec(&self) -> OfficialRouteSpec {
OfficialRouteSpec {
codex_dir: self.dir.clone(),
profile_id: DEFAULT_OFFICIAL_PROFILE_ID.into(),
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn native_official_checks_exact_live_token_even_for_team_members_sharing_workspace() {
let fixture = Fixture::new();
fixture.oauth("live-user-a-token", "shared-team-workspace");
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
let config_before = fs::read(crate::config_path(&fixture.dir)).unwrap();
let spec = fixture.spec();
let allowed = verify_request(
&spec,
"Bearer live-user-a-token",
Some("shared-team-workspace"),
)
.unwrap();
assert_eq!(allowed.base_url, CHATGPT_BASE);
assert_eq!(allowed.account_id.as_deref(), Some("shared-team-workspace"));
for (authorization, account) in [
("Bearer another-user-token", Some("shared-team-workspace")),
("Bearer live-user-a-token", Some("other-workspace")),
("Bearer live-user-a-token", None),
("Bearer PROXY_MANAGED", Some("shared-team-workspace")),
("Bearer\nlive-user-a-token", Some("shared-team-workspace")),
("", Some("shared-team-workspace")),
] {
let failure = verify_request(&spec, authorization, account)
.err()
.expect("must reject stale or wrong account credentials")
.to_string();
assert!(!failure.contains("live-user-a-token"));
assert!(!failure.contains("another-user-token"));
assert!(!failure.contains("shared-team-workspace"));
}
assert_eq!(
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
auth_before
);
assert_eq!(
fs::read(crate::config_path(&fixture.dir)).unwrap(),
config_before
);
}
#[test]
fn native_official_login_and_token_refresh_are_owned_by_codex_without_cached_credentials() {
let fixture = Fixture::new();
let route = route_for_current(&fixture.dir).unwrap().unwrap();
assert!(route.api_key.is_none());
assert!(route.headers.is_empty());
let spec = route.official.unwrap();
assert!(verify_request(&spec, "Bearer absent", None).is_err());
fixture.oauth("first-token", "account");
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_ok());
fixture.oauth("refreshed-token", "account");
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_err());
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_ok());
let latest = fs::read(crate::auth_path(&fixture.dir)).unwrap();
route_for_current(&fixture.dir).unwrap();
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), latest);
fs::remove_file(crate::auth_path(&fixture.dir)).unwrap();
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_err());
assert!(route_for_current(&fixture.dir).unwrap().is_some());
}
#[test]
fn native_official_does_not_reuse_inactive_profile_or_third_party_oauth() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.oauth("account-a-token", "workspace");
let scope = Some(fixture.dir.display().to_string());
let other = save_official_profile_inner(OfficialProfileInput {
config_dir: scope.clone(), id: None, provider_name: "Account B".into(),
model: Some("official-model".into()), config_text: Some(fs::read_to_string(crate::config_path(&fixture.dir)).unwrap()),
auth_json: Some(json!({"auth_mode":"chatgpt","tokens":{"access_token":"account-b-token","account_id":"workspace"}}).to_string()),
}).unwrap();
let spec = fixture.spec();
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
switch_official_profile_inner(scope, other.profile.id.clone()).unwrap();
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
let current = route_for_current(&fixture.dir)
.unwrap()
.unwrap()
.official
.unwrap();
assert_eq!(current.profile_id, other.profile.id);
assert!(verify_request(&current, "Bearer account-b-token", Some("workspace")).is_ok());
fs::write(crate::config_path(&fixture.dir), "model_provider='custom'\nmodel='third'\n[model_providers.custom]\nname='Third'\nbase_url='https://third.example.test/v1'\nrequires_openai_auth=false\n").unwrap();
assert!(route_for_current(&fixture.dir).unwrap().is_none());
assert!(verify_request(&current, "Bearer account-b-token", Some("workspace")).is_err());
}
#[test]
fn native_api_key_uses_only_openai_api_origin_and_never_accepts_oauth_or_mixed_auth() {
let fixture = Fixture::new();
fs::write(
crate::auth_path(&fixture.dir),
json!({"auth_mode":"apikey","OPENAI_API_KEY":"api-key-fixture","tokens":null})
.to_string(),
)
.unwrap();
let spec = fixture.spec();
let verified = verify_request(&spec, "Bearer api-key-fixture", None).unwrap();
assert_eq!(verified.base_url, OPENAI_API_BASE);
assert!(verified.account_id.is_none());
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
assert!(
verify_request(&spec, "Bearer api-key-fixture", Some("old-oauth-account")).is_err()
);
fs::write(
crate::auth_path(&fixture.dir),
json!({"OPENAI_API_KEY":"api-key-fixture","tokens":{"access_token":"oauth-fixture"}})
.to_string(),
)
.unwrap();
assert!(verify_request(&spec, "Bearer api-key-fixture", None).is_err());
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
fs::write(crate::auth_path(&fixture.dir), "{ broken-secret-json").unwrap();
let failure = verify_request(&spec, "Bearer api-key-fixture", None)
.err()
.unwrap()
.to_string();
assert!(!failure.contains("broken-secret-json"));
assert!(!failure.contains("api-key-fixture"));
}
#[test]
fn native_models_serve_only_verified_owned_catalog_and_never_read_arbitrary_json() {
let fixture = Fixture::new();
let spec = fixture.spec();
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
let config_path = crate::config_path(&fixture.dir);
let mut doc = fs::read_to_string(&config_path)
.unwrap()
.parse::<DocumentMut>()
.unwrap();
crate::providers::model_catalog::prepare_model_catalog(
&fixture.dir,
"native-test",
&[crate::providers::model_catalog::ProviderModelMapping {
model: "local-model".into(),
display_name: "Local".into(),
context_window: None,
}],
"local-model",
&mut doc,
)
.unwrap();
fs::write(&config_path, doc.to_string()).unwrap();
let owned = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
let before = fs::read(&config_path).unwrap();
assert_eq!(
native_models(&spec).unwrap()["models"][0]["slug"],
"local-model"
);
assert_eq!(fs::read(&config_path).unwrap(), before);
let outside = fixture.dir.join("unrelated-auth.json");
fs::write(&outside, r#"{"models":["must-not-leak"]}"#).unwrap();
doc["model_catalog_json"] = toml_edit::value(outside.display().to_string());
fs::write(&config_path, doc.to_string()).unwrap();
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
#[cfg(unix)]
{
fs::remove_file(&owned).unwrap();
std::os::unix::fs::symlink(&outside, &owned).unwrap();
doc["model_catalog_json"] = toml_edit::value(owned.display().to_string());
fs::write(&config_path, doc.to_string()).unwrap();
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
}
}
#[test]
fn native_local_models_endpoint_accepts_owned_live_oauth_without_network_refresh_or_fallback() {
use super::super::config::{RoutingTuning, ROUTE_TOKEN_HEADER};
use super::super::proxy::{ProxyHandle, ProxyOptions};
use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;
struct Server(ProxyHandle);
impl Drop for Server {
fn drop(&mut self) {
self.0.shutdown();
}
}
let fixture = Fixture::new();
fixture.oauth("local-native-access", "local-native-account");
let route = route_for_current(&fixture.dir).unwrap().unwrap();
let local_token = "native-local-route-token-fixture-000000000000";
let server = Server(
ProxyHandle::start(
IpAddr::V4(Ipv4Addr::LOCALHOST),
0,
local_token.into(),
vec![route],
ProxyOptions {
auto_failover_enabled: true,
tuning: RoutingTuning::default(),
},
)
.unwrap(),
);
let client = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
for access in ["local-native-access", "local-native-refreshed"] {
fixture.oauth(access, "local-native-account");
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
let response = client
.get(format!("http://127.0.0.1:{}/v1/models", server.0.port()))
.header(ROUTE_TOKEN_HEADER, local_token)
.header("authorization", format!("Bearer {access}"))
.header("chatgpt-account-id", "local-native-account")
.send()
.unwrap();
assert_eq!(response.status().as_u16(), 200);
let body: Value = serde_json::from_str(&response.text().unwrap()).unwrap();
assert_eq!(body, json!({"models":[]}));
assert_eq!(
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
auth_before
);
}
let snapshot = server.0.snapshot();
assert_eq!(snapshot.success_count, 2);
assert_eq!(snapshot.failover_count, 0);
assert_eq!(
snapshot.last_provider_id.as_deref(),
Some("official:openai-official")
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,773 @@
use super::*;
use std::fs;
use std::sync::atomic::AtomicU64;
struct Fixture {
dir: PathBuf,
providers: Vec<SavedProvider>,
original: String,
port: u16,
}
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let number = NEXT.fetch_add(1, Ordering::Relaxed);
let tag = format!("failover-test-{}-{number}", std::process::id());
let dir = std::env::temp_dir().join(&tag);
fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut providers = Vec::new();
for index in 0..3 {
let provider = SavedProvider {
id: format!("{tag}-{index}"),
provider_name: format!("Provider {index}"),
base_url: format!("https://{tag}-{index}.example.test/v1"),
model: "same-model".into(),
api_key: Some(format!("fixture-provider-secret-{index}")),
toml_config: None,
wire_api: "responses".into(),
requires_openai_auth: false,
model_mappings: vec![],
};
providers.push(crate::providers::save_provider_inner(provider).unwrap());
}
let primary = &providers[0];
let original = format!("# preserve user config\nmodel_provider = \"custom\"\nmodel = \"same-model\"\nmodel_reasoning_effort = \"high\"\n\n[model_providers.custom]\nname = \"Provider 0\"\nbase_url = {:?}\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = true\nexperimental_bearer_token = {:?}\nrequest_max_retries = 4\n\n[model_providers.custom.http_headers]\nx-fixture = \"primary-header\"\n\n[mcp_servers.fixture]\ncommand = \"fixture-tool\"\n", primary.base_url, primary.api_key.as_deref().unwrap());
fs::write(crate::config_path(&dir), &original).unwrap();
fs::write(
dir.join("auth.json"),
"{\"OPENAI_API_KEY\":\"untouched-official-auth-fixture\"}",
)
.unwrap();
crate::providers::remember_active_provider_on_connection(
&crate::app_db::open().unwrap(),
&dir,
&primary.id,
)
.unwrap();
Self {
dir,
providers,
original,
port: free_port(),
}
}
fn scope(&self) -> Option<String> {
Some(self.dir.display().to_string())
}
fn settings(&self) -> FailoverSettings {
FailoverSettings {
router_enabled: true,
takeover_enabled: true,
auto_failover_enabled: true,
listen_port: self.port,
provider_ids: vec![self.providers[0].id.clone(), self.providers[1].id.clone()],
..Default::default()
}
}
fn text(&self) -> String {
fs::read_to_string(crate::config_path(&self.dir)).unwrap()
}
fn enable(&self) -> FailoverStatus {
save_settings(self.scope(), self.settings()).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
SHUTTING_DOWN.store(false, Ordering::Release);
// If a test deliberately left invalid TOML, repair only its private fixture.
if let Ok(record) = load_record(&self.dir) {
if let Ok(mut runtimes) = lock_manager() {
if let Some(runtime) = runtimes.remove(&self.dir) {
runtime.proxy.shutdown();
}
}
let _ = record;
}
if let Ok(conn) = crate::app_db::open() {
let _ = conn.execute(
"DELETE FROM provider_failover WHERE codex_dir = ?1",
[normalized_path_scope(&self.dir)],
);
let _ = conn.execute(
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
[normalized_path_scope(&self.dir)],
);
for provider in &self.providers {
let _ = conn.execute("DELETE FROM providers WHERE id = ?1", [&provider.id]);
}
}
let _ = fs::remove_dir_all(&self.dir);
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn stop(fixture: &Fixture) {
let mut settings = load_record(&fixture.dir).unwrap().settings;
settings.router_enabled = false;
save_settings(fixture.scope(), settings).unwrap();
}
fn parsed(fixture: &Fixture) -> DocumentMut {
fixture.text().parse().unwrap()
}
#[test]
fn listener_takeover_and_auto_switches_are_independent() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
let mut settings = fixture.settings();
settings.takeover_enabled = false;
settings.auto_failover_enabled = false;
let listening = save_settings(fixture.scope(), settings.clone()).unwrap();
assert!(listening.running);
assert!(!listening.takeover_active);
assert!(!listening.auto_failover_active);
assert_eq!(fixture.text(), fixture.original);
settings.takeover_enabled = true;
let single = save_settings(fixture.scope(), settings.clone()).unwrap();
assert!(single.takeover_active);
assert!(!single.auto_failover_active);
assert_eq!(single.runtime.providers.len(), 1);
assert_eq!(single.primary.as_ref().unwrap().id, fixture.providers[0].id);
settings.auto_failover_enabled = true;
let automatic = save_settings(fixture.scope(), settings).unwrap();
assert!(automatic.auto_failover_active);
assert_eq!(automatic.runtime.providers.len(), 2);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
stop(&fixture);
let stopped = get_status(fixture.scope()).unwrap();
assert!(!stopped.running && !stopped.takeover_active);
assert!(stopped.settings.auto_failover_enabled);
assert_eq!(stopped.settings.provider_ids.len(), 2);
assert_eq!(
parsed(&fixture)["mcp_servers"]["fixture"]["command"].as_str(),
Some("fixture-tool")
);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn enabling_auto_switches_to_p1_even_if_current_is_elsewhere() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut settings = fixture.settings();
settings.provider_ids = vec![
fixture.providers[2].id.clone(),
fixture.providers[0].id.clone(),
];
let status = save_settings(fixture.scope(), settings).unwrap();
assert!(status.auto_failover_active);
assert_eq!(status.primary.as_ref().unwrap().id, fixture.providers[2].id);
assert_eq!(status.runtime.providers[0].id, fixture.providers[2].id);
assert_eq!(status.settings.provider_ids[0], fixture.providers[2].id);
stop(&fixture);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[2].base_url.as_str())
);
}
#[test]
fn a_single_provider_queue_and_an_empty_running_queue_are_valid() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut settings = fixture.settings();
settings.provider_ids.clear();
let single = save_settings(fixture.scope(), settings).unwrap();
assert_eq!(
single.settings.provider_ids,
vec![fixture.providers[0].id.clone()]
);
assert!(single.auto_failover_active);
let mut settings = single.settings;
settings.provider_ids.clear();
let empty = save_settings(fixture.scope(), settings).unwrap();
assert!(empty.running && empty.auto_failover_active);
assert!(empty.runtime.providers.is_empty());
}
#[test]
fn queue_accepts_different_models_and_can_be_prepared_while_stopped() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fixture.providers[1].model = "another-model".into();
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(
status
.providers
.iter()
.find(|p| p.id == fixture.providers[1].id)
.unwrap()
.eligible
);
let mut settings = fixture.settings();
settings.router_enabled = false;
settings.takeover_enabled = false;
settings.auto_failover_enabled = false;
let saved = save_settings(fixture.scope(), settings).unwrap();
assert!(!saved.running);
assert_eq!(fixture.text(), fixture.original);
assert_eq!(saved.settings.provider_ids.len(), 2);
fixture.enable();
}
#[test]
fn settings_and_scope_validation_have_no_live_side_effects() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut invalid = fixture.settings();
invalid.listen_port = 1000;
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.provider_ids.push(invalid.provider_ids[0].clone());
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.provider_ids = vec!["official:openai-official".into()];
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.takeover_enabled = false;
assert!(save_settings(fixture.scope(), invalid).is_err());
assert_eq!(fixture.text(), fixture.original);
}
#[test]
fn failed_bind_and_missing_p1_leave_configuration_and_settings_unchanged() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let blocker =
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
assert_eq!(fixture.text(), fixture.original);
assert!(!get_status(fixture.scope()).unwrap().running);
drop(blocker);
let mut settings = fixture.settings();
settings.provider_ids = vec!["missing-p1".into()];
assert!(save_settings(fixture.scope(), settings).is_err());
assert_eq!(fixture.text(), fixture.original);
assert!(!get_status(fixture.scope()).unwrap().settings.router_enabled);
}
#[test]
fn queue_and_tuning_updates_keep_the_endpoint_and_update_all_parameters() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let before = fixture.text();
let mut settings = fixture.settings();
settings.provider_ids.reverse();
settings.tuning = RoutingTuning {
max_retries: 8,
streaming_first_byte_timeout: 10,
streaming_idle_timeout: 0,
non_streaming_timeout: 900,
circuit_failure_threshold: 7,
circuit_success_threshold: 4,
circuit_timeout_seconds: 45,
circuit_error_rate_threshold: 0.75,
circuit_min_requests: 15,
};
let status = save_settings(fixture.scope(), settings.clone()).unwrap();
assert_eq!(status.settings, settings);
assert_eq!(fixture.text(), before);
assert_eq!(status.runtime.providers[0].id, fixture.providers[1].id);
let mut bad = status.settings;
bad.listen_port = free_port();
assert!(save_settings(fixture.scope(), bad).is_err());
assert_eq!(fixture.text(), before);
}
#[test]
fn manual_switch_keeps_routing_and_auto_but_official_never_joins_api_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
let switched = get_status(fixture.scope()).unwrap();
assert!(switched.running && switched.auto_failover_active);
assert_eq!(switched.primary.unwrap().id, fixture.providers[2].id);
with_provider_change(fixture.scope(), || {
crate::providers::official_profiles::switch_official_profile_inner(
fixture.scope(),
crate::providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.into(),
)
})
.unwrap();
let official = get_status(fixture.scope()).unwrap();
assert!(official.running && official.takeover_active);
assert!(!official.auto_failover_active);
assert!(official.settings.auto_failover_enabled);
assert!(official.primary.unwrap().official);
assert!(official
.runtime
.providers
.iter()
.all(|p| p.id.starts_with("official:")));
assert!(official.providers.iter().all(|p| !p.official));
}
#[test]
fn official_takeover_preserves_auth_and_can_restore_a_builtin_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let original = "model='official-model'\n[mcp_servers.test]\ncommand='tool'\n";
fs::write(crate::config_path(&fixture.dir), original).unwrap();
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
let mut settings = fixture.settings();
settings.auto_failover_enabled = false;
settings.provider_ids.clear();
let status = save_settings(fixture.scope(), settings).unwrap();
assert!(status.primary.unwrap().official);
assert!(status.takeover_active && !status.auto_failover_active);
let doc = parsed(&fixture);
assert_eq!(
doc["model_providers"]["openai"]["requires_openai_auth"].as_bool(),
Some(true)
);
assert!(doc["model_providers"]["openai"]
.get("experimental_bearer_token")
.is_none());
assert!(doc["model_providers"]["openai"]["http_headers"]
.get(ROUTE_TOKEN_HEADER)
.is_some());
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
stop(&fixture);
assert!(parsed(&fixture).get("model_providers").is_none());
assert_eq!(
parsed(&fixture)["mcp_servers"]["test"]["command"].as_str(),
Some("tool")
);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
}
#[test]
fn failed_manual_switch_reattaches_original_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let error = with_provider_change::<()>(fixture.scope(), || {
Err(CodexxError::Config("expected switch failure".into()))
})
.unwrap_err();
assert!(error.to_string().contains("expected switch failure"));
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert_eq!(status.primary.unwrap().id, fixture.providers[0].id);
}
#[test]
fn restore_keeps_external_edits_and_does_not_leave_local_headers() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let mut doc = parsed(&fixture);
doc["model_reasoning_effort"] = value("max");
doc["model_providers"]["custom"]["request_max_retries"] = value(9);
doc["model_providers"]["custom"]["external_new_setting"] = value("preserved");
fs::write(crate::config_path(&fixture.dir), doc.to_string()).unwrap();
stop(&fixture);
let restored = parsed(&fixture);
assert_eq!(restored["model_reasoning_effort"].as_str(), Some("max"));
assert_eq!(
restored["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(9)
);
assert_eq!(
restored["model_providers"]["custom"]["external_new_setting"].as_str(),
Some("preserved")
);
assert_eq!(
restored["model_providers"]["custom"]["http_headers"]["x-fixture"].as_str(),
Some("primary-header")
);
assert!(!fixture.text().contains(ROUTE_GENERATION_HEADER));
}
#[test]
fn external_provider_change_detaches_without_stopping_the_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let external = fixture
.original
.replace(
&fixture.providers[0].base_url,
&fixture.providers[2].base_url,
)
.replace("fixture-provider-secret-0", "fixture-provider-secret-2");
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && !status.takeover_active);
assert!(!status.settings.takeover_enabled);
assert_eq!(fixture.text(), external);
}
#[test]
fn failed_restore_keeps_the_listener_alive() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let managed = fixture.text();
fs::write(crate::config_path(&fixture.dir), "invalid = [").unwrap();
let mut off = fixture.settings();
off.router_enabled = false;
assert!(save_settings(fixture.scope(), off).is_err());
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
fs::write(crate::config_path(&fixture.dir), managed).unwrap();
stop(&fixture);
}
#[test]
fn exit_restores_and_rejects_queued_enabling_then_restart_resumes_same_port() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
shutdown_all().unwrap();
assert!(!fixture.text().contains("http://127.0.0.1:"));
let record = load_record(&fixture.dir).unwrap();
assert!(record.settings.router_enabled && record.settings.takeover_enabled);
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
SHUTTING_DOWN.store(false, Ordering::Release);
initialize().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.takeover_active);
assert_eq!(status.settings.listen_port, fixture.port);
}
#[test]
fn old_managed_backups_keep_their_original_provider_after_later_takeovers() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let old = fixture.text();
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
stop(&fixture);
fs::write(crate::config_path(&fixture.dir), old).unwrap();
recover_stale_route(fixture.scope()).unwrap();
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn old_enabled_backups_settings_migrate_to_full_p1_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let old = serde_json::json!({"settings":{"enabled":true,"providerIds":[fixture.providers[1].id]},"journals":[{"primaryId":fixture.providers[0].id,"providerKey":"custom","originalTable":"name='old'","port":fixture.port,"token":"old-only-test-token"}]});
crate::app_db::open()
.unwrap()
.execute(
"INSERT INTO provider_failover(codex_dir,record_json) VALUES(?1,?2)",
params![normalized_path_scope(&fixture.dir), old.to_string()],
)
.unwrap();
let migrated = load_record(&fixture.dir).unwrap();
assert_eq!(migrated.version, 2);
assert!(
migrated.settings.router_enabled
&& migrated.settings.takeover_enabled
&& migrated.settings.auto_failover_enabled
);
assert_eq!(
migrated.settings.provider_ids,
vec![
fixture.providers[0].id.clone(),
fixture.providers[1].id.clone()
]
);
assert_eq!(migrated.settings.tuning, RoutingTuning::default());
}
#[test]
fn status_never_serializes_provider_credentials_or_local_tokens() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let status = fixture.enable();
let text = serde_json::to_string(&status).unwrap();
assert!(!text.contains("fixture-provider-secret"));
let record = load_record(&fixture.dir).unwrap();
assert!(!text.contains(&record.journals.last().unwrap().token));
}
#[test]
fn deleting_one_queue_member_keeps_the_rest_and_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
crate::providers::delete_provider_inner(&fixture.providers[1].id).unwrap();
refresh_saved_routes().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert_eq!(status.runtime.providers.len(), 1);
assert_eq!(status.runtime.providers[0].id, fixture.providers[0].id);
}
#[test]
fn listener_can_run_without_a_configured_provider() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fs::remove_file(crate::config_path(&fixture.dir)).unwrap();
let status = save_settings(
fixture.scope(),
FailoverSettings {
router_enabled: true,
listen_port: fixture.port,
..Default::default()
},
)
.unwrap();
assert!(status.running && !status.takeover_active);
assert!(!crate::config_path(&fixture.dir).exists());
}
#[test]
fn successful_fallback_updates_logical_provider_and_preserves_old_backup_identity() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fixture.providers[1].model = "different-default".into();
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
fixture.enable();
let earlier_backup = fixture.text();
let mut runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
let notice = SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
};
record_selection(notice, &mut runtimes).unwrap();
drop(runtimes);
let selected = get_status(fixture.scope()).unwrap();
assert_eq!(selected.primary.unwrap().id, fixture.providers[1].id);
assert_eq!(parsed(&fixture)["model"].as_str(), Some("same-model"));
let direct = direct_document(&fixture.dir, &parsed(&fixture)).unwrap();
assert_eq!(
direct["model_providers"]["custom"]["name"].as_str(),
Some("Provider 1")
);
assert_eq!(
direct["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("fixture-provider-secret-1")
);
stop(&fixture);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[1].base_url.as_str())
);
fs::write(crate::config_path(&fixture.dir), earlier_backup).unwrap();
recover_stale_route(fixture.scope()).unwrap();
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn late_success_cannot_override_a_manual_switch_or_a_changed_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let notice = {
let runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
}
};
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
record_selection(notice, &mut lock_manager().unwrap()).unwrap();
assert_eq!(
get_status(fixture.scope()).unwrap().primary.unwrap().id,
fixture.providers[2].id
);
}
#[test]
fn a_late_success_from_a_stopped_listener_cannot_change_the_restarted_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let notice = {
let runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
}
};
stop(&fixture);
fixture.enable();
let mut runtimes = lock_manager().unwrap();
let restarted = runtimes.get(&fixture.dir).unwrap();
assert_eq!(notice.token, restarted.token);
assert_eq!(notice.event.revision, restarted.proxy.revision());
assert_ne!(notice.instance_id, restarted.instance_id);
record_selection(notice, &mut runtimes).unwrap();
drop(runtimes);
assert_eq!(
get_status(fixture.scope()).unwrap().primary.unwrap().id,
fixture.providers[0].id
);
}
#[test]
fn a_journal_write_failure_after_p1_rolls_back_files_and_common_recovery_marker() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
let target = &fixture.providers[2].id;
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_p1_journal BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
let mut settings = fixture.settings();
settings.provider_ids = vec![target.clone()];
let result = save_settings(fixture.scope(), settings);
crate::app_db::open()
.unwrap()
.execute_batch("DROP TRIGGER reject_p1_journal")
.unwrap();
assert!(result.is_err());
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
assert_eq!(before.config, after.config);
assert_eq!(before.auth, after.auth);
assert_eq!(before.selected, after.selected);
assert_eq!(before.common_handled, after.common_handled);
assert!(!get_status(fixture.scope()).unwrap().running);
}
#[test]
fn p1_journal_failure_restores_official_endpoint_before_oauth() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fs::write(crate::config_path(&fixture.dir), "model='official-model'\n").unwrap();
fs::write(crate::auth_path(&fixture.dir),r#"{"auth_mode":"chatgpt","tokens":{"access_token":"fixture-oauth","account_id":"fixture-account"}}"#).unwrap();
fixture.providers[2].requires_openai_auth = true;
fixture.providers[2] =
crate::providers::save_provider_inner(fixture.providers[2].clone()).unwrap();
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
let mut settings = fixture.settings();
settings.provider_ids = vec![fixture.providers[2].id.clone()];
// Verify the prepared direct route really reads the global auth file. This
// is the interval a failed journal write must undo before publishing OAuth.
switch_to_p1(&fixture.dir, &mut settings).unwrap();
let prepared = FileCheckpoint::capture(&fixture.dir).unwrap();
let doc = parsed(&fixture);
let key = doc["model_provider"].as_str().unwrap();
assert_eq!(
doc["model_providers"][key]["requires_openai_auth"].as_bool(),
Some(true)
);
assert!(doc["model_providers"][key]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
crate::providers::replacement_write_order(
prepared.config.as_deref(),
before.config.as_deref()
),
crate::providers::LiveWriteOrder::ConfigFirst
);
before.restore(&fixture.dir, &prepared).unwrap();
let target = &fixture.providers[2].id;
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_official_p1 BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
let result = save_settings(fixture.scope(), settings);
crate::app_db::open()
.unwrap()
.execute_batch("DROP TRIGGER reject_official_p1")
.unwrap();
assert!(result
.err()
.unwrap()
.to_string()
.contains("fixture-journal-failure"));
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
assert_eq!(before.config, after.config);
assert_eq!(before.auth, after.auth);
assert_eq!(before.selected, after.selected);
assert_eq!(before.common_handled, after.common_handled);
assert!(!get_status(fixture.scope()).unwrap().running);
}
#[test]
fn invalid_persisted_settings_restore_direct_without_restarting_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
runtime.proxy.shutdown();
let mut record = load_record(&fixture.dir).unwrap();
record.settings.tuning.max_retries = 11;
save_record(&fixture.dir, &record).unwrap();
initialize().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(!status.running && !status.takeover_active);
assert!(!fixture.text().contains("http://127.0.0.1:"));
assert!(status.message.unwrap().contains("路由设置需要检查"));
}
#[test]
fn startup_status_write_failure_keeps_the_attached_listener_alive() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
shutdown_all().unwrap();
let conn = crate::app_db::open().unwrap();
conn.execute_batch("CREATE TRIGGER reject_redundant_status BEFORE UPDATE ON provider_failover WHEN NEW.record_json=OLD.record_json BEGIN SELECT RAISE(ABORT,'fixture-status-failure'); END;").unwrap();
SHUTTING_DOWN.store(false, Ordering::Release);
let result = initialize();
conn.execute_batch("DROP TRIGGER reject_redundant_status")
.unwrap();
assert!(result
.unwrap_err()
.to_string()
.contains("fixture-status-failure"));
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert!(fixture.text().contains("http://127.0.0.1:"));
assert!(std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, fixture.port)).is_ok());
stop(&fixture);
}
@@ -0,0 +1,293 @@
use super::*;
struct OwnedRouteFixture {
dir: PathBuf,
raw: String,
local_token: String,
}
impl OwnedRouteFixture {
fn new() -> Self {
let unique = format!(
"{}-{}",
std::process::id(),
Local::now().timestamp_nanos_opt().unwrap()
);
let dir = std::env::temp_dir().join(format!("codex-x-failover-integration-{unique}"));
fs::create_dir_all(&dir).expect("create isolated Codex directory");
let local_token = format!("local-token-{unique}");
let original = r#"name = "Primary"
base_url = "https://primary.example.test/v1"
wire_api = "responses"
requires_openai_auth = false
experimental_bearer_token = "real-test-key"
supports_websockets = true
"#;
let raw = format!(
r#"model_provider = "custom"
model = "test-model"
model_reasoning_effort = "high"
[model_providers.custom]
name = "Primary"
base_url = "http://127.0.0.1:45555/v1"
wire_api = "responses"
requires_openai_auth = false
experimental_bearer_token = "{local_token}"
supports_websockets = false
request_max_retries = 0
stream_max_retries = 0
[mcp_servers.example]
command = "local-fixture-command"
"#
);
fs::write(config_path(&dir), &raw).expect("write synthetic local route");
let record = json!({
"settings": {"enabled":false,"providerIds":[]},
"journals": [{"primaryId":"fixture-primary", "providerKey":"custom",
"originalTable":original, "port":45555, "token":local_token}],
});
app_db::open()
.expect("open isolated test store")
.execute(
"INSERT INTO provider_failover (codex_dir, record_json) VALUES (?1, ?2)",
params![paths::normalized_path_scope(&dir), record.to_string()],
)
.expect("seed owned route journal");
Self {
dir,
raw,
local_token,
}
}
}
impl Drop for OwnedRouteFixture {
fn drop(&mut self) {
if let Ok(conn) = app_db::open() {
let _ = conn.execute(
"DELETE FROM provider_failover WHERE codex_dir = ?1",
[paths::normalized_path_scope(&self.dir)],
);
}
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn managed_route_state_detection_and_drafts_use_original_provider_without_writes() {
let fixture = OwnedRouteFixture::new();
let mut detected = providers::detected_live_custom_provider(&fixture.dir)
.expect("detect logical provider")
.expect("third-party provider");
assert_eq!(detected.base_url, "https://primary.example.test/v1");
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
assert!(!detected
.toml_config
.as_deref()
.unwrap()
.contains(&fixture.local_token));
detected.toml_config = None;
let draft = build_provider_toml_draft_inner(detected, Some(fixture.dir.display().to_string()))
.expect("inherit direct route in editor draft");
assert!(!draft.contains("127.0.0.1"));
assert!(!draft.contains(&fixture.local_token));
assert!(draft.contains("local-fixture-command"));
let state =
build_state_after_migration(fixture.dir.clone()).expect("read logical application state");
let state_json = serde_json::to_value(&state).expect("serialize public state");
assert_eq!(
state_json["providers"][0]["baseUrl"],
"https://primary.example.test/v1"
);
assert_eq!(state.model.as_deref(), Some("test-model"));
assert_eq!(
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
fixture.raw
);
assert!(!auth_path(&fixture.dir).exists());
}
#[test]
fn cached_managed_toml_submission_restores_owned_route_but_preserves_new_api_key() {
let fixture = OwnedRouteFixture::new();
for (input_key, expected_key) in [
(
Some(fixture.local_token.clone()),
Some("real-test-key".to_string()),
),
(
Some("new-user-key".to_string()),
Some("new-user-key".to_string()),
),
(
Some(format!(" {} ", fixture.local_token)),
Some("real-test-key".to_string()),
),
(None, None),
] {
let input = direct_provider_toml_input(ProviderTomlInput {
config_dir: Some(fixture.dir.display().to_string()),
config_text: fixture.raw.replace("\"high\"", "\"low\""),
api_key: input_key,
})
.expect("unwrap stale editor payload");
assert_eq!(input.api_key, expected_key);
let doc = input
.config_text
.parse::<DocumentMut>()
.expect("parse direct payload");
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("low"));
assert_eq!(
doc["model_providers"]["custom"]["base_url"].as_str(),
Some("https://primary.example.test/v1")
);
assert!(!input.config_text.contains(&fixture.local_token));
assert!(input.config_text.contains("local-fixture-command"));
}
assert_eq!(
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
fixture.raw
);
assert!(!auth_path(&fixture.dir).exists());
}
#[test]
fn read_unwrap_handles_multiple_owned_tables_before_detecting_selected_provider() {
let mut fixture = OwnedRouteFixture::new();
let second_original = "name = \"Inactive\"\nbase_url = \"https://secondary.example.test/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-test-key\"\n";
fixture.raw.push_str("\n[model_providers.inactive]\nname = \"Inactive\"\nbase_url = \"http://127.0.0.1:45556/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-local-token\"\n");
fs::write(config_path(&fixture.dir), &fixture.raw).expect("write second local table");
let conn = app_db::open().expect("read fixture store");
let scope = paths::normalized_path_scope(&fixture.dir);
let text: String = conn
.query_row(
"SELECT record_json FROM provider_failover WHERE codex_dir = ?1",
[&scope],
|row| row.get(0),
)
.expect("read recovery journal");
let mut record: Value = serde_json::from_str(&text).expect("parse recovery journal");
record["journals"].as_array_mut().unwrap().push(json!({
"primaryId":"inactive", "providerKey":"inactive", "originalTable":second_original,
"port":45556, "token":"secondary-local-token"
}));
conn.execute(
"UPDATE provider_failover SET record_json = ?1 WHERE codex_dir = ?2",
params![record.to_string(), scope],
)
.expect("append recovery identity");
drop(conn);
let detected = providers::detected_live_custom_provider(&fixture.dir)
.expect("detect selected provider")
.expect("selected third-party provider");
assert_eq!(detected.base_url, "https://primary.example.test/v1");
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
assert!(!detected.toml_config.unwrap().contains("127.0.0.1"));
assert_eq!(
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
fixture.raw
);
}
#[test]
fn native_owned_route_keeps_official_status_quota_refresh_and_snapshot_credentials_scoped() {
let _guard = app_db::test_db_guard();
let mut fixture = OwnedRouteFixture::new();
let original = "name = 'OpenAI'\nwire_api = 'responses'\nrequires_openai_auth = true\nsupports_websockets = true\n";
fixture.raw = format!("model_provider='custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nwire_api='responses'\nrequires_openai_auth=true\nsupports_websockets=false\nbase_url='http://127.0.0.1:45555/v1'\nhttp_headers={{'x-codex-x-route-token'='{}'}}\n[mcp_servers.keep]\ncommand='keep-native-mcp'\n", fixture.local_token);
fs::write(config_path(&fixture.dir), &fixture.raw).unwrap();
let record = json!({
"version":2,
"settings":{"routerEnabled":false,"takeoverEnabled":false,"autoFailoverEnabled":false,"providerIds":[]},
"journals":[{"primaryId":"official:openai-official","providerKey":"custom","originalTable":original,
"listenAddress":"127.0.0.1","port":45555,"token":fixture.local_token,"official":true,"tableExisted":true,"providersExisted":true}]
});
app_db::open()
.unwrap()
.execute(
"UPDATE provider_failover SET record_json=?1 WHERE codex_dir=?2",
params![
record.to_string(),
paths::normalized_path_scope(&fixture.dir)
],
)
.unwrap();
let auth = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-current-access","refresh_token":"native-refresh","account_id":"native-account"}});
fs::write(auth_path(&fixture.dir), auth.to_string()).unwrap();
let config_before = fs::read(config_path(&fixture.dir)).unwrap();
let auth_before = fs::read(auth_path(&fixture.dir)).unwrap();
let state = build_state_after_migration(fixture.dir.clone()).unwrap();
assert!(state.is_official_provider);
assert_eq!(
state.active_official_profile_id.as_deref(),
Some("openai-official")
);
assert!(providers::detected_live_custom_provider(&fixture.dir)
.unwrap()
.is_none());
let profiles = list_official_profiles_inner(Some(fixture.dir.display().to_string())).unwrap();
assert!(profiles[0].is_current);
assert!(profiles[0].can_query_quota);
let credentials = providers::official_profiles::official_profile_quota_credentials(
&fixture.dir,
"openai-official",
)
.unwrap();
assert_eq!(credentials.access_token, "native-current-access");
let route = failover::native_official::route_for_current(&fixture.dir)
.unwrap()
.unwrap();
assert!(route.api_key.is_none());
let spec = route.official.unwrap();
assert!(failover::native_official::verify_request(
&spec,
"Bearer native-current-access",
Some("native-account")
)
.is_ok());
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
assert_eq!(fs::read(auth_path(&fixture.dir)).unwrap(), auth_before);
let refreshed = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-refreshed-access","refresh_token":"native-refreshed-refresh","account_id":"native-account"}});
fs::write(auth_path(&fixture.dir), refreshed.to_string()).unwrap();
assert_eq!(
providers::official_profiles::official_profile_quota_credentials(
&fixture.dir,
"openai-official"
)
.unwrap()
.access_token,
"native-refreshed-access"
);
assert!(failover::native_official::verify_request(
&spec,
"Bearer native-current-access",
Some("native-account")
)
.is_err());
assert!(failover::native_official::verify_request(
&spec,
"Bearer native-refreshed-access",
Some("native-account")
)
.is_ok());
assert!(providers::capture_live_chatgpt_config(&fixture.dir).unwrap());
let snapshot_path = providers::official_snapshot_path_for_test(&fixture.dir).unwrap();
let snapshot: Value = serde_json::from_slice(&fs::read(&snapshot_path).unwrap()).unwrap();
let saved_config = snapshot["config"].as_str().unwrap();
assert!(!saved_config.contains("127.0.0.1"));
assert!(!saved_config.contains("x-codex-x-route-token"));
assert!(!saved_config.contains(&fixture.local_token));
assert!(saved_config.contains("keep-native-mcp"));
assert_eq!(
snapshot["auth"]["tokens"]["access_token"],
"native-refreshed-access"
);
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(auth_path(&fixture.dir)).unwrap()).unwrap(),
refreshed
);
fs::remove_file(snapshot_path).unwrap();
}
+207 -66
View File
@@ -22,6 +22,7 @@ mod constants;
mod context_config;
mod desktop_lifecycle;
mod error;
mod failover;
mod file_io;
mod live_config;
mod paths;
@@ -608,9 +609,15 @@ async fn read_ccswitch_official_auth(
#[tauri::command]
async fn import_ccswitch_codex_providers(db_path: Option<String>) -> Result<ImportResult> {
tauri::async_runtime::spawn_blocking(move || import_ccswitch_codex_providers_inner(db_path))
.await
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
let result = import_ccswitch_codex_providers_inner(db_path)?;
failover::refresh_saved_routes().map_err(|error| {
CodexxError::Config(format!("供应商已导入,但自动切换状态更新失败:{error}"))
})?;
Ok(result)
})
.await
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
}
fn get_about_info_inner(config_dir: Option<String>) -> Result<AboutInfo> {
@@ -978,6 +985,15 @@ fn finish_provider_selection(
result
}
#[tauri::command]
async fn get_provider_config_base(config_dir: Option<String>) -> Result<String> {
tauri::async_runtime::spawn_blocking(move || {
providers::get_provider_config_base_inner(config_dir)
})
.await
.map_err(|error| CodexxError::Config(format!("读取供应商通用配置失败: {error}")))?
}
#[tauri::command]
async fn build_provider_toml_draft(
provider: SavedProvider,
@@ -996,7 +1012,11 @@ async fn build_provider_toml_draft(
}
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
save_provider_inner(provider)
let saved = save_provider_inner(provider)?;
failover::refresh_saved_routes().map_err(|error| {
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
})?;
Ok(saved)
}
#[tauri::command]
@@ -1042,17 +1062,46 @@ async fn get_usage_statistics(
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
}
#[tauri::command]
async fn get_provider_failover(config_dir: Option<String>) -> Result<failover::FailoverStatus> {
tauri::async_runtime::spawn_blocking(move || failover::get_status(config_dir))
.await
.map_err(|error| CodexxError::Config(format!("读取自动切换设置失败: {error}")))?
}
#[tauri::command]
async fn save_provider_failover(
config_dir: Option<String>,
settings: failover::FailoverSettings,
) -> Result<failover::FailoverStatus> {
tauri::async_runtime::spawn_blocking(move || failover::save_settings(config_dir, settings))
.await
.map_err(|error| CodexxError::Config(format!("保存自动切换设置失败: {error}")))?
}
#[tauri::command]
async fn reset_provider_failover_health(
config_dir: Option<String>,
provider_id: Option<String>,
) -> Result<failover::FailoverStatus> {
tauri::async_runtime::spawn_blocking(move || failover::reset_health(config_dir, provider_id))
.await
.map_err(|error| CodexxError::Config(format!("重置供应商健康状态失败: {error}")))?
}
#[tauri::command]
async fn activate_saved_provider(
config_dir: Option<String>,
provider_id: String,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
failover::with_provider_change(config_dir.clone(), || {
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
})
})
.await
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
@@ -1062,14 +1111,25 @@ async fn activate_saved_provider(
async fn save_active_provider(
provider: SavedProvider,
config_dir: Option<String>,
apply_common_config: Option<bool>,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let provider_id = provider.id.clone();
let result = save_active_provider_inner(provider, config_dir)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
let result = failover::with_provider_change(config_dir.clone(), || {
let provider_id = provider.id.clone();
let result = if apply_common_config.unwrap_or(false) {
providers::save_active_provider_with_common_config_inner(provider, config_dir)?
} else {
save_active_provider_inner(provider, config_dir)?
};
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
})?;
failover::refresh_saved_routes().map_err(|error| {
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
})?;
Ok(result)
})
.await
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
@@ -1077,9 +1137,14 @@ async fn save_active_provider(
#[tauri::command]
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
.await
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
delete_saved_provider_inner(id.trim(), config_dir)?;
failover::refresh_saved_routes().map_err(|error| {
CodexxError::Config(format!("供应商已删除,但自动切换状态更新失败:{error}"))
})
})
.await
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
}
#[tauri::command]
@@ -1097,14 +1162,16 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
#[tauri::command]
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = switch_official_profile_inner(
config_dir,
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
failover::with_provider_change(config_dir.clone(), || {
let result = switch_official_profile_inner(
config_dir,
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
})
.await
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
@@ -1153,9 +1220,26 @@ async fn get_official_profile(
#[tauri::command]
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
.await
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
tauri::async_runtime::spawn_blocking(move || {
// Saving a separate account is a library edit, not a manual route change.
let applies_live = match input.id.as_ref() {
Some(id) => {
get_official_profile_inner(input.config_dir.clone(), id.clone())?
.profile
.is_current
}
None => false,
};
if applies_live {
failover::with_provider_change(input.config_dir.clone(), || {
save_official_profile_inner(input)
})
} else {
save_official_profile_inner(input)
}
})
.await
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
}
#[tauri::command]
@@ -1177,7 +1261,9 @@ async fn switch_official_profile(
profile_id: String,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
switch_official_profile_inner(config_dir, profile_id)
failover::with_provider_change(config_dir.clone(), || {
switch_official_profile_inner(config_dir, profile_id)
})
})
.await
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
@@ -1195,11 +1281,13 @@ async fn delete_official_profile(config_dir: Option<String>, profile_id: String)
#[tauri::command]
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
failover::with_provider_change(input.config_dir.clone(), || {
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
})
.await
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
@@ -1208,16 +1296,18 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
#[tauri::command]
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = save_official_config_inner(
input.config_dir,
input.model,
input.auth_json,
input.config_text,
)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
failover::with_provider_change(input.config_dir.clone(), || {
let result = save_official_config_inner(
input.config_dir,
input.model,
input.auth_json,
input.config_text,
)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
})
.await
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
@@ -1396,18 +1486,41 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
.map_err(|e| CodexxError::Config(format!("禁用外部提示词失败: {e}")))?
}
fn direct_provider_toml_input(mut input: ProviderTomlInput) -> Result<ProviderTomlInput> {
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
let doc = parse_toml_document(&config_path(&codex_dir), &input.config_text)?;
let direct = failover::direct_document(&codex_dir, &doc)?;
if direct.to_string() != doc.to_string() {
let old_id = string_value(&doc, "model_provider");
let old_token = providers::experimental_bearer_token_from_doc(&doc, old_id.as_deref());
// Only replace the known local credential echoed back by the editor.
// A newly entered key belongs to the user's edit and must be retained.
if old_token.is_some() && input.api_key.as_deref().map(str::trim) == old_token.as_deref() {
let direct_id = string_value(&direct, "model_provider");
input.api_key =
providers::experimental_bearer_token_from_doc(&direct, direct_id.as_deref());
}
input.config_text = direct.to_string();
}
Ok(input)
}
#[tauri::command]
async fn save_provider_toml_config(
input: ProviderTomlInput,
provider_id: Option<String>,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = save_provider_toml_config_inner(input)?;
Ok(match provider_id {
Some(provider_id) => {
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
}
None => result,
failover::with_provider_change(input.config_dir.clone(), || {
let input = direct_provider_toml_input(input)?;
let result = save_provider_toml_config_inner(input)?;
Ok(match provider_id {
Some(provider_id) => finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
),
None => result,
})
})
})
.await
@@ -1437,13 +1550,16 @@ async fn fetch_provider_models(
#[tauri::command]
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let provider_id = input.provider_id.clone();
let result = switch_provider_inner(input)?;
Ok(match provider_id {
Some(provider_id) => {
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
}
None => result,
failover::with_provider_change(input.config_dir.clone(), || {
let provider_id = input.provider_id.clone();
let result = switch_provider_inner(input)?;
Ok(match provider_id {
Some(provider_id) => finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
),
None => result,
})
})
})
.await
@@ -1534,11 +1650,14 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
let backup_config = match backup_config {
Some(bytes) => {
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
Some(
migrated_legacy_prompt_config_text(&cfg, &text)?
.unwrap_or(text)
.into_bytes(),
)
let text = migrated_legacy_prompt_config_text(&cfg, &text)?.unwrap_or(text);
let doc = parse_toml_document(&cfg, &text)?;
let direct = failover::direct_document(&codex_dir, &doc)?;
Some(if direct.to_string() == doc.to_string() {
text.into_bytes()
} else {
direct.to_string().into_bytes()
})
}
None => None,
};
@@ -1578,9 +1697,16 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
#[tauri::command]
async fn restore_backup(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || restore_backup_inner(config_dir, backup_id))
.await
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
failover::with_provider_change(config_dir.clone(), || {
let mut result = restore_backup_inner(config_dir.clone(), backup_id)?;
failover::recover_stale_route(config_dir)?;
result.state = build_state_after_migration(PathBuf::from(&result.state.codex_dir))?;
Ok(result)
})
})
.await
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
}
#[tauri::command]
@@ -1629,6 +1755,14 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
desktop_lifecycle::setup_system_tray(app)?;
failover::attach_app_handle(app.handle().clone());
if let Err(error) = failover::initialize() {
desktop_lifecycle::report_failover_lifecycle_error(
app.handle(),
"自动切换暂时无法启动",
&error.to_string(),
);
}
Ok(())
})
.on_window_event(desktop_lifecycle::handle_window_event)
@@ -1666,11 +1800,15 @@ pub fn run() {
delete_saved_prompt,
enable_saved_prompt,
list_saved_providers,
get_provider_config_base,
build_provider_toml_draft,
save_provider,
duplicate_provider,
update_codex_context_window,
get_usage_statistics,
get_provider_failover,
save_provider_failover,
reset_provider_failover_health,
save_active_provider,
activate_saved_provider,
delete_saved_provider,
@@ -1706,3 +1844,6 @@ pub fn run() {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod failover_integration_tests;
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -27,13 +27,15 @@ pub(crate) use connection::{
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
ProviderModelsResult,
};
pub(crate) use live::detected_live_custom_provider;
pub(crate) use live::{
activate_saved_provider_inner, build_provider_toml_draft_inner,
build_provider_toml_draft_with_origin_inner, delete_saved_provider_inner,
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
get_provider_config_base_inner, save_active_provider_inner,
save_active_provider_with_common_config_inner, save_official_config_inner,
save_provider_toml_config_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
ProviderTomlInput,
};
pub(crate) use live::{detected_live_custom_provider, replacement_write_order, LiveWriteOrder};
#[cfg(test)]
pub(crate) use live::{
reset_official_provider_inner, restore_official_provider_inner,
@@ -201,7 +201,13 @@ fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(document_is_official(&doc).then_some(text))
let direct = crate::failover::direct_document(codex_dir, &doc)?;
let text = if direct.to_string() == doc.to_string() {
text
} else {
direct.to_string()
};
Ok(document_is_official(&direct).then_some(text))
}
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
@@ -229,7 +235,8 @@ pub(crate) fn build_official_config_text(
} else {
String::new()
};
let mut doc = parse_toml_document(&path, &text)?;
let doc = parse_toml_document(&path, &text)?;
let mut doc = crate::failover::direct_document(codex_dir, &doc)?;
doc["model_provider"] = value("custom");
doc.as_table_mut().remove("base_url");
@@ -302,6 +309,7 @@ pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
let doc = crate::failover::direct_document(codex_dir, &doc)?;
Ok(document_is_official(&doc))
}
@@ -1,6 +1,7 @@
use super::live::{
apply_official_config_with_snapshot_locked, persist_detected_live_custom_provider,
read_live_file_snapshot, AppliedLiveFiles, LiveAuthAction,
apply_official_config_with_snapshot_locked, official_activation_config_text,
persist_detected_live_custom_provider, read_live_file_snapshot, AppliedLiveFiles,
LiveAuthAction,
};
use super::official_auth::{
build_official_config_text, capture_live_official_config_before_provider_switch,
@@ -436,6 +437,7 @@ pub(crate) fn list_official_profiles_inner(
let selected = selected_profile_id(&codex_dir)?;
let config_path = crate::config_path(&codex_dir);
let config = parse_toml_document(&config_path, &read_to_string_if_exists(&config_path)?)?;
let config = crate::failover::direct_document(&codex_dir, &config)?;
let is_official = document_is_official(&config);
let live_model = crate::string_value(&config, "model");
let live_auth = is_official
@@ -867,6 +869,7 @@ fn switch_official_profile_with_before_apply(
&target.config_text,
target.profile.model.as_deref(),
)?;
let config = official_activation_config_text(&codex_dir, &config)?;
let auth = parse_auth(&target.auth_json)?;
with_mutation(&codex_dir, |mutation| {
let previous_id = selected_profile_id(&codex_dir)?;
@@ -1533,10 +1536,14 @@ mod tests {
Some(b.profile.id.as_str())
);
assert_eq!(live_auth(&dir), auth("b"));
assert_eq!(
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
config("b").trim_end()
);
let configured = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(configured["model"].as_str(), Some("model-b"));
assert!(document_is_official(&configured));
assert_eq!(configured["approval_policy"].as_str(), Some("never"));
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
let refreshed = auth("b-refreshed");
write_json(&auth_path(&dir), &refreshed).unwrap();
@@ -1569,10 +1576,14 @@ mod tests {
write_json(&auth_path(&dir), &auth("a-refreshed")).unwrap();
switch(&dir, &b.profile.id);
assert_eq!(live_auth(&dir), refreshed);
assert_eq!(
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
b_config.trim_end()
);
let configured = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(configured["model"].as_str(), Some("model-b"));
assert!(document_is_official(&configured));
assert_eq!(configured["desktop"]["notify"].as_bool(), Some(true));
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
assert_eq!(live_auth(&dir), auth("a-refreshed"));
fs::remove_dir_all(dir).unwrap();
+112 -7
View File
@@ -1,3 +1,6 @@
use super::storage::{
source_text_is_subagent, sqlite_subagent_thread_ids, thread_source_is_internal,
};
use crate::error::{CodexxError, Result};
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
use rusqlite::{params_from_iter, types::Value as SqlValue, Connection, OpenFlags};
@@ -81,6 +84,59 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
}
}
// Catalog visibility is independent of the thread index. A stale or incomplete
// index must never turn a source-marked internal catalog row into a user thread.
pub(super) fn catalog_internal_thread_ids(
conn: &Connection,
columns: &HashSet<String>,
) -> Result<HashSet<String>> {
if !columns.contains("thread_id") {
return Ok(HashSet::new());
}
let source = text_expr(columns, "source_kind", "NULL");
let thread_source = text_expr(columns, "thread_source", "NULL");
let mut statement = conn
.prepare(&format!(
"SELECT thread_id, {source}, {thread_source} FROM local_thread_catalog"
))
.map_err(database_error)?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.map_err(database_error)?;
let mut ids = HashSet::new();
for row in rows {
let (id, source, thread_source) = row.map_err(database_error)?;
if source.as_deref().is_some_and(source_text_is_subagent)
|| thread_source
.as_deref()
.is_some_and(thread_source_is_internal)
{
ids.insert(id);
}
}
Ok(ids)
}
pub(super) fn scan_catalog_internal_thread_ids(paths: &[PathBuf]) -> Result<HashSet<String>> {
let mut ids = HashSet::new();
for path in paths {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(database_error)?;
let columns = catalog_columns(&conn)?;
ids.extend(catalog_internal_thread_ids(&conn, &columns)?);
}
Ok(ids)
}
fn collect_catalog_repair_threads(
paths: &[PathBuf],
target_provider: &str,
@@ -100,6 +156,7 @@ fn collect_catalog_repair_threads(
if !columns.contains("id") {
continue;
}
let internal_ids = sqlite_subagent_thread_ids(&conn, &columns)?;
let display_title = coalesce_text_expr(
&columns,
&["name", "title", "preview", "first_user_message"],
@@ -150,7 +207,7 @@ fn collect_catalog_repair_threads(
.map_err(database_error)?;
for row in rows {
let thread = row.map_err(database_error)?;
if !syncable_thread_ids.contains(&thread.id) {
if !syncable_thread_ids.contains(&thread.id) || internal_ids.contains(&thread.id) {
continue;
}
let replace = threads
@@ -238,8 +295,13 @@ pub(super) fn scan_catalog_sync(
target_provider: &str,
syncable_thread_ids: &HashSet<String>,
) -> Result<CatalogSyncScan> {
let internal_ids = scan_catalog_internal_thread_ids(catalog_paths)?;
let syncable_thread_ids: HashSet<String> = syncable_thread_ids
.difference(&internal_ids)
.cloned()
.collect();
let sources =
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
collect_catalog_repair_threads(thread_paths, target_provider, &syncable_thread_ids)?;
let mut scan = CatalogSyncScan {
sources,
..CatalogSyncScan::default()
@@ -680,18 +742,35 @@ pub(super) fn apply_catalog_updates(
sources: &HashMap<String, CatalogRepairThread>,
provider_thread_ids: &HashSet<String>,
) -> Result<CatalogUpdateCounts> {
let internal_ids = catalog_internal_thread_ids(conn, columns)?;
let provider_thread_ids: HashSet<String> = provider_thread_ids
.difference(&internal_ids)
.cloned()
.collect();
let sources = sources
.iter()
.filter(|(id, thread)| {
provider_thread_ids.contains(*id)
&& !source_text_is_subagent(&thread.source_kind)
&& !thread
.thread_source
.as_deref()
.is_some_and(thread_source_is_internal)
})
.map(|(id, thread)| (id.clone(), thread.clone()))
.collect::<HashMap<_, _>>();
let mut counts = CatalogUpdateCounts::default();
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
Some(local_catalog_host_id(conn)?)
} else {
None
};
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
snapshot_catalog_provider_changes(conn, columns, target_provider, &provider_thread_ids)?;
if let Some(host_id) = repair_host.as_deref() {
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
snapshot_hidden_catalog_sources(conn, columns, host_id, &sources)?;
}
if columns.contains("model_provider") && columns.contains("thread_id") {
for thread_id in provider_thread_ids {
for thread_id in &provider_thread_ids {
conn.execute(
"UPDATE local_thread_catalog SET model_provider = ?1 \
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
@@ -701,9 +780,9 @@ pub(super) fn apply_catalog_updates(
}
}
if let Some(host_id) = repair_host.as_deref() {
reactivate_catalog_sources(conn, columns, host_id, sources)?;
reactivate_catalog_sources(conn, columns, host_id, &sources)?;
let (inserted, observation_sequence, max_source_updated_at) =
insert_missing_catalog_sources(conn, columns, host_id, sources)?;
insert_missing_catalog_sources(conn, columns, host_id, &sources)?;
counts.inserted_rows = inserted;
if inserted > 0 {
update_catalog_sync_state(conn, host_id, observation_sequence, max_source_updated_at)?;
@@ -1114,4 +1193,30 @@ mod tests {
("openai".to_string(), 1, 7)
);
}
#[test]
fn internal_catalog_rows_and_sources_ignore_even_explicit_sync_candidates() {
let conn = Connection::open_in_memory().expect("open internal catalog fixture");
create_catalog_schema(&conn);
insert_catalog_row(&conn, "hidden-review", "openai", 1);
conn.execute("UPDATE local_thread_catalog SET thread_source = 'guardian_review' WHERE thread_id = 'hidden-review'", [])
.expect("mark internal catalog source");
let mut internal_source = repair_source("missing-review", "custom");
internal_source.source_kind = r#"{"internal":"guardian"}"#.to_string();
internal_source.thread_source = None;
let sources = HashMap::from([(internal_source.id.clone(), internal_source)]);
let candidates = HashSet::from(["hidden-review".to_string(), "missing-review".to_string()]);
let counts = apply_and_commit(&conn, "custom", &sources, &candidates);
assert_eq!(counts.provider_rows, 0);
assert_eq!(counts.inserted_rows, 0);
assert_eq!(
catalog_state(&conn, "hidden-review"),
("openai".to_string(), 1, 7)
);
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM local_thread_catalog", [], |row| {
row.get(0)
})
.expect("count preserved catalog");
assert_eq!(rows, 1);
}
}
+3 -2
View File
@@ -23,8 +23,9 @@ pub(crate) use storage::{
scan_sqlite, sqlite_session_db_paths,
};
pub(crate) use storage::{
session_project_title, session_titles_by_id, sqlite_candidate_paths,
sqlite_candidate_paths_with_timeout, usage_thread_identities, UsageThreadIdentity,
session_project_title, session_titles_by_id, source_kind_is_internal, sqlite_candidate_paths,
sqlite_candidate_paths_with_timeout, thread_source_is_internal, usage_thread_identities,
UsageThreadIdentity,
};
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
+651 -49
View File
@@ -8,10 +8,12 @@ use crate::paths::home_dir;
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
use crate::{config_path, string_value};
use rusqlite::{Connection, OpenFlags};
use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use toml_edit::DocumentMut;
@@ -36,6 +38,9 @@ fn is_rollout_file(path: &Path) -> bool {
}
pub(super) fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
if let Some(thread_id) = rollout_filename_thread_id(path) {
return thread_id == id;
}
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
@@ -201,12 +206,13 @@ pub(super) fn scan_provider_rollouts(
codex_dir: &Path,
target_provider: &str,
excluded_thread_ids: &HashSet<String>,
authoritative_rollout_paths: &HashMap<String, String>,
) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(
codex_dir,
target_provider,
None,
None,
Some(authoritative_rollout_paths),
false,
Some(excluded_thread_ids),
true,
@@ -226,7 +232,7 @@ pub(super) fn scan_rollouts_for_thread_ids(
Some(rollout_paths_by_thread_id),
false,
None,
false,
true,
)
}
@@ -295,6 +301,34 @@ fn scan_rollouts_with_thread_filter(
.canonicalize()
.ok()
.and_then(|canonical| referenced.get(&canonical));
let identity = match read_rollout_identity(&path) {
Ok(identity) => identity,
Err(failure) => {
scan.scan_failures.push(failure);
continue;
}
};
let identity_id = identity.payload.id.as_deref().unwrap_or_default().trim();
if expected_thread_ids
.is_some_and(|expected| expected.len() != 1 || !expected.contains(identity_id))
{
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
path.display()
));
continue;
}
let authoritative_identity = rollout_paths_by_thread_id.is_none_or(|authority| {
is_authoritative_rollout(codex_dir, &path, identity_id, authority)
});
if identity.payload.is_internal() {
if authoritative_identity {
scan.internal_thread_ids.insert(identity_id.to_string());
}
if exclude_source_marked_subagents {
continue;
}
}
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => {
@@ -328,20 +362,21 @@ fn scan_rollouts_with_thread_filter(
record.get_mut("payload").and_then(Value::as_object_mut)
{
file_session_meta_count += 1;
if thread_id.is_none() {
if file_session_meta_count == 1 {
thread_id = payload
.get("id")
.and_then(Value::as_str)
.map(ToString::to_string);
}
if cwd.is_none() {
cwd = payload
.get("cwd")
.and_then(Value::as_str)
.and_then(normalize_workspace_path);
}
if payload.get("source").is_some_and(source_value_is_subagent) {
is_subagent = true;
is_subagent =
payload.get("source").is_some_and(source_value_is_subagent)
|| payload
.get("thread_source")
.and_then(Value::as_str)
.is_some_and(thread_source_is_internal);
}
if payload.get("model_provider").and_then(Value::as_str)
!= Some(target_provider)
@@ -367,6 +402,13 @@ fn scan_rollouts_with_thread_filter(
next_text.push_str(line_ending);
}
if is_subagent && authoritative_identity {
if let Some(id) = thread_id.as_ref() {
if expected_thread_ids.is_none_or(|expected| expected.contains(id)) {
scan.internal_thread_ids.insert(id.clone());
}
}
}
if (exclude_source_marked_subagents && is_subagent)
|| thread_id
.as_ref()
@@ -386,6 +428,11 @@ fn scan_rollouts_with_thread_filter(
path.display()
));
}
if invalid_json_lines > 0 || invalid_session_meta > 0 {
// A broken orphan remains a warning, but must never become a
// provider/catalog candidate merely because one metadata line parsed.
continue;
}
if file_session_meta_count == 0 {
if expected_thread_ids.is_some() {
@@ -416,6 +463,7 @@ fn scan_rollouts_with_thread_filter(
continue;
}
scan.session_meta_count += file_session_meta_count;
scan.provider_candidate_paths.insert(path.clone());
scan.thread_ids.insert(thread_id.clone());
if let Some(cwd) = cwd {
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
@@ -1093,12 +1141,10 @@ pub(super) fn sqlite_subagent_thread_ids(
.map(str::trim)
.filter(|value| !value.is_empty())
{
if thread_source.eq_ignore_ascii_case("subagent") {
if thread_source_is_internal(thread_source) {
ids.insert(id);
} else {
ids.remove(&id);
continue;
}
continue;
}
if let Some(source) = source
@@ -1108,8 +1154,6 @@ pub(super) fn sqlite_subagent_thread_ids(
{
if source_text_is_subagent(source) {
ids.insert(id);
} else {
ids.remove(&id);
}
}
}
@@ -1120,32 +1164,279 @@ pub(super) fn sqlite_subagent_thread_ids(
pub(crate) fn source_value_is_subagent(source: &Value) -> bool {
match source {
Value::String(source) => source.trim().eq_ignore_ascii_case("subagent"),
Value::Object(source) => source.contains_key("subagent"),
Value::String(source) => source_kind_is_internal(source),
Value::Object(source) => source.contains_key("subagent") || source.contains_key("internal"),
_ => false,
}
}
fn source_text_is_subagent(source: &str) -> bool {
pub(crate) fn source_kind_is_internal(source: &str) -> bool {
let source = source.trim().to_ascii_lowercase();
source == "subagent"
|| source == "internal"
|| source.starts_with("subagent_")
|| source.starts_with("internal_")
}
pub(crate) fn thread_source_is_internal(source: &str) -> bool {
matches!(
source.trim().to_ascii_lowercase().as_str(),
"subagent" | "guardian_review" | "memory_consolidation"
) || source_text_is_subagent(source)
}
pub(crate) fn source_text_is_subagent(source: &str) -> bool {
let source = source.trim();
source.eq_ignore_ascii_case("subagent")
source_kind_is_internal(source)
|| serde_json::from_str::<Value>(source)
.ok()
.as_ref()
.is_some_and(source_value_is_subagent)
}
// Only retain identity fields from the first rollout record. Instructions and
// other potentially large metadata are streamed past, never stored or logged.
#[derive(Default)]
struct InternalSource(bool);
impl<'de> Deserialize<'de> for InternalSource {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct SourceVisitor;
impl<'de> Visitor<'de> for SourceVisitor {
type Value = InternalSource;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("session source")
}
fn visit_str<E: serde::de::Error>(
self,
value: &str,
) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(source_kind_is_internal(value)))
}
fn visit_map<M: MapAccess<'de>>(
self,
mut map: M,
) -> std::result::Result<Self::Value, M::Error> {
let mut internal = false;
while let Some(key) = map.next_key::<String>()? {
internal |= key == "subagent" || key == "internal";
map.next_value::<IgnoredAny>()?;
}
Ok(InternalSource(internal))
}
fn visit_seq<A: SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(InternalSource(false))
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_bool<E: serde::de::Error>(
self,
_: bool,
) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
}
deserializer.deserialize_any(SourceVisitor)
}
}
#[derive(Default, Deserialize)]
struct RolloutIdentityPayload {
id: Option<String>,
#[serde(default)]
source: InternalSource,
thread_source: Option<String>,
}
impl RolloutIdentityPayload {
fn is_internal(&self) -> bool {
self.source.0
|| self
.thread_source
.as_deref()
.is_some_and(thread_source_is_internal)
}
}
#[derive(Deserialize)]
struct RolloutIdentityRecord {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
payload: RolloutIdentityPayload,
}
/// Classify only the rollout's own first metadata record. A user-created fork
/// can replay an internal parent's metadata later without becoming internal.
pub(super) fn rollout_text_is_internal(text: &str) -> bool {
RolloutIdentityRecord::deserialize(&mut serde_json::Deserializer::from_str(text))
.is_ok_and(|record| record.kind == "session_meta" && record.payload.is_internal())
}
pub(super) fn rollout_path_has_syncable_identity(path: &Path) -> Result<bool> {
read_rollout_identity(path)
.map(|record| !record.payload.is_internal())
.map_err(CodexxError::Config)
}
fn is_filename_uuid(id: &str) -> bool {
id.len() == 36
&& id.bytes().enumerate().all(|(index, byte)| {
if matches!(index, 8 | 13 | 18 | 23) {
byte == b'-'
} else {
byte.is_ascii_hexdigit()
}
})
}
/// Codex thread/revert keeps the thread ID and appends a distinct rollout ID:
/// rollout-<timestamp>-<thread-id>_<rollout-id>.jsonl. The suffix is never the
/// conversation identity. Share this parser with lookup/deletion fallback so
/// neither path can accidentally select a different thread by its rollout ID.
fn rollout_filename_thread_id(path: &Path) -> Option<&str> {
let name = path.file_name()?.to_str()?;
let stem = name
.strip_suffix(".jsonl.zst")
.or_else(|| name.strip_suffix(".jsonl"))?;
let core = stem
.rsplit_once('_')
.and_then(|(prefix, rollout_id)| {
if !is_filename_uuid(rollout_id) {
return None;
}
let thread_id = prefix.get(prefix.len().checked_sub(36)?..)?;
let delimiter = prefix.get(prefix.len().checked_sub(37)?..prefix.len() - 36)?;
(is_filename_uuid(thread_id) && delimiter == "-").then_some(prefix)
})
.unwrap_or(stem);
let thread_id = core.get(core.len().checked_sub(36)?..)?;
let delimiter = core.get(core.len().checked_sub(37)?..core.len() - 36)?;
(is_filename_uuid(thread_id) && delimiter == "-").then_some(thread_id)
}
fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityRecord, String> {
let file = fs::File::open(path)
.map_err(|_| format!("无法读取会话文件来源信息: {}", path.display()))?;
let record = RolloutIdentityRecord::deserialize(&mut serde_json::Deserializer::from_reader(
BufReader::new(file),
))
.map_err(|_| {
format!(
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {}",
path.display()
)
})?;
if record.kind != "session_meta" {
return Err(format!("会话文件缺少起始 session_meta: {}", path.display()));
}
let Some(id) = record
.payload
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
return Err(format!("会话来源信息缺少线程 ID: {}", path.display()));
};
if rollout_filename_thread_id(path).is_some_and(|filename_id| filename_id != id) {
return Err(format!(
"会话来源信息与文件线程 ID 不一致: {}",
path.display()
));
}
Ok(record)
}
fn is_authoritative_rollout(
codex_dir: &Path,
path: &Path,
id: &str,
authority: &HashMap<String, String>,
) -> bool {
let Some(authoritative_path) = authority.get(id) else {
return true;
};
let raw = PathBuf::from(authoritative_path);
let resolved = if raw.is_absolute() {
raw
} else {
codex_dir.join(raw)
};
resolved
.canonicalize()
.ok()
.zip(path.canonicalize().ok())
.is_some_and(|(expected, actual)| expected == actual)
}
/// Read rollout identities independently of the SQLite provider candidate list.
/// This also protects internal threads whose database source is missing/stale.
/// Any failure is returned as a scan failure so an unknown identity cannot be
/// silently promoted into a visible Desktop catalog entry.
pub(super) fn rollout_internal_thread_ids(
codex_dir: &Path,
authoritative_rollout_paths: &HashMap<String, String>,
) -> (HashSet<String>, Vec<String>) {
let mut paths = Vec::new();
let mut failures = Vec::new();
let mut ids = HashSet::new();
collect_rollout_paths(&codex_dir.join("sessions"), &mut paths, &mut failures);
for path in paths {
let record = match read_rollout_identity(&path) {
Ok(record) => record,
Err(failure) => {
failures.push(failure);
continue;
}
};
let id = record.payload.id.as_deref().unwrap_or_default().trim();
if !is_authoritative_rollout(codex_dir, &path, id, authoritative_rollout_paths) {
continue;
}
if record.payload.is_internal() {
ids.insert(id.to_string());
}
}
(ids, failures)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsageThreadIdentity {
pub(crate) is_subagent: bool,
pub(crate) classification_known: bool,
pub(crate) parent_id: Option<String>,
pub(crate) parent_conflict: bool,
/// Only the current database's rollout reference can override stale copies.
pub(crate) rollout_path: Option<PathBuf>,
}
fn usage_identities_on_connection(
conn: &Connection,
) -> Result<HashMap<String, (bool, bool, HashSet<String>)>> {
) -> Result<HashMap<String, (bool, bool, HashSet<String>, Option<String>)>> {
if !sqlite_has_table(conn, "threads")? {
return Ok(HashMap::new());
}
@@ -1156,9 +1447,10 @@ fn usage_identities_on_connection(
let subagents = sqlite_subagent_thread_ids(conn, &cols)?;
let source = sql_select_column(&cols, "source", "NULL");
let thread_source = sql_select_column(&cols, "thread_source", "NULL");
let rollout_path = sql_select_column(&cols, "rollout_path", "NULL");
let mut statement = conn
.prepare(&format!(
"SELECT id, {source}, {thread_source} FROM threads"
"SELECT id, {source}, {thread_source}, {rollout_path} FROM threads"
))
.map_err(|error| CodexxError::Database(error.to_string()))?;
let rows = statement
@@ -1167,12 +1459,13 @@ fn usage_identities_on_connection(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1).ok().flatten(),
row.get::<_, Option<String>>(2).ok().flatten(),
row.get::<_, Option<String>>(3).ok().flatten(),
))
})
.map_err(|error| CodexxError::Database(error.to_string()))?;
let mut identities = HashMap::new();
for row in rows {
let (id, source, thread_source) =
let (id, source, thread_source, rollout_path) =
row.map_err(|error| CodexxError::Database(error.to_string()))?;
if id.trim().is_empty() {
continue;
@@ -1202,7 +1495,10 @@ fn usage_identities_on_connection(
parents.insert(parent);
}
}
identities.insert(id, (is_subagent, classification_known, parents));
identities.insert(
id,
(is_subagent, classification_known, parents, rollout_path),
);
}
if sqlite_has_table(conn, "thread_spawn_edges")? {
let cols = table_column_set(conn, "thread_spawn_edges")?;
@@ -1221,7 +1517,7 @@ fn usage_identities_on_connection(
for row in rows {
let (child, parent) =
row.map_err(|error| CodexxError::Database(error.to_string()))?;
if let Some((true, _, parents)) = identities.get_mut(&child) {
if let Some((true, _, parents, _)) = identities.get_mut(&child) {
if let Some(parent) = parent
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
@@ -1241,7 +1537,7 @@ pub(crate) fn usage_thread_identities(codex_dir: &Path) -> HashMap<String, Usage
let timeout = Duration::from_millis(50);
let discovery = discover_sqlite_databases_with_busy_timeout(codex_dir, Some(timeout));
let mut active_ids = HashSet::new();
let mut combined = HashMap::<String, (bool, bool, HashSet<String>)>::new();
let mut combined = HashMap::<String, (bool, bool, HashSet<String>, Option<String>)>::new();
for path in discovery.active_first_session_paths() {
let Ok(conn) = Connection::open_with_flags(
&path,
@@ -1256,14 +1552,17 @@ pub(crate) fn usage_thread_identities(codex_dir: &Path) -> HashMap<String, Usage
continue;
};
let is_active = discovery.active_paths.contains(&path);
for (id, (is_subagent, classification_known, parents)) in identities {
for (id, (is_subagent, classification_known, parents, rollout_path)) in identities {
if is_active {
active_ids.insert(id.clone());
combined.insert(id, (is_subagent, classification_known, parents));
combined.insert(
id,
(is_subagent, classification_known, parents, rollout_path),
);
} else if !active_ids.contains(&id) {
let existing = combined
.entry(id)
.or_insert_with(|| (false, false, HashSet::new()));
.or_insert_with(|| (false, false, HashSet::new(), None));
existing.0 |= is_subagent;
existing.1 |= classification_known;
existing.2.extend(parents);
@@ -1272,20 +1571,34 @@ pub(crate) fn usage_thread_identities(codex_dir: &Path) -> HashMap<String, Usage
}
combined
.into_iter()
.map(|(id, (is_subagent, classification_known, parents))| {
let parent_conflict = is_subagent && parents.len() > 1;
let parent_id =
(is_subagent && parents.len() == 1).then(|| parents.into_iter().next().unwrap());
(
id,
UsageThreadIdentity {
is_subagent,
classification_known,
parent_id,
parent_conflict,
},
)
})
.map(
|(id, (is_subagent, classification_known, parents, rollout_path))| {
let parent_conflict = is_subagent && parents.len() > 1;
let parent_id = (is_subagent && parents.len() == 1)
.then(|| parents.into_iter().next().unwrap());
let rollout_path =
rollout_path
.filter(|path| !path.trim().is_empty())
.map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
codex_dir.join(path)
}
});
(
id,
UsageThreadIdentity {
is_subagent,
classification_known,
parent_id,
parent_conflict,
rollout_path,
},
)
},
)
.collect()
}
@@ -1340,7 +1653,7 @@ pub(super) fn scan_sqlite_with_paths(
let mut syncable_thread_ids = HashSet::new();
let mut archived_thread_ids = HashSet::new();
let mut rollout_paths_by_thread_id = HashMap::new();
let mut subagent_ids = HashSet::new();
let mut subagent_ids = rollouts.internal_thread_ids.clone();
let mut mismatched_ids = HashSet::new();
for path in sqlite_paths {
let conn = match Connection::open_with_flags(
@@ -1533,7 +1846,8 @@ pub(super) fn list_session_previews_with_paths(
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let is_archived = archived != 0;
let is_subagent = subagent_thread_ids.contains(&id);
let is_subagent =
subagent_thread_ids.contains(&id) || rollouts.internal_thread_ids.contains(&id);
let needs_sync = !is_archived
&& !is_subagent
&& (rollouts.mismatched_thread_ids.contains(&id)
@@ -1579,11 +1893,23 @@ pub(super) fn list_session_previews_with_paths(
.then_with(|| a.id.cmp(&b.id))
});
let mut seen = HashSet::new();
let sessions = candidates
// Internal tasks can greatly outnumber their parents. Apply the display
// limit after reserving space for real conversations, otherwise hiding the
// newest internal rows in the frontend could leave an empty session list.
let (ordinary, internal): (Vec<_>, Vec<_>) = candidates
.into_iter()
.filter(|session| seen.insert(session.id.clone()))
.partition(|session| !session.is_subagent);
let mut sessions = ordinary
.into_iter()
.chain(internal)
.take(limit.max(1))
.collect();
.collect::<Vec<_>>();
sessions.sort_by(|a, b| {
b.updated_at_ms
.cmp(&a.updated_at_ms)
.then_with(|| a.id.cmp(&b.id))
});
Ok((sessions, warnings))
}
@@ -1647,7 +1973,277 @@ mod tests {
}
#[test]
fn usage_thread_identity_reuses_edges_source_and_authoritative_thread_source() {
fn internal_sources_cover_serialized_and_display_forms_without_title_heuristics() {
for source in [
"subagent",
" subagent_review ",
"subagent_thread_spawn_parent_d1",
"subagent_memory_consolidation",
"internal",
"internal_guardian",
"internal_memory_consolidation",
r#"{"internal":"guardian"}"#,
r#"{"internal":"memory_consolidation"}"#,
r#"{"subagent":"review"}"#,
r#"{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}"#,
] {
assert!(source_text_is_subagent(source), "{source}");
}
for source in [
"cli",
"vscode",
"exec",
"mcp",
"unknown",
"user",
"codex-auto-review",
r#"{"custom":"chatgpt"}"#,
r#"{"custom":"codex-auto-review"}"#,
] {
assert!(!source_text_is_subagent(source), "{source}");
}
for source in ["subagent", "guardian_review", "memory_consolidation"] {
assert!(thread_source_is_internal(source));
}
assert!(!thread_source_is_internal("future_feature"));
}
#[test]
fn sqlite_internal_classification_keeps_edges_and_all_current_internal_sources() {
let dir = temp_codex_dir("current-internal-sources");
let conn = create_identity_database(&dir.join("state_10.sqlite"));
conn.execute_batch(
r#"INSERT INTO threads VALUES
('parent', 'user', 'cli'),
('empty-user', NULL, NULL),
('user-fork', 'user', 'vscode'),
('guardian', 'guardian_review', 'exec'),
('memory', 'memory_consolidation', NULL),
('internal-source', 'user', '{"internal":"guardian"}'),
('edge-cli', NULL, 'cli'),
('edge-user', 'user', 'cli'),
('edge-future', 'future_feature', 'unknown'),
('review-display', NULL, 'subagent_review');
INSERT INTO thread_spawn_edges VALUES
('parent', 'edge-cli'), ('parent', 'edge-user'), ('parent', 'edge-future');"#,
)
.unwrap();
let cols = table_column_set(&conn, "threads").unwrap();
let internal = sqlite_subagent_thread_ids(&conn, &cols).unwrap();
assert_eq!(
internal,
[
"guardian",
"memory",
"internal-source",
"edge-cli",
"edge-user",
"edge-future",
"review-display"
]
.into_iter()
.map(String::from)
.collect()
);
drop(conn);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rollout_identity_streams_large_headers_and_ignores_replayed_parent_metadata() {
let dir = temp_codex_dir("rollout-internal-identities");
fs::create_dir_all(dir.join("sessions")).unwrap();
let large_instructions = "x".repeat(3 * 1024 * 1024);
let internal = serde_json::json!({"type":"session_meta","payload":{"id":"internal","source":{"internal":"guardian"},"base_instructions":{"text":large_instructions}}}).to_string();
fs::write(
dir.join("sessions/rollout-test-internal.jsonl"),
format!("{internal}\nnot even JSON body\n"),
)
.unwrap();
let fork = serde_json::json!({"type":"session_meta","payload":{"id":"fork","source":"vscode","thread_source":"user","forked_from_id":"internal"}}).to_string();
let fork_text = format!("{fork}\n{internal}\n");
fs::write(dir.join("sessions/rollout-test-fork.jsonl"), &fork_text).unwrap();
assert!(rollout_text_is_internal(&internal));
assert!(!rollout_text_is_internal(&fork_text));
let (ids, failures) = rollout_internal_thread_ids(&dir, &HashMap::new());
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(ids, HashSet::from(["internal".to_string()]));
let scan =
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
assert_eq!(scan.internal_thread_ids, ids);
assert!(scan.thread_ids.contains("fork"));
assert!(!scan.thread_ids.contains("internal"));
assert_eq!(scan.changes.len(), 1);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn reverted_rollout_filenames_match_the_thread_not_the_rollout_id() {
let thread = "019f6000-0000-7000-8000-000000000901";
let rollout = "019f6000-0000-7000-8000-000000000902";
for extension in ["jsonl", "jsonl.zst"] {
let ordinary =
PathBuf::from(format!("rollout-2026-09-17T13-10-16-{thread}.{extension}"));
let reverted = PathBuf::from(format!(
"rollout-2026-09-17T13-10-16-{thread}_{rollout}.{extension}"
));
let other_thread =
PathBuf::from(format!("rollout-2026-09-17T13-10-16-{rollout}.{extension}"));
assert_eq!(rollout_filename_thread_id(&ordinary), Some(thread));
assert_eq!(rollout_filename_thread_id(&reverted), Some(thread));
assert!(rollout_filename_matches_id(&ordinary, thread));
assert!(rollout_filename_matches_id(&reverted, thread));
assert!(!rollout_filename_matches_id(&reverted, rollout));
assert!(!rollout_filename_matches_id(
&reverted,
&format!("{thread}_{rollout}")
));
assert!(!rollout_filename_matches_id(&other_thread, thread));
assert!(rollout_filename_matches_id(&other_thread, rollout));
let legacy_prefix = PathBuf::from(format!("rollout-custom_label-{thread}.{extension}"));
assert_eq!(rollout_filename_thread_id(&legacy_prefix), Some(thread));
}
assert!(rollout_filename_matches_id(
Path::new("rollout-test-legacy-id.jsonl"),
"legacy-id"
));
}
#[test]
fn reverted_rollout_identity_preserves_strict_metadata_and_internal_checks() {
let dir = temp_codex_dir("reverted-rollout-identity");
let thread = "019f6000-0000-7000-8000-000000000911";
let rollout = "019f6000-0000-7000-8000-000000000912";
let path = dir.join(format!(
"rollout-2026-09-17T13-10-16-{thread}_{rollout}.jsonl"
));
let write_meta = |id: &str, source: Value| {
let record = serde_json::json!({"type":"session_meta", "payload":{
"id":id, "source":source, "thread_source":"user", "model_provider":"openai"
}});
fs::write(&path, format!("{record}\n")).unwrap();
};
write_meta(thread, serde_json::json!("vscode"));
assert_eq!(
read_rollout_identity(&path).unwrap().payload.id.as_deref(),
Some(thread)
);
assert!(rollout_path_has_syncable_identity(&path).unwrap());
for wrong_id in [rollout, "019f6000-0000-7000-8000-000000000913"] {
write_meta(wrong_id, serde_json::json!("vscode"));
assert!(read_rollout_identity(&path).is_err());
assert!(rollout_path_has_syncable_identity(&path).is_err());
}
write_meta(thread, serde_json::json!({"internal":"guardian"}));
assert!(read_rollout_identity(&path).unwrap().payload.is_internal());
assert!(!rollout_path_has_syncable_identity(&path).unwrap());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rollout_identity_rejects_wrong_thread_id_and_malformed_headers() {
let dir = temp_codex_dir("rollout-unknown-identities");
fs::create_dir_all(dir.join("sessions")).unwrap();
fs::write(dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000301.jsonl"), r#"{"type":"session_meta","payload":{"id":"019f6000-0000-7000-8000-000000000302","source":{"internal":"guardian"}}}"#).unwrap();
fs::write(dir.join("sessions/rollout-test-broken.jsonl"), "{broken").unwrap();
fs::write(
dir.join("sessions/rollout-test-missing.jsonl"),
r#"{"type":"session_meta","payload":{"source":{"internal":"guardian"}}}"#,
)
.unwrap();
let (ids, failures) = rollout_internal_thread_ids(&dir, &HashMap::new());
assert!(ids.is_empty());
assert_eq!(failures.len(), 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn current_rollout_authority_ignores_a_stale_internal_copy_of_the_same_thread() {
let dir = temp_codex_dir("rollout-authoritative-classification");
fs::create_dir_all(dir.join("sessions")).unwrap();
let current = dir.join("sessions/rollout-current.jsonl");
let copy = dir.join("sessions/rollout-copy-shared.jsonl");
let normal_copy = dir.join("sessions/rollout-normal-copy-shared.jsonl");
let normal = r#"{"type":"session_meta","payload":{"id":"shared","source":"vscode","model_provider":"openai"}}"#;
fs::write(&current, normal).unwrap();
fs::write(&normal_copy, normal).unwrap();
fs::write(&copy, r#"{"type":"session_meta","payload":{"id":"shared","source":{"internal":"guardian"},"model_provider":"openai"}}"#).unwrap();
let authority = HashMap::from([("shared".to_string(), current.display().to_string())]);
let (ids, failures) = rollout_internal_thread_ids(&dir, &authority);
assert!(ids.is_empty());
assert!(failures.is_empty());
let scan = scan_provider_rollouts(&dir, "custom", &HashSet::new(), &authority).unwrap();
assert!(scan.internal_thread_ids.is_empty());
assert_eq!(scan.thread_ids, HashSet::from(["shared".to_string()]));
assert_eq!(scan.changes.len(), 2);
assert_eq!(
scan.provider_candidate_paths,
HashSet::from([current, normal_copy])
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rollout_internal_identity_keeps_previews_and_sync_candidates_consistent() {
let dir = temp_codex_dir("internal-preview-alignment");
let database = dir.join("state_10.sqlite");
create_thread_database(&database, "internal", "openai");
let conn = Connection::open(&database).unwrap();
conn.execute_batch("INSERT INTO threads VALUES ('parent', 'openai', 'Parent', 3), ('empty-user', 'openai', NULL, 2), ('user-named-review', 'openai', 'codex-auto-review', 1);").unwrap();
drop(conn);
let rollouts = RolloutScan {
internal_thread_ids: HashSet::from(["internal".to_string()]),
..RolloutScan::default()
};
let paths = [database];
let sqlite = scan_sqlite_with_paths(&paths, &rollouts, "custom").unwrap();
assert_eq!(sqlite.subagent_threads, 1);
assert!(!sqlite.syncable_thread_ids.contains("internal"));
assert!(!sqlite.mismatched_thread_ids.contains("internal"));
for id in ["parent", "empty-user", "user-named-review"] {
assert!(sqlite.syncable_thread_ids.contains(id));
}
let (previews, failures) =
list_session_previews_with_paths(&paths, &rollouts, "custom", 100).unwrap();
assert!(failures.is_empty());
for preview in previews {
assert_eq!(preview.is_subagent, preview.id == "internal");
assert_eq!(preview.needs_sync, preview.id != "internal");
}
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn internal_tasks_do_not_exhaust_the_preview_limit_before_user_conversations() {
let dir = temp_codex_dir("preview-limit-keeps-users");
let database = dir.join("state_10.sqlite");
create_thread_database(&database, "older-parent", "openai");
let conn = Connection::open(&database).unwrap();
conn.execute_batch("ALTER TABLE threads ADD COLUMN source TEXT;
INSERT INTO threads VALUES ('new-internal-one', 'openai', 'Internal', 100, 'internal_guardian'),
('new-internal-two', 'openai', 'Internal', 99, 'subagent_review'),
('new-internal-three', 'openai', 'Internal', 98, 'subagent'),
('older-user-fork', 'openai', NULL, 2, 'vscode');").unwrap();
drop(conn);
let (previews, warnings) =
list_session_previews_with_paths(&[database], &RolloutScan::default(), "custom", 2)
.unwrap();
assert!(warnings.is_empty());
assert_eq!(
previews
.iter()
.map(|preview| preview.id.as_str())
.collect::<Vec<_>>(),
vec!["older-user-fork", "older-parent"]
);
assert!(previews
.iter()
.all(|preview| !preview.is_subagent && preview.needs_sync));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn usage_thread_identity_preserves_current_internal_evidence_despite_user_label() {
let dir = temp_codex_dir("usage-identities-source");
let path = dir.join("state_10.sqlite");
let conn = create_identity_database(&path);
@@ -1669,6 +2265,7 @@ mod tests {
is_subagent: true,
parent_id: Some("parent".into()),
parent_conflict: false,
rollout_path: None,
}
);
assert_eq!(identities["source"], identities["edge"]);
@@ -1679,19 +2276,21 @@ mod tests {
is_subagent: true,
parent_id: None,
parent_conflict: false,
rollout_path: None,
}
);
assert_eq!(identities["source-only"], identities["thread-source"]);
assert_eq!(identities["user-override"], identities["edge"]);
assert_eq!(
identities["user-override"],
identities["parent"],
UsageThreadIdentity {
classification_known: true,
is_subagent: false,
parent_id: None,
parent_conflict: false,
rollout_path: None,
}
);
assert_eq!(identities["parent"], identities["user-override"]);
assert_eq!(fs::read(&path).unwrap(), before);
fs::remove_dir_all(dir).unwrap();
}
@@ -1718,6 +2317,7 @@ mod tests {
classification_known: false,
parent_id: None,
parent_conflict: false,
rollout_path: None,
}
);
}
@@ -1750,6 +2350,7 @@ mod tests {
is_subagent: false,
parent_id: None,
parent_conflict: false,
rollout_path: None,
}
);
assert_eq!(
@@ -1783,6 +2384,7 @@ mod tests {
is_subagent: true,
parent_id: None,
parent_conflict: true,
rollout_path: None,
}
);
assert_eq!(identities["legacy"], identities["conflict"]);
+321 -14
View File
@@ -1,9 +1,9 @@
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
scan_sqlite_with_paths, SqliteDiscovery,
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
};
use super::transaction::{
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
@@ -85,16 +85,22 @@ fn scan_provider_sync_data(
target_provider: &str,
discovery: &SqliteDiscovery,
) -> Result<ProviderSyncScan> {
let active_sqlite = scan_sqlite_with_paths(
let active_unclassified = scan_sqlite_with_paths(
&discovery.active_paths,
&RolloutScan::default(),
target_provider,
)?;
let mut sqlite = scan_sqlite_with_paths(
&discovery.thread_paths,
&RolloutScan::default(),
target_provider,
)?;
let (mut internal_thread_ids, _classification_failures) =
rollout_internal_thread_ids(codex_dir, &active_unclassified.rollout_paths_by_thread_id);
internal_thread_ids.extend(scan_catalog_internal_thread_ids(&discovery.related_paths)?);
let classification = RolloutScan {
internal_thread_ids,
..RolloutScan::default()
};
let active_sqlite =
scan_sqlite_with_paths(&discovery.active_paths, &classification, target_provider)?;
let mut sqlite =
scan_sqlite_with_paths(&discovery.thread_paths, &classification, target_provider)?;
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
@@ -111,6 +117,7 @@ fn scan_provider_sync_data(
}
let mut excluded_thread_ids = archived_thread_ids.clone();
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
excluded_thread_ids.extend(classification.internal_thread_ids.iter().cloned());
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
@@ -126,9 +133,30 @@ fn scan_provider_sync_data(
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
};
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
let mut rollouts = scan_provider_rollouts(
codex_dir,
target_provider,
&excluded_thread_ids,
&active_unclassified.rollout_paths_by_thread_id,
)?;
// Provider synchronization changes provider routing only; cwd remains independently managed.
rollouts.cwd_by_thread_id.clear();
// Rollout metadata can reveal an internal thread even when the SQLite row
// has no source column (or an older index still calls it a user thread).
excluded_thread_ids.extend(rollouts.internal_thread_ids.iter().cloned());
rollouts
.internal_thread_ids
.extend(classification.internal_thread_ids.iter().cloned());
sqlite
.subagent_thread_ids
.extend(rollouts.internal_thread_ids.iter().cloned());
sqlite
.syncable_thread_ids
.retain(|id| !excluded_thread_ids.contains(id));
sqlite
.mismatched_thread_ids
.retain(|id| !excluded_thread_ids.contains(id));
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
let catalog = scan_catalog_sync(
@@ -152,6 +180,8 @@ fn scan_provider_sync_data(
.cloned()
.collect::<HashSet<_>>();
let mut warnings = rollouts.warnings.clone();
// The full provider/index scans below own warnings and blocking failures;
// repeating the lightweight identity scan's messages would duplicate them.
warnings.extend(active_sqlite.warnings.iter().cloned());
warnings.extend(sqlite.warnings.iter().cloned());
warnings.extend(legacy_index_warnings);
@@ -247,9 +277,11 @@ pub(super) fn session_sync_status_with_discovery(
let mut mismatched_ids = sqlite_mismatch_ids.clone();
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
for session in &mut sessions {
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
session.needs_sync = true;
}
// The same eligibility set drives both UI status and every mutation path.
session.needs_sync = !session.archived
&& !session.is_subagent
&& scan.syncable_thread_ids.contains(&session.id)
&& mismatched_ids.contains(&session.id);
}
let needs_sync = !scan.rollouts.changes.is_empty()
|| scan.sqlite.mismatched_threads > 0
@@ -672,6 +704,176 @@ mod tests {
path
}
fn rows_except_parent(
path: &Path,
table: &str,
id_column: &str,
parent: &str,
) -> Vec<Vec<rusqlite::types::Value>> {
let conn = Connection::open(path).expect("open snapshot database");
let mut stmt = conn
.prepare(&format!(
"SELECT * FROM {table} WHERE {id_column} <> ?1 ORDER BY {id_column}"
))
.expect("prepare row snapshot");
let column_count = stmt.column_count();
stmt.query_map([parent], |row| {
(0..column_count).map(|index| row.get(index)).collect()
})
.expect("read row snapshots")
.collect::<std::result::Result<Vec<_>, _>>()
.expect("collect row snapshots")
}
#[test]
fn internal_rollout_evidence_prevents_catalog_reactivation_and_insertion() {
let codex_dir = temp_codex_dir("internal-catalog-regression");
write_config(&codex_dir, "custom");
let parent = "019f6000-0000-7000-8000-000000000900";
let hidden = "019f6000-0000-7000-8000-000000000901";
let missing = "019f6000-0000-7000-8000-000000000902";
let catalog_only = "019f6000-0000-7000-8000-000000000903";
let source_only = "019f6000-0000-7000-8000-000000000904";
let parent_rollout = write_rollout(&codex_dir, parent, "openai");
let state = codex_dir.join("state_10.sqlite");
create_thread_database_with_rollout(&state, parent, "openai", &parent_rollout);
let conn = Connection::open(&state).expect("open regression index");
conn.execute_batch(
"ALTER TABLE threads ADD COLUMN source TEXT;
ALTER TABLE threads ADD COLUMN thread_source TEXT;
ALTER TABLE threads ADD COLUMN parent_thread_id TEXT;
ALTER TABLE threads ADD COLUMN cwd TEXT;",
)
.expect("extend source metadata");
conn.execute("UPDATE threads SET title = ?1, source = 'cli', thread_source = 'user', cwd = '/tmp/project' WHERE id = ?2",
("Review this error: The following is the Codex agent history whose request action you are assessing", parent))
.expect("set genuine parent title");
let mut internal_files = Vec::new();
for (id, rollout_source, sqlite_source) in [
(hidden, serde_json::json!({"internal": "guardian"}), "cli"),
(
missing,
serde_json::json!({"subagent": {"review": null}}),
"cli",
),
(catalog_only, serde_json::json!("cli"), "cli"),
(source_only, serde_json::json!("cli"), "subagent_review"),
] {
let path = write_rollout(&codex_dir, id, "openai");
let record = serde_json::json!({"type":"session_meta", "payload":{
"id": id, "model_provider":"openai", "source":rollout_source, "cwd":"/tmp/private"
}});
fs::write(&path, format!("{record}\n")).expect("write source metadata");
conn.execute("INSERT INTO threads (id, model_provider, title, rollout_path, source, thread_source, cwd)
VALUES (?1, 'openai', 'codex-auto-review', ?2, ?3, NULL, '/tmp/private')",
(id, path.display().to_string(), sqlite_source)).expect("insert internal source");
internal_files.push((
path.clone(),
fs::read(&path).expect("snapshot internal rollout"),
));
}
drop(conn);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[
(parent, "openai"),
(hidden, "openai"),
(catalog_only, "openai"),
],
);
let conn = Connection::open(&catalog).expect("open regression catalog");
conn.execute(
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
[hidden],
)
.expect("keep review hidden");
conn.execute("UPDATE local_thread_catalog SET source_kind = 'internal', thread_source = 'guardian_review', missing_candidate = 1 WHERE thread_id = ?1", [catalog_only])
.expect("mark catalog-only internal source");
drop(conn);
let before_threads = rows_except_parent(&state, "threads", "id", parent);
let before_catalog =
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan mixed sources");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.mismatched_sessions, 1, "{status:?}");
assert!(
status
.sessions
.iter()
.find(|session| session.id == parent)
.expect("parent preview")
.needs_sync
);
for session in status
.sessions
.iter()
.filter(|session| session.id != parent)
{
assert!(session.is_subagent, "internal preview {}", session.id);
assert!(
!session.needs_sync,
"internal must not be pending {}",
session.id
);
}
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync only parent");
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&state, parent), "custom");
assert_eq!(catalog_provider(&catalog, parent), "custom");
assert_eq!(
rows_except_parent(&state, "threads", "id", parent),
before_threads
);
assert_eq!(
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent),
before_catalog
);
assert!(catalog_provider_and_visibility(&catalog, missing).is_none());
assert!(catalog_provider_and_visibility(&catalog, source_only).is_none());
for (path, original) in internal_files {
assert_eq!(
fs::read(path).expect("read preserved internal rollout"),
original
);
}
fs::remove_dir_all(codex_dir).expect("remove regression fixture");
}
#[test]
fn authoritative_user_rollout_is_not_hidden_by_an_internal_duplicate() {
let codex_dir = temp_codex_dir("user-internal-duplicate");
write_config(&codex_dir, "custom");
let id = "019f6000-0000-7000-8000-000000000905";
let active = codex_dir.join("sessions/rollout-active-name.jsonl");
write_rollout_at(&active, id, "openai");
let stale = write_subagent_rollout(&codex_dir, id, "old-parent", "openai");
let stale_bytes = fs::read(&stale).expect("snapshot stale internal duplicate");
let database = codex_dir.join("state_10.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &active);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan authoritative user");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.subagent_threads, 0);
assert_eq!(status.mismatched_sessions, 1);
assert!(!status.sessions[0].is_subagent);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync authoritative user");
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, id), "custom");
assert_eq!(catalog_provider(&catalog, id), "custom");
assert_eq!(
fs::read(stale).expect("read untouched internal duplicate"),
stale_bytes
);
fs::remove_dir_all(codex_dir).expect("remove duplicate fixture");
}
#[test]
fn live_provider_is_used_as_the_sync_target() {
let codex_dir = temp_codex_dir("live-provider-target");
@@ -860,6 +1062,111 @@ mod tests {
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn reverted_rollout_sync_uses_stable_thread_id_and_keeps_internal_threads_hidden() {
let codex_dir = temp_codex_dir("reverted-rollout-sync");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let parent = "019f6000-0000-7000-8000-000000000920";
let parent_rollout_id = "019f6000-0000-7000-8000-000000000921";
let internal = "019f6000-0000-7000-8000-000000000922";
let internal_rollout_id = "019f6000-0000-7000-8000-000000000923";
let parent_path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-16-{parent}_{parent_rollout_id}.jsonl"
));
write_rollout_at(&parent_path, parent, "openai");
let internal_path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-17-{internal}_{internal_rollout_id}.jsonl"
));
let internal_metadata = serde_json::json!({"type":"session_meta","payload":{
"id":internal, "model_provider":"openai", "source":{"internal":"guardian"}
}});
fs::write(&internal_path, format!("{internal_metadata}\n"))
.expect("write reverted internal rollout");
let internal_bytes = fs::read(&internal_path).expect("snapshot internal rollout");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, parent, "openai", &parent_path);
Connection::open(&database).expect("open reverted thread index").execute(
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'internal review', ?2)",
(internal, internal_path.display().to_string()),
).expect("insert internal thread without database source markers");
let before_internal = rows_except_parent(&database, "threads", "id", parent);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("check reverted rollouts");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status
.warnings
.iter()
.all(|warning| !warning.contains("线程 ID 不一致")));
assert_eq!(status.mismatched_sessions, 1);
assert_eq!(status.subagent_threads, 1);
for preview in &status.sessions {
assert_eq!(preview.is_subagent, preview.id == internal);
assert_eq!(preview.needs_sync, preview.id == parent);
}
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("synchronize reverted user rollout through transaction guards");
assert!(result.status.scan_complete);
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, parent), SHARED_SESSION_PROVIDER);
assert_eq!(catalog_provider(&catalog, parent), SHARED_SESSION_PROVIDER);
for excluded in [parent_rollout_id, internal, internal_rollout_id] {
assert!(catalog_provider_and_visibility(&catalog, excluded).is_none());
}
assert_eq!(
rows_except_parent(&database, "threads", "id", parent),
before_internal
);
assert_eq!(
fs::read(&internal_path).expect("read unchanged internal rollout"),
internal_bytes
);
let record: serde_json::Value = serde_json::from_str(
fs::read_to_string(&parent_path)
.expect("read synchronized parent")
.trim(),
)
.expect("parse synchronized parent metadata");
assert_eq!(record["payload"]["id"], parent);
assert_eq!(record["payload"]["model_provider"], SHARED_SESSION_PROVIDER);
fs::remove_dir_all(codex_dir).expect("remove reverted rollout fixture");
}
#[test]
fn reverted_rollout_cannot_be_indexed_by_its_distinct_rollout_id() {
let codex_dir = temp_codex_dir("reverted-rollout-wrong-index-id");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let thread_id = "019f6000-0000-7000-8000-000000000924";
let rollout_id = "019f6000-0000-7000-8000-000000000925";
let path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-16-{thread_id}_{rollout_id}.jsonl"
));
write_rollout_at(&path, thread_id, "openai");
let original = fs::read(&path).expect("snapshot original rollout");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, rollout_id, "openai", &path);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("check mismatched reverted rollout reference");
assert!(!status.scan_complete);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("线程 ID 不一致")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("rollout ID must not substitute for its stable thread ID");
assert_eq!(thread_provider(&database, rollout_id), "openai");
assert_eq!(fs::read(&path).expect("read protected rollout"), original);
assert!(catalog_provider_and_visibility(&catalog, thread_id).is_none());
assert!(catalog_provider_and_visibility(&catalog, rollout_id).is_none());
fs::remove_dir_all(codex_dir).expect("remove invalid reverted reference fixture");
}
#[test]
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
@@ -1125,7 +1432,7 @@ mod tests {
assert_eq!(result.updated_rollouts, 1);
assert!(result.updated_threads > 0);
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&legacy, id), "openai");
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
assert!(fs::read_to_string(rollout)
.expect("read synchronized active user rollout")
@@ -2,7 +2,10 @@ use super::catalog::{
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
restore_catalog_updates, CatalogRepairThread,
};
use super::storage::{apply_session_changes, restore_session_changes};
use super::storage::{
apply_session_changes, restore_session_changes, rollout_path_has_syncable_identity,
rollout_text_is_internal, sqlite_subagent_thread_ids,
};
use super::types::{RolloutScan, SessionFileChange};
use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
@@ -182,6 +185,11 @@ fn apply_sqlite_updates(
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
sorted_thread_ids.sort();
for update in pending.iter_mut() {
let internal_ids = if update.thread_columns.contains("id") {
sqlite_subagent_thread_ids(&update.conn, &update.thread_columns)?
} else {
HashSet::new()
};
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
{
let archived_filter = if update.thread_columns.contains("archived") {
@@ -203,6 +211,9 @@ fn apply_sqlite_updates(
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
);
for thread_id in &sorted_thread_ids {
if internal_ids.contains(*thread_id) {
continue;
}
update
.conn
.execute(&snapshot_sql, (target_provider, thread_id))
@@ -234,7 +245,7 @@ fn apply_sqlite_updates(
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
);
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
if !syncable_thread_ids.contains(thread_id) {
if !syncable_thread_ids.contains(thread_id) || internal_ids.contains(thread_id) {
continue;
}
update
@@ -247,12 +258,16 @@ fn apply_sqlite_updates(
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
let local_syncable_ids = syncable_thread_ids
.difference(&internal_ids)
.cloned()
.collect();
let catalog_counts = apply_catalog_updates(
&update.conn,
&update.catalog_columns,
target_provider,
catalog_sources,
syncable_thread_ids,
&local_syncable_ids,
)?;
update.counts.provider_rows += catalog_counts.provider_rows;
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
@@ -402,6 +417,8 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MutationPoint {
BeforeSqliteLock,
BeforeRolloutMutation,
AfterRolloutMutation,
AfterSqliteCommit(usize),
}
@@ -411,6 +428,30 @@ pub(super) struct MutationResult {
pub(super) sqlite_updates: SqliteUpdateCounts,
}
// A file may be reclassified after scanning. In particular, the general file
// writer skips concurrently changed rollouts; that must not leave their old IDs
// eligible for a catalog insert or a provider update in this transaction.
fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
let mut paths = rollouts.provider_candidate_paths.clone();
for change in &rollouts.changes {
if rollout_text_is_internal(&change.original_text) {
return Err(CodexxError::Config(
"内部会话不能同步为普通会话。".to_string(),
));
}
paths.insert(change.path.clone());
}
for path in paths {
if !rollout_path_has_syncable_identity(&path)? {
return Err(CodexxError::Config(format!(
"会话类型已变化,已停止同步;请重新检查会话:{}",
path.display()
)));
}
}
Ok(())
}
pub(super) fn execute_provider_sync_mutation<F>(
rollouts: &RolloutScan,
pending_sqlite: &mut [PendingSqliteUpdate],
@@ -424,8 +465,12 @@ where
F: FnMut(MutationPoint) -> Result<()>,
{
let result = (|| -> Result<MutationResult> {
hook(MutationPoint::BeforeRolloutMutation)?;
validate_rollout_classification(rollouts)?;
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
journal.applied_rollouts = applied_rollouts;
hook(MutationPoint::AfterRolloutMutation)?;
validate_rollout_classification(rollouts)?;
apply_sqlite_updates(
pending_sqlite,
rollouts,
@@ -452,3 +452,89 @@ fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
drop(wal_guard);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn internal_reclassification_before_mutation_aborts_without_registering_or_rewriting() {
for existing_provider in ["openai", "custom"] {
let codex_dir = temp_dir("internal-before-mutation");
let id = "019f6000-0000-7000-8000-000000000980";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let existing = String::from_utf8(write_rollout(&rollout, id))
.expect("UTF-8 fixture")
.replace("openai", existing_provider);
fs::write(&rollout, existing).expect("set current rollout provider");
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let internal = format!(
"{}\n",
serde_json::json!({"type":"session_meta", "payload":{
"id":id, "model_provider":existing_provider, "source":{"internal":"guardian"}
}})
);
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
None,
|point| {
if point == MutationPoint::BeforeRolloutMutation {
fs::write(&rollout, &internal).expect("reclassify rollout during sync");
}
Ok(())
},
)
.expect_err("reclassification must abort sync");
assert!(error.to_string().contains("会话类型已变化"), "{error}");
assert_eq!(thread_provider(&database, id), "openai");
assert_eq!(
fs::read_to_string(&rollout).expect("read internal rollout"),
internal
);
fs::remove_dir_all(codex_dir).expect("remove race fixture");
}
}
#[test]
fn internal_reclassification_after_file_writes_rolls_back_other_files_and_all_databases() {
let codex_dir = temp_dir("internal-after-rollout-mutation");
let parent = "019f6000-0000-7000-8000-000000000981";
let child = "019f6000-0000-7000-8000-000000000982";
let parent_rollout = codex_dir.join(format!("sessions/rollout-test-{parent}.jsonl"));
let child_rollout = codex_dir.join(format!("sessions/rollout-test-{child}.jsonl"));
let original_parent = write_rollout(&parent_rollout, parent);
write_rollout(&child_rollout, child);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, parent, &parent_rollout);
Connection::open(&database)
.expect("open fixture index")
.execute(
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
(child, child_rollout.display().to_string()),
)
.expect("insert second thread");
let internal = format!(
"{}\n",
serde_json::json!({"type":"session_meta", "payload":{
"id":child, "model_provider":"openai", "source":{"internal":"guardian"}
}})
);
let error =
sync_sessions_provider_with_hook(Some(codex_dir.display().to_string()), None, |point| {
if point == MutationPoint::AfterRolloutMutation {
fs::write(&child_rollout, &internal)
.expect("publish internal metadata during sync");
}
Ok(())
})
.expect_err("reclassification must prevent database commits");
assert!(error.to_string().contains("会话类型已变化"), "{error}");
assert_eq!(thread_provider(&database, parent), "openai");
assert_eq!(thread_provider(&database, child), "openai");
assert_eq!(
fs::read(&parent_rollout).expect("read restored parent"),
original_parent
);
assert_eq!(
fs::read_to_string(&child_rollout).expect("preserve newer internal source"),
internal
);
fs::remove_dir_all(codex_dir).expect("remove rollback fixture");
}
@@ -59,8 +59,12 @@ pub(crate) struct RolloutScan {
pub(crate) mismatched_rollouts: usize,
pub(crate) mismatched_session_meta: usize,
pub(crate) changes: Vec<SessionFileChange>,
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
pub(crate) cwd_by_thread_id: HashMap<String, String>,
pub(crate) thread_ids: HashSet<String>,
/// Non-user threads identified from their own rollout metadata. Retained
/// even when the rollout is excluded from provider synchronization.
pub(crate) internal_thread_ids: HashSet<String>,
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
+3 -1
View File
@@ -201,6 +201,7 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
let auth = auth_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let doc = parse_toml_document(&cfg, &text)?;
let doc = crate::failover::direct_document(&codex_dir, &doc)?;
let model = string_value(&doc, "model");
let model_provider = string_value(&doc, "model_provider");
let is_official_provider = document_is_official(&doc);
@@ -235,7 +236,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
)?;
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
} else {
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
let candidates =
matching_saved_provider_ids_from_config(&doc.to_string(), &saved_providers);
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
};
+27 -7
View File
@@ -4571,7 +4571,8 @@ fn session_previews_return_subagents_with_explicit_marker() {
let root_b = "019f6000-0000-7000-8000-000000000002";
let child = "019f6000-0000-7000-8000-000000000003";
let orphan_subagent = "019f6000-0000-7000-8000-000000000004";
let forked_user = "019f6000-0000-7000-8000-000000000005";
let mislabeled_child = "019f6000-0000-7000-8000-000000000005";
let forked_user = "019f6000-0000-7000-8000-000000000006";
let rollout = codex_dir.join("sessions/rollout.jsonl");
seed_thread_database(
&database,
@@ -4579,6 +4580,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
(root_a, &rollout),
(root_b, &rollout),
(child, &rollout),
(mislabeled_child, &rollout),
(forked_user, &rollout),
],
Some((root_a, child)),
@@ -4588,6 +4590,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
"ALTER TABLE threads ADD COLUMN title TEXT;
ALTER TABLE threads ADD COLUMN source TEXT;
ALTER TABLE threads ADD COLUMN thread_source TEXT;
ALTER TABLE threads ADD COLUMN forked_from_id TEXT;
UPDATE threads SET title = 'same title';",
)
.expect("extend thread schema");
@@ -4598,14 +4601,19 @@ fn session_previews_return_subagents_with_explicit_marker() {
.expect("mark child subagent");
conn.execute(
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
[forked_user],
[mislabeled_child],
)
.expect("mark forked user thread");
.expect("seed generic user label on an actual spawned child");
conn.execute(
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id) VALUES (?1, ?2)",
(root_a, mislabeled_child),
)
.expect("insert actual child spawn edge");
conn.execute(
"UPDATE threads SET thread_source = 'user', source = 'cli', forked_from_id = ?1 WHERE id = ?2",
(root_a, forked_user),
)
.expect("insert user fork edge");
.expect("mark a real user fork without a spawn edge");
conn.execute(
"INSERT INTO threads (id, model_provider, rollout_path, title, source)
VALUES (?1, 'openai', ?2, 'same title', ?3)",
@@ -4620,14 +4628,25 @@ fn session_previews_return_subagents_with_explicit_marker() {
let rollouts = scan_rollouts(&codex_dir, "openai").expect("scan rollouts");
let scan = scan_sqlite(&codex_dir, &rollouts, "openai").expect("scan sqlite");
assert_eq!(scan.sqlite_threads, 5);
assert_eq!(scan.sqlite_threads, 6);
assert_eq!(scan.top_level_threads, 3);
assert_eq!(scan.subagent_threads, 2);
assert_eq!(scan.subagent_threads, 3);
assert!(scan.syncable_thread_ids.contains(forked_user));
assert!(!scan.syncable_thread_ids.contains(mislabeled_child));
let (previews, warnings) =
list_session_previews(&codex_dir, &rollouts, "openai", 50).expect("list previews");
assert!(warnings.is_empty());
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 2);
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 3);
for preview in &previews {
assert_eq!(
preview.is_subagent,
[child, orphan_subagent, mislabeled_child].contains(&preview.id.as_str())
);
if preview.is_subagent {
assert!(!preview.needs_sync);
}
}
assert_eq!(
previews
.into_iter()
@@ -4638,6 +4657,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
root_b.to_string(),
child.to_string(),
orphan_subagent.to_string(),
mislabeled_child.to_string(),
forked_user.to_string(),
])
);
+147 -12
View File
@@ -219,7 +219,7 @@ impl<'de> Visitor<'de> for UsageSourceSeed {
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
Ok(UsageSource {
is_subagent: self.0 == 0 && value.trim().eq_ignore_ascii_case("subagent"),
is_subagent: self.0 == 0 && crate::sessions::source_kind_is_internal(value),
parent: (self.0 == 3).then(|| value.to_owned()),
})
}
@@ -231,7 +231,10 @@ impl<'de> Visitor<'de> for UsageSourceSeed {
const PATH: [&str; 3] = ["subagent", "thread_spawn", "parent_thread_id"];
let mut source = UsageSource::default();
while let Some(key) = map.next_key::<String>()? {
if PATH.get(self.0).copied() == Some(key.as_str()) {
if self.0 == 0 && key == "internal" {
map.next_value::<IgnoredAny>()?;
source.is_subagent = true;
} else if PATH.get(self.0).copied() == Some(key.as_str()) {
let child = map.next_value_seed(UsageSourceSeed(self.0 + 1))?;
source.is_subagent |= self.0 == 0;
source.parent = child.parent;
@@ -286,6 +289,7 @@ struct LogPayload {
thread_id: Option<String>,
forked_from_id: Option<String>,
source: Option<UsageSource>,
thread_source: Option<String>,
cwd: Option<String>,
model: Option<String>,
info: Option<TokenInfo>,
@@ -413,7 +417,11 @@ impl ParsedLog {
self.is_subagent = payload
.source
.as_ref()
.is_some_and(|source| source.is_subagent);
.is_some_and(|source| source.is_subagent)
|| payload
.thread_source
.as_deref()
.is_some_and(crate::sessions::thread_source_is_internal);
self.replays_parent = forked.is_some();
self.spawn_parent = spawned.clone();
self.invalid_spawn_parent = self.id.is_some() && self.id == self.spawn_parent;
@@ -857,6 +865,7 @@ struct SessionLog {
fn combine_files(
cache: &DirectoryCache,
coverage: &mut UsageCoverage,
indexed_identities: &HashMap<String, crate::sessions::UsageThreadIdentity>,
) -> BTreeMap<String, SessionLog> {
let mut sessions: BTreeMap<String, SessionLog> = BTreeMap::new();
let mut issues = ParseIssues::default();
@@ -907,7 +916,38 @@ fn combine_files(
}
session.events.extend(parsed.events.iter().cloned());
}
for session in sessions.values_mut() {
let mut canonical_files: Option<HashMap<PathBuf, &CachedFile>> = None;
for (id, session) in &mut sessions {
// Keep token records from every copy, but use the current database's
// actual rollout for identity. A stale archived/internal copy with the
// same ID must not hide a user conversation or supply a different parent.
let authoritative = indexed_identities
.get(id)
.and_then(|identity| identity.rollout_path.as_ref())
.and_then(|path| {
cache.files.get(path).or_else(|| {
let canonical = path.canonicalize().ok()?;
canonical_files
.get_or_insert_with(|| {
cache
.files
.iter()
.filter_map(|(path, entry)| {
path.canonicalize().ok().map(|path| (path, entry))
})
.collect()
})
.get(&canonical)
.copied()
})
})
.map(|entry| &entry.parsed)
.filter(|parsed| parsed.meta_seen && parsed.id.as_ref() == Some(id));
if let Some(parsed) = authoritative {
session.is_subagent = parsed.is_subagent;
session.spawn_parent.clone_from(&parsed.spawn_parent);
session.invalid_spawn_parent = parsed.invalid_spawn_parent;
}
// The same rollout can coexist in sessions and archived_sessions, or
// have overlapping resume segments. Merge its token records once.
let mut seen = HashSet::new();
@@ -1104,7 +1144,7 @@ impl Bucket {
fn conversation_identities(
sessions: &BTreeMap<String, SessionLog>,
codex_dir: &Path,
indexed_identities: HashMap<String, crate::sessions::UsageThreadIdentity>,
) -> HashMap<String, crate::sessions::UsageThreadIdentity> {
let mut identities = sessions
.iter()
@@ -1116,18 +1156,24 @@ fn conversation_identities(
classification_known: session.is_subagent,
parent_id: session.spawn_parent.clone(),
parent_conflict: session.invalid_spawn_parent,
rollout_path: None,
},
)
})
.collect::<HashMap<_, _>>();
for (id, mut indexed) in crate::sessions::usage_thread_identities(codex_dir) {
for (id, mut indexed) in indexed_identities {
// Older indexes may have only id/title columns. Absence of a source
// marker must not erase a positive subagent classification in the log.
if !indexed.classification_known && identities.contains_key(&id) {
continue;
}
// The session manager's current SQLite classification is authoritative.
// A source-only subagent marker may still need its rollout's parent ID.
// A generic user/default database marker must not promote a thread
// whose own rollout explicitly identifies it as an internal task.
if identities.get(&id).is_some_and(|log| log.is_subagent) {
indexed.is_subagent = true;
indexed.classification_known = true;
}
// A source-only internal marker may still need its rollout's parent ID.
if indexed.is_subagent && indexed.parent_id.is_none() && !indexed.parent_conflict {
if let Some(log) = identities.get(&id) {
indexed.parent_id.clone_from(&log.parent_id);
@@ -1207,8 +1253,9 @@ where
.and_then(|date| midnight(&timezone, date))
.map(|time| time.with_timezone(&Utc));
let range_end = now.with_timezone(&Utc);
let sessions = combine_files(cache, &mut coverage);
let identities = conversation_identities(&sessions, codex_dir);
let indexed_identities = crate::sessions::usage_thread_identities(codex_dir);
let sessions = combine_files(cache, &mut coverage, &indexed_identities);
let identities = conversation_identities(&sessions, indexed_identities);
let mut total = Bucket::default();
let mut days: BTreeMap<NaiveDate, Bucket> = BTreeMap::new();
let mut models: BTreeMap<String, Bucket> = BTreeMap::new();
@@ -2044,7 +2091,7 @@ mod tests {
)
.unwrap();
let changed = fixture.stats("all", None);
assert_eq!(changed.sessions[0].id, "child");
assert_eq!(changed.sessions[0].id, "main");
assert_eq!(changed.totals.total_tokens, 55);
}
@@ -2089,7 +2136,95 @@ mod tests {
[],
)
.unwrap();
assert_eq!(fixture.stats("all", None).totals.session_count, 2);
assert_eq!(fixture.stats("all", None).totals.session_count, 1);
}
#[test]
fn guardian_and_memory_usage_stays_counted_without_creating_user_conversation_rows() {
let mut fixture = Fixture::new();
for (index, source) in [
json!({"internal":"guardian"}),
json!({"internal":"memory_consolidation"}),
json!("internal_guardian"),
json!("subagent_review"),
]
.into_iter()
.enumerate()
{
let id = format!("internal-{index}");
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
metadata["payload"]["source"] = source;
fixture.write(
&format!("sessions/{id}.jsonl"),
&[
metadata,
context("codex-auto-review"),
total("2026-09-08T01:00:01Z", 50, 5),
],
);
}
let stats = fixture.stats("all", None);
assert_eq!(stats.totals.total_tokens, 220);
assert_eq!(stats.totals.session_count, 0);
assert!(stats.sessions.is_empty());
}
#[test]
fn current_rollout_controls_classification_while_duplicate_usage_is_preserved() {
let mut fixture = Fixture::new();
let current = [
meta("main", "2026-09-08T01:00:00Z", None),
context("model"),
total("2026-09-08T01:00:01Z", 100, 10),
];
fixture.write("sessions/current.jsonl", &current);
let mut stale = current.to_vec();
stale[0]["payload"]["source"] = json!({"internal":"guardian"});
stale.push(total("2026-09-08T01:00:02Z", 150, 15));
fixture.write("archived_sessions/stale-copy.jsonl", &stale);
let db = rusqlite::Connection::open(fixture.dir.join("state_5.sqlite")).unwrap();
db.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, thread_source TEXT, source TEXT, rollout_path TEXT);
INSERT INTO threads VALUES ('main', 'User conversation', 'user', 'cli', 'sessions/current.jsonl');").unwrap();
let stats = fixture.stats("all", None);
assert_eq!(stats.totals.total_tokens, 165);
assert_eq!(stats.totals.session_count, 1);
assert_eq!(stats.sessions.len(), 1);
assert_eq!(stats.sessions[0].id, "main");
assert_eq!(stats.sessions[0].totals.total_tokens, 165);
// An explicit internal source in the authoritative file still wins
// over generic user/cli labels, even with a normal duplicate present.
db.execute("UPDATE threads SET rollout_path = 'archived_sessions/stale-copy.jsonl' WHERE id = 'main'", []).unwrap();
let internal = fixture.stats("all", None);
assert_eq!(internal.totals.total_tokens, 165);
assert_eq!(internal.totals.session_count, 0);
assert!(internal.sessions.is_empty());
}
#[test]
fn first_metadata_thread_source_hides_internal_tasks_without_database_markers() {
let mut fixture = Fixture::new();
for (index, source) in ["guardian_review", "memory_consolidation"]
.into_iter()
.enumerate()
{
let id = format!("internal-{index}");
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
metadata["payload"]["source"] = json!("cli");
metadata["payload"]["thread_source"] = json!(source);
fixture.write(
&format!("sessions/{id}.jsonl"),
&[
metadata,
context("model"),
total("2026-09-08T01:00:01Z", 50, 5),
],
);
}
let stats = fixture.stats("all", None);
assert_eq!(stats.totals.total_tokens, 110);
assert_eq!(stats.totals.session_count, 0);
assert!(stats.sessions.is_empty());
}
#[test]
+4 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.18",
"version": "0.3.19",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -35,6 +35,9 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"resources": {
"../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+137 -20
View File
@@ -2,6 +2,7 @@ import React from "react";
import { flushSync } from "react-dom";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react";
import {
SessionManagementPage,
@@ -757,6 +758,7 @@ function App() {
const [officialProfiles, setOfficialProfiles] = React.useState<OfficialProfileSummary[]>([]);
const [editingOfficialProfileId, setEditingOfficialProfileId] = React.useState<string | null>(DEFAULT_OFFICIAL_PROFILE_ID);
const [creatingProvider, setCreatingProvider] = React.useState(false);
const [providerCreationBase, setProviderCreationBase] = React.useState("");
const [selectedPresetId, setSelectedPresetId] = React.useState("custom");
const [selectedPresetVariantId, setSelectedPresetVariantId] = React.useState("");
const [officialAuthDirty, setOfficialAuthDirty] = React.useState(false);
@@ -797,6 +799,7 @@ function App() {
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
const [providerCommonConfigDirty, setProviderCommonConfigDirty] = React.useState(false);
const [providerDraftRefreshToken, setProviderDraftRefreshToken] = React.useState(0);
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
const [providerTestingId, setProviderTestingId] = React.useState("");
@@ -825,6 +828,7 @@ function App() {
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerModelsRequestRef = React.useRef(0);
const providerDraftRequestRef = React.useRef(0);
const providerCreationRequestRef = React.useRef(0);
const officialDraftRequestRef = React.useRef(0);
const officialProfilesRequestRef = React.useRef(0);
const officialProfilesLiveRef = React.useRef(officialProfiles);
@@ -853,6 +857,11 @@ function App() {
const skillsMcpAutoLoadAttemptedRef = React.useRef("");
const skillsMcpRequestRef = React.useRef(0);
const activeConfigDirKeyRef = React.useRef("");
const routingWakeRef = React.useRef<() => void>(() => {});
const routingRequestRefreshRef = React.useRef<() => void>(() => {});
const routingUiRef = React.useRef({ ready: false, editing: false });
routingUiRef.current = { ready: Boolean(state) && !refreshing, editing: providerMode !== "list" };
const themeTransitionTimerRef = React.useRef<number | null>(null);
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm), [providerForm]);
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
@@ -1391,6 +1400,7 @@ function App() {
setCreatingProvider(false);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderTomlDraft("");
setAvailableProviderModels([]);
setProviderModelsLoading(false);
@@ -1447,6 +1457,88 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const directory = configDir;
const scope = normalizedConfigDirForComparison(directory);
if (!scope) return;
let disposed = false;
let inFlight = false;
let pending = false;
let eventRevision = 0;
let unlisten: (() => void) | undefined;
const canRead = () => !disposed
&& routingUiRef.current.ready && !routingUiRef.current.editing
&& document.visibilityState !== "hidden"
&& scope === activeConfigDirKeyRef.current
&& loadingTokensRef.current.size === 0 && actionBusyTokensRef.current.size === 0;
const drain = async () => {
if (!pending || inFlight || !canRead()) return;
pending = false;
inFlight = true;
const eventGeneration = eventRevision;
const refreshGeneration = refreshRequestRef.current;
const loadingGeneration = loadingGenerationRef.current;
const actionGeneration = actionBusyGenerationRef.current;
const profileGeneration = officialProfilesRequestRef.current;
const stillCurrent = () => canRead()
&& refreshGeneration === refreshRequestRef.current
&& loadingGeneration === loadingGenerationRef.current
&& actionGeneration === actionBusyGenerationRef.current
&& eventGeneration === eventRevision;
try {
const [next, profiles] = await Promise.allSettled([
invoke<CodexState>("get_codex_state", { configDir: directory }),
invoke<OfficialProfileSummary[]>("list_official_profiles", { configDir: directory }),
]);
if (!stillCurrent()) {
pending = !disposed;
return;
}
if (next.status === "fulfilled" && normalizedConfigDirForComparison(next.value.codexDir) === scope) {
setState((current) => current && stillCurrent()
&& normalizedConfigDirForComparison(current.codexDir) === scope ? next.value : current);
}
if (profiles.status === "fulfilled" && profileGeneration === officialProfilesRequestRef.current) {
officialProfilesRequestRef.current += 1;
officialProfilesLiveRef.current = profiles.value;
setOfficialProfiles(profiles.value);
}
} catch {
// A transient bridge failure leaves the current screen and drafts intact.
} finally {
inFlight = false;
// Coalesce events arriving during a read. A busy action/editor will
// resume this pending update after it closes, preserving unsaved drafts.
if (pending && canRead()) void drain();
}
};
const wake = () => { void drain(); };
const request = () => { pending = true; eventRevision += 1; wake(); };
routingWakeRef.current = wake;
routingRequestRefreshRef.current = request;
const onFocus = () => request();
const onVisibility = () => { if (document.visibilityState !== "hidden") request(); };
// Backend paths may be canonical while this home is a symlink/alias. Events
// are hints only: always re-read this configured home, never the payload path.
void listen<{ codexDir: string }>("provider-routing-changed", () => request())
.then((release) => { if (disposed) release(); else unlisten = release; }).catch(() => {});
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
return () => {
disposed = true;
pending = false;
unlisten?.();
if (routingWakeRef.current === wake) routingWakeRef.current = () => {};
if (routingRequestRefreshRef.current === request) routingRequestRefreshRef.current = () => {};
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [configDir]);
React.useEffect(() => {
routingWakeRef.current();
}, [loading, actionBusy, refreshing, providerMode]);
React.useEffect(() => {
const directory = state?.codexDir;
if (!directory || !(tab === "dashboard" || (tab === "provider" && providerMode === "list"))) return;
@@ -1831,7 +1923,7 @@ function App() {
const applyAfterSave = editingDetectedProvider
|| Boolean(editingProviderId && editingProviderId === effectiveActiveProviderId);
const applied = applyAfterSave
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null })
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null, applyCommonConfig: providerCommonConfigDirty })
: null;
if (!applyAfterSave) await invoke<SavedProvider>("save_provider", { provider });
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
@@ -1844,6 +1936,7 @@ function App() {
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setToast(applied && pendingProvider.modelMappings?.length
? (lang === "zh" ? "已保存,请重启 Codex 更新模型菜单" : "Saved. Restart Codex to update its model menu")
: applied
@@ -2368,6 +2461,7 @@ function App() {
};
const openOfficialEdit = async (profileId = DEFAULT_OFFICIAL_PROFILE_ID) => {
providerCreationRequestRef.current += 1;
const requestId = ++officialDraftRequestRef.current;
const actionToken = beginActionBusy("loadOfficialDraft");
const profile = officialProfiles.find((item) => item.id === profileId);
@@ -2498,30 +2592,44 @@ function App() {
}
};
const newCustomProviderForm = (): SavedProvider => ({
const newCustomProviderForm = (configText = providerCreationBase): SavedProvider => ({
...blankProviderForm,
model: state?.model?.trim() || blankProviderForm.model,
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
tomlConfig: state?.configText?.trim() || "",
tomlConfig: configText.trim(),
});
const openAddProvider = () => {
officialDraftRequestRef.current += 1;
setCreatingProvider(true);
setSelectedPresetId("custom");
setSelectedPresetVariantId("");
setEditingOfficialProfileId(null);
setOfficialAuthDirty(false);
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
const next = newCustomProviderForm();
resetAvailableProviderModels();
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderMode("form");
const requestId = ++providerCreationRequestRef.current;
const requestedDir = activeConfigDirKeyRef.current;
const contextGeneration = refreshRequestRef.current;
// Read a fresh, shared configuration once for this creation flow. The UI's
// last state may be stale or a legacy provider-only template.
return call(
() => invoke<string>("get_provider_config_base", { configDir: configDir || null }),
(configText) => {
if (requestId !== providerCreationRequestRef.current || requestedDir !== activeConfigDirKeyRef.current
|| contextGeneration !== refreshRequestRef.current) return;
officialDraftRequestRef.current += 1;
setProviderCreationBase(configText);
setCreatingProvider(true);
setSelectedPresetId("custom");
setSelectedPresetVariantId("");
setEditingOfficialProfileId(null);
setOfficialAuthDirty(false);
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
const next = newCustomProviderForm(configText);
resetAvailableProviderModels();
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
},
);
};
const applyProviderPreset = (presetId: string, variantId: string) => {
@@ -2551,12 +2659,14 @@ function App() {
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderApiKeyVisible(false);
setAvailableProviderModels(variant?.models.map((entry) => ({ id: entry.model })) || []);
setProviderMode("form");
};
const openEditProvider = (provider: SavedProvider) => {
providerCreationRequestRef.current += 1;
setCreatingProvider(false);
resetAvailableProviderModels();
setEditingProviderId(provider.id);
@@ -2564,6 +2674,7 @@ function App() {
setProviderForm(provider);
setProviderTomlDraft(provider.tomlConfig?.trim() || buildProviderTomlPreview(provider));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
};
@@ -2590,6 +2701,7 @@ function App() {
};
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; apiKey?: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
providerCreationRequestRef.current += 1;
setCreatingProvider(false);
resetAvailableProviderModels();
const id = uniqueId(
@@ -2611,6 +2723,7 @@ function App() {
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
};
@@ -2677,7 +2790,7 @@ function App() {
const syncCount = sessionMismatchCount(status);
setToast(hasMismatches
? (lang === "zh" ? `有 ${syncCount} 条会话需要同步` : `${syncCount} session(s) need syncing`)
: (lang === "zh" ? "全部会话已同步" : "All sessions are synced"));
: (lang === "zh" ? "普通会话已同步" : "User conversations are synced"));
},
);
endActionBusy(actionToken);
@@ -3031,6 +3144,7 @@ function App() {
setCreatingProvider(false);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
}}
onOfficialModelChange={(value) => setOfficialForm((current) => ({ ...current, model: value }))}
onOfficialNameChange={(value) => setOfficialForm((current) => ({ ...current, providerName: value }))}
@@ -3059,10 +3173,11 @@ function App() {
onWireApiChange={(value) => setProviderForm((current) => ({ ...current, wireApi: value }))}
onRequiresAuthChange={(value) => setProviderForm((current) => ({ ...current, requiresOpenaiAuth: value }))}
onToggleApiKeyVisibility={() => setProviderApiKeyVisible((value) => !value)}
onProviderTomlDraftChange={(value) => {
onProviderTomlDraftChange={(value, origin = "manual") => {
providerDraftRequestRef.current += 1;
setProviderTomlDraft(value);
setProviderTomlDirty(true);
if (origin === "manual") setProviderCommonConfigDirty(true);
}}
onResetProviderToml={() => {
providerDraftRequestRef.current += 1;
@@ -3072,6 +3187,7 @@ function App() {
|| providerTomlPreview,
);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderDraftRefreshToken((token) => token + 1);
}}
onSaveProvider={saveProviderConfig}
@@ -3289,6 +3405,7 @@ function App() {
<SettingsPage
lang={lang}
configDir={configDir}
onChange={async () => { routingRequestRefreshRef.current(); }}
generalRequest={settingsGeneralRequest}
configHealthStatus={<ConfigHealthStatus
lang={lang}
@@ -0,0 +1,205 @@
import { useEffect, useId, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Activity, AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Clock3, Loader2, Plus, Power, RefreshCw, RotateCcw, Server, ShieldCheck, Shuffle, Trash2 } from "lucide-react";
import { Button, IconButton } from "../components/ui";
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft, type RoutingDraft, type RoutingSettings } from "../routingSettings";
import "../styles/provider-failover.css";
type Language = "zh" | "en";
type Provider = { id: string; providerName: string; model: string; models: string[]; baseUrl?: string | null; eligible: boolean; reason: string | null; official: boolean };
type Health = { id: string; state: "closed" | "open" | "half_open"; cooldownSeconds: number; lastStatus: number | null; consecutiveFailures: number; consecutiveSuccesses: number; totalRequests: number; failedRequests: number };
export type FailoverStatus = {
settings: RoutingSettings; running: boolean; takeoverActive: boolean; autoFailoverActive: boolean; address: string | null;
primary: Provider | null; providers: Provider[];
runtime: { requestCount: number; failoverCount: number; inFlight: number; successCount: number; failureCount: number; uptimeSeconds: number; lastRequestAt: string | null; lastProviderId: string | null; lastError: string | null; providers: Health[] };
message: string | null;
};
type PageState = { dir: string; status: FailoverStatus; draft: RoutingDraft; baseline: RoutingDraft };
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
function SwitchRow({ id, icon, title, hint, checked, disabled, onChange }: {
id: string; icon: React.ReactNode; title: string; hint: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
}) {
return <div className="cx-failover-toggle-row"><span className="cx-failover-symbol">{icon}</span><div className="cx-failover-toggle-copy"><label id={`${id}-label`} htmlFor={id}>{title}</label><p id={`${id}-hint`}>{hint}</p></div><button id={id} className="cx-failover-switch" type="button" role="switch" aria-checked={checked} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} disabled={disabled} onClick={() => onChange(!checked)}><span /></button></div>;
}
export function ProviderFailoverPage({ lang, configDir, active = true, onChange }: {
lang: Language; configDir: string; active?: boolean; onChange?: () => void | Promise<void>;
}) {
const zh = lang === "zh";
const tr = (cn: string, en: string) => zh ? cn : en;
const id = useId();
const [page, setPage] = useState<PageState | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState<string | null>(null);
const [warning, setWarning] = useState("");
const [saveError, setSaveError] = useState("");
const [notice, setNotice] = useState("");
const [refresh, setRefresh] = useState(0);
const generation = useRef(0);
const busy = useRef(false);
const mounted = useRef(true);
const current = useRef({ active, configDir });
const pageRef = useRef(page);
current.current = { active, configDir };
pageRef.current = page;
const acceptStatus = (status: FailoverStatus, preserveDraft: boolean) => {
setPage((previous) => {
const saved = routingDraft(status.settings);
const keep = preserveDraft && previous?.dir === configDir && !sameRoutingDraft(previous.draft, previous.baseline)
&& !sameRoutingDraft(previous.draft, saved);
return { dir: configDir, status, draft: keep ? previous.draft : saved, baseline: keep ? previous.baseline : routingDraft(status.settings) };
});
};
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => { setNotice(""); setSaveError(""); setWarning(""); }, [configDir]);
useEffect(() => {
const version = ++generation.current;
if (!active) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const valid = () => mounted.current && generation.current === version && current.current.active && current.current.configDir === configDir;
const load = async () => {
if (!valid()) return;
if (busy.current || document.hidden) { timer = setTimeout(() => void load(), 5000); return; }
setLoading(pageRef.current?.dir !== configDir);
try {
const status = await invoke<FailoverStatus>("get_provider_failover", { configDir: configDir || null });
if (!valid()) return;
acceptStatus(status, true);
setWarning("");
} catch (error) {
if (valid()) setWarning(errorText(error));
} finally {
if (valid()) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
}
};
void load();
return () => { ++generation.current; if (timer) clearTimeout(timer); };
}, [active, configDir, refresh]);
const view = page?.dir === configDir ? page : null;
const status = view?.status;
const draft = view?.draft;
const dirty = Boolean(view && !sameRoutingDraft(view.draft, view.baseline));
const changedElsewhere = Boolean(view && dirty && !sameRoutingDraft(routingDraft(view.status.settings), view.baseline));
const parsed = draft ? parseRoutingDraft(draft) : null;
const selected = draft?.providerIds ?? [];
const providers = status?.providers.filter((provider) => !provider.official) ?? [];
const candidates = providers.filter((provider) => !selected.includes(provider.id));
const invalidQueue = selected.some((item) => !providers.find((provider) => provider.id === item)?.eligible);
const enablingAuto = Boolean(draft?.autoFailoverEnabled && !status?.settings.autoFailoverEnabled);
const validation = changedElsewhere ? tr("设置已在别处更新,请先撤销修改,再重新保存。", "Settings changed elsewhere. Discard your changes before saving again.")
: parsed && !parsed.settings ? tr("请检查标红的输入项。", "Check the highlighted fields.")
: draft?.takeoverEnabled && !draft.routerEnabled ? tr("请先开启路由总开关。", "Enable the routing service first.")
: draft?.takeoverEnabled && !status?.primary?.eligible ? status?.primary?.reason || tr("请先选择一个可用的供应商或官方账号。", "Select an available provider or official account first.")
: enablingAuto && (!draft?.routerEnabled || !draft.takeoverEnabled) ? tr("请先开启本地路由和 Codex 路由。", "Enable the local router and Codex routing first.")
: enablingAuto && !selected.length && (!status?.primary?.eligible || status.primary.official) ? tr("请添加至少一个 API 供应商作为 P1。", "Add an API provider as P1 first.")
: invalidQueue ? tr("队列中有不可用的供应商,请移除或修复后保存。", "Remove or fix unavailable providers in the queue before saving.") : "";
const controlsBusy = saving || resetting !== null;
const edit = (patch: Partial<RoutingDraft>) => {
if (busy.current) return;
setPage((previous) => previous?.dir === configDir ? { ...previous, draft: { ...previous.draft, ...patch } } : previous);
setSaveError(""); setNotice("");
};
const reset = () => {
if (!view || busy.current) return;
acceptStatus(view.status, false); setNotice(""); setSaveError("");
};
const reorder = (index: number, offset: number) => {
if (index + offset < 0 || index + offset >= selected.length) return;
const providerIds = [...selected];
[providerIds[index], providerIds[index + offset]] = [providerIds[index + offset], providerIds[index]];
edit({ providerIds });
};
const save = async () => {
if (!view || !draft || !dirty || validation || busy.current || !active || !parsed?.settings) return;
const version = ++generation.current;
busy.current = true; setSaving(true); setSaveError(""); setNotice("");
try {
const result = await invoke<FailoverStatus>("save_provider_failover", { configDir: configDir || null, settings: parsed.settings });
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
acceptStatus(result, false); setWarning("");
setNotice(tr("路由设置已保存。", "Routing settings saved."));
}
if (current.current.configDir === configDir) {
try { await onChange?.(); } catch { /* A later status read remains authoritative. */ }
}
} catch (error) {
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
} finally {
busy.current = false;
if (mounted.current) { setSaving(false); setRefresh((value) => value + 1); }
}
};
const resetHealth = async (providerId: string) => {
if (busy.current || !active) return;
const version = ++generation.current;
busy.current = true; setResetting(providerId); setSaveError(""); setNotice("");
try {
const result = await invoke<FailoverStatus>("reset_provider_failover_health", { configDir: configDir || null, providerId });
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
acceptStatus(result, true);
setNotice(tr("已清除该供应商的故障状态,可重新尝试。", "Provider health reset. It can be tried again."));
}
} catch (error) {
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
} finally {
busy.current = false;
if (mounted.current) { setResetting(null); setRefresh((value) => value + 1); }
}
};
const healthFor = (providerId: string) => status?.runtime.providers.find((provider) => provider.id === providerId);
const healthBadge = (providerId: string) => {
const health = healthFor(providerId);
if (!status?.running || !health) return <span className="cx-failover-health cx-failover-health--idle">{tr("未使用", "Not used")}</span>;
const label = health.state === "open" ? `${tr("暂时跳过", "Unavailable")} · ${Math.ceil(health.cooldownSeconds)}s`
: health.state === "half_open" ? tr("试探恢复", "Recovering") : tr("正常", "Healthy");
return <span className={`cx-failover-health cx-failover-health--${health.state}`} title={tr(`连续失败 ${health.consecutiveFailures} 次 · ${health.failedRequests}/${health.totalRequests} 次失败`, `${health.consecutiveFailures} consecutive failures · ${health.failedRequests}/${health.totalRequests} failed`)}><i />{label}</span>;
};
const primary = status?.primary;
const recent = status?.runtime.lastProviderId === primary?.id ? primary : providers.find((provider) => provider.id === status?.runtime.lastProviderId);
const uptime = status ? `${Math.floor(status.runtime.uptimeSeconds / 3600)}h ${Math.floor(status.runtime.uptimeSeconds % 3600 / 60)}m` : "—";
const fieldError = (key: keyof NonNullable<typeof parsed>["errors"]) => parsed?.errors[key];
return <section className="cx-failover" aria-busy={loading || controlsBusy}>
<header className="cx-failover-heading"><div><h3>{tr("路由与故障转移", "Routing & failover")}</h3><p>{tr("管理 Codex 的连接方式,让请求在供应商故障时继续完成。", "Manage Codex routing and keep requests moving when a provider is unavailable.")}</p></div><IconButton size="sm" label={tr("刷新运行状态", "Refresh routing status")} icon={<RefreshCw size={17} className={loading ? "cx-page-spin" : ""} />} disabled={controlsBusy || loading} onClick={() => setRefresh((value) => value + 1)} /></header>
{warning && <div className="cx-failover-message cx-failover-message--warning" role="status"><AlertCircle size={18} /><div>{status && <strong>{tr("状态更新失败,仍显示上次结果。", "Unable to update status. Showing the last result.")}</strong>}<span>{warning}</span></div>{!status && <Button size="sm" variant="secondary" onClick={() => setRefresh((value) => value + 1)}>{tr("重试", "Retry")}</Button>}</div>}
{!status || !draft ? (loading ? <div className="cx-failover-loading"><Loader2 size={20} className="cx-page-spin" />{tr("正在读取路由设置…", "Loading routing settings…")}</div> : null) : <>
<section className="cx-failover-card">
<div className="cx-failover-card-heading"><h4><Server size={18} />{tr("本地路由", "Local routing")}</h4><span className={`cx-failover-state${status.running ? " cx-failover-state--running" : ""}`}><i />{status.running ? tr("运行中", "Running") : tr("已停止", "Stopped")}</span></div>
<SwitchRow id={`${id}-router`} icon={<Power size={22} />} title={tr("路由总开关", "Routing service")} hint={tr("启动本机路由服务;更改将在保存后生效。", "Run the local routing service. Changes take effect when saved.")} checked={draft.routerEnabled} disabled={controlsBusy} onChange={(checked) => edit({ routerEnabled: checked, ...(!checked ? { takeoverEnabled: false } : {}) })} />
<div className="cx-failover-address-grid">
<label htmlFor={`${id}-address`}>{tr("监听地址", "Listen address")}<input id={`${id}-address`} value={draft.listenAddress} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenAddress"))} onChange={(event) => edit({ listenAddress: event.target.value })} spellCheck={false} /><small className={fieldError("listenAddress") ? "cx-failover-field-error" : ""}>{fieldError("listenAddress") ? tr("请输入有效 IPv4、IPv6 或 localhost。", "Enter a valid IPv4, IPv6 or localhost.") : "IPv4 / IPv6 / localhost"}</small></label>
<label htmlFor={`${id}-port`}>{tr("监听端口", "Listen port")}<input id={`${id}-port`} type="number" min={1024} max={65535} value={draft.listenPort} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenPort"))} onChange={(event) => edit({ listenPort: event.target.value })} /><small className={fieldError("listenPort") ? "cx-failover-field-error" : ""}>{fieldError("listenPort") ? tr("有效范围:1024–65535", "Valid range: 1024–65535") : status.running ? tr("停止并保存后可修改地址和端口。", "Stop and save before changing the address or port.") : "1024–65535"}</small></label>
</div>
<SwitchRow id={`${id}-takeover`} icon={<ShieldCheck size={22} />} title={tr("为 Codex 启用路由", "Route Codex requests")} hint={tr("让 Codex 请求经过本地路由;关闭后恢复直接连接。", "Send Codex requests through this router. Turn off to restore direct access.")} checked={draft.takeoverEnabled} disabled={controlsBusy || !draft.takeoverEnabled && (!draft.routerEnabled || !primary?.eligible)} onChange={(checked) => edit({ takeoverEnabled: checked })} />
{(!primary?.eligible || !draft.routerEnabled) && <p className="cx-failover-inline-hint">{!draft.routerEnabled ? tr("先开启路由总开关,再选择是否接管 Codex。", "Enable the routing service before routing Codex requests.") : primary?.reason || tr("请先在供应商页面选择一个供应商或已登录的官方账号。", "Select a provider or a signed-in official account first.")}</p>}
<div className="cx-failover-primary"><div className="cx-failover-provider-copy"><span className="cx-failover-label">{tr("当前供应商", "Current provider")}</span><strong>{primary?.providerName || tr("尚未选择", "None selected")}</strong><small>{primary?.baseUrl || primary?.model || "—"}</small></div>{primary?.official && <span className="cx-failover-state">{tr("官方登录", "Official account")}</span>}<span className={`cx-failover-state${status.takeoverActive ? " cx-failover-state--running" : ""}`}>{status.takeoverActive ? tr("已接管", "Routed") : tr("直接连接", "Direct access")}</span></div>
{status.running && status.address && <div className="cx-failover-service-address"><span>{tr("当前路由地址", "Active route")}</span><code>{status.address}</code></div>}
</section>
<section className="cx-failover-card">
<div className="cx-failover-card-heading"><h4><Shuffle size={18} />{tr("自动故障转移", "Automatic failover")}</h4><span className={`cx-failover-state${status.autoFailoverActive ? " cx-failover-state--running" : ""}`}>{status.autoFailoverActive ? tr("已启用", "Active") : tr("未运行", "Inactive")}</span></div>
<SwitchRow id={`${id}-auto`} icon={<Shuffle size={22} />} title={tr("自动切换供应商", "Switch providers automatically")} hint={tr("启用并保存后会切到 P1;每次请求按 P1 → P2 → P3 的顺序尝试。", "Enabling and saving switches to P1. Each request tries P1 → P2 → P3 in order.")} checked={draft.autoFailoverEnabled} disabled={controlsBusy || !draft.autoFailoverEnabled && (!draft.routerEnabled || !draft.takeoverEnabled)} onChange={(checked) => edit({ autoFailoverEnabled: checked, ...(checked && !selected.length && primary?.eligible && !primary.official ? { providerIds: [primary.id] } : {}) })} />
<div className="cx-failover-queue-heading"><div><h4>{tr("优先级队列", "Priority queue")}</h4><p>{tr("可以提前准备队列。请求保留 Codex 中选择的模型,请确保供应商支持它。", "Prepare your queue at any time. Requests keep the model selected in Codex; providers must support it.")}</p></div><span>{selected.length}/64</span></div>
{!selected.length ? <div className="cx-failover-empty"><strong>{tr("还没有队列供应商", "Your queue is empty")}</strong><span>{tr("从下方添加供应商,第一位就是 P1;一个供应商也可以启用。", "Add providers below. The first is P1; a one-provider queue is also supported.")}</span></div> : <ol className="cx-failover-queue">{selected.map((providerId, index) => {
const provider = providers.find((item) => item.id === providerId);
const health = healthFor(providerId);
return <li key={providerId} className={`${index === 0 ? "cx-failover-provider--first" : ""}${!provider?.eligible ? " cx-failover-provider--invalid" : ""}`}><span className="cx-failover-order">P{index + 1}</span><div className="cx-failover-provider-copy"><strong>{provider?.providerName || tr("供应商已删除", "Provider deleted")}</strong><small title={provider?.baseUrl || undefined}>{!provider?.eligible ? provider?.reason || tr("请移除此项或检查供应商配置。", "Remove this item or fix its configuration.") : provider.baseUrl || provider.model}</small></div>{healthBadge(providerId)}<div className="cx-failover-row-actions">{health && health.state !== "closed" && <IconButton size="sm" label={`${tr("重置健康状态", "Reset health")} ${provider?.providerName || ""}`} icon={<RotateCcw size={15} className={resetting === providerId ? "cx-page-spin" : ""} />} disabled={controlsBusy} onClick={() => void resetHealth(providerId)} />}<IconButton size="sm" label={`${tr("上移", "Move up")} ${provider?.providerName || ""}`} icon={<ArrowUp size={15} />} disabled={controlsBusy || index === 0} onClick={() => reorder(index, -1)} /><IconButton size="sm" label={`${tr("下移", "Move down")} ${provider?.providerName || ""}`} icon={<ArrowDown size={15} />} disabled={controlsBusy || index === selected.length - 1} onClick={() => reorder(index, 1)} /><IconButton size="sm" label={`${tr("移除", "Remove")} ${provider?.providerName || ""}`} variant="ghost" icon={<Trash2 size={15} />} disabled={controlsBusy} onClick={() => edit({ providerIds: selected.filter((item) => item !== providerId) })} /></div></li>;
})}</ol>}
<div className="cx-failover-candidates"><h4>{tr("添加供应商", "Add providers")}</h4>{candidates.length ? <div className="cx-failover-candidate-list">{candidates.map((provider) => <button type="button" className="cx-failover-candidate" key={provider.id} disabled={controlsBusy || !provider.eligible || selected.length >= 64} title={provider.reason || provider.baseUrl || undefined} onClick={() => edit({ providerIds: [...selected, provider.id] })}><div><strong>{provider.providerName}</strong><small>{provider.eligible ? provider.baseUrl || provider.model : provider.reason}</small></div><Plus size={17} /><span className="cx-failover-sr-only">{tr("添加", "Add")}</span></button>)}</div> : <p>{tr("所有可选供应商已在队列中;也可以到供应商页面添加更多。", "All available providers are already queued. Add more on the Providers page.")}</p>}</div>
{primary?.official && <p className="cx-failover-inline-hint">{tr("官方账号仅使用当前登录,不参与自动切换。队列只包含 API 供应商。", "Official accounts use the current login only. Only API providers participate in failover.")}</p>}
</section>
<section className="cx-failover-card cx-failover-tuning"><div className="cx-failover-card-heading"><h4><Clock3 size={18} />{tr("重试、超时与恢复", "Retries, timeouts & recovery")}</h4><span className="cx-failover-section-note">{tr("自动故障转移时生效", "Applied during automatic failover")}</span></div>{(["retry", "timeout", "recovery"] as const).map((group) => <div className={`cx-failover-field-group cx-failover-field-group--${group}`} key={group}><h5>{group === "retry" ? tr("重试策略", "Retry policy") : group === "timeout" ? tr("超时时间", "Timeouts") : tr("故障恢复", "Recovery policy")}</h5><div className="cx-failover-fields">{routingFields.filter((field) => field.group === group).map((field) => <label key={field.key} htmlFor={`${id}-${field.key}`}>{zh ? field.zh : field.en}<div className="cx-failover-field-input"><input id={`${id}-${field.key}`} type="number" min={field.min} max={field.max} step={field.key === "circuitErrorRateThreshold" ? "any" : 1} value={draft[field.key]} disabled={controlsBusy} aria-invalid={Boolean(fieldError(field.key))} aria-describedby={`${id}-${field.key}-hint`} onChange={(event) => edit({ [field.key]: event.target.value })} /><span>{field.min}–{field.max}</span></div><small id={`${id}-${field.key}-hint`} className={fieldError(field.key) ? "cx-failover-field-error" : ""}>{fieldError(field.key) ? tr(`请输入 ${field.min}–${field.max} 范围内的${field.key === "circuitErrorRateThreshold" ? "数值" : "整数"}。`, `Enter ${field.min}–${field.max}${field.key === "circuitErrorRateThreshold" ? "." : " (whole numbers)."}`) : zh ? field.hintZh : field.hintEn}</small></label>)}</div></div>)}</section>
<section className="cx-failover-card cx-failover-runtime"><div className="cx-failover-card-heading"><h4><Activity size={18} />{tr("运行状态", "Runtime status")}</h4><span className="cx-failover-section-note">{tr("运行时间", "Uptime")} {uptime}</span></div><dl><div><dt>{tr("处理请求", "Requests")}</dt><dd>{status.runtime.requestCount.toLocaleString()}</dd></div><div><dt>{tr("成功 / 失败", "Succeeded / failed")}</dt><dd>{status.runtime.successCount.toLocaleString()} <span>/ {status.runtime.failureCount.toLocaleString()}</span></dd></div><div><dt>{tr("自动切换", "Failovers")}</dt><dd>{status.runtime.failoverCount.toLocaleString()}</dd></div><div><dt>{tr("正在处理", "In progress")}</dt><dd>{status.runtime.inFlight.toLocaleString()}</dd></div></dl><div className="cx-failover-runtime-current"><span>{tr("最近使用", "Last used")}</span><strong>{recent?.providerName || tr("等待请求", "Waiting for requests")}</strong>{status.runtime.lastRequestAt && <time>{new Date(status.runtime.lastRequestAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit" })}</time>}</div>{status.message && <p className="cx-failover-runtime-note">{status.message}</p>}{status.runtime.lastError && <p className="cx-failover-runtime-note"><AlertCircle size={15} />{status.runtime.lastError}</p>}</section>
<p className="cx-failover-bottom-note">{tr("回复已经开始后不会重复发送。关闭 Codex-X 窗口仍会在后台运行;完全退出时恢复直接连接。", "A reply that has already started is never replayed. Closing this window keeps routing active; quitting Codex-X restores direct access.")}</p>
{saveError && <div className="cx-failover-message cx-failover-message--warning" role="alert"><AlertCircle size={18} /><span>{saveError}</span></div>}
{notice && <div className="cx-failover-message cx-failover-message--success" role="status"><CheckCircle2 size={18} /><span>{notice}</span></div>}
<footer className="cx-failover-save"><div aria-live="polite" className={validation ? "cx-failover-field-error" : ""}>{validation || (dirty ? tr("有未保存的修改", "Unsaved changes") : tr("更改将在保存后生效", "Changes take effect when saved"))}</div><div><Button size="sm" variant="secondary" disabled={!dirty || controlsBusy} onClick={reset} icon={<RotateCcw size={15} />}>{tr("撤销修改", "Discard changes")}</Button><Button size="sm" disabled={!dirty || controlsBusy || Boolean(validation)} icon={saving ? <Loader2 size={15} className="cx-page-spin" /> : undefined} onClick={() => void save()}>{saving ? tr("保存中…", "Saving…") : tr("保存设置", "Save settings")}</Button></div></footer>
</>}
</section>;
}
+2 -2
View File
@@ -194,7 +194,7 @@ export type ProvidersPageProps = {
onWireApiChange: (value: string) => void;
onRequiresAuthChange: (value: boolean) => void;
onToggleApiKeyVisibility: () => void;
onProviderTomlDraftChange: (value: string) => void;
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
onResetProviderToml: () => void;
onSaveProvider: () => void;
};
@@ -868,7 +868,7 @@ function ProviderForm({
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
<div className="cx-providers-context-actions">
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={(value) => onProviderTomlDraftChange(value, "context")} disabled={formBusy} onBusyChange={setContextWindowBusy} />
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
</div>
</div>
@@ -161,7 +161,7 @@ export function SessionManagementPage({
? {
syncEyebrow: "会话同步",
title: "会话管理",
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
description: "同步普通会话到共享列表,内部任务不参与同步。不会修改聊天内容。",
syncTo: "同步到",
check: "检查会话",
checking: "检查中...",
@@ -170,7 +170,7 @@ export function SessionManagementPage({
clickToCheck: "点击检查会话",
scanIncomplete: "无法确认同步状态,请查看下方原因",
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
allSynced: "全部会话已同步",
allSynced: "普通会话已同步",
sessionCount: (count: number) => `${count} 条会话`,
local: "本地会话",
list: "会话列表",
@@ -192,6 +192,7 @@ export function SessionManagementPage({
selectSession: "选择会话",
archived: "已归档",
internal: "内部",
internalHint: "内部任务仅供查看,不参与会话同步。",
pending: "待同步",
unknownProvider: "未知供应商",
noModel: "未记录",
@@ -214,7 +215,7 @@ export function SessionManagementPage({
: {
syncEyebrow: "SESSION SYNC",
title: "Session management",
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
description: "Keep user conversations in one shared history. Internal tasks are excluded from sync; chat content is unchanged.",
syncTo: "Sync to",
check: "Check sessions",
checking: "Checking...",
@@ -223,7 +224,7 @@ export function SessionManagementPage({
clickToCheck: "Check sessions to get started",
scanIncomplete: "Unable to verify sync status. See the reason below.",
needsSync: (count: number) => `${count} session(s) need syncing`,
allSynced: "All sessions are synced",
allSynced: "User conversations are synced",
sessionCount: (count: number) => `${count} sessions`,
local: "LOCAL SESSIONS",
list: "Sessions",
@@ -245,6 +246,7 @@ export function SessionManagementPage({
selectSession: "Select session",
archived: "Archived",
internal: "Internal",
internalHint: "Internal tasks are available for inspection and excluded from session sync.",
pending: "Needs sync",
unknownProvider: "Unknown provider",
noModel: "Not recorded",
@@ -400,6 +402,7 @@ export function SessionManagementPage({
className={cx("cx-session-toggle", showInternalSessions && "cx-session-toggle--active")}
checked={showInternalSessions}
onCheckedChange={onShowInternalSessionsChange}
title={copy.internalHint}
label={copy.showInternal(sessionStatus?.subagentThreads ?? 0)}
/>
)}
@@ -473,7 +476,7 @@ export function SessionManagementPage({
)}
{items.map((item) => (
<div
className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")}
className={cx("cx-session-row", item.needsSync && !item.isSubagent && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")}
key={item.id}
role="row"
onClick={(event) => {
@@ -494,8 +497,8 @@ export function SessionManagementPage({
<div className="cx-session-row-title">
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
{item.archived && <span className="cx-session-state">{copy.archived}</span>}
{item.isSubagent && <span className="cx-session-state">{copy.internal}</span>}
{item.needsSync && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
{item.isSubagent && <span className="cx-session-state" title={copy.internalHint}>{copy.internal}</span>}
{item.needsSync && !item.isSubagent && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
</div>
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
</div>
+22 -6
View File
@@ -11,11 +11,13 @@ import {
RefreshCw,
Sparkles,
SlidersHorizontal,
Shuffle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell } from "../components/ui";
import { UsageStatisticsPage } from "./UsageStatisticsPage";
import { ProviderFailoverPage } from "./ProviderFailoverPage";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -114,18 +116,25 @@ export type SettingsPageProps = {
restartBusy?: boolean;
generalRequest?: number;
configHealthStatus?: ReactNode;
onChange?: () => void | Promise<void>;
};
// PageTransition keeps the previous content during its exit animation. Context
// still propagates the current activity state so requests stop immediately,
// while the retained usage component keeps its filters between tabs.
const SettingsUsageActiveContext = createContext(false);
const SettingsFailoverActiveContext = createContext(false);
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
const active = useContext(SettingsUsageActiveContext);
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
}
function SettingsFailoverPanel({ lang, configDir, onChange }: Pick<SettingsPageProps, "lang" | "configDir" | "onChange">) {
const active = useContext(SettingsFailoverActiveContext);
return <ProviderFailoverPage lang={lang} configDir={configDir} active={active} onChange={onChange} />;
}
type SettingRowProps = {
icon: LucideIcon;
title: ReactNode;
@@ -160,10 +169,12 @@ export function SettingsPage({
restartBusy = false,
generalRequest = 0,
configHealthStatus,
onChange,
}: SettingsPageProps) {
const [tab, setTab] = useState<"general" | "usage">("general");
const [tab, setTab] = useState<"general" | "usage" | "failover">("general");
useEffect(() => { setTab("general"); }, [generalRequest]);
const [usageOpened, setUsageOpened] = useState(false);
const [failoverOpened, setFailoverOpened] = useState(false);
const tabId = useId();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
@@ -179,19 +190,20 @@ export function SettingsPage({
<section className="cx-utility cx-page cx-page--settings">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
{(["general", "usage"] as const).map((value, index) => {
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
{(["general", "usage", "failover"] as const).map((value, index, tabs) => {
const Icon = value === "general" ? SlidersHorizontal : value === "usage" ? BarChart3 : Shuffle;
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); if (value === "failover") setFailoverOpened(true); };
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
tabRefs.current[next]?.click();
tabRefs.current[next]?.focus();
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : value === "usage" ? (lang === "zh" ? "用量统计" : "Usage statistics") : (lang === "zh" ? "路由与故障转移" : "Routing & failover")}</button>;
})}
</div>
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
<SettingsFailoverActiveContext.Provider value={active && tab === "failover"}>
<PageTransition pageKey={`settings:${tab}`}>
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
<SettingRow
@@ -268,7 +280,11 @@ export function SettingsPage({
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
</div>
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-failover-panel`} aria-labelledby={`${tabId}-failover-tab`} hidden={tab !== "failover"}>
{failoverOpened && <SettingsFailoverPanel lang={lang} configDir={configDir} onChange={onChange} />}
</div>
</PageTransition>
</SettingsFailoverActiveContext.Provider>
</SettingsUsageActiveContext.Provider>
<ModalShell
+58
View File
@@ -0,0 +1,58 @@
// Parameter ranges and save/reset semantics adapted from CC Switch (MIT).
// Copyright (c) 2025 Jason Young. See THIRD_PARTY_NOTICES.md.
export const routingFields = [
{ key: "maxRetries", min: 0, max: 10, value: 3, group: "retry", zh: "最大重试次数", en: "Maximum retries", hintZh: "失败后最多再尝试几家供应商,0 表示不重试。", hintEn: "Additional providers to try after a failure. 0 disables retries." },
{ key: "circuitFailureThreshold", min: 1, max: 20, value: 4, group: "retry", zh: "连续失败阈值", en: "Consecutive failures", hintZh: "连续失败达到此次数后,暂时跳过该供应商。", hintEn: "Temporarily skip a provider after this many consecutive failures." },
{ key: "streamingFirstByteTimeout", min: 1, max: 120, value: 60, group: "timeout", zh: "流式首字节超时", en: "First response timeout", hintZh: "等待开始返回内容的时间,单位为秒。", hintEn: "Seconds to wait for the first response data." },
{ key: "streamingIdleTimeout", min: 0, max: 600, value: 120, group: "timeout", zh: "流式静默超时", en: "Stream idle timeout", hintZh: "回复过程中等待下一段内容的秒数;0 表示不限制。", hintEn: "Seconds between response chunks. 0 disables this timeout." },
{ key: "nonStreamingTimeout", min: 60, max: 1200, value: 600, group: "timeout", zh: "非流式总超时", en: "Non-streaming timeout", hintZh: "等待完整回复的最长时间,单位为秒。", hintEn: "Total seconds to wait for a complete non-streaming response." },
{ key: "circuitSuccessThreshold", min: 1, max: 10, value: 2, group: "recovery", zh: "恢复成功次数", en: "Recovery successes", hintZh: "试探恢复时,连续成功几次后恢复正常。", hintEn: "Successful trial requests required to restore normal routing." },
{ key: "circuitTimeoutSeconds", min: 0, max: 300, value: 60, group: "recovery", zh: "恢复等待时间", en: "Recovery wait", hintZh: "暂时跳过后,隔多少秒再尝试。", hintEn: "Seconds to wait before trying an unavailable provider again." },
{ key: "circuitErrorRateThreshold", min: 0, max: 100, value: 60, group: "recovery", zh: "错误率阈值 (%)", en: "Error rate threshold (%)", hintZh: "达到此比例时,也会暂时跳过供应商。", hintEn: "Also skip a provider when its failure rate reaches this percentage." },
{ key: "circuitMinRequests", min: 5, max: 100, value: 10, group: "recovery", zh: "最小请求数", en: "Minimum requests", hintZh: "累计达到此请求数后,才开始判断错误率。", hintEn: "Requests required before evaluating the error rate." },
] as const;
export type RoutingField = typeof routingFields[number]["key"];
export type RoutingSettings = Record<RoutingField, number> & {
version: number; routerEnabled: boolean; takeoverEnabled: boolean; autoFailoverEnabled: boolean;
listenAddress: string; listenPort: number; providerIds: string[];
};
export type RoutingDraft = Omit<RoutingSettings, RoutingField | "listenPort"> & Record<RoutingField | "listenPort", string>;
export function routingDraft(settings: RoutingSettings): RoutingDraft {
const values = Object.fromEntries(routingFields.map(({ key }) => [key, String(key === "circuitErrorRateThreshold" ? Number((settings[key] * 100).toFixed(6)) : settings[key])])) as Record<RoutingField, string>;
return { ...settings, ...values, listenPort: String(settings.listenPort), providerIds: [...settings.providerIds] };
}
export function sameRoutingDraft(a: RoutingDraft, b: RoutingDraft): boolean {
return a.routerEnabled === b.routerEnabled && a.takeoverEnabled === b.takeoverEnabled
&& a.autoFailoverEnabled === b.autoFailoverEnabled && a.listenAddress === b.listenAddress && a.listenPort === b.listenPort
&& routingFields.every(({ key }) => a[key] === b[key])
&& a.providerIds.length === b.providerIds.length && a.providerIds.every((id, index) => id === b.providerIds[index]);
}
export function parseRoutingDraft(draft: RoutingDraft): { settings: RoutingSettings | null; errors: Partial<Record<RoutingField | "listenAddress" | "listenPort" | "providerIds", string>> } {
const errors: ReturnType<typeof parseRoutingDraft>["errors"] = {};
const numbers = {} as Record<RoutingField, number>;
for (const field of routingFields) {
const text = draft[field.key].trim();
const pattern = field.key === "circuitErrorRateThreshold" ? /^\d+(?:\.\d+)?$/ : /^\d+$/;
const value = Number(text);
if (!pattern.test(text) || !Number.isFinite(value) || value < field.min || value > field.max) errors[field.key] = `${field.min}–${field.max}`;
numbers[field.key] = field.key === "circuitErrorRateThreshold" ? value / 100 : value;
}
let address = draft.listenAddress.trim();
if (address.toLowerCase() === "localhost") address = "127.0.0.1";
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(address) && address.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
let ipv6 = false;
if (address.includes(":")) {
try { ipv6 = new URL(`http://[${address}]/`).hostname.startsWith("["); } catch { /* invalid literal */ }
}
if (!ipv4 && !ipv6) errors.listenAddress = "IPv4 / IPv6 / localhost";
const port = Number(draft.listenPort);
if (!/^\d+$/.test(draft.listenPort.trim()) || port < 1024 || port > 65535) errors.listenPort = "1024–65535";
if (draft.providerIds.length > 64 || new Set(draft.providerIds).size !== draft.providerIds.length) errors.providerIds = "1–64";
return { errors, settings: Object.keys(errors).length ? null : {
...draft, ...numbers, version: 2, listenAddress: address, listenPort: port, providerIds: [...draft.providerIds],
} };
}
@@ -0,0 +1,128 @@
.cx-failover {
--rf-text: #202a3a; --rf-muted: #566378; --rf-border: #ced7e4; --rf-surface: #ffffff; --rf-inset: #f4f7fb; --rf-input: #ffffff;
--rf-blue: #215adb; --rf-blue-bg: #edf3ff; --rf-blue-border: #a4bff3;
--rf-green: #17643f; --rf-green-bg: #e9f8ef; --rf-green-border: #9cceb0;
--rf-amber: #815109; --rf-amber-bg: #fff5df; --rf-amber-border: #e3c27b;
--rf-red: #b32d37; --rf-red-bg: #fff0f0; --rf-red-border: #e2a6aa;
display: grid; gap: 18px; max-width: 1120px; color: var(--rf-text); font-size: 14px;
}
:root[data-theme="dark"] .cx-failover {
--rf-text: #f1f5fb; --rf-muted: #c0cad9; --rf-border: #525e70; --rf-surface: #303744; --rf-inset: #272e39; --rf-input: #202731;
--rf-blue: #a3c4ff; --rf-blue-bg: #263e65; --rf-blue-border: #6089ca;
--rf-green: #a2e6b9; --rf-green-bg: #233e31; --rf-green-border: #4e8162;
--rf-amber: #f5d191; --rf-amber-bg: #453927; --rf-amber-border: #957647;
--rf-red: #ffb3bc; --rf-red-bg: #4b2c35; --rf-red-border: #9b5d6b;
}
.cx-failover h3, .cx-failover h4, .cx-failover h5, .cx-failover p { margin: 0; }
.cx-failover h3 { color: var(--rf-text); font-size: 20px; font-weight: 750; }
.cx-failover h4 { color: var(--rf-text); font-size: 15px; font-weight: 720; }
.cx-failover-heading { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
.cx-failover-heading p { margin-top: 6px; color: var(--rf-muted); line-height: 1.6; font-size: 13px; }
.cx-failover-card { overflow: hidden; border: 1px solid var(--rf-border); border-radius: 13px; background: var(--rf-surface); box-shadow: 0 3px 12px #00000006; }
.cx-failover-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--rf-border); background: var(--rf-inset); }
.cx-failover-card-heading h4 { display: flex; align-items: center; gap: 9px; }
.cx-failover-card-heading h4 svg { color: var(--rf-blue); }
.cx-failover-section-note { color: var(--rf-muted); font-size: 12px; }
.cx-failover-toggle-row { display: flex; align-items: center; gap: 13px; padding: 19px 20px; }
.cx-failover-toggle-copy { min-width: 0; flex: 1; }
.cx-failover-toggle-copy label { display: inline-block; color: var(--rf-text); font-size: 14px; font-weight: 710; cursor: pointer; }
.cx-failover-toggle-copy p { margin-top: 5px; line-height: 1.6; color: var(--rf-muted); font-size: 12px; }
.cx-failover-symbol { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; color: var(--rf-blue); background: var(--rf-blue-bg); border: 1px solid var(--rf-blue-border); border-radius: 11px; }
.cx-failover-switch { position: relative; flex: 0 0 44px; width: 44px; height: 26px; border: 1px solid #738198; border-radius: 20px; background: #78859a; cursor: pointer; transition: background-color 130ms ease; }
.cx-failover-switch span { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #0003; transition: transform 130ms ease; }
.cx-failover-switch[aria-checked="true"] { background: #2866ed; border-color: #2866ed; }
.cx-failover-switch[aria-checked="true"] span { transform: translateX(18px); }
.cx-failover-switch:disabled { cursor: not-allowed; border-style: dashed; }
.cx-failover-switch:disabled:not([aria-checked="true"]) { background: #697383; }
.cx-failover-switch:focus-visible, .cx-failover-candidate:focus-visible, .cx-failover input:focus-visible { outline: 2px solid var(--rf-blue); outline-offset: 3px; }
.cx-failover-address-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 0 20px 18px 73px; border-bottom: 1px solid var(--rf-border); }
.cx-failover-address-grid label, .cx-failover-fields label { display: grid; gap: 8px; align-content: start; color: var(--rf-text); font-size: 13px; font-weight: 650; }
.cx-failover input { box-sizing: border-box; width: 100%; min-width: 0; height: 39px; padding: 8px 11px; color: var(--rf-text); background: var(--rf-input); border: 1px solid var(--rf-border); border-radius: 7px; font: inherit; font-size: 14px; font-variant-numeric: tabular-nums; }
.cx-failover input:disabled { color: var(--rf-muted); background: var(--rf-inset); cursor: not-allowed; }
.cx-failover input[aria-invalid="true"] { border-color: var(--rf-red); box-shadow: 0 0 0 1px var(--rf-red); }
.cx-failover label small { font-size: 12px; font-weight: 400; color: var(--rf-muted); line-height: 1.55; }
.cx-failover .cx-failover-field-error { color: var(--rf-red); }
.cx-failover p.cx-failover-inline-hint { padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.6; font-size: 12px; }
.cx-failover-primary { display: flex; gap: 12px; align-items: center; margin: 0 20px 18px; padding: 14px; border-radius: 9px; border: 1px solid var(--rf-border); background: var(--rf-inset); }
.cx-failover-provider-copy { flex: 1; min-width: 0; display: grid; gap: 5px; }
.cx-failover-provider-copy strong, .cx-failover-candidate strong { color: var(--rf-text); font-size: 14px; font-weight: 740; overflow-wrap: anywhere; }
.cx-failover-provider-copy small, .cx-failover-candidate small { color: var(--rf-muted); font-size: 12px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-failover-label { color: var(--rf-muted); font-size: 12px; }
.cx-failover-state { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; flex: 0 0 auto; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); border: 1px solid var(--rf-border); font-size: 12px; font-weight: 650; }
.cx-failover-state i, .cx-failover-health i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: currentColor; }
.cx-failover-state--running { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
.cx-failover-service-address { display: flex; gap: 12px; align-items: center; padding: 0 20px 18px; color: var(--rf-muted); font-size: 12px; }
.cx-failover-service-address code { padding: 6px 10px; border-radius: 6px; background: var(--rf-inset); color: var(--rf-text); overflow-wrap: anywhere; }
.cx-failover-queue-heading { display: flex; align-items: flex-start; gap: 16px; justify-content: space-between; padding: 3px 20px 14px; }
.cx-failover-queue-heading p { margin-top: 7px; font-size: 12px; color: var(--rf-muted); line-height: 1.6; }
.cx-failover-queue-heading > span { color: var(--rf-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.cx-failover-empty { display: grid; gap: 8px; margin: 0 20px; padding: 20px; border-radius: 9px; border: 1px dashed var(--rf-border); background: var(--rf-inset); }
.cx-failover-empty strong { font-size: 14px; font-weight: 650; }
.cx-failover-empty span { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
.cx-failover-queue { display: grid; gap: 9px; list-style: none; margin: 0 20px; padding: 0; }
.cx-failover-queue > li { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid var(--rf-border); border-radius: 9px; background: var(--rf-surface); }
.cx-failover-queue > li.cx-failover-provider--first { border-color: var(--rf-blue-border); background: var(--rf-blue-bg); }
.cx-failover-order { display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 30px; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 750; }
.cx-failover-provider--first .cx-failover-order { background: #2866ed; color: #fff; }
.cx-failover-queue > li.cx-failover-provider--invalid { border-color: var(--rf-red-border); }
.cx-failover-provider--invalid .cx-failover-provider-copy small { color: var(--rf-red); white-space: normal; }
.cx-failover-row-actions { display: flex; gap: 4px; flex: 0 0 auto; }
.cx-failover-health { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; padding: 5px 8px; border-radius: 7px; border: 1px solid var(--rf-border); color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 620; }
.cx-failover-health--closed { background: var(--rf-green-bg); color: var(--rf-green); border-color: var(--rf-green-border); }
.cx-failover-health--half_open { background: var(--rf-amber-bg); color: var(--rf-amber); border-color: var(--rf-amber-border); }
.cx-failover-health--open { background: var(--rf-red-bg); color: var(--rf-red); border-color: var(--rf-red-border); }
.cx-failover-candidates { padding: 18px 20px 20px; }
.cx-failover-candidates > p { padding-top: 10px; font-size: 12px; line-height: 1.6; color: var(--rf-muted); }
.cx-failover-candidate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 11px; }
.cx-failover-candidate { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--rf-border); border-radius: 8px; text-align: left; background: var(--rf-inset); color: var(--rf-text); cursor: pointer; }
.cx-failover-candidate > div { min-width: 0; display: grid; gap: 5px; }
.cx-failover-candidate > svg { flex: 0 0 auto; color: var(--rf-blue); }
.cx-failover-candidate:hover:not(:disabled) { border-color: var(--rf-blue); background: var(--rf-blue-bg); }
.cx-failover-candidate:disabled { cursor: not-allowed; border-style: dashed; }
.cx-failover-candidate:disabled svg { color: var(--rf-muted); }
.cx-failover-field-group { padding: 18px 20px 20px; }
.cx-failover-field-group + .cx-failover-field-group { border-top: 1px solid var(--rf-border); }
.cx-failover-field-group h5 { color: var(--rf-blue); margin-bottom: 12px; font-size: 13px; font-weight: 700; }
.cx-failover-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.cx-failover-field-input { position: relative; }
.cx-failover-field-input input { padding-right: 68px; }
.cx-failover-field-input > span { position: absolute; right: 24px; top: 11px; color: var(--rf-muted); font-size: 11px; font-weight: 400; pointer-events: none; }
.cx-failover-runtime dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin: 0; padding: 20px; }
.cx-failover-runtime dt { color: var(--rf-muted); font-size: 12px; }
.cx-failover-runtime dd { margin: 8px 0 0; font-size: 23px; font-weight: 740; font-variant-numeric: tabular-nums; }
.cx-failover-runtime dd span { color: var(--rf-muted); font-size: 18px; font-weight: 500; }
.cx-failover-runtime-current { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--rf-border); color: var(--rf-muted); font-size: 12px; }
.cx-failover-runtime-current strong { color: var(--rf-text); font-size: 14px; }
.cx-failover-runtime-current time { margin-left: auto; }
.cx-failover-runtime p.cx-failover-runtime-note { display: flex; gap: 7px; margin: 0; padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.65; font-size: 12px; overflow-wrap: anywhere; }
.cx-failover-runtime-note svg { flex: 0 0 auto; margin-top: 2px; }
.cx-failover-message { display: flex; align-items: center; gap: 9px; padding: 13px 15px; border: 1px solid var(--rf-border); border-radius: 9px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-failover-message svg { flex: 0 0 auto; }
.cx-failover-message > div { flex: 1; min-width: 0; display: grid; gap: 3px; }
.cx-failover-message--warning { color: var(--rf-amber); background: var(--rf-amber-bg); border-color: var(--rf-amber-border); }
.cx-failover-message--success { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
.cx-failover-bottom-note { color: var(--rf-muted); font-size: 12px; line-height: 1.7; }
.cx-failover-save { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; border: 1px solid var(--rf-border); border-radius: 10px; background: var(--rf-surface); box-shadow: 0 -4px 18px #0000000a; }
.cx-failover-save > div:first-child { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
.cx-failover-save > div:first-child.cx-failover-field-error { color: var(--rf-red); }
.cx-failover-save > div:last-child { display: flex; flex: 0 0 auto; gap: 8px; }
.cx-failover-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 180px; color: var(--rf-muted); font-size: 14px; }
.cx-failover-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
@media (max-width: 850px) {
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cx-failover-queue > li { flex-wrap: wrap; }
.cx-failover-queue .cx-failover-provider-copy { flex-basis: calc(100% - 52px); }
.cx-failover-row-actions { margin-left: auto; }
}
@media (max-width: 620px) {
.cx-failover-address-grid { padding-left: 20px; grid-template-columns: 1fr; }
.cx-failover-candidate-list, .cx-failover-fields, .cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: 1fr; }
.cx-failover-primary { flex-wrap: wrap; }
.cx-failover-service-address { align-items: flex-start; flex-direction: column; }
.cx-failover-runtime dl { grid-template-columns: 1fr 1fr; }
.cx-failover-save { align-items: stretch; flex-direction: column; }
.cx-failover-save > div:last-child { justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) { .cx-failover-switch, .cx-failover-switch span { transition: none; } }
+135 -4
View File
@@ -11,6 +11,7 @@
const commands = [];
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [] };
const callbacks = new Map();
const eventListeners = new Map();
let nextCallback = 1;
let output;
let loginButton;
@@ -26,10 +27,25 @@
let transferMode = "success";
let lastTransfer = null;
let existingImportAvailable = true;
const defaultRoutingSettings = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false,
listenAddress: "127.0.0.1", listenPort: 15721, providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60,
streamingIdleTimeout: 120, nonStreamingTimeout: 600, circuitFailureThreshold: 4, circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
let failoverSettings = defaultRoutingSettings();
const resetRoutingHealth = new Set();
let failoverMode = "normal";
let providerBaseReadFailure = false;
const sharedProviderTables = '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n'
+ '\n[mcp_servers.fixture_docs.env]\nFIXTURE_TIMEOUT = "1000"\n'
+ '\n[desktop]\nsansFontSize = 14\ncodeFontSize = 13\n'
+ '\n[marketplaces.fixture]\nsource_type = "local"\nsource = "/fixture/marketplace"\n'
+ '\n[plugins."browser@fixture"]\nenabled = true\n'
+ '\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
const fixtureSkill = (id, name, enabled = true) => ({ id, name, description: "Fixture skill", note: "合成测试备注", directory: id, enabled, source: "Codex", path: `${codexDir}/skills/${id}`, contentHash: null, updateStatus: "未检查" });
const fixtureMcp = (id, name, enabled = true) => ({ id, name, enabled, transport: "stdio", source: "Codex", note: "合成测试备注", summary: "npx fixture-tools", command: "npx", url: null, configJson: { command: "npx", args: ["fixture-tools"] } });
const transferState = { codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`, skills: [fixtureSkill("writing", "写作助手")], mcpServers: [fixtureMcp("docs", "项目文档")], warnings: [] };
const fixtureSessions = ["项目排错记录", "功能设计讨论"].map((title, index) => ({ id: `fixture-session-${index + 1}`, title, modelProvider: "custom", model: "fixture-model", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/fixture-${index}.jsonl`, updatedAtMs: Date.now() - index * 60000, archived: false, hasUserEvent: true, isSubagent: false, needsSync: false }));
fixtureSessions.push({ id: "fixture-internal-guardian", title: "The following is the Codex agent history whose request action you are assessing...", modelProvider: "openai", model: "codex-auto-review", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/guardian.jsonl`, updatedAtMs: Date.now() - 120000, archived: false, hasUserEvent: false, isSubagent: true, needsSync: false });
function healthReport() {
const broken = configHealthMode !== "healthy";
@@ -69,7 +85,7 @@
providerName: "OpenAI Official",
model: "fixture-official-model",
authJson: officialAuth("one"),
configText: officialToml("fixture-official-model"),
configText: officialToml("fixture-official-model") + sharedProviderTables,
isDefault: true,
}]]);
@@ -134,7 +150,7 @@
// Visible inherited settings for provider-preset regression checks.
detectedProvider.tomlConfig = '# fixture inherited settings\nmodel_reasoning_effort = "high"\napproval_policy = "on-request"\n'
+ toml(detectedProvider)
+ '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n\n[features]\nshell_snapshot = true\n\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
+ sharedProviderTables + '\n[features]\nshell_snapshot = true\n';
let savedProviders = [{
id: "fixture-saved",
providerName: "Fixture Saved Provider",
@@ -203,6 +219,7 @@
&& Array.isArray(saved.officialProfiles)
&& saved.officialProfiles.some((profile) => profile.id === defaultOfficialId)) {
state = saved.state;
if (saved.failoverSettings?.version === 2) failoverSettings = { ...defaultRoutingSettings(), ...saved.failoverSettings };
savedProviders = saved.savedProviders;
savedPrompts = saved.savedPrompts;
nextOfficialId = saved.nextOfficialId;
@@ -221,6 +238,7 @@
savedProviders,
savedPrompts,
nextOfficialId,
failoverSettings,
officialProfiles: Array.from(officialProfiles.values()),
}));
} catch {
@@ -241,6 +259,7 @@
return {
startupWizardSeen: localStorage.getItem("codexx.startupWizardSeen"),
transferMode, lastTransfer,
failoverSettings: clone(failoverSettings), failoverMode,
pendingSync: pending.sync.length,
pendingDetail: pending.detail.length,
pendingQuota: pending.quota.length,
@@ -693,6 +712,50 @@
});
}
function emitRoutingChanged() {
for (const [eventId, listener] of eventListeners) {
if (listener.event === "provider-routing-changed") callbacks.get(listener.handler)?.({ event: listener.event, id: eventId, payload: { codexDir } });
}
}
function failoverStatus() {
const summary = (provider) => ({ id: provider.id, providerName: provider.providerName, model: provider.model,
baseUrl: provider.baseUrl, official: false, models: [...new Set([provider.model, ...(provider.modelMappings || []).map((mapping) => mapping.model)])] });
const providers = savedProviders.map((provider) => {
const reason = provider.wireApi !== "responses" ? "此供应商暂不支持 Responses 接口" : !provider.apiKey ? "请先填写 API Key" : null;
return { ...summary(provider), eligible: !reason, reason };
});
const profile = state.isOfficialProvider ? officialProfiles.get(state.activeOfficialProfileId || defaultOfficialId) : null;
const primary = profile ? { id: `official:${profile.id}`, providerName: profile.providerName, model: profile.model, models: [profile.model],
baseUrl: "https://chatgpt.com/codex", official: true, eligible: Boolean(profile.authJson), reason: profile.authJson ? null : "请先登录官方账号" }
: providers.find((provider) => provider.id === state.activeSavedProviderId) || null;
const running = failoverSettings.routerEnabled;
const takeoverActive = Boolean(running && failoverSettings.takeoverEnabled && primary?.eligible);
const autoFailoverActive = Boolean(takeoverActive && failoverSettings.autoFailoverEnabled && !primary.official);
const routes = !takeoverActive ? [] : autoFailoverActive ? failoverSettings.providerIds.map((id) => providers.find((provider) => provider.id === id)).filter(Boolean) : [primary];
const samples = running && ["cooling", "recovering"].includes(failoverMode);
const host = failoverSettings.listenAddress === "0.0.0.0" ? "127.0.0.1" : failoverSettings.listenAddress === "::" ? "[::1]" : failoverSettings.listenAddress.includes(":") ? `[${failoverSettings.listenAddress}]` : failoverSettings.listenAddress;
return clone({ settings: failoverSettings, running, takeoverActive, autoFailoverActive, address: running ? `http://${host}:${failoverSettings.listenPort}/v1` : null, primary, providers,
runtime: { requestCount: samples ? 28 : 0, failoverCount: samples ? 2 : 0, inFlight: 0, successCount: samples ? 26 : 0, failureCount: samples ? 2 : 0,
uptimeSeconds: running ? 365 : 0, lastRequestAt: samples ? new Date().toISOString() : null,
lastProviderId: samples ? routes.at(-1)?.id || null : null, lastError: null,
providers: routes.map((provider, index) => { const state = resetRoutingHealth.has(provider.id) || !samples ? "closed" : index === 0 ? (failoverMode === "recovering" ? "half_open" : "open") : "closed";
return { id: provider.id, state, cooldownSeconds: state === "open" ? 25 : 0, lastStatus: state === "closed" ? 200 : 503,
consecutiveFailures: state === "open" ? 4 : 0, consecutiveSuccesses: state === "half_open" ? 1 : 0, totalRequests: samples ? 14 : 0, failedRequests: state === "closed" ? 0 : 4 }; }) },
message: primary?.official && failoverSettings.autoFailoverEnabled ? "当前为官方登录,仅使用当前账号;自动故障转移暂不运行。" : autoFailoverActive && !routes.length ? "队列为空,请添加 API 供应商。" : null });
}
function seedFailover() {
for (const [id, providerName, model] of [["fixture-failover-primary", "主供应商", "gpt-example"], ["fixture-failover-backup-a", "备用一", "gpt-example"], ["fixture-failover-backup-b", "备用二", "gpt-example"], ["fixture-failover-other", "不同模型供应商", "other-model"]]) {
saveProvider({ id, providerName, model, apiKey: "sk-fixture-only-failover", baseUrl: `https://${id}.example.test/v1`, wireApi: "responses", requiresOpenaiAuth: false, tomlConfig: "", modelMappings: [] });
}
switchProvider(savedProviders.find((provider) => provider.id === "fixture-failover-primary"));
failoverMode = "normal";
failoverSettings = defaultRoutingSettings();
resetRoutingHealth.clear();
persist(); render(); emitRoutingChanged();
}
async function invoke(command, args = {}) {
counts[command] = (counts[command] || 0) + 1;
commands.push(command);
@@ -707,6 +770,54 @@
async function dispatch(command, args) {
switch (command) {
case "get_provider_config_base": {
if (providerBaseReadFailure) throw new Error("Fixture:无法读取供应商配置底稿");
// Model a legacy route-only file with the official integration snapshot
// still available. Rust tests cover the actual parser/recovery rules.
const config = state.configText.includes("[mcp_servers")
? state.configText : state.configText + sharedProviderTables;
return config.replace(/^\s*experimental_bearer_token\s*=.*\n?/gm, "");
}
case "get_provider_failover":
if (failoverMode === "error") throw new Error("Fixture:暂时无法读取运行状态");
return failoverStatus();
case "save_provider_failover": {
if (failoverMode === "save-error") throw new Error("Fixture:设置保存失败,原设置未改变");
const next = clone(args.settings);
const status = failoverStatus();
const ranges = { maxRetries: [0, 10], streamingFirstByteTimeout: [1, 120], streamingIdleTimeout: [0, 600], nonStreamingTimeout: [60, 1200], circuitFailureThreshold: [1, 20], circuitSuccessThreshold: [1, 10], circuitTimeoutSeconds: [0, 300], circuitErrorRateThreshold: [0, 1], circuitMinRequests: [5, 100] };
for (const [key, [min, max]] of Object.entries(ranges)) if (!Number.isFinite(next[key]) || next[key] < min || next[key] > max || key !== "circuitErrorRateThreshold" && !Number.isInteger(next[key])) throw new Error(`Fixture:参数 ${key} 超出范围`);
if (!Number.isInteger(next.listenPort) || next.listenPort < 1024 || next.listenPort > 65535) throw new Error("Fixture:监听端口无效");
const host = next.listenAddress.trim();
let validAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
if (host.includes(":")) { try { validAddress = new URL(`http://[${host}]/`).hostname.startsWith("["); } catch {} }
if (host === "localhost") validAddress = true;
if (!validAddress) throw new Error("Fixture:监听地址无效");
next.listenAddress = host === "localhost" ? "127.0.0.1" : host;
if (status.running && (next.listenAddress !== failoverSettings.listenAddress || next.listenPort !== failoverSettings.listenPort)) throw new Error("Fixture:停止路由后再修改监听地址");
if (!next.routerEnabled) next.takeoverEnabled = false;
if (next.takeoverEnabled && !status.primary?.eligible) throw new Error("Fixture:请先选择可用供应商或登录官方账号");
if (next.providerIds.length > 64 || new Set(next.providerIds).size !== next.providerIds.length || next.providerIds.some((id) => !status.providers.find((provider) => provider.id === id)?.eligible)) throw new Error("Fixture:队列包含无效或重复供应商");
const enablingAuto = next.autoFailoverEnabled && !failoverSettings.autoFailoverEnabled;
if (enablingAuto && (!next.routerEnabled || !next.takeoverEnabled)) throw new Error("Fixture:请先开启本地路由和 Codex 路由");
if (enablingAuto && !next.providerIds.length) {
if (!status.primary?.eligible || status.primary.official) throw new Error("Fixture:请添加 API 供应商到队列");
next.providerIds.push(status.primary.id);
}
if (enablingAuto) switchProvider(savedProviders.find((provider) => provider.id === next.providerIds[0]));
failoverSettings = next;
emitRoutingChanged();
return failoverStatus();
}
case "reset_provider_failover_health":
if (args.providerId) resetRoutingHealth.add(args.providerId); else { failoverMode = "normal"; resetRoutingHealth.clear(); }
return failoverStatus();
case "plugin:event|listen": {
const eventId = nextCallback++;
eventListeners.set(eventId, { event: args.event, handler: args.handler });
return eventId;
}
case "plugin:event|unlisten": eventListeners.delete(args.eventId); return null;
case "check_codex_config": return healthReport();
case "repair_codex_config": {
if (args.expectedFingerprint !== healthReport().fingerprint) throw new Error("配置已变更,请重新检查。");
@@ -846,8 +957,8 @@
case "fetch_provider_models": return { models: [{ id: "fixture-model-a" }, { id: "fixture-model-b" }], status: 200, durationMs: 1 };
case "get_session_sync_status": return {
codexDir, targetProvider: state.modelProvider, rolloutFiles: 2, sessionMetaCount: 2,
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 2,
topLevelThreads: 2, subagentThreads: 0, mismatchedThreads: 0, mismatchedSessions: 0,
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 3,
topLevelThreads: 2, subagentThreads: 1, mismatchedThreads: 0, mismatchedSessions: 0,
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: clone(fixtureSessions),
};
case "get_skills_mcp_state": return clone(transferState);
@@ -878,6 +989,7 @@
}
}
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener(event, eventId) { eventListeners.delete(eventId); } };
window.__TAURI_INTERNALS__ = {
invoke,
transformCallback(callback, once = false) {
@@ -1020,6 +1132,25 @@
button.addEventListener("click", () => setUsageMode(mode));
controls.append(button);
}
for (const [text, action] of [
["Fixture:精简的供应商配置", () => { state.configText = 'model_reasoning_effort = "high"\ndisable_response_storage = true\n' + toml(detectedProvider); providerBaseReadFailure = false; render(); }],
["Fixture:供应商底稿读取失败", () => { providerBaseReadFailure = true; render(); }],
["Fixture:供应商底稿读取正常", () => { providerBaseReadFailure = false; render(); }],
["Fixture:准备自动切换数据", seedFailover],
["Fixture:自动切换状态正常", () => { failoverMode = "normal"; render(); }],
["Fixture:自动切换状态失败", () => { failoverMode = "error"; render(); }],
["Fixture:自动切换保存失败", () => { failoverMode = "save-error"; render(); }],
["Fixture:自动切换冷却状态", () => { failoverMode = "cooling"; resetRoutingHealth.clear(); render(); }],
["Fixture:自动切换恢复状态", () => { failoverMode = "recovering"; resetRoutingHealth.clear(); render(); }],
["Fixture:路由外部设置变化", () => { failoverSettings.maxRetries = failoverSettings.maxRetries === 3 ? 4 : 3; render(); emitRoutingChanged(); }],
["Fixture:路由切换官方账号", () => { switchOfficial(defaultOfficialId); emitRoutingChanged(); }],
["Fixture:路由切换其他模型", () => { const provider = savedProviders.find((item) => item.id === "fixture-failover-other"); if (provider) switchProvider(provider); emitRoutingChanged(); }],
["Fixture:删除第一备用", () => { savedProviders = savedProviders.filter((provider) => provider.id !== failoverSettings.providerIds[0]); render(); }],
]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
button.addEventListener("click", action); controls.append(button);
}
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "Fixture:重置测试数据";
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft } from "../src/routingSettings.ts";
const saved = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false, listenAddress: "127.0.0.1", listenPort: 15721,
providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60, streamingIdleTimeout: 120, nonStreamingTimeout: 600,
circuitFailureThreshold: 4, circuitSuccessThreshold: 2, circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
test("saved routing settings roundtrip without changing switches, queue or percentage", () => {
const settings = { ...saved(), routerEnabled: true, takeoverEnabled: true, autoFailoverEnabled: true, providerIds: ["p2", "p1"], circuitErrorRateThreshold: 0.625 };
const draft = routingDraft(settings);
assert.equal(draft.circuitErrorRateThreshold, "62.5");
assert.deepEqual(parseRoutingDraft(draft), { settings, errors: {} });
draft.providerIds.reverse();
assert.deepEqual(settings.providerIds, ["p2", "p1"]);
});
test("routing and saved failover preference remain independent when the service is stopped", () => {
const settings = { ...saved(), autoFailoverEnabled: true, providerIds: ["only-one"], streamingIdleTimeout: 0 };
assert.deepEqual(parseRoutingDraft(routingDraft(settings)).settings, settings);
assert.ok(parseRoutingDraft(routingDraft({ ...settings, providerIds: [] })).settings);
});
test("numeric fields accept their documented boundaries and reject empty, fractional or out-of-range values", () => {
for (const field of routingFields) {
for (const boundary of [field.min, field.max]) {
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: String(boundary) });
assert.ok(result.settings, `${field.key}: ${boundary}`);
}
const invalid = ["", " ", "NaN", "1e2", String(field.min - 1), String(field.max + 1)];
if (field.key !== "circuitErrorRateThreshold") invalid.push("1.5");
for (const value of invalid) {
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: value });
assert.equal(result.settings, null, `${field.key}: ${value}`);
assert.ok(result.errors[field.key]);
}
}
});
test("listen settings allow IPv4, IPv6 and localhost but never coerce URLs or invalid ports", () => {
for (const address of ["127.0.0.1", "0.0.0.0", "::1", "::", "2001:db8::1", "localhost"]) {
const result = parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address });
assert.ok(result.settings, address);
assert.equal(result.settings.listenAddress, address === "localhost" ? "127.0.0.1" : address);
}
for (const address of ["", "999.0.0.1", "127.000.0.1", "https://localhost", "::1/path", "example.com", "[::1]"]) {
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address }).errors.listenAddress, address);
}
for (const port of ["", "12x", "1023", "65536", "15721.5"]) {
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).errors.listenPort, port);
}
for (const port of ["1024", "65535"]) assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).settings);
});
test("the queue accepts one through 64 providers without an eight-provider limit and rejects duplicates", () => {
const draft = routingDraft(saved());
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["single"] }).settings);
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 64 }, (_, i) => String(i)) }).settings);
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 65 }, (_, i) => String(i)) }).errors.providerIds);
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["same", "same"] }).errors.providerIds);
});
test("draft comparison detects independent switch, listening, tuning and queue changes", () => {
const original = routingDraft({ ...saved(), providerIds: ["a", "b"] });
assert.ok(sameRoutingDraft(original, routingDraft({ ...saved(), providerIds: ["a", "b"] })));
for (const change of [{ routerEnabled: true }, { takeoverEnabled: true }, { autoFailoverEnabled: true }, { listenAddress: "::1" }, { listenPort: "15722" }, { providerIds: ["b", "a"] }, ...routingFields.map(({ key }) => ({ [key]: original[key] + "0" }))]) {
assert.equal(sameRoutingDraft(original, { ...original, ...change }), false);
}
});
+126
View File
@@ -8,6 +8,132 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-09-18:按 CC Switch 对齐路由控制与故障转移
- 对照 CC Switch `06082e189d65e6d6dbadc35dacdac1ce6c79d89a` 的 UI → IPC → 数据库 → 运行时链路,
将监听服务、Codex 请求接管、自动故障转移拆成三个独立状态;设置版本升级为 2,并迁移旧单开关与
“主供应商之外的候补”记录。队列现在保存完整 P1 起始顺序,单项队列可用,不再要求模型 ID 相同。
保存设置、首次开启自动模式时先启用有效 P1;空队列只在首次开启时自动补入当前已保存的第三方供应商。
- 参数采用 Codex 对应默认值与边界:重试 3 次、响应头/流式首块分别等待 60 秒、流式静默 120 秒、
非流式单次总期限 600 秒;熔断失败/恢复阈值为 4/2,等待 60 秒,错误率 60%、最少 10 个样本。
前后端都校验;支持 IPv4/IPv6/localhost,端口或地址变更要求停止监听。通配监听写给本机客户端时
使用回环地址,绑定非回环接口也保留本地认证。
- 按上游状态机适配 Closed/Open/HalfOpen 熔断与单探测许可;参数热更新不清空未改变供应商的健康记录。
尝试上限为重试次数加一,每家每请求至多一次,熔断跳过不占次数。已提交响应后不重放整条请求;
含服务端状态的请求只尝试本次快照队首,不提供历史 response/session 的永久供应商绑定。
- 官方账号支持独立原生 HTTP/SSE 接管,始终排除第三方自动队列。Codex 负责登录和 token 刷新;
每请求验证当前选中 profile、实时 token/account,OAuth 与官方 API Key 分别固定官方上游,不从
未选中账号借用认证。官方模型端点只提供受管本地目录或空目录,不宣称完整远程模型发现。
- 手动切换或编辑当前供应商使用完整操作包装,暂时撤下覆盖后再恢复接管,不再直接关闭全部路由。
自动成功选择按监听实例身份、运行 revision、令牌和配置代次验证后更新逻辑当前项;过期返回不能覆盖
后来操作,也不能在停止重启后因 token/revision 恰好相同而污染新实例。
路由设置、供应商更新、退出及崩溃恢复继续保留条件写入、认证写入顺序和失败回滚,恢复成功后才停监听。
- 官方状态、额度、登录监控和快照读取解包后的逻辑配置;本地地址、认证令牌和代次头不能变成保存的
供应商凭据。路由事件只提示前端静默重读当前目录,使用目录/请求代次检查并延后编辑期间的更新,
不清空页面和未保存草稿。既有通用配置继承、官方/第三方认证隔离及内部会话过滤修复继续保留。
- 本轮替代 2026-09-17 初版中的同模型限制、单开关、固定 30 秒冷却和手动切换关闭功能等约束;
不包含 CC Switch 的全部协议转换器、其他应用代理、日志计费或 OAuth 设备码反代。
具体对应、适用边界和上游差异见 [路由对照说明](ROUTING_CC_SWITCH_PARITY.md)。
- 复用的熔断器与压缩处理保留 MIT 来源和版权;`THIRD_PARTY_NOTICES.md` 记录上游提交及完整许可。
验证使用合成凭据、临时 CODEX_HOME 和本机服务,没有对用户真实供应商发送测试请求。
- 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查通过,macOS `.app` 构建成功。
浏览器验证三层开关、P1 排序、不同模型队列、参数范围与撤销、静默超时设为 0、72.5% 错误率、
熔断重置/恢复状态、暗色对比,以及失败保存后的草稿保留。
解锁后已正常退出旧版、替换并启动本地 App;新旧 ZIP 的完整性、可执行文件哈希与签名均通过校验。
实际安装版确认路由页面、已有队列和参数可读取,独立监听能启动并停止,结束后恢复原有关闭状态。
全程 config.toml 与 auth.json 哈希一致,没有接管真实账号或发送供应商测试请求。
应用目录仅保留一份 Codex-X,旧版与构建副本压缩归档;尚未推送 GitHub。
## 2026-09-18:第三方供应商通用配置继承
- 已只读确认:当前 live 文件已退化为供应商/模型及少量根字段,默认官方账号的同目录可信快照仍保留
MCP、desktop、marketplaces 等完整集成配置。因此官方新增能显示完整内容,第三方只能继承精简 live。
另有启用漏洞:旧实现将 reasoning/disable_response_storage 等任意额外根字段视为完整配置,
导致带这些字段的旧模板可整体覆盖当前通用设置。
- 新增只读配置底稿接口,添加入口先读取一次,所有自定义/厂商预设共用该底稿;读取失败直接提示,
不继续用精简模板替代。目录/请求变化使旧返回失效。旧精简模板编辑时也补入当前通用设置。
- 当前文件仅有供应商相关字段时,可从同目录默认官方快照恢复 mcp_servers、desktop、marketplaces、
plugins、projects 五个集成表;不复制历史登录凭据、供应商路由、模型、安全策略、计费档位或通知命令。
正常完整 live 为通用设置的权威来源,不用历史快照覆盖它;底稿生成本身不写 live 或 auth.json。
- 普通启用/编辑供应商基于最新通用配置,只更新供应商与模型设置;旧模板缺项不会删除 MCP 等设置,
历史模板的过期通用值也不会覆盖现值。手工编辑当前供应商完整 TOML 有独立显式应用路径,保留用户
增删通用设置的能力。1M 复选框不被视作手工修改通用配置。
- 新建或换接口地址时清理上一个供应商的专属请求头、环境认证和查询参数,MCP 自己的 env 原样保留。
- 每个 CODEX_HOME 在首次成功应用修复后的配置时记录处理标记,之后不再把用户有意清空的通用配置
当成旧版损坏反复回补;预览和仅保存未启用的供应商不置标记,失败应用也不置标记。
标记写入失败沿用现有 config/auth/官方快照回滚,不留下半完成的设置。
- 往返回归另外复现了普通切回官方会重放旧配置的问题,官方切换现也保留最新通用设置,仅从目标官方
配置读取模型、官方路由与账号约束;账号认证切换顺序和显式官方编辑语义保持原有规则。
- 553 项后端测试、45 项前端测试、类型/格式检查与 macOS 构建通过。新增回归覆盖旧精简模板、已有
精简 live、跨目录快照拒绝、MCP env、未知通用字段、显式删除、失败回滚、模型上下文与账号往返隔离。
模拟界面确认自定义/千问/DeepSeek 与官方预设切换、保存后再编辑都保留集成表;读取底稿失败停留列表
并提示错误,恢复读取后可正常新增。
- 本地 App 已重新构建、签名/二进制校验、替换并启动;真实目录下只读打开自定义及千问新增草稿,
确认五个集成表与 node_repl.env 都存在。config.toml、auth.json 和官方快照前后哈希一致,
没有保存/启用测试供应商。旧 App 与构建副本已压缩归档,仅保留一份安装;尚未推送 GitHub。
## 2026-09-17:回退会话文件名的 ID 校验回归
- 上次内部类型检查增加了文件名与首条 session_meta.id 的核对,但直接取文件名末尾 UUID,
把 thread/revert 生成的 `rollout-<timestamp>-<thread-id>_<rollout-id>.jsonl` 中 rollout ID
当成线程 ID。实际首条元数据和活动 SQLite 都仍使用前面的稳定 thread ID,导致正常记录误报。
已只读核对用户报告的三个文件的身份字段与索引,不读取输出正文、不改动实际日志或数据库。
- 依据 [Codex 0.154.0 文件名定义](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/rollout/src/rollout_file_name.rs),
文件身份与查找匹配统一解析稳定 thread ID;支持普通与回退文件名,并保留现有压缩文件名匹配。
rollout ID 不作为会话别名,不能据此选中另一条会话。索引权威路径、元数据精确匹配和内部类型检查保留。
- 新增四项回归,覆盖普通/回退文件名、日志 ID 不冒充会话 ID、真正元数据不一致、内部回退记录不修改、
从检查到事务同步成功,以及 SQLite 以错误 ID 引用文件时仍拒绝同步。
- 544 项后端测试、格式检查和 macOS 构建通过;本地 App 已校验替换并启动,旧安装和构建副本压缩归档。
本次未推送 GitHub,未对真实会话执行同步、删除或重命名。
更新后只读复查实际目录,界面显示「普通会话已同步」,上述 ID 不一致及异常文件警告已消失。
## 2026-09-17:可选的供应商故障自动切换(#49)
- 按用户要求只在设置新增第三个标签「故障自动切换」,沿用标签过渡;供应商列表不增加入口。
默认关闭,当前已启用的第三方供应商优先,最多选择 8 家备用并调整顺序。运行状态轮询保留草稿,
切页暂停、目录变化或旧响应不能覆盖新页面;开启/关闭提示重新打开 Codex。
- 对照 CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a 的代理与熔断实现:故障必须由实际请求判断,
不能用模型列表测速代替。新增仅监听 127.0.0.1 的 HTTP Responses 转发,接管当前 provider 的传输字段,
不修改模型、模型目录、会话、auth.json 或全局认证;官方登录不参与候补。
- 每次请求只尝试显式选中的候补,且其配置必须包含完全相同的模型 ID。连接/超时、认证/限流及部分
服务端故障触发切换;普通参数错误不轮询。失败供应商冷却 30 秒,每家每次请求最多尝试一次。
服务端会话状态绑定的请求不跨供应商重放;已发送响应后绝不重试整条请求,避免重复生成或工具调用。
- 入站要求随机本地令牌、固定 Host,拒绝浏览器来源及任意目标转发;请求头、正文、并发与等待均有上限。
出站独立使用每家保存的凭据,不透传本地令牌或官方账号头,不跟随重定向;不记录请求正文和密钥。
- 应用数据库增加按 CODEX_HOME 隔离的设置与恢复记录,先备份/写恢复记录再原子覆盖传输字段。
关闭功能、手动切换/编辑当前供应商及退出时先恢复直连再停监听;恢复失败阻止退出并保持服务可用。
成功退出后拒绝排队的重新启用请求,避免遗留失效地址。启动按原端口恢复,无法恢复则回到直连并提示。
- 仅还原仍属于本功能的字段,保留随后编辑的其他设置;历史恢复记录也可识别旧备份里的本地地址。
状态、供应商识别、编辑草稿及旧 TOML 提交都使用真实上游,防止产生 localhost 供应商或保存本地令牌。
保存/删除/导入供应商会即时刷新运行队列,已失效的候补使自动切换暂停;后台也检查外部配置变化。
- 验证:540 项 Rust 测试和 45 项前端测试、类型/格式检查通过;其中 21 项真实本机 HTTP/SSE 测试
覆盖故障候补、流式首块边界、凭据隔离、关闭/改队列、认证与长度限制,以及持续流超过单次读取超时。
浏览器确认明暗主题、三个标签切换、添加/排序/保存、失败保留旧状态、冷却显示和离开标签停止轮询。
本次仅使用合成服务与临时配置验证,没有启用用户真实供应商的自动切换,也没有向其发送测试请求。
- 本地 macOS App 已构建、签名/二进制校验、替换并启动;旧安装与构建副本保留为校验过的 ZIP,
系统只注册 Applications 中的一份 App。尚未推送 GitHub,也未发布 Windows 安装包。
## 2026-09-17:内部会话误报待同步与侧栏污染
- 原过滤主要识别 subagent,遗漏了 internal/guardian、memory_consolidation 及其 SQLite 表示;
泛化的 thread_source=user 还会清除已有的内部来源或 spawn 关系。跳过 rollout 后,部分内部 ID
仍留在数据库同步候选中,可能被补入桌面会话目录。这些类型在 Codex 0.153.4 已存在,并非只由新版引入。
- 活动 SQLite、其权威 rollout 的首条 session_meta、spawn 关系和已有桌面目录共同提供分类依据;
当前存储中的明确内部证据不会被默认 user 标记覆盖。旧数据库的同 ID 副本不覆盖活动记录的归属。
不按标题、模型名、空正文或缺少用户事件猜测;普通父会话及仅有 forked_from_id 的用户分叉继续保留。
- 检查计数、预览、rollout/SQLite 写入和目录补建均排除内部任务;事务提交前后复核 rollout 身份,
分类发生变化时回滚。内部记录不会因同步改写 provider、工作目录或缺失标记,也不新增侧栏条目。
列表达到上限时优先保留普通会话;“显示内部会话”仅影响展示,不会把内部记录加入同步候选。
- 用量扫描共用内部类型识别,内部用量仍计入总量,有明确父会话时合并,不生成单独的用户会话行。
相同 ID 的多个日志仍按原规则合并用量,但类型和子代理父关系以当前数据库引用的文件为准,
避免旧内部副本隐藏当前用户会话;仅有首条 thread_source 标记的内部任务也按相同规则处理。
- 回归使用临时数据库与日志,覆盖自动审核、来源冲突、真实用户分叉、旧副本、超长元数据与写入竞态。
本次不对真实会话执行同步或清理;既有侧栏污染不会自动删除,防止误删用户历史。
- 505 项 Rust 测试、45 项前端测试、类型及格式检查通过。模拟界面确认默认仅显示普通会话,
开启内部会话显示后仍无待同步标记,且仅有内部差异时同步按钮不可用;尚未在 Windows 真机验证。
- macOS App 已重新构建、签名与二进制校验、替换并启动;旧安装和构建副本经校验后压缩归档,
仅保留一份应用注册。本地版本仍为 0.3.18,修复列在 Unreleased,尚未提交或推送 GitHub。
## 2026-09-17:v0.3.18 发布准备
- 汇总后台配置检查、统一检查入口、会话/MCP/Skills 导出导入、大日志统计和模型推理等级/DeepSeek WS 修复。
+208
View File
@@ -0,0 +1,208 @@
# Codex 路由与故障转移:CC Switch 对照说明
本说明记录 Codex-X 本轮路由实现与 CC Switch 的对应关系,供维护者检查行为和后续回归使用。
入口为「设置 → 路由与故障转移」。本轮对齐 Codex 的监听、接管、优先队列、请求重试、熔断和
原生官方账号路由;不等于移植 CC Switch 的全部应用适配器和请求转换功能。
参考版本固定为 CC Switch 提交
[`06082e189d65e6d6dbadc35dacdac1ce6c79d89a`](https://github.com/farion1231/cc-switch/tree/06082e189d65e6d6dbadc35dacdac1ce6c79d89a)
(2026-09-15)。以下描述以该版本源码及本项目实现为准,不能用另一版本的说明文案替代代码行为。
## 三层开关
三个状态分别持久化;界面应同时区分「保存的偏好」和「目前实际运行的状态」。
| 设置字段 | 默认值 | 行为 |
| --- | --- | --- |
| `routerEnabled` | `false` | 启动本地监听服务;单独开启不会改写 Codex 配置。 |
| `takeoverEnabled` | `false` | 将当前 Codex 供应商的传输配置指向本地监听;要求路由服务开启。 |
| `autoFailoverEnabled` | `false` | 对第三方 API 请求使用完整优先队列;关闭时只使用当前供应商。 |
- 首次开启自动故障转移要求服务与接管均已开启。保存设置、让开关从关变为开时立即启用队列 P1。
- 关闭接管会先恢复直连,监听服务可以继续运行。关闭路由总开关会恢复直连并关闭接管,
但保留自动故障转移偏好、队列和参数。
- 官方登录使用独立的原生官方路由,自动队列暂不参与;这不要求删除原有第三方队列。
- 手动切换第三方供应商、编辑当前配置、切换官方账号时,完整原操作包在
`with_provider_change` 中:暂时撤下本地配置覆盖,完成原操作,再按新的逻辑供应商恢复接管。
不再沿用初版“手动切换就关闭全部自动切换”的行为。
- 运行状态的 `running`、`takeoverActive`、`autoFailoverActive` 不能互相代替。
未接管、当前为官方账号或恢复失败时,保存的自动开关与实际自动路由状态可能不同。
CC Switch 的对应字段位于 `GlobalProxyConfig`、`AppProxyConfig`,由
[`commands/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/proxy.rs)
和 [`services/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/services/proxy.rs)
实现。Codex-X 按 `CODEX_HOME` 隔离设置和运行实例;CC Switch 的设置按应用类型保存。
此外,CC Switch 在最后一个应用取消接管时会尝试停止服务,Codex-X 保留独立监听开关的含义。
## 完整队列与 P1
`providerIds` 保存 **P1、P2、P3……整个队列**,不是“当前主供应商之外的备用列表”。
当前供应商也可以加入;队列允许只有一家。新请求在自动模式下从 P1 开始,跳过暂时被熔断的项。
成功使用后面的项后,逻辑当前供应商会更新,但下一次请求仍按队列优先级选择。
开启自动模式时,空队列只会自动加入当前已保存、可用于路由的第三方供应商。
如果当前是官方账号或尚未保存的临时供应商,需要先选定有效 P1。P1 校验与启用失败不能留下
“界面已经开启、实际目标却未切换”的半完成状态;Codex-X 使用配置及状态检查点进行恢复。
队列可在停止状态编辑。已经开启自动模式后,移除最后一个成员不会偷偷重填当前供应商;
没有可用目标时请求返回 503。已删除或无效的供应商不再参与转发。供应商保存、删除、导入会刷新
运行配置,后台也定期检查外部变化。
没有“必须配置相同模型 ID”的入队限制。路由不修改请求中的 `model`,也不会把它替换为每家
供应商的默认模型;该模型是否可用仍取决于相应上游。参数不兼容等非重试错误会直接返回。
队列顺序在 Codex-X 的路由页独立保存,最多 64 项;这是资源上限。CC Switch 使用
`providers.in_failover_queue`,并按供应商列表的 `sort_index`、ID 排序,没有同模型或至少两家的限制。
对应源码为
[`commands/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/failover.rs)
和 [`database/dao/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/failover.rs)。
## 参数默认值与范围
配置版本为 2,IPC 使用 camelCase、平铺字段。下表是 Codex 的参数,不是 CC Switch 中 Claude
那组不同的默认值。前端与 Rust 服务端都会校验;界面错误率使用百分数,持久化使用 0–1。
| 字段 | 默认值 | 可配置范围 / 含义 |
| --- | --- | --- |
| `listenAddress` | `127.0.0.1` | IPv4、IPv6 字面量或 `localhost`;不接受任意主机名。 |
| `listenPort` | `15721` | 整数 1024–65535。 |
| `providerIds` | `[]` | 有序供应商 ID,最多 64 项,不接受重复、空值或官方账号 ID。 |
| `maxRetries` | `3` | 0–10;一次请求最多尝试 `maxRetries + 1` 个候选,每家至多一次。 |
| `streamingFirstByteTimeout` | `60` 秒 | 1–120 秒;分别用于等待响应头及首个响应数据块。 |
| `streamingIdleTimeout` | `120` 秒 | 0–600 秒;流式相邻数据读取间隔,0 表示不设置静默超时。 |
| `nonStreamingTimeout` | `600` 秒 | 60–1200 秒;单次非流式上游尝试的总期限,包括接收正文。 |
| `circuitFailureThreshold` | `4` | 1–20;达到连续失败次数即熔断。 |
| `circuitSuccessThreshold` | `2` | 1–10;半开探测达到此成功次数后关闭熔断。 |
| `circuitTimeoutSeconds` | `60` 秒 | 0–300 秒;从熔断到允许探测的等待时间。 |
| `circuitErrorRateThreshold` | `0.6` | 0–1,即界面的 0–100%;达到该错误率可触发熔断。 |
| `circuitMinRequests` | `10` | 5–100;错误率判断所需的最少样本数。 |
监听地址、端口只能在停止服务后修改。`localhost` 规范为 `127.0.0.1`;监听所有网卡时,
写入本机 Codex 的连接地址使用回环地址:`0.0.0.0 → 127.0.0.1`,`:: → ::1`。
IPv6 URL 使用方括号,实际绑定使用 `IpAddr`,不照搬上游裸 IPv6 字符串拼接的问题。
自动故障转移关闭或当前为官方账号时,运行路径只尝试当前目标,不使用上述熔断策略;
响应头等待和非流式请求采用 600 秒默认期限,不设置流式首数据块与静默期限。
本地入站读取、连接建立和向客户端写出仍有独立资源保护,不能将此理解为所有等待都无限制。
上游依据:
[`database/dao/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/proxy.rs)、
[`proxy/types.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/types.rs)、
[`AutoFailoverConfigPanel.tsx`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src/components/proxy/AutoFailoverConfigPanel.tsx)。
## 请求重试与熔断
一次请求持有开始时的路由、凭据和参数快照。改队列或参数影响后续请求,不把一条正在处理的请求
换成另一套凭据。被熔断或被其他半开探测占用的候选不消耗实际尝试次数。
连接失败、响应头等待失败、尚未输出时的首包失败及可重试 HTTP 错误可尝试下一家。
HTTP 400–599 中,以下状态不进入候补重试:`400/405/406/413/414/415/422/501`。
这些请求错误不累计供应商熔断失败。3xx 不跟随重定向,也不携带凭据跳转到另一地址。
拿到首个流式数据块后才提交下游响应;提交响应头或开始输出后,不再重试整条请求。
随后中断只结束当前流并更新错误提示,避免重复生成和重复工具执行。非流式正文在本次尝试的
总期限内完整读取;不是每收到一块数据就重新开始总计时。
带 `previous_response_id`、`conversation`、`background=true`、输入引用、文件 ID、加密内容或
不透明轮次状态的请求,只尝试**本次配置快照的队首**,不会失败后跨候补重放。
实现没有保存“历史 response ID / session → 原供应商”的长期绑定,不能承诺识别每段历史状态的
原始账户。此前一次请求在 P2 成功,并不意味着之后所有含历史状态的请求都自动绑定到 P2。
熔断器保留上游状态机:
| 状态 | 行为 |
| --- | --- |
| `closed` | 允许请求;连续失败达到阈值,或达到最少样本后错误率达到阈值,则转为 `open`。 |
| `open` | 等待恢复时间;到期后允许进入 `half_open`。 |
| `half_open` | 同时最多放行一个真实请求进行探测;失败立即重新熔断,成功达到恢复阈值后转为 `closed`。 |
探测由后续真实请求触发,不是定时向供应商发送额外测试消息。错误率使用当前熔断器生命周期的
累计样本,不是按分钟滚动的时间窗口;恢复关闭或手动重置会清空相应统计。
单项自动队列同样使用熔断器。关闭自动模式和官方路由绕过熔断器。
修改熔断参数会热更新已有实例,不借此清空失败记录。更换供应商的实际连接或凭据会重建对应健康
状态;移出运行路由、停止监听后,也不能把旧内存统计当作持续存在的探测结果。
「撤销修改」(恢复最近保存值)与「重置健康状态」是不同操作;后者不会主动验证服务已恢复。
上游依据:
[`provider_router.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/provider_router.rs)、
[`forwarder.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/forwarder.rs)、
[`circuit_breaker.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/circuit_breaker.rs)。
## 原生官方账号隔离
官方登录可以接入本地 HTTP/SSE 路由,但不能加入第三方自动队列。切换到官方账号时,保留已保存
的自动偏好和队列,实际只运行当前官方账号;不会拿 OAuth 请求尝试第三方候补。
- Codex 继续拥有登录和 token 刷新。本模块不实现设备码登录,不主动刷新 token,也不写 `auth.json`。
- 本地官方配置使用 `requires_openai_auth=true`,关闭 WebSocket,并通过
`x-codex-x-route-token` 验证本地调用者;不把 OAuth 替换为第三方 API Key 占位符。
- 每次请求检查逻辑官方状态、当前选中的 profile、实时认证文件中的精确 token 和 account ID。
同一 Team workspace 下不同用户的 token 也不能混用;不从未选中账号快照借用认证。
- OAuth 只发往 `https://chatgpt.com/backend-api/codex`;官方 API Key 认证只发往
`https://api.openai.com/v1`。拒绝混用两种认证,实际目的地址以请求时通过验证的认证类型为准。
- `x-codex-x-route-token` 和内部配置代次头不会转发上游,供应商自定义头也不能重新注入它们。
- 官方状态、邮箱/套餐摘要、额度查询、登录监控和快照捕获读取解包后的逻辑配置;不能把 localhost
识别为新第三方供应商,也不能把本地路由令牌保存为账号凭据。
- 官方 `GET /v1/models` 只读取当前引用且经过归属检查的本地受管模型目录,否则返回 `{"models":[]}`。
不请求外部模型目录,也不宣称它是完整的官方模型发现接口。尚未登录时可建立接管配置,实际请求仍须登录。
这对应 CC Switch 的 `apply_codex_official_proxy_route`、`is_codex_official_provider` 和官方认证透传分支,
不是 `codex_oauth_auth.rs` 中为其他客户端代管设备码登录/刷新、再转换请求的 OAuth 反代模式。
参考
[`codex_config.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/codex_config.rs)
及 [`providers/codex.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/providers/codex.rs)。
## 配置恢复、切换与退出
接管先保存恢复记录,再条件原子写入本地传输字段。恢复记录包含原始供应商表、监听位置、认证
令牌和配置代次标识,按 `CODEX_HOME` 隔离。恢复使用原始、已安装和当前值进行比较,保留随后
编辑的其他字段,并清除仍属于本功能的地址、令牌及代次头。历史恢复记录可识别旧备份中的本地路由。
成功转到队列其他供应商后,后台检查运行 revision、令牌、配置归属与队列成员身份,再更新逻辑
当前供应商和恢复记录。后台通知另外校验监听实例身份,停止后重新启动也不能将旧请求误认为新实例
的结果。过期请求不能覆盖之后的手动选择。不会因此重写当前请求的模型名、会话数据或 MCP、desktop 等通用配置。界面只静默读取当前目录状态,编辑中的表单暂缓刷新,避免覆盖草稿。
启动时先处理遗留的受管配置,再按保存的监听/接管偏好恢复服务。如果供应商已在外部改变,不
强行接管新配置;启动恢复失败时报告具体原因。退出先恢复直连,再停止监听;恢复失败
保留可服务的实例并阻止普通退出。成功退出后拒绝排队的重新启用请求。
关闭窗口继续驻留托盘。停止监听不主动截断已经开始输出的流,但停止后不再发起新的上游尝试;
退出整个进程仍不能保证未完成流继续。首次接管、关闭接管或更换账号后,已经运行的 Codex 可能缓存
原配置;需要重新打开客户端或新建会话的情况不能通过修改文件强制消除。
这些恢复保护是 Codex-X 保留的实现约束。参考版本的 CC Switch 在退出时先停服务再恢复,恢复失败
主要记录日志后继续退出;本项目没有照搬这种失败路径。
## 适用边界与实现差异
- 当前数据面提供 `POST /v1/responses`、`POST /v1/responses/compact`、`GET /v1/models`,使用 HTTP/SSE,
不提供 WebSocket 升级或任意 URL 转发。
- 第三方接口需兼容 Responses。没有 Chat Completions/Anthropic 协议转换、请求整流器、图片降级、
Claude/Gemini/Grok 应用接管或跨应用配置管理;不能把这些上游能力写成本项目已具备。
- 队列不限制模型名称,但不保证任意第三方都支持请求中的模型、工具或服务端状态。
厂商专用 `query_params` 目前不支持自动路由;无效认证、缺失环境变量或不适用的认证头会报告原因。
- 本地监听即使绑定非回环地址,也保留随机令牌、Host 和来源检查。不是无需认证的公开代理。
不跟随重定向,不向第三方传递官方账号头、Cookie、本地令牌或其他账户凭据。
- 入站头最多 32 KiB,正文及每层解压结果最多 64 MiB,查询串最多 4 KiB;使用 8 个有界工作线程。
支持 gzip、deflate、zstd 请求解码,不记录原始请求正文或 token。
- 运行统计是传输层统计:完整非流式响应或首个流式输出计为一次成功;之后的流中断另行报告。
这不是对完整答案质量或整个工具调用流程成功的判断。该统计也不是订阅额度或金额统计。
- 本轮未提供 CC Switch 的请求日志、成本计费及其他应用的开关。已有「用量统计」与官方额度查询
仍由各自功能负责。
## 代码与许可来源
本项目对应实现为 `failover/config.rs`、`controller.rs`、`proxy.rs`、`circuit_breaker.rs`、
`native_official.rs`,以及命令、官方配置读取和前端事件接线。
熔断器状态机与部分压缩处理按 CC Switch 上述提交的 MIT 代码适配;保留其版权声明
`Copyright (c) 2025 Jason Young`。同步锁、有界本地 HTTP 处理、状态持久化、配置恢复及界面接入
按 Codex-X 的现有结构实现。完整许可和来源见 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
验证使用临时目录、合成认证和本机 HTTP/SSE 服务,覆盖参数校验、P1 与队列、熔断、超时、
已提交输出后的禁止重放、并发切换、恢复失败、原生官方隔离、模型目录归属和草稿保留。
本轮 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查及 macOS `.app` 构建通过;
浏览器检查已覆盖三层开关、队列排序、不同模型、参数编辑、熔断重置和明暗主题。
本地 macOS App 已校验替换并启动,实际安装版验证了页面读取及监听服务独立启停,
结束后恢复原关闭状态,现有 config.toml 与 auth.json 哈希未变;尚未验证 Windows 原生安装包。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.18",
"version": "0.3.19",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",