mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-02 03:24:52 +08:00
feat: add routing failover and fix config/session sync for v0.3.19
This commit is contained in:
@@ -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
@@ -2,7 +2,11 @@
|
||||
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [Unreleased]
|
||||
## [v0.3.19] - 2026-09-18
|
||||
|
||||
- 新增「设置 → 路由与故障转移」:可管理本地路由、安排供应商优先顺序,并在服务异常时自动切换;支持调整超时和重试参数,官方登录也可使用本地路由。
|
||||
- 修复添加或切换第三方供应商时,MCP、桌面设置等配置丢失的问题。
|
||||
- 修复内部任务被误同步,以及回退后的正常会话被误报异常的问题。
|
||||
|
||||
## [v0.3.18] - 2026-09-17
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.18",
|
||||
"version": "0.3.19",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+46
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤t, "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(¤t, "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();
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(¤t, normal).unwrap();
|
||||
fs::write(&normal_copy, normal).unwrap();
|
||||
fs::write(©, 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"]);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)?
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -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", ¤t);
|
||||
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]
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 修复。
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user